mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-10 10:57:43 +01:00
Compare commits
140 Commits
v4.2.5
...
19275-fixe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0f6c7a6a0 | ||
|
|
d2e74e9d50 | ||
|
|
02571130b2 | ||
|
|
46a3ce2559 | ||
|
|
1850c21714 | ||
|
|
13ddd5fd20 | ||
|
|
60cdf89cad | ||
|
|
77bfc40579 | ||
|
|
2f8936d493 | ||
|
|
e0b6a31504 | ||
|
|
8567aa96e4 | ||
|
|
459c4bfd9d | ||
|
|
918470a2bb | ||
|
|
c73cc0a36a | ||
|
|
6b9b66aecb | ||
|
|
b6d10ae6d8 | ||
|
|
7420c25687 | ||
|
|
248c94bd35 | ||
|
|
96cf8d14dc | ||
|
|
2356a3c125 | ||
|
|
0d81007fdf | ||
|
|
c108c738ae | ||
|
|
cac41cd093 | ||
|
|
27b26ec49c | ||
|
|
7c2776d721 | ||
|
|
1f93471659 | ||
|
|
d3768feb31 | ||
|
|
70cc7c7563 | ||
|
|
8b091fb219 | ||
|
|
44cb1a9139 | ||
|
|
bb9b0b8f8a | ||
|
|
785ad505ba | ||
|
|
8aacef60a3 | ||
|
|
d8fc052bbe | ||
|
|
1f79411878 | ||
|
|
94d19e8f15 | ||
|
|
f337ef1134 | ||
|
|
6ed41f6680 | ||
|
|
28e62d21a9 | ||
|
|
03f3f5c957 | ||
|
|
fe7fb94e44 | ||
|
|
82b9e4ca26 | ||
|
|
457fb977a7 | ||
|
|
13c20957a6 | ||
|
|
30208549ba | ||
|
|
bf286df670 | ||
|
|
2be257db48 | ||
|
|
2207ea1a32 | ||
|
|
10e1ae8292 | ||
|
|
f8f5ab8d61 | ||
|
|
92317248a3 | ||
|
|
426e6439e3 | ||
|
|
621b29cd71 | ||
|
|
8f5d273f08 | ||
|
|
45779a24a4 | ||
|
|
f17bbe610e | ||
|
|
bad820001d | ||
|
|
a5106b858d | ||
|
|
bbd5e9cab9 | ||
|
|
12231ad71a | ||
|
|
88ef9ecfa3 | ||
|
|
6f78b3d0cd | ||
|
|
d3f42deb32 | ||
|
|
db4fb8f406 | ||
|
|
5b8eaced1a | ||
|
|
ada0c7f687 | ||
|
|
b750d0dff2 | ||
|
|
e1e514251e | ||
|
|
7d80a45bf8 | ||
|
|
09854a3d54 | ||
|
|
39a96ddf3a | ||
|
|
be26f86b62 | ||
|
|
fd2bcda8b8 | ||
|
|
817d7efee3 | ||
|
|
9a1d9365cd | ||
|
|
ada4a4b93c | ||
|
|
64a98fd87f | ||
|
|
bd8e00a935 | ||
|
|
af5a600583 | ||
|
|
8ab73501d1 | ||
|
|
447e108d97 | ||
|
|
e186113cb3 | ||
|
|
40452ead62 | ||
|
|
34d80beaa2 | ||
|
|
b1d014b520 | ||
|
|
7db0765ed2 | ||
|
|
b8cc2d7116 | ||
|
|
d332a0c0d7 | ||
|
|
f07e2dd4e2 | ||
|
|
d7b9b09d56 | ||
|
|
9da4cf31ab | ||
|
|
bf1a9a6e2d | ||
|
|
c50b1c989d | ||
|
|
79b0c0f5d6 | ||
|
|
43840e6a72 | ||
|
|
7c152e9234 | ||
|
|
d7709a2a55 | ||
|
|
dce694afa9 | ||
|
|
c5801f9881 | ||
|
|
f86647dc28 | ||
|
|
0094703609 | ||
|
|
f286449284 | ||
|
|
4f45328c77 | ||
|
|
994e7eb9f4 | ||
|
|
ed135102be | ||
|
|
78332d44c7 | ||
|
|
80926cda8f | ||
|
|
d924d4eb33 | ||
|
|
b1e7d7c76b | ||
|
|
092f7549ca | ||
|
|
2f51dfc07a | ||
|
|
906654d807 | ||
|
|
749a83d742 | ||
|
|
cdd25368e7 | ||
|
|
7d64d3b5ed | ||
|
|
5e22ef59c5 | ||
|
|
19d1282683 | ||
|
|
2266a8af67 | ||
|
|
5d81f911d6 | ||
|
|
89e3f3d3e9 | ||
|
|
292463c0de | ||
|
|
a9fd191086 | ||
|
|
1a60cb9884 | ||
|
|
76c3c613a9 | ||
|
|
528248b560 | ||
|
|
8823b07745 | ||
|
|
29c25e39fc | ||
|
|
d103e13732 | ||
|
|
6d69c76b83 | ||
|
|
f9c8d12a51 | ||
|
|
3ef7ab4416 | ||
|
|
741645c9f7 | ||
|
|
d226af420b | ||
|
|
5c88317745 | ||
|
|
d83c2f45bc | ||
|
|
83ca0ef955 | ||
|
|
9c3e7f2c5d | ||
|
|
7794c6cfcb | ||
|
|
8dc1d68aee | ||
|
|
d9066d6cff |
@@ -15,7 +15,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.2.5
|
||||
placeholder: v4.2.8
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
@@ -27,7 +27,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.2.5
|
||||
placeholder: v4.2.8
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
@@ -12,6 +12,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
if: github.repository == 'netbox-community/netbox'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
|
||||
1
.github/workflows/close-stale-issues.yml
vendored
1
.github/workflows/close-stale-issues.yml
vendored
@@ -13,6 +13,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
if: github.repository == 'netbox-community/netbox'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
|
||||
1
.github/workflows/lock-threads.yml
vendored
1
.github/workflows/lock-threads.yml
vendored
@@ -13,6 +13,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
lock:
|
||||
if: github.repository == 'netbox-community/netbox'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v5
|
||||
|
||||
@@ -13,6 +13,7 @@ env:
|
||||
|
||||
jobs:
|
||||
makemessages:
|
||||
if: github.repository == 'netbox-community/netbox'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NETBOX_CONFIGURATION: netbox.configuration_testing
|
||||
|
||||
@@ -88,8 +88,7 @@ mkdocs-material
|
||||
|
||||
# Introspection for embedded code
|
||||
# https://github.com/mkdocstrings/mkdocstrings/blob/main/CHANGELOG.md
|
||||
# See #18568
|
||||
mkdocstrings[python-legacy]==0.27.0
|
||||
mkdocstrings[python]
|
||||
|
||||
# Library for manipulating IP prefixes and addresses
|
||||
# https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst
|
||||
@@ -133,8 +132,7 @@ strawberry-graphql
|
||||
|
||||
# Strawberry GraphQL Django extension
|
||||
# https://github.com/strawberry-graphql/strawberry-django/releases
|
||||
# Pinned to v0.52.0 for suspected upstream bug; see #18329
|
||||
strawberry-graphql-django==0.52.0
|
||||
strawberry-graphql-django
|
||||
|
||||
# SVG image rendering (used for rack elevations)
|
||||
# https://github.com/mozman/svgwrite/blob/master/NEWS.rst
|
||||
|
||||
@@ -427,6 +427,7 @@
|
||||
"e3",
|
||||
"xdsl",
|
||||
"docsis",
|
||||
"moca",
|
||||
"bpon",
|
||||
"epon",
|
||||
"10g-epon",
|
||||
@@ -500,6 +501,9 @@
|
||||
"n",
|
||||
"mrj21",
|
||||
"fc",
|
||||
"fc-pc",
|
||||
"fc-upc",
|
||||
"fc-apc",
|
||||
"lc",
|
||||
"lc-pc",
|
||||
"lc-upc",
|
||||
@@ -565,6 +569,9 @@
|
||||
"n",
|
||||
"mrj21",
|
||||
"fc",
|
||||
"fc-pc",
|
||||
"fc-upc",
|
||||
"fc-apc",
|
||||
"lc",
|
||||
"lc-pc",
|
||||
"lc-upc",
|
||||
|
||||
@@ -54,6 +54,7 @@ Icons](https://github.com/google/material-design-icons) icon's name; or be
|
||||
`None` for no icon.
|
||||
|
||||
For instance, the OIDC backend may be customized with
|
||||
|
||||
```python
|
||||
SOCIAL_AUTH_BACKEND_ATTRS = {
|
||||
'oidc': ("My awesome SSO", "login"),
|
||||
|
||||
@@ -233,3 +233,15 @@ This parameter controls how frequently a failed job is retried, up to the maximu
|
||||
Default: `0` (retries disabled)
|
||||
|
||||
The maximum number of times a background task will be retried before being marked as failed.
|
||||
|
||||
## DISK_BASE_UNIT
|
||||
|
||||
Default: `1000`
|
||||
|
||||
The base unit for disk sizes. Set this to `1024` to use binary prefixes (MiB, GiB, etc.) instead of decimal prefixes (MB, GB, etc.).
|
||||
|
||||
## RAM_BASE_UNIT
|
||||
|
||||
Default: `1000`
|
||||
|
||||
The base unit for RAM sizes. Set this to `1024` to use binary prefixes (MiB, GiB, etc.) instead of decimal prefixes (MB, GB, etc.).
|
||||
|
||||
@@ -25,6 +25,7 @@ Height: {{ rack.u_height }}U
|
||||
To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`.
|
||||
|
||||
If you need to use the config context data in an export template, you'll should use the function `get_config_context` to get all the config context data. For example:
|
||||
|
||||
```
|
||||
{% for server in queryset %}
|
||||
{% set data = server.get_config_context() %}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Release Checklist
|
||||
|
||||
This documentation describes the process of packaging and publishing a new NetBox release. There are three types of release:
|
||||
This documentation describes the process of packaging and publishing a new NetBox release. There are three types of releases:
|
||||
|
||||
* Major release (e.g. v3.7.8 to v4.0.0)
|
||||
* Minor release (e.g. v4.0.10 to v4.1.0)
|
||||
* Patch release (e.g. v4.1.0 to v4.1.1)
|
||||
|
||||
While major releases generally introduce some very substantial change to the application, they are typically treated the same as minor version increments for the purpose of release packaging.
|
||||
While major releases generally introduce some very substantial changes to the application, they are typically treated the same as minor version increments for the purpose of release packaging.
|
||||
|
||||
For patch releases (e.g. upgrading from v4.2.2 to v4.2.3), begin at the [patch releases](#patch-releases) heading below. For minor or major releases, complete the entire checklist.
|
||||
|
||||
@@ -31,6 +31,29 @@ Close the [release milestone](https://github.com/netbox-community/netbox/milesto
|
||||
|
||||
Check that a link to the release notes for the new version is present in the navigation menu (defined in `mkdocs.yml`), and that a summary of all major new features has been added to `docs/index.md`.
|
||||
|
||||
### Update the Dependency Requirements Matrix
|
||||
|
||||
For every minor release, update the dependency requirements matrix in `docs/installation/upgrading.md` ("All versions") to reflect the supported versions of Python, PostgreSQL, and Redis:
|
||||
|
||||
1. Add a new row with the supported dependency versions.
|
||||
2. Include a documentation link using the release tag format: `https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md`
|
||||
3. Bold any version changes for clarity.
|
||||
|
||||
**Example Update:**
|
||||
|
||||
```markdown
|
||||
| NetBox Version | Python min | Python max | PostgreSQL min | Redis min | Documentation |
|
||||
|:--------------:|:----------:|:----------:|:--------------:|:---------:|:-------------------------------------------------------------------------------------------------:|
|
||||
| 4.2 | 3.10 | 3.12 | **13** | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md) |
|
||||
```
|
||||
|
||||
### Update System Requirements
|
||||
|
||||
If a new Django release is adopted or other major dependencies (Python, PostgreSQL, Redis) change:
|
||||
|
||||
* Update the installation guide (`docs/installation/index.md`) with the new minimum versions.
|
||||
* Update the upgrade guide (`docs/installation/upgrading.md`) for the current version accordingly.
|
||||
|
||||
### Manually Perform a New Install
|
||||
|
||||
Start the documentation server and navigate to the current version of the installation docs:
|
||||
@@ -39,15 +62,25 @@ Start the documentation server and navigate to the current version of the instal
|
||||
mkdocs serve
|
||||
```
|
||||
|
||||
Follow these instructions to perform a new installation of NetBox in a temporary environment. This process must not be automated: The goal of this step is to catch any errors or omissions in the documentation, and ensure that it is kept up-to-date for each release. Make any necessary changes to the documentation before proceeding with the release.
|
||||
Follow these instructions to perform a new installation of NetBox in a temporary environment. This process must not be automated: The goal of this step is to catch any errors or omissions in the documentation and ensure that it is kept up to date for each release. Make any necessary changes to the documentation before proceeding with the release.
|
||||
|
||||
### Test Upgrade Paths
|
||||
|
||||
Upgrading from a previous version typically involves database migrations, which must work without errors. Supported upgrade paths include from one minor version to another within the same major version (i.e. 4.0 to 4.1), as well as from the latest patch version of the previous minor version (i.e. 3.7 to 4.0 or to 4.1). Prior to release, test all these supported paths by loading demo data from the source version and performing a `./manage.py migrate`.
|
||||
Upgrading from a previous version typically involves database migrations, which must work without errors.
|
||||
Test the following supported upgrade paths:
|
||||
|
||||
- From one minor version to another within the same major version (e.g. 4.0 to 4.1).
|
||||
- From the latest patch version of the previous minor version (e.g. 3.7 to 4.0 or 4.1).
|
||||
|
||||
Prior to release, test all these supported paths by loading demo data from the source version and performing:
|
||||
|
||||
```no-highlight
|
||||
./manage.py migrate
|
||||
```
|
||||
|
||||
### Merge the `feature` Branch
|
||||
|
||||
Submit a pull request to merge the `feature` branch into the `main` branch in preparation for its release. Once it has been merged, continue with the section for patch releases below.
|
||||
Submit a pull request to merge the `feature` branch into the `main` branch in preparation for its release. Once it has been merged, continue with the section for the patch releases below.
|
||||
|
||||
### Rebuild Demo Data (After Release)
|
||||
|
||||
@@ -59,7 +92,7 @@ After the release of a new minor version, generate a new demo data snapshot comp
|
||||
|
||||
### Create a Release Branch
|
||||
|
||||
Begin by creating a new branch (based off of `main`) to effect the release. This will comprise the changes listed below.
|
||||
Begin by creating a new branch (based on `main`) to effect the release. This will comprise the changes listed below.
|
||||
|
||||
```
|
||||
git checkout main
|
||||
@@ -117,7 +150,7 @@ This will automatically update the schema file at `contrib/generated_schema.json
|
||||
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
|
||||
tx pull --force
|
||||
```
|
||||
|
||||
Then, compile these portable (`.po`) files for use in the application:
|
||||
@@ -136,7 +169,7 @@ Then, compile these portable (`.po`) files for use in the application:
|
||||
* Add a section for this release at the top of the changelog page for the minor version (e.g. `docs/release-notes/version-4.2.md`) listing all relevant changes made in this release.
|
||||
|
||||
!!! tip
|
||||
Put yourself in the shoes of the user when recording change notes. Focus on the effect that each change has for the end user, rather than the specific bits of code that were modified in a PR. Ensure that each message conveys meaning absent context of the initial feature request or bug report. Remember to include key words or phrases (such as exception names) that can be easily searched.
|
||||
Put yourself in the shoes of the user when recording change notes. Focus on the effect that each change has for the end user, rather than the specific bits of code that were modified in a PR. Ensure that each message conveys meaning absent context of the initial feature request or bug report. Remember to include keywords or phrases (such as exception names) that can be easily searched.
|
||||
|
||||
### Submit a Pull Request
|
||||
|
||||
|
||||
@@ -33,10 +33,10 @@ To download translated strings automatically, you'll need to:
|
||||
Once you have the client set up, run the following command from the project root (e.g. `/opt/netbox/`):
|
||||
|
||||
```no-highlight
|
||||
TX_TOKEN=$TOKEN tx pull
|
||||
TX_TOKEN=$TOKEN tx pull --force
|
||||
```
|
||||
|
||||
This will download all portable (`.po`) translation files from Transifex, updating them locally as needed.
|
||||
This will download all portable (`.po`) translation files from Transifex, updating them locally as needed. (The `--force` argument instructs the client to disregard the timestamps of local translation files.)
|
||||
|
||||
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:
|
||||
|
||||
|
||||
@@ -246,7 +246,7 @@ Once NetBox has been configured, we're ready to proceed with the actual installa
|
||||
|
||||
* Create a Python virtual environment
|
||||
* Installs all required Python packages
|
||||
* Run database schema migrations
|
||||
* Run database schema migrations (skip with `--readonly`)
|
||||
* Builds the documentation locally (for offline use)
|
||||
* Aggregate static resource files on disk
|
||||
|
||||
@@ -266,6 +266,9 @@ sudo PYTHON=/usr/bin/python3.10 /opt/netbox/upgrade.sh
|
||||
!!! note
|
||||
Upon completion, the upgrade script may warn that no existing virtual environment was detected. As this is a new installation, this warning can be safely ignored.
|
||||
|
||||
!!! note
|
||||
To run the script on a node connected to a database in read-only mode, include the `--readonly` parameter. This will skip the application of any database migrations.
|
||||
|
||||
## Create a Super User
|
||||
|
||||
NetBox does not come with any predefined user accounts. You'll need to create a super user (administrative account) to be able to log into NetBox. First, enter the Python virtual environment created by the upgrade script:
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
|
||||
The installation instructions provided here have been tested to work on Ubuntu 22.04 and CentOS 8.3. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
|
||||
|
||||
<iframe width="560" height="315" src="https://www.youtube.com/embed/_y5JRiW_PLM" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
|
||||
|
||||
The following sections detail how to set up a new instance of NetBox:
|
||||
|
||||
1. [PostgreSQL database](1-postgresql.md)
|
||||
|
||||
@@ -17,11 +17,52 @@ Prior to upgrading your NetBox instance, be sure to carefully review all [releas
|
||||
|
||||
NetBox requires the following dependencies:
|
||||
|
||||
| Dependency | Supported Versions |
|
||||
|------------|--------------------|
|
||||
| Python | 3.10, 3.11, 3.12 |
|
||||
| PostgreSQL | 13+ |
|
||||
| Redis | 4.0+ |
|
||||
=== "Current Version"
|
||||
|
||||
| Dependency | Supported Versions |
|
||||
|------------|--------------------|
|
||||
| Python | 3.10, 3.11, 3.12 |
|
||||
| PostgreSQL | 13+ |
|
||||
| Redis | 4.0+ |
|
||||
|
||||
=== "All Versions"
|
||||
|
||||
| NetBox Version | Python min | Python max | PostgreSQL min | Redis min | Documentation |
|
||||
|:--------------:|:----------:|:----------:|:--------------:|:---------:|:-------------------------------------------------------------------------------------------------:|
|
||||
| 4.2 | 3.10 | 3.12 | **13** | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.2.0/docs/installation/index.md) |
|
||||
| 4.1 | 3.10 | 3.12 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.1.0/docs/installation/index.md) |
|
||||
| 4.0 | **3.10** | **3.12** | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v4.0.0/docs/installation/index.md) |
|
||||
| 3.7 | 3.8 | 3.11 | 12 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.7.0/docs/installation/index.md) |
|
||||
| 3.6 | 3.8 | **3.11** | **12** | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.6.0/docs/installation/index.md) |
|
||||
| 3.5 | 3.8 | 3.10 | 11 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.5.0/docs/installation/index.md) |
|
||||
| 3.4 | 3.8 | 3.10 | **11** | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.4.0/docs/installation/index.md) |
|
||||
| 3.3 | 3.8 | 3.10 | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.3.0/docs/installation/index.md) |
|
||||
| 3.2 | **3.8** | **3.10** | 10 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.2.0/docs/installation/index.md) |
|
||||
| 3.1 | 3.7 | 3.9 | **10** | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.1.0/docs/installation/index.md) |
|
||||
| 3.0 | **3.7** | 3.9 | 9.6 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v3.0.0/docs/installation/index.md) |
|
||||
| 2.11 | 3.6 | **3.9** | 9.6 | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v2.11.0/docs/installation/index.md) |
|
||||
| 2.10 | 3.6 | 3.8 | **9.6** | 4.0 | [Link](https://github.com/netbox-community/netbox/blob/v2.10.0/docs/installation/index.md) |
|
||||
| 2.9 | 3.6 | 3.8 | 9.5 | **4.0** | [Link](https://github.com/netbox-community/netbox/blob/v2.9.0/docs/installation/index.md) |
|
||||
| 2.8 | **3.6** | **3.8** | **9.5** | **3.4** | [Link](https://github.com/netbox-community/netbox/blob/v2.8.0/docs/installation/index.md) |
|
||||
| 2.7 | 3.5 | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.7.0/docs/installation/index.md) |
|
||||
| 2.6 | 3.5 | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.6.0/docs/installation/index.md) |
|
||||
| 2.5 | **3.5** | 3.7 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.5.0/docs/installation/index.md) |
|
||||
| 2.4 | **3.4** | **3.7** | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.4.0/docs/installation/index.md) |
|
||||
| 2.3 | 2.7 | 3.6 | 9.4 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.3.0/docs/installation/postgresql.md) |
|
||||
| 2.2 | 2.7 | 3.6 | **9.4** | - | [Link](https://github.com/netbox-community/netbox/blob/v2.2.0/docs/installation/postgresql.md) |
|
||||
| 2.1 | 2.7 | 3.6 | 9.3 | - | [Link](https://github.com/netbox-community/netbox/blob/v2.1.0/docs/installation/postgresql.md) |
|
||||
| 2.0 | 2.7 | **3.6** | **9.3** | - | [Link](https://github.com/netbox-community/netbox/blob/v2.0.0/docs/installation/postgresql.md) |
|
||||
| 1.9 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.9.0-r1/docs/installation/postgresql.md) |
|
||||
| 1.8 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.8.0/docs/installation/postgresql.md) |
|
||||
| 1.7 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.7.0/docs/installation/postgresql.md) |
|
||||
| 1.6 | 2.7 | 3.5 | 9.2 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.6.0/docs/installation/postgresql.md) |
|
||||
| 1.5 | 2.7 | 3.5 | **9.2** | - | [Link](https://github.com/netbox-community/netbox/blob/v1.5.0/docs/installation/postgresql.md) |
|
||||
| 1.4 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.4.0/docs/installation/postgresql.md) |
|
||||
| 1.3 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.3.0/docs/installation/postgresql.md) |
|
||||
| 1.2 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.2.0/docs/installation/postgresql.md) |
|
||||
| 1.1 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/v1.1.0/docs/getting-started.md) |
|
||||
| 1.0 | 2.7 | 3.5 | 9.1 | - | [Link](https://github.com/netbox-community/netbox/blob/1.0.0/docs/getting-started.md) |
|
||||
|
||||
|
||||
## 3. Install the Latest Release
|
||||
|
||||
@@ -83,17 +124,19 @@ sudo cp /opt/netbox-$OLDVER/gunicorn.py /opt/netbox/
|
||||
|
||||
### Option B: Check Out a Git Release
|
||||
|
||||
This guide assumes that NetBox is installed at `/opt/netbox`. First, determine the latest release either by visiting our [releases page](https://github.com/netbox-community/netbox/releases) or by running the following `git` commands:
|
||||
This guide assumes that NetBox is installed at `/opt/netbox`. First, determine the latest release either by visiting our [releases page](https://github.com/netbox-community/netbox/releases) or by running the following command:
|
||||
|
||||
```
|
||||
sudo git fetch --tags
|
||||
git describe --tags $(git rev-list --tags --max-count=1)
|
||||
git ls-remote --tags https://github.com/netbox-community/netbox.git \
|
||||
| grep -o 'refs/tags/v[0-9]*\.[0-9]*\.[0-9]*$' \
|
||||
| tail -n 1 \
|
||||
| sed 's|refs/tags/||'
|
||||
```
|
||||
|
||||
Check out the desired release by specifying its tag:
|
||||
Check out the desired release by specifying its tag. For example:
|
||||
|
||||
```
|
||||
sudo git checkout v4.2.0
|
||||
sudo git checkout v4.2.7
|
||||
```
|
||||
|
||||
## 4. Run the Upgrade Script
|
||||
@@ -111,6 +154,9 @@ sudo ./upgrade.sh
|
||||
sudo PYTHON=/usr/bin/python3.10 ./upgrade.sh
|
||||
```
|
||||
|
||||
!!! note
|
||||
To run the script on a node connected to a database in read-only mode, include the `--readonly` parameter. This will skip the application of any database migrations.
|
||||
|
||||
This script performs the following actions:
|
||||
|
||||
* Destroys and rebuilds the Python virtual environment
|
||||
|
||||
@@ -60,6 +60,7 @@ query {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In addition, filtering can be done on list of related objects as shown in the following query:
|
||||
|
||||
```
|
||||
@@ -98,8 +99,8 @@ Certain queries can return multiple types of objects, for example cable terminat
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
The field "class_type" is an easy way to distinguish what type of object it is when viewing the returned data, or when filtering. It contains the class name, for example "CircuitTermination" or "ConsoleServerPort".
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -204,6 +204,7 @@ To ease development, it is recommended to go ahead and install the plugin at thi
|
||||
```no-highlight
|
||||
$ pip install -e .
|
||||
```
|
||||
|
||||
More information on editable builds can be found at [Editable installs for pyproject.toml ](https://peps.python.org/pep-0660/).
|
||||
|
||||
## Configure NetBox
|
||||
|
||||
@@ -150,5 +150,5 @@ The [NAPALM automation](https://github.com/napalm-automation/napalm) library pro
|
||||
* Modified the interface serializer to include three discrete fields relating to connections: `is_connected` (boolean), `interface_connection`, and `circuit_termination`
|
||||
* Added two new fields to the inventory item serializer: `asset_tag` and `description`
|
||||
* Added "wireless" to interface type filter (in addition to physical, virtual, and LAG)
|
||||
* Added a new endpoint at /api/ipam/prefixes/<pk>/available-ips/ to retrieve or create available IPs within a prefix
|
||||
* Added a new endpoint at /api/ipam/prefixes/<pk\>/available-ips/ to retrieve or create available IPs within a prefix
|
||||
* Extended `parent_device` on DeviceSerializer to include the `url` and `display_name` of the parent Device, and the `url` of the DeviceBay
|
||||
|
||||
@@ -1,5 +1,99 @@
|
||||
# NetBox v4.2
|
||||
|
||||
## v4.2.8 (2025-04-22)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#17136](https://github.com/netbox-community/netbox/issues/17136) - Introduce the `--readonly` flag on upgrade script
|
||||
* [#17908](https://github.com/netbox-community/netbox/issues/17908) - Add trace buttons to terminations under cable view
|
||||
* [#18879](https://github.com/netbox-community/netbox/issues/18879) - Enable filtering prefixes by group of assigned VLAN
|
||||
* [#18976](https://github.com/netbox-community/netbox/issues/18976) - Include FHRP group name on interface lists
|
||||
* [#18978](https://github.com/netbox-community/netbox/issues/18978) - Add 802.1Q mode to interface filter form
|
||||
* [#19038](https://github.com/netbox-community/netbox/issues/19038) - Show count of related VLAN groups under cluster view
|
||||
* [#19040](https://github.com/netbox-community/netbox/issues/19040) - Add "copy to clipboard" button for rendered config
|
||||
* [#19056](https://github.com/netbox-community/netbox/issues/19056) - Enable filtering devices by location slug
|
||||
* [#19196](https://github.com/netbox-community/netbox/issues/19196) - Add filtering by VLAN translation policy to interface filter forms
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#18500](https://github.com/netbox-community/netbox/issues/18500) - `prepare_cloned_fields()` should validate cloning support on model
|
||||
* [#18669](https://github.com/netbox-community/netbox/issues/18669) - Ensure default custom field values are respected when creating objects via the REST API
|
||||
* [#18881](https://github.com/netbox-community/netbox/issues/18881) - Include missing related object counts under certain views
|
||||
* [#18955](https://github.com/netbox-community/netbox/issues/18955) - Omit "clear" button on required choice fields
|
||||
* [#18959](https://github.com/netbox-community/netbox/issues/18959) - Preserve ordering of terminations in cable traces
|
||||
* [#18961](https://github.com/netbox-community/netbox/issues/18961) - Virtual chassis form should exclude members of other VCs when adding members
|
||||
* [#19166](https://github.com/netbox-community/netbox/issues/19166) - Fix custom field choices bulk import support for `base_choices`
|
||||
* [#19189](https://github.com/netbox-community/netbox/issues/19189) - The `load_yaml()` convenience method on BaseScript should use SafeLoader
|
||||
* [#19195](https://github.com/netbox-community/netbox/issues/19195) - Language cookie should respect `SESSION_COOKIE_SECURE` value
|
||||
* [#19230](https://github.com/netbox-community/netbox/issues/19230) - Allow label reuse when creating multiple components from a pattern
|
||||
* [#19268](https://github.com/netbox-community/netbox/issues/19268) - Restore editing conflict protection for several object forms
|
||||
|
||||
---
|
||||
|
||||
## v4.2.7 (2025-04-10)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#16144](https://github.com/netbox-community/netbox/issues/16144) - Add support for plugin models to GetReturnURLMixin
|
||||
* [#18138](https://github.com/netbox-community/netbox/issues/18138) - Enable filtering of ObjectVar and MultiObjectVar input selections for custom fields
|
||||
* [#18656](https://github.com/netbox-community/netbox/issues/18656) - Enable FHRP group assignment when bulk importing IP addresses
|
||||
* [#18980](https://github.com/netbox-community/netbox/issues/18980) - Optimize bulk updates of custom field values when custom fields are added/removed
|
||||
* [#19018](https://github.com/netbox-community/netbox/issues/19018) - Add MoCA interface type
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#18553](https://github.com/netbox-community/netbox/issues/18553) - Avoid clearing site of assigned virtual machines when editing a cluster
|
||||
* [#18738](https://github.com/netbox-community/netbox/issues/18738) - Respect declared ordering of custom scripts within a module
|
||||
* [#18895](https://github.com/netbox-community/netbox/issues/18895) - Fix GraphQL support for interfaces which terminate virtual circuits
|
||||
* [#18904](https://github.com/netbox-community/netbox/issues/18904) - Add missing tags column to config contexts table
|
||||
* [#18964](https://github.com/netbox-community/netbox/issues/18964) - Fix "select all" behavior on object lists
|
||||
* [#18965](https://github.com/netbox-community/netbox/issues/18965) - "Run script" button should respect default commit toggle for custom scripts
|
||||
* [#18991](https://github.com/netbox-community/netbox/issues/18991) - Fix cable path tracing for pass-through ports in REST API
|
||||
* [#18999](https://github.com/netbox-community/netbox/issues/18999) - Fix filtering of inventory items with no manufacturer in GraphQL API
|
||||
* [#19021](https://github.com/netbox-community/netbox/issues/19021) - Preserve JSONField stylign when `help_text` is passed
|
||||
* [#19023](https://github.com/netbox-community/netbox/issues/19023) - `get_field_value()` should honor null values on bound form fields
|
||||
* [#19030](https://github.com/netbox-community/netbox/issues/19030) - Prevent pagination buttons from overlapping bulk action buttons on object lists
|
||||
* [#19041](https://github.com/netbox-community/netbox/issues/19041) - Fix `IndexError` exception when creating multiple front ports with a label
|
||||
* [#19092](https://github.com/netbox-community/netbox/issues/19092) - Fix clearing of scope field when bulk editing prefixes
|
||||
* [#19122](https://github.com/netbox-community/netbox/issues/19122) - Fix styling of server error page
|
||||
|
||||
---
|
||||
|
||||
## v4.2.6 (2025-03-21)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#17503](https://github.com/netbox-community/netbox/issues/17503) - Add rack title above rack on rack detail view
|
||||
* [#17686](https://github.com/netbox-community/netbox/issues/17686) - Add config option for disk space divisor
|
||||
* [#18579](https://github.com/netbox-community/netbox/issues/18579) - Update filtersets and filter forms to include contact filters where missing
|
||||
* [#18744](https://github.com/netbox-community/netbox/issues/18744) - Ensure contact link in tables is hyperlinked
|
||||
* [#18816](https://github.com/netbox-community/netbox/issues/18816) - Add FC/UPC, FC/APC and FC/PC port types
|
||||
* [#18880](https://github.com/netbox-community/netbox/issues/18880) - Delay enqueuing background tasks until DB transaction is committed to avoid race condition
|
||||
* [#18939](https://github.com/netbox-community/netbox/issues/18939) - Support site group search for ASNs
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#18409](https://github.com/netbox-community/netbox/issues/18409) - Eliminate N+1 issue by adding generic prefetch operation to Interface API endpoint
|
||||
* [#18557](https://github.com/netbox-community/netbox/issues/18557) - Update JSONField to enclose bare string values in quotes
|
||||
* [#18582](https://github.com/netbox-community/netbox/issues/18582) - Fix prefix bulk import with associated VLAN and conflicting VLAN IDs
|
||||
* [#18742](https://github.com/netbox-community/netbox/issues/18742) - Ensure location list and detail views show related VLAN group information
|
||||
* [#18782](https://github.com/netbox-community/netbox/issues/18782) - Ensure misconfigured object list widgets on the dashboard now degrade gracefully
|
||||
* [#18833](https://github.com/netbox-community/netbox/issues/18833) - Fix inventory item bulk edit to ensure that component name and type are both validated Ensure
|
||||
* [#18838](https://github.com/netbox-community/netbox/issues/18838) - Ensure that local context data correctly rejects falsy values
|
||||
* [#18845](https://github.com/netbox-community/netbox/issues/18845) - Restore default sort behavior of name column on devices list view
|
||||
* [#18863](https://github.com/netbox-community/netbox/issues/18863) - Exempt MPTT-based models from ordering fix introduced in #18279
|
||||
* [#18869](https://github.com/netbox-community/netbox/issues/18869) - Ensure numeric conversion helper always return a clean decimal value
|
||||
* [#18872](https://github.com/netbox-community/netbox/issues/18872) - Ensure that `kind` is a required field when making journal entries
|
||||
* [#18884](https://github.com/netbox-community/netbox/issues/18884) - Ensure tag deserialization is handled correctly
|
||||
* [#18887](https://github.com/netbox-community/netbox/issues/18887) - Allow VM interface objects to be set on prefix object-type custom field
|
||||
* [#18926](https://github.com/netbox-community/netbox/issues/18926) - Fix icon displayed for GitHub authentication on login page
|
||||
* [#18928](https://github.com/netbox-community/netbox/issues/18928) - Support cascading deletions when cleaning up expired changelog records
|
||||
* [#18933](https://github.com/netbox-community/netbox/issues/18933) - Allow filtering VLAN groups by associated site groups
|
||||
* [#18944](https://github.com/netbox-community/netbox/issues/18944) - Ensure clearing "Widget type" field when adding widgets to dashboard does not cause a "ValueError: Unregistered widget class" error
|
||||
* [#18949](https://github.com/netbox-community/netbox/issues/18949) - Add missing contacts property to GraphQL types where the associated model has a connection to a contact
|
||||
|
||||
---
|
||||
|
||||
## v4.2.5 (2025-03-06)
|
||||
|
||||
### Enhancements
|
||||
@@ -8,7 +102,6 @@
|
||||
* [#17542](https://github.com/netbox-community/netbox/issues/17542) - Add contact assignments to VPN tunnels
|
||||
* [#17944](https://github.com/netbox-community/netbox/issues/17944) - Allow script inputs to be filtered on ObjectVar and MultiObjectVar selections
|
||||
* [#18024](https://github.com/netbox-community/netbox/issues/18024) - Add permalink URL pattern to match a custom script by module and class name
|
||||
* [#18095](https://github.com/netbox-community/netbox/issues/18095) - Ensure contacts are shown on children of objects with contacts
|
||||
* [#18141](https://github.com/netbox-community/netbox/issues/18141) - Support "Quick Add" for plugins
|
||||
* [#18403](https://github.com/netbox-community/netbox/issues/18403) - Improve performance of job list views
|
||||
* [#18693](https://github.com/netbox-community/netbox/issues/18693) - Support setting VLAN translation on bulk edit of interfaces
|
||||
|
||||
@@ -28,12 +28,7 @@ plugins:
|
||||
- mkdocstrings:
|
||||
handlers:
|
||||
python:
|
||||
setup_commands:
|
||||
- import os
|
||||
- import django
|
||||
- os.chdir('netbox/')
|
||||
- os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings")
|
||||
- django.setup()
|
||||
paths: ["netbox"]
|
||||
options:
|
||||
heading_level: 3
|
||||
members_order: source
|
||||
|
||||
@@ -28,6 +28,7 @@ from netbox.config import get_config
|
||||
from netbox.views import generic
|
||||
from users import forms, tables
|
||||
from users.models import UserConfig
|
||||
from utilities.string import remove_linebreaks
|
||||
from utilities.views import register_model_view
|
||||
|
||||
|
||||
@@ -123,12 +124,18 @@ class LoginView(View):
|
||||
|
||||
# Set the user's preferred language (if any)
|
||||
if language := request.user.config.get('locale.language'):
|
||||
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
|
||||
response.set_cookie(
|
||||
key=settings.LANGUAGE_COOKIE_NAME,
|
||||
value=language,
|
||||
max_age=request.session.get_expiry_age(),
|
||||
secure=settings.SESSION_COOKIE_SECURE,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
else:
|
||||
logger.debug(f"Login form validation failed for username: {form['username'].value()}")
|
||||
username = form['username'].value()
|
||||
logger.debug(f"Login form validation failed for username: {remove_linebreaks(username)}")
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'form': form,
|
||||
@@ -140,10 +147,10 @@ class LoginView(View):
|
||||
redirect_url = data.get('next', settings.LOGIN_REDIRECT_URL)
|
||||
|
||||
if redirect_url and url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None):
|
||||
logger.debug(f"Redirecting user to {redirect_url}")
|
||||
logger.debug(f"Redirecting user to {remove_linebreaks(redirect_url)}")
|
||||
else:
|
||||
if redirect_url:
|
||||
logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_url}")
|
||||
logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {remove_linebreaks(redirect_url)}")
|
||||
redirect_url = reverse('home')
|
||||
|
||||
return HttpResponseRedirect(redirect_url)
|
||||
@@ -218,7 +225,12 @@ class UserConfigView(LoginRequiredMixin, View):
|
||||
|
||||
# Set/clear language cookie
|
||||
if language := form.cleaned_data['locale.language']:
|
||||
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
|
||||
response.set_cookie(
|
||||
key=settings.LANGUAGE_COOKIE_NAME,
|
||||
value=language,
|
||||
max_age=request.session.get_expiry_age(),
|
||||
secure=settings.SESSION_COOKIE_SECURE,
|
||||
)
|
||||
else:
|
||||
response.delete_cookie(settings.LANGUAGE_COOKIE_NAME)
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
|
||||
)
|
||||
|
||||
|
||||
class ProviderAccountFilterSet(NetBoxModelFilterSet):
|
||||
class ProviderAccountFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
|
||||
provider_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Provider.objects.all(),
|
||||
label=_('Provider (ID)'),
|
||||
|
||||
@@ -66,11 +66,12 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ProviderAccountFilterForm(NetBoxModelFilterSetForm):
|
||||
class ProviderAccountFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
model = ProviderAccount
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('provider_id', 'account', name=_('Attributes')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
provider_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Provider.objects.all(),
|
||||
|
||||
@@ -43,7 +43,7 @@ class ProviderType(NetBoxObjectType, ContactsMixin):
|
||||
fields='__all__',
|
||||
filters=ProviderAccountFilter
|
||||
)
|
||||
class ProviderAccountType(NetBoxObjectType):
|
||||
class ProviderAccountType(ContactsMixin, NetBoxObjectType):
|
||||
provider: Annotated["ProviderType", strawberry.lazy('circuits.graphql.types')]
|
||||
|
||||
circuits: List[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]]
|
||||
|
||||
@@ -111,7 +111,7 @@ class CircuitTerminationTable(NetBoxTable):
|
||||
provider = tables.Column(
|
||||
verbose_name=_('Provider'),
|
||||
linkify=True,
|
||||
accessor='circuit.provider'
|
||||
accessor='circuit__provider'
|
||||
)
|
||||
term_side = tables.Column(
|
||||
verbose_name=_('Side')
|
||||
|
||||
@@ -170,11 +170,16 @@ class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
'related_models': self.get_related_models(
|
||||
request,
|
||||
instance,
|
||||
omit=(CircuitTermination,),
|
||||
extra=(
|
||||
(
|
||||
Circuit.objects.restrict(request.user, 'view').filter(terminations___provider_network=instance),
|
||||
'provider_network_id',
|
||||
),
|
||||
(
|
||||
CircuitTermination.objects.restrict(request.user, 'view').filter(_provider_network=instance),
|
||||
'provider_network_id',
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db import models
|
||||
from django.db.migrations.operations import AlterModelOptions
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from core.events import *
|
||||
from netbox.events import EventType, EVENT_TYPE_KIND_DANGER, EVENT_TYPE_KIND_SUCCESS, EVENT_TYPE_KIND_WARNING
|
||||
from utilities.migration import custom_deconstruct
|
||||
|
||||
# Ignore verbose_name & verbose_name_plural Meta options when calculating model migrations
|
||||
@@ -26,6 +29,15 @@ class CoreConfig(AppConfig):
|
||||
# Register models
|
||||
register_models(*self.get_models())
|
||||
|
||||
# Register core events
|
||||
EventType(OBJECT_CREATED, _('Object created')).register()
|
||||
EventType(OBJECT_UPDATED, _('Object updated')).register()
|
||||
EventType(OBJECT_DELETED, _('Object deleted'), destructive=True).register()
|
||||
EventType(JOB_STARTED, _('Job started')).register()
|
||||
EventType(JOB_COMPLETED, _('Job completed'), kind=EVENT_TYPE_KIND_SUCCESS).register()
|
||||
EventType(JOB_FAILED, _('Job failed'), kind=EVENT_TYPE_KIND_WARNING).register()
|
||||
EventType(JOB_ERRORED, _('Job errored'), kind=EVENT_TYPE_KIND_DANGER).register()
|
||||
|
||||
# Clear Redis cache on startup in development mode
|
||||
if settings.DEBUG:
|
||||
try:
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from netbox.events import EventType, EVENT_TYPE_KIND_DANGER, EVENT_TYPE_KIND_SUCCESS, EVENT_TYPE_KIND_WARNING
|
||||
|
||||
__all__ = (
|
||||
'JOB_COMPLETED',
|
||||
'JOB_ERRORED',
|
||||
@@ -22,12 +18,3 @@ JOB_STARTED = 'job_started'
|
||||
JOB_COMPLETED = 'job_completed'
|
||||
JOB_FAILED = 'job_failed'
|
||||
JOB_ERRORED = 'job_errored'
|
||||
|
||||
# Register core events
|
||||
EventType(OBJECT_CREATED, _('Object created')).register()
|
||||
EventType(OBJECT_UPDATED, _('Object updated')).register()
|
||||
EventType(OBJECT_DELETED, _('Object deleted'), destructive=True).register()
|
||||
EventType(JOB_STARTED, _('Job started')).register()
|
||||
EventType(JOB_COMPLETED, _('Job completed'), kind=EVENT_TYPE_KIND_SUCCESS).register()
|
||||
EventType(JOB_FAILED, _('Job failed'), kind=EVENT_TYPE_KIND_WARNING).register()
|
||||
EventType(JOB_ERRORED, _('Job errored'), kind=EVENT_TYPE_KIND_DANGER).register()
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import uuid
|
||||
from functools import partial
|
||||
|
||||
import django_rq
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.db import models, transaction
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -258,10 +259,12 @@ class Job(models.Model):
|
||||
|
||||
# Schedule the job to run at a specific date & time.
|
||||
elif schedule_at:
|
||||
queue.enqueue_at(schedule_at, func, job_id=str(job.job_id), job=job, **kwargs)
|
||||
callback = partial(queue.enqueue_at, schedule_at, func, job_id=str(job.job_id), job=job, **kwargs)
|
||||
transaction.on_commit(callback)
|
||||
|
||||
# Schedule the job to run asynchronously at this first available opportunity.
|
||||
else:
|
||||
queue.enqueue(func, job_id=str(job.job_id), job=job, **kwargs)
|
||||
callback = partial(queue.enqueue, func, job_id=str(job.job_id), job=job, **kwargs)
|
||||
transaction.on_commit(callback)
|
||||
|
||||
return job
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.contrib.contenttypes.prefetch import GenericPrefetch
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
@@ -442,7 +443,18 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
||||
|
||||
class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
||||
queryset = Interface.objects.prefetch_related(
|
||||
'_path', 'cable__terminations',
|
||||
GenericPrefetch(
|
||||
"cable__terminations__termination",
|
||||
[
|
||||
Interface.objects.select_related("device", "cable"),
|
||||
],
|
||||
),
|
||||
GenericPrefetch(
|
||||
"_path__path_objects",
|
||||
[
|
||||
Interface.objects.select_related("device", "cable"),
|
||||
],
|
||||
),
|
||||
'l2vpn_terminations', # Referenced by InterfaceSerializer.l2vpn_termination
|
||||
'ip_addresses', # Referenced by Interface.count_ipaddresses()
|
||||
'fhrp_group_assignments', # Referenced by Interface.count_fhrp_groups()
|
||||
|
||||
@@ -986,6 +986,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
|
||||
# Coaxial
|
||||
TYPE_DOCSIS = 'docsis'
|
||||
TYPE_MOCA = 'moca'
|
||||
|
||||
# PON
|
||||
TYPE_BPON = 'bpon'
|
||||
@@ -1182,6 +1183,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
_('Coaxial'),
|
||||
(
|
||||
(TYPE_DOCSIS, 'DOCSIS'),
|
||||
(TYPE_MOCA, 'MoCA'),
|
||||
)
|
||||
),
|
||||
(
|
||||
@@ -1345,6 +1347,9 @@ class PortTypeChoices(ChoiceSet):
|
||||
TYPE_SC_UPC = 'sc-upc'
|
||||
TYPE_SC_APC = 'sc-apc'
|
||||
TYPE_FC = 'fc'
|
||||
TYPE_FC_PC = 'fc-pc'
|
||||
TYPE_FC_UPC = 'fc-upc'
|
||||
TYPE_FC_APC = 'fc-apc'
|
||||
TYPE_LC = 'lc'
|
||||
TYPE_LC_PC = 'lc-pc'
|
||||
TYPE_LC_UPC = 'lc-upc'
|
||||
@@ -1405,6 +1410,9 @@ class PortTypeChoices(ChoiceSet):
|
||||
_('Fiber Optic'),
|
||||
(
|
||||
(TYPE_FC, 'FC'),
|
||||
(TYPE_FC_PC, 'FC/PC'),
|
||||
(TYPE_FC_UPC, 'FC/UPC'),
|
||||
(TYPE_FC_APC, 'FC/APC'),
|
||||
(TYPE_LC, 'LC'),
|
||||
(TYPE_LC_PC, 'LC/PC'),
|
||||
(TYPE_LC_UPC, 'LC/UPC'),
|
||||
|
||||
@@ -1057,6 +1057,13 @@ class DeviceFilterSet(
|
||||
lookup_expr='in',
|
||||
label=_('Location (ID)'),
|
||||
)
|
||||
location = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Location.objects.all(),
|
||||
field_name='location',
|
||||
lookup_expr='in',
|
||||
to_field_name='slug',
|
||||
label=_('Location (slug)'),
|
||||
)
|
||||
rack_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='rack',
|
||||
queryset=Rack.objects.all(),
|
||||
@@ -1682,6 +1689,10 @@ class MACAddressFilterSet(NetBoxModelFilterSet):
|
||||
|
||||
|
||||
class CommonInterfaceFilterSet(django_filters.FilterSet):
|
||||
mode = django_filters.MultipleChoiceFilter(
|
||||
choices=InterfaceModeChoices,
|
||||
label=_('802.1Q Mode')
|
||||
)
|
||||
vlan_id = django_filters.CharFilter(
|
||||
method='filter_vlan_id',
|
||||
label=_('Assigned VLAN')
|
||||
|
||||
@@ -1161,27 +1161,45 @@ class InventoryItemImportForm(NetBoxModelImportForm):
|
||||
else:
|
||||
self.fields['parent'].queryset = InventoryItem.objects.none()
|
||||
|
||||
def clean_component_name(self):
|
||||
content_type = self.cleaned_data.get('component_type')
|
||||
component_name = self.cleaned_data.get('component_name')
|
||||
def clean(self):
|
||||
super().clean()
|
||||
cleaned_data = self.cleaned_data
|
||||
component_type = cleaned_data.get('component_type')
|
||||
component_name = cleaned_data.get('component_name')
|
||||
device = self.cleaned_data.get("device")
|
||||
|
||||
if not device and hasattr(self, 'instance') and hasattr(self.instance, 'device'):
|
||||
device = self.instance.device
|
||||
|
||||
if not all([device, content_type, component_name]):
|
||||
return None
|
||||
|
||||
model = content_type.model_class()
|
||||
try:
|
||||
component = model.objects.get(device=device, name=component_name)
|
||||
self.instance.component = component
|
||||
except ObjectDoesNotExist:
|
||||
raise forms.ValidationError(
|
||||
_("Component not found: {device} - {component_name}").format(
|
||||
device=device, component_name=component_name
|
||||
if component_type:
|
||||
if device is None:
|
||||
cleaned_data.pop('component_type', None)
|
||||
if component_name is None:
|
||||
cleaned_data.pop('component_type', None)
|
||||
raise forms.ValidationError(
|
||||
_("Component name must be specified when component type is specified")
|
||||
)
|
||||
)
|
||||
if all([device, component_name]):
|
||||
try:
|
||||
model = component_type.model_class()
|
||||
self.instance.component = model.objects.get(device=device, name=component_name)
|
||||
except ObjectDoesNotExist:
|
||||
cleaned_data.pop('component_type', None)
|
||||
cleaned_data.pop('component_name', None)
|
||||
raise forms.ValidationError(
|
||||
_("Component not found: {device} - {component_name}").format(
|
||||
device=device, component_name=component_name
|
||||
)
|
||||
)
|
||||
else:
|
||||
cleaned_data.pop('component_type', None)
|
||||
if not component_name:
|
||||
raise forms.ValidationError(
|
||||
_("Component name must be specified when component type is specified")
|
||||
)
|
||||
else:
|
||||
if component_name:
|
||||
raise forms.ValidationError(
|
||||
_("Component type must be specified when component name is specified")
|
||||
)
|
||||
return cleaned_data
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -6,7 +6,7 @@ from dcim.constants import *
|
||||
from dcim.models import *
|
||||
from extras.forms import LocalConfigContextFilterForm
|
||||
from extras.models import ConfigTemplate
|
||||
from ipam.models import ASN, VRF
|
||||
from ipam.models import ASN, VRF, VLANTranslationPolicy
|
||||
from netbox.choices import *
|
||||
from netbox.forms import NetBoxModelFilterSetForm
|
||||
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
||||
@@ -1332,6 +1332,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
FieldSet('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only', name=_('Attributes')),
|
||||
FieldSet('vrf_id', 'l2vpn_id', 'mac_address', 'wwn', name=_('Addressing')),
|
||||
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
|
||||
FieldSet('mode', 'vlan_translation_policy_id', name=_('802.1Q Switching')),
|
||||
FieldSet('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', name=_('Wireless')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
@@ -1403,6 +1404,16 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
required=False,
|
||||
label=_('PoE type')
|
||||
)
|
||||
mode = forms.MultipleChoiceField(
|
||||
choices=InterfaceModeChoices,
|
||||
required=False,
|
||||
label=_('802.1Q mode')
|
||||
)
|
||||
vlan_translation_policy_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VLANTranslationPolicy.objects.all(),
|
||||
required=False,
|
||||
label=_('VLAN Translation Policy')
|
||||
)
|
||||
rf_role = forms.MultipleChoiceField(
|
||||
choices=WirelessRoleChoices,
|
||||
required=False,
|
||||
|
||||
@@ -55,19 +55,23 @@ class ComponentCreateForm(forms.Form):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate that all replication fields generate an equal number of values
|
||||
# Validate that all replication fields generate an equal number of values (or a single value)
|
||||
if not (patterns := self.cleaned_data.get(self.replication_fields[0])):
|
||||
return
|
||||
|
||||
pattern_count = len(patterns)
|
||||
for field_name in self.replication_fields:
|
||||
value_count = len(self.cleaned_data[field_name])
|
||||
if self.cleaned_data[field_name] and value_count != pattern_count:
|
||||
raise forms.ValidationError({
|
||||
field_name: _(
|
||||
"The provided pattern specifies {value_count} values, but {pattern_count} are expected."
|
||||
).format(value_count=value_count, pattern_count=pattern_count)
|
||||
}, code='label_pattern_mismatch')
|
||||
if self.cleaned_data[field_name]:
|
||||
if value_count == 1:
|
||||
# If the field resolves to a single value (because no pattern was used), multiply it by the number
|
||||
# of expected values. This allows us to reuse the same label when creating multiple components.
|
||||
self.cleaned_data[field_name] = self.cleaned_data[field_name] * pattern_count
|
||||
elif value_count != pattern_count:
|
||||
raise forms.ValidationError({
|
||||
field_name: _(
|
||||
"The provided pattern specifies {value_count} values, but {pattern_count} are expected."
|
||||
).format(value_count=value_count, pattern_count=pattern_count)
|
||||
}, code='label_pattern_mismatch')
|
||||
|
||||
|
||||
#
|
||||
@@ -153,6 +157,7 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
|
||||
self.fields['rear_port'].choices = choices
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Check that the number of FrontPortTemplates to be created matches the selected number of RearPortTemplate
|
||||
# positions
|
||||
@@ -302,6 +307,7 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
|
||||
self.fields['rear_port'].choices = choices
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Check that the number of FrontPorts to be created matches the selected number of RearPort positions
|
||||
frontport_count = len(self.cleaned_data['name'])
|
||||
@@ -402,6 +408,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'virtual_chassis_id': 'null',
|
||||
'site_id': '$site',
|
||||
'rack_id': '$rack',
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ class PathEndpointMixin:
|
||||
|
||||
connected_endpoints: List[Annotated[Union[
|
||||
Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')], # noqa: F821
|
||||
Annotated["VirtualCircuitTerminationType", strawberry.lazy('circuits.graphql.types')], # noqa: F821
|
||||
Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
|
||||
Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
|
||||
Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
|
||||
|
||||
@@ -429,7 +429,7 @@ class InterfaceTemplateType(ModularComponentTemplateType):
|
||||
)
|
||||
class InventoryItemType(ComponentType):
|
||||
role: Annotated["InventoryItemRoleType", strawberry.lazy('dcim.graphql.types')] | None
|
||||
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
|
||||
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] | None
|
||||
|
||||
child_items: List[Annotated["InventoryItemType", strawberry.lazy('dcim.graphql.types')]]
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import itertools
|
||||
from collections import defaultdict
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -16,7 +15,7 @@ from dcim.utils import decompile_path_node, object_to_path_node
|
||||
from netbox.models import ChangeLoggedModel, PrimaryModel
|
||||
from utilities.conversion import to_meters
|
||||
from utilities.exceptions import AbortRequest
|
||||
from utilities.fields import ColorField
|
||||
from utilities.fields import ColorField, GenericArrayForeignKey
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from wireless.models import WirelessLink
|
||||
from .device_components import FrontPort, RearPort, PathEndpoint
|
||||
@@ -494,13 +493,16 @@ class CablePath(models.Model):
|
||||
return ObjectType.objects.get_for_id(ct_id)
|
||||
|
||||
@property
|
||||
def path_objects(self):
|
||||
"""
|
||||
Cache and return the complete path as lists of objects, derived from their annotation within the path.
|
||||
"""
|
||||
if not hasattr(self, '_path_objects'):
|
||||
self._path_objects = self._get_path()
|
||||
return self._path_objects
|
||||
def _path_decompiled(self):
|
||||
res = []
|
||||
for step in self.path:
|
||||
nodes = []
|
||||
for node in step:
|
||||
nodes.append(decompile_path_node(node))
|
||||
res.append(nodes)
|
||||
return res
|
||||
|
||||
path_objects = GenericArrayForeignKey("_path_decompiled")
|
||||
|
||||
@property
|
||||
def origins(self):
|
||||
@@ -757,42 +759,6 @@ class CablePath(models.Model):
|
||||
self.delete()
|
||||
retrace.alters_data = True
|
||||
|
||||
def _get_path(self):
|
||||
"""
|
||||
Return the path as a list of prefetched objects.
|
||||
"""
|
||||
# Compile a list of IDs to prefetch for each type of model in the path
|
||||
to_prefetch = defaultdict(list)
|
||||
for node in self._nodes:
|
||||
ct_id, object_id = decompile_path_node(node)
|
||||
to_prefetch[ct_id].append(object_id)
|
||||
|
||||
# Prefetch path objects using one query per model type. Prefetch related devices where appropriate.
|
||||
prefetched = {}
|
||||
for ct_id, object_ids in to_prefetch.items():
|
||||
model_class = ObjectType.objects.get_for_id(ct_id).model_class()
|
||||
queryset = model_class.objects.filter(pk__in=object_ids)
|
||||
if hasattr(model_class, 'device'):
|
||||
queryset = queryset.prefetch_related('device')
|
||||
prefetched[ct_id] = {
|
||||
obj.id: obj for obj in queryset
|
||||
}
|
||||
|
||||
# Replicate the path using the prefetched objects.
|
||||
path = []
|
||||
for step in self.path:
|
||||
nodes = []
|
||||
for node in step:
|
||||
ct_id, object_id = decompile_path_node(node)
|
||||
try:
|
||||
nodes.append(prefetched[ct_id][object_id])
|
||||
except KeyError:
|
||||
# Ignore stale (deleted) object IDs
|
||||
pass
|
||||
path.append(nodes)
|
||||
|
||||
return path
|
||||
|
||||
def get_cable_ids(self):
|
||||
"""
|
||||
Return all Cable IDs within the path.
|
||||
|
||||
@@ -184,8 +184,11 @@ class CabledObjectModel(models.Model):
|
||||
@cached_property
|
||||
def link_peers(self):
|
||||
if self.cable:
|
||||
peers = self.cable.terminations.exclude(cable_end=self.cable_end).prefetch_related('termination')
|
||||
return [peer.termination for peer in peers]
|
||||
return [
|
||||
peer.termination
|
||||
for peer in self.cable.terminations.all()
|
||||
if peer.cable_end != self.cable_end
|
||||
]
|
||||
return []
|
||||
|
||||
@property
|
||||
|
||||
@@ -225,8 +225,7 @@ class CableTraceSVG:
|
||||
"""
|
||||
nodes_height = 0
|
||||
nodes = []
|
||||
# Sort them by name to make renders more readable
|
||||
for i, term in enumerate(sorted(terminations, key=lambda x: str(x))):
|
||||
for i, term in enumerate(terminations):
|
||||
node = Node(
|
||||
position=(offset_x + i * width, self.cursor),
|
||||
width=width,
|
||||
|
||||
@@ -143,9 +143,8 @@ class PlatformTable(NetBoxTable):
|
||||
class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
name = tables.TemplateColumn(
|
||||
verbose_name=_('Name'),
|
||||
accessor=Accessor('label'),
|
||||
template_code=DEVICE_LINK,
|
||||
linkify=True
|
||||
linkify=True,
|
||||
)
|
||||
status = columns.ChoiceFieldColumn(
|
||||
verbose_name=_('Status'),
|
||||
|
||||
@@ -146,6 +146,11 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
url_params={'location_id': 'pk'},
|
||||
verbose_name=_('Devices')
|
||||
)
|
||||
vlangroup_count = columns.LinkedCountColumn(
|
||||
viewname='ipam:vlangroup_list',
|
||||
url_params={'location': 'pk'},
|
||||
verbose_name=_('VLAN Groups')
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:location_list'
|
||||
)
|
||||
@@ -157,8 +162,9 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
model = Location
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count', 'device_count',
|
||||
'description', 'slug', 'contacts', 'tags', 'actions', 'created', 'last_updated',
|
||||
'description', 'slug', 'contacts', 'tags', 'actions', 'created', 'last_updated', 'vlangroup_count',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'site', 'status', 'facility', 'tenant', 'rack_count', 'device_count', 'description'
|
||||
'pk', 'name', 'site', 'status', 'facility', 'tenant', 'rack_count', 'device_count', 'vlangroup_count',
|
||||
'description'
|
||||
)
|
||||
|
||||
@@ -35,7 +35,7 @@ WEIGHT = """
|
||||
"""
|
||||
|
||||
DEVICE_LINK = """
|
||||
{{ value|default:'<span class="badge text-bg-info">Unnamed device</span>' }}
|
||||
{{ record.label|default:'<span class="badge text-bg-info">Unnamed device</span>' }}
|
||||
"""
|
||||
|
||||
DEVICEBAY_STATUS = """
|
||||
@@ -64,7 +64,7 @@ INTERFACE_IPADDRESSES = """
|
||||
|
||||
INTERFACE_FHRPGROUPS = """
|
||||
{% for assignment in value.all %}
|
||||
<a href="{{ assignment.group.get_absolute_url }}">{{ assignment.group.get_protocol_display }}: {{ assignment.group.group_id }}</a>
|
||||
<a href="{{ assignment.group.get_absolute_url }}">{{ assignment.group }}</a>
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import json
|
||||
|
||||
from django.test import override_settings
|
||||
from django.test import override_settings, tag
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework import status
|
||||
@@ -1904,6 +1904,27 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase):
|
||||
},
|
||||
]
|
||||
|
||||
@tag('regression') # Issue #18991
|
||||
def test_front_port_paths(self):
|
||||
device = Device.objects.first()
|
||||
rear_port = RearPort.objects.create(
|
||||
device=device, name='Rear Port 10', type=PortTypeChoices.TYPE_8P8C
|
||||
)
|
||||
interface1 = Interface.objects.create(device=device, name='Interface 1')
|
||||
front_port = FrontPort.objects.create(
|
||||
device=device,
|
||||
name='Rear Port 10',
|
||||
type=PortTypeChoices.TYPE_8P8C,
|
||||
rear_port=rear_port,
|
||||
)
|
||||
Cable.objects.create(a_terminations=[interface1], b_terminations=[front_port])
|
||||
|
||||
self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
|
||||
url = reverse(f'dcim-api:{self.model._meta.model_name}-paths', kwargs={'pk': front_port.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
|
||||
class RearPortTest(APIViewTestCases.APIViewTestCase):
|
||||
model = RearPort
|
||||
@@ -1947,6 +1968,23 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
|
||||
},
|
||||
]
|
||||
|
||||
@tag('regression') # Issue #18991
|
||||
def test_rear_port_paths(self):
|
||||
device = Device.objects.first()
|
||||
interface1 = Interface.objects.create(device=device, name='Interface 1')
|
||||
rear_port = RearPort.objects.create(
|
||||
device=device,
|
||||
name='Rear Port 10',
|
||||
type=PortTypeChoices.TYPE_8P8C,
|
||||
)
|
||||
Cable.objects.create(a_terminations=[interface1], b_terminations=[rear_port])
|
||||
|
||||
self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
|
||||
url = reverse(f'dcim-api:{self.model._meta.model_name}-paths', kwargs={'pk': rear_port.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
|
||||
class ModuleBayTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ModuleBay
|
||||
|
||||
@@ -2561,6 +2561,8 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
locations = Location.objects.all()[:2]
|
||||
params = {'location_id': [locations[0].pk, locations[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'location': [locations[0].slug, locations[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_rack(self):
|
||||
racks = Rack.objects.all()[:2]
|
||||
@@ -4151,7 +4153,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_mode(self):
|
||||
params = {'mode': InterfaceModeChoices.MODE_ACCESS}
|
||||
params = {'mode': [InterfaceModeChoices.MODE_ACCESS]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_description(self):
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.choices import DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices, InterfaceModeChoices
|
||||
from dcim.choices import (
|
||||
DeviceFaceChoices,
|
||||
DeviceStatusChoices,
|
||||
InterfaceModeChoices,
|
||||
InterfaceTypeChoices,
|
||||
PortTypeChoices,
|
||||
)
|
||||
from dcim.forms import *
|
||||
from dcim.models import *
|
||||
from ipam.models import VLAN
|
||||
@@ -118,6 +124,51 @@ class DeviceTestCase(TestCase):
|
||||
self.assertIn('position', form.errors)
|
||||
|
||||
|
||||
class FrontPortTestCase(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.device = create_test_device('Panel Device 1')
|
||||
cls.rear_ports = (
|
||||
RearPort(name='RearPort1', device=cls.device, type=PortTypeChoices.TYPE_8P8C),
|
||||
RearPort(name='RearPort2', device=cls.device, type=PortTypeChoices.TYPE_8P8C),
|
||||
RearPort(name='RearPort3', device=cls.device, type=PortTypeChoices.TYPE_8P8C),
|
||||
RearPort(name='RearPort4', device=cls.device, type=PortTypeChoices.TYPE_8P8C),
|
||||
)
|
||||
RearPort.objects.bulk_create(cls.rear_ports)
|
||||
|
||||
def test_front_port_label_count_valid(self):
|
||||
"""
|
||||
Test that generating an equal number of names and labels passes form validation.
|
||||
"""
|
||||
front_port_data = {
|
||||
'device': self.device.pk,
|
||||
'name': 'FrontPort[1-4]',
|
||||
'label': 'Port[1-4]',
|
||||
'type': PortTypeChoices.TYPE_8P8C,
|
||||
'rear_port': [f'{rear_port.pk}:1' for rear_port in self.rear_ports],
|
||||
}
|
||||
form = FrontPortCreateForm(front_port_data)
|
||||
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
def test_front_port_label_count_mismatch(self):
|
||||
"""
|
||||
Check that attempting to generate a differing number of names and labels results in a validation error.
|
||||
"""
|
||||
bad_front_port_data = {
|
||||
'device': self.device.pk,
|
||||
'name': 'FrontPort[1-4]',
|
||||
'label': 'Port[1-2]',
|
||||
'type': PortTypeChoices.TYPE_8P8C,
|
||||
'rear_port': [f'{rear_port.pk}:1' for rear_port in self.rear_ports],
|
||||
}
|
||||
form = FrontPortCreateForm(bad_front_port_data)
|
||||
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('label', form.errors)
|
||||
|
||||
|
||||
class InterfaceTestCase(TestCase):
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -13,7 +13,7 @@ from django.views.generic import View
|
||||
|
||||
from circuits.models import Circuit, CircuitTermination
|
||||
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
|
||||
from ipam.models import ASN, IPAddress, Prefix, VLANGroup
|
||||
from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN
|
||||
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
|
||||
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
||||
from netbox.views import generic
|
||||
@@ -237,7 +237,7 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
'related_models': self.get_related_models(
|
||||
request,
|
||||
regions,
|
||||
omit=(Cluster, Prefix, WirelessLAN),
|
||||
omit=(Cluster, CircuitTermination, Prefix, WirelessLAN),
|
||||
extra=(
|
||||
(Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
|
||||
(Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
|
||||
@@ -247,8 +247,19 @@ class RegionView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
).distinct(),
|
||||
'region_id'
|
||||
),
|
||||
(
|
||||
VLANGroup.objects.restrict(request.user, 'view').filter(
|
||||
scope_type=ContentType.objects.get_for_model(Region),
|
||||
scope_id__in=regions
|
||||
).distinct(),
|
||||
'region'
|
||||
),
|
||||
|
||||
# Handle these relations manually to avoid erroneous filter name resolution
|
||||
(
|
||||
CircuitTermination.objects.restrict(request.user, 'view').filter(_region__in=regions),
|
||||
'region_id'
|
||||
),
|
||||
(Cluster.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
|
||||
(Prefix.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
|
||||
(WirelessLAN.objects.restrict(request.user, 'view').filter(_region__in=regions), 'region_id'),
|
||||
@@ -336,10 +347,29 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
'related_models': self.get_related_models(
|
||||
request,
|
||||
groups,
|
||||
omit=(Cluster, Prefix, WirelessLAN),
|
||||
omit=(Cluster, CircuitTermination, Prefix, WirelessLAN),
|
||||
extra=(
|
||||
(Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
||||
(Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
||||
(Device.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
||||
(VLAN.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
||||
(
|
||||
ASN.objects.restrict(request.user, 'view').filter(
|
||||
sites__group__in=groups
|
||||
).distinct(),
|
||||
'site_group_id'),
|
||||
(
|
||||
VirtualMachine.objects.restrict(request.user, 'view').filter(
|
||||
site__group__in=groups),
|
||||
'site_group_id'
|
||||
),
|
||||
(
|
||||
VLANGroup.objects.restrict(request.user, 'view').filter(
|
||||
scope_type=ContentType.objects.get_for_model(SiteGroup),
|
||||
scope_id__in=groups
|
||||
).distinct(),
|
||||
'site_group'
|
||||
),
|
||||
(
|
||||
Circuit.objects.restrict(request.user, 'view').filter(
|
||||
terminations___site_group=instance
|
||||
@@ -348,6 +378,10 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
),
|
||||
|
||||
# Handle these relations manually to avoid erroneous filter name resolution
|
||||
(
|
||||
CircuitTermination.objects.restrict(request.user, 'view').filter(_site_group__in=groups),
|
||||
'site_group_id'
|
||||
),
|
||||
(
|
||||
Cluster.objects.restrict(request.user, 'view').filter(_site_group__in=groups),
|
||||
'site_group_id'
|
||||
@@ -455,6 +489,7 @@ class SiteView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
(Cluster.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
|
||||
(Prefix.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
|
||||
(WirelessLAN.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
|
||||
(CircuitTermination.objects.restrict(request.user, 'view').filter(_site=instance), 'site_id'),
|
||||
),
|
||||
),
|
||||
}
|
||||
@@ -505,18 +540,24 @@ class SiteContactsView(ObjectContactsView):
|
||||
@register_model_view(Location, 'list', path='', detail=False)
|
||||
class LocationListView(generic.ObjectListView):
|
||||
queryset = Location.objects.add_related_count(
|
||||
Location.objects.add_related_count(
|
||||
Location.objects.all(),
|
||||
Device,
|
||||
'location',
|
||||
'device_count',
|
||||
cumulative=True
|
||||
),
|
||||
Rack,
|
||||
'location',
|
||||
'rack_count',
|
||||
cumulative=True
|
||||
)
|
||||
Location.objects.add_related_count(
|
||||
Location.objects.add_related_count(
|
||||
Location.objects.all(),
|
||||
Device,
|
||||
'location',
|
||||
'device_count',
|
||||
cumulative=True
|
||||
),
|
||||
Rack,
|
||||
'location',
|
||||
'rack_count',
|
||||
cumulative=True
|
||||
),
|
||||
VLANGroup,
|
||||
'location',
|
||||
'vlangroup_count',
|
||||
cumulative=True
|
||||
)
|
||||
filterset = filtersets.LocationFilterSet
|
||||
filterset_form = forms.LocationFilterForm
|
||||
table = tables.LocationTable
|
||||
@@ -528,11 +569,12 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
locations = instance.get_descendants(include_self=True)
|
||||
location_content_type = ContentType.objects.get_for_model(instance)
|
||||
return {
|
||||
'related_models': self.get_related_models(
|
||||
request,
|
||||
locations,
|
||||
omit=[CableTermination, Cluster, Prefix, WirelessLAN],
|
||||
omit=[CableTermination, CircuitTermination, Cluster, Prefix, WirelessLAN],
|
||||
extra=(
|
||||
(
|
||||
Circuit.objects.restrict(request.user, 'view').filter(
|
||||
@@ -542,9 +584,15 @@ class LocationView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
),
|
||||
|
||||
# Handle these relations manually to avoid erroneous filter name resolution
|
||||
(
|
||||
CircuitTermination.objects.restrict(request.user, 'view').filter(_location=instance),
|
||||
'location_id'
|
||||
),
|
||||
(Cluster.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
|
||||
(Prefix.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
|
||||
(WirelessLAN.objects.restrict(request.user, 'view').filter(_location=instance), 'location_id'),
|
||||
(VLANGroup.objects.restrict(request.user, 'view').filter(
|
||||
scope_type_id=location_content_type.id, scope_id=instance.id), 'location'),
|
||||
),
|
||||
),
|
||||
}
|
||||
@@ -805,7 +853,18 @@ class RackView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
])
|
||||
|
||||
return {
|
||||
'related_models': self.get_related_models(request, instance, [CableTermination]),
|
||||
'related_models': self.get_related_models(
|
||||
request,
|
||||
instance,
|
||||
omit=(CableTermination,),
|
||||
extra=(
|
||||
(
|
||||
VLANGroup.objects.restrict(request.user, 'view').filter(
|
||||
scope_type=ContentType.objects.get_for_model(Rack),
|
||||
scope_id=instance.pk
|
||||
), 'rack'),
|
||||
),
|
||||
),
|
||||
'next_rack': next_rack,
|
||||
'prev_rack': prev_rack,
|
||||
'svg_extra': svg_extra,
|
||||
@@ -2025,7 +2084,7 @@ class PlatformBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
@register_model_view(Device, 'list', path='', detail=False)
|
||||
class DeviceListView(generic.ObjectListView):
|
||||
queryset = Device.objects.all()
|
||||
queryset = Device.objects.select_related('virtual_chassis')
|
||||
filterset = filtersets.DeviceFilterSet
|
||||
filterset_form = forms.DeviceFilterForm
|
||||
table = tables.DeviceTable
|
||||
|
||||
@@ -9,6 +9,7 @@ import requests
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Model
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import NoReverseMatch, resolve, reverse
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -42,6 +43,27 @@ def get_object_type_choices():
|
||||
]
|
||||
|
||||
|
||||
def object_list_widget_supports_model(model: Model) -> bool:
|
||||
"""Test whether a model is supported by the ObjectListWidget
|
||||
|
||||
In theory there could be more than one reason why a model isn't supported by the
|
||||
ObjectListWidget, although we've only identified one so far--there's no resolve-able 'list' URL
|
||||
for the model. Add more tests if more conditions arise.
|
||||
"""
|
||||
def can_resolve_model_list_view(model: Model) -> bool:
|
||||
try:
|
||||
reverse(get_viewname(model, action='list'))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
tests = [
|
||||
can_resolve_model_list_view,
|
||||
]
|
||||
|
||||
return all(test(model) for test in tests)
|
||||
|
||||
|
||||
def get_bookmarks_object_type_choices():
|
||||
return [
|
||||
(object_type_identifier(ot), object_type_name(ot))
|
||||
@@ -234,6 +256,17 @@ class ObjectListWidget(DashboardWidget):
|
||||
raise forms.ValidationError(_("Invalid format. URL parameters must be passed as a dictionary."))
|
||||
return data
|
||||
|
||||
def clean_model(self):
|
||||
if model_info := self.cleaned_data['model']:
|
||||
app_label, model_name = model_info.split('.')
|
||||
model = ObjectType.objects.get_by_natural_key(app_label, model_name).model_class()
|
||||
if not object_list_widget_supports_model(model):
|
||||
raise forms.ValidationError(
|
||||
_(f"Invalid model selection: {self['model'].data} is not supported.")
|
||||
)
|
||||
|
||||
return model_info
|
||||
|
||||
def render(self, request):
|
||||
app_label, model_name = self.config['model'].split('.')
|
||||
model = ObjectType.objects.get_by_natural_key(app_label, model_name).model_class()
|
||||
@@ -257,7 +290,7 @@ class ObjectListWidget(DashboardWidget):
|
||||
parameters['per_page'] = page_size
|
||||
parameters['embedded'] = True
|
||||
|
||||
if parameters:
|
||||
if parameters and htmx_url is not None:
|
||||
try:
|
||||
htmx_url = f'{htmx_url}?{urlencode(parameters, doseq=True)}'
|
||||
except ValueError:
|
||||
@@ -284,7 +317,8 @@ class RSSFeedWidget(DashboardWidget):
|
||||
|
||||
class ConfigForm(WidgetConfigForm):
|
||||
feed_url = forms.URLField(
|
||||
label=_('Feed URL')
|
||||
label=_('Feed URL'),
|
||||
assume_scheme='https'
|
||||
)
|
||||
requires_internet = forms.BooleanField(
|
||||
label=_('Requires external connection'),
|
||||
|
||||
@@ -96,7 +96,7 @@ class CustomFieldChoiceSetImportForm(CSVModelForm):
|
||||
class Meta:
|
||||
model = CustomFieldChoiceSet
|
||||
fields = (
|
||||
'name', 'description', 'extra_choices', 'order_alphabetically',
|
||||
'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
|
||||
)
|
||||
|
||||
def clean_extra_choices(self):
|
||||
|
||||
@@ -14,7 +14,7 @@ from netbox.events import get_event_type_choices
|
||||
from netbox.forms import NetBoxModelForm
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from users.models import Group, User
|
||||
from utilities.forms import add_blank_choice, get_field_value
|
||||
from utilities.forms import get_field_value
|
||||
from utilities.forms.fields import (
|
||||
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField,
|
||||
DynamicModelMultipleChoiceField, JSONField, SlugField,
|
||||
@@ -687,8 +687,7 @@ class ImageAttachmentForm(forms.ModelForm):
|
||||
class JournalEntryForm(NetBoxModelForm):
|
||||
kind = forms.ChoiceField(
|
||||
label=_('Kind'),
|
||||
choices=add_blank_choice(JournalEntryKindChoices),
|
||||
required=False
|
||||
choices=JournalEntryKindChoices
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import requests
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import DEFAULT_DB_ALIAS
|
||||
from django.utils import timezone
|
||||
from packaging import version
|
||||
|
||||
@@ -53,7 +52,7 @@ class Command(BaseCommand):
|
||||
ending=""
|
||||
)
|
||||
self.stdout.flush()
|
||||
ObjectChange.objects.filter(time__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS)
|
||||
ObjectChange.objects.filter(time__lt=cutoff).delete()
|
||||
if options['verbosity']:
|
||||
self.stdout.write("Done.", self.style.SUCCESS)
|
||||
elif options['verbosity']:
|
||||
|
||||
25
netbox/extras/migrations/0123_journalentry_kind_default.py
Normal file
25
netbox/extras/migrations/0123_journalentry_kind_default.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from django.db import migrations
|
||||
|
||||
from extras.choices import JournalEntryKindChoices
|
||||
|
||||
|
||||
def set_kind_default(apps, schema_editor):
|
||||
"""
|
||||
Set kind to "info" on any entries with no kind assigned.
|
||||
"""
|
||||
JournalEntry = apps.get_model('extras', 'JournalEntry')
|
||||
JournalEntry.objects.filter(kind='').update(kind=JournalEntryKindChoices.KIND_INFO)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0122_charfield_null_choices'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
code=set_kind_default,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
@@ -200,7 +200,7 @@ class ConfigContextModel(models.Model):
|
||||
super().clean()
|
||||
|
||||
# Verify that JSON data is provided as an object
|
||||
if self.local_context_data and type(self.local_context_data) is not dict:
|
||||
if self.local_context_data is not None and type(self.local_context_data) is not dict:
|
||||
raise ValidationError(
|
||||
{'local_context_data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'}
|
||||
)
|
||||
|
||||
@@ -9,6 +9,8 @@ from django.conf import settings
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.validators import RegexValidator, ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import F, Func, Value
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.urls import reverse
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
@@ -281,12 +283,20 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
Populate initial custom field data upon either a) the creation of a new CustomField, or
|
||||
b) the assignment of an existing CustomField to new object types.
|
||||
"""
|
||||
if self.default is None:
|
||||
# We have to convert None to a JSON null for jsonb_set()
|
||||
value = RawSQL("'null'::jsonb", [])
|
||||
else:
|
||||
value = Value(self.default, models.JSONField())
|
||||
for ct in content_types:
|
||||
model = ct.model_class()
|
||||
instances = model.objects.exclude(**{'custom_field_data__contains': self.name})
|
||||
for instance in instances:
|
||||
instance.custom_field_data[self.name] = self.default
|
||||
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
|
||||
ct.model_class().objects.update(
|
||||
custom_field_data=Func(
|
||||
F('custom_field_data'),
|
||||
Value([self.name]),
|
||||
value,
|
||||
function='jsonb_set'
|
||||
)
|
||||
)
|
||||
|
||||
def remove_stale_data(self, content_types):
|
||||
"""
|
||||
@@ -295,22 +305,27 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
"""
|
||||
for ct in content_types:
|
||||
if model := ct.model_class():
|
||||
instances = model.objects.filter(custom_field_data__has_key=self.name)
|
||||
for instance in instances:
|
||||
del instance.custom_field_data[self.name]
|
||||
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
|
||||
model.objects.update(
|
||||
custom_field_data=F('custom_field_data') - self.name
|
||||
)
|
||||
|
||||
def rename_object_data(self, old_name, new_name):
|
||||
"""
|
||||
Called when a CustomField has been renamed. Updates all assigned object data.
|
||||
Called when a CustomField has been renamed. Removes the original key and inserts the new
|
||||
one, copying the value of the old key.
|
||||
"""
|
||||
for ct in self.object_types.all():
|
||||
model = ct.model_class()
|
||||
params = {f'custom_field_data__{old_name}__isnull': False}
|
||||
instances = model.objects.filter(**params)
|
||||
for instance in instances:
|
||||
instance.custom_field_data[new_name] = instance.custom_field_data.pop(old_name)
|
||||
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
|
||||
ct.model_class().objects.update(
|
||||
custom_field_data=Func(
|
||||
F('custom_field_data') - old_name,
|
||||
Value([new_name]),
|
||||
Func(
|
||||
F('custom_field_data'),
|
||||
function='jsonb_extract_path_text',
|
||||
template=f"to_jsonb(%(expressions)s -> '{old_name}')"
|
||||
),
|
||||
function='jsonb_set')
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
@@ -515,7 +530,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
|
||||
# URL
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_URL:
|
||||
field = LaxURLField(required=required, initial=initial)
|
||||
field = LaxURLField(assume_scheme='https', required=required, initial=initial)
|
||||
|
||||
# JSON
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_JSON:
|
||||
@@ -532,6 +547,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
}
|
||||
if not for_csv_import:
|
||||
kwargs['query_params'] = self.related_object_filter
|
||||
kwargs['selector'] = True
|
||||
|
||||
field = field_class(**kwargs)
|
||||
|
||||
@@ -546,6 +562,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
}
|
||||
if not for_csv_import:
|
||||
kwargs['query_params'] = self.related_object_filter
|
||||
kwargs['selector'] = True
|
||||
|
||||
field = field_class(**kwargs)
|
||||
|
||||
|
||||
@@ -117,6 +117,15 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
|
||||
def __str__(self):
|
||||
return self.python_name
|
||||
|
||||
@property
|
||||
def ordered_scripts(self):
|
||||
script_objects = {s.name: s for s in self.scripts.all()}
|
||||
ordered = [
|
||||
script_objects.pop(sc) for sc in self.module_scripts.keys() if sc in script_objects
|
||||
]
|
||||
ordered.extend(script_objects.items())
|
||||
return ordered
|
||||
|
||||
@property
|
||||
def module_scripts(self):
|
||||
|
||||
|
||||
@@ -528,14 +528,9 @@ class BaseScript:
|
||||
"""
|
||||
Return data from a YAML file
|
||||
"""
|
||||
try:
|
||||
from yaml import CLoader as Loader
|
||||
except ImportError:
|
||||
from yaml import Loader
|
||||
|
||||
file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
|
||||
with open(file_path, 'r') as datafile:
|
||||
data = yaml.load(datafile, Loader=Loader)
|
||||
data = yaml.load(datafile, Loader=yaml.SafeLoader)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@@ -498,13 +498,16 @@ class ConfigContextTable(NetBoxTable):
|
||||
orderable=False,
|
||||
verbose_name=_('Synced')
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='extras:configcontext_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ConfigContext
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'weight', 'is_active', 'is_synced', 'description', 'regions', 'sites', 'locations',
|
||||
'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants',
|
||||
'data_source', 'data_file', 'data_synced', 'created', 'last_updated',
|
||||
'data_source', 'data_file', 'data_synced', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'weight', 'is_active', 'is_synced', 'description')
|
||||
|
||||
|
||||
48
netbox/extras/tests/test_dashboard.py
Normal file
48
netbox/extras/tests/test_dashboard.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from django.test import tag, TestCase
|
||||
|
||||
from extras.dashboard.widgets import ObjectListWidget
|
||||
|
||||
|
||||
class ObjectListWidgetTests(TestCase):
|
||||
def test_widget_config_form_validates_model(self):
|
||||
model_info = 'extras.notification'
|
||||
form = ObjectListWidget.ConfigForm({'model': model_info})
|
||||
self.assertFalse(form.is_valid())
|
||||
|
||||
@tag('regression')
|
||||
def test_widget_fails_gracefully(self):
|
||||
"""
|
||||
Example:
|
||||
'2829fd9b-5dee-4c9a-81f2-5bd84c350a27': {
|
||||
'class': 'extras.ObjectListWidget',
|
||||
'color': 'indigo',
|
||||
'title': 'Object List',
|
||||
'config': {
|
||||
'model': 'extras.notification',
|
||||
'page_size': None,
|
||||
'url_params': None
|
||||
}
|
||||
}
|
||||
"""
|
||||
config = {
|
||||
# 'class': 'extras.ObjectListWidget', # normally popped off, left for clarity
|
||||
'color': 'yellow',
|
||||
'title': 'this should fail',
|
||||
'config': {
|
||||
'model': 'extras.notification',
|
||||
'page_size': None,
|
||||
'url_params': None,
|
||||
},
|
||||
}
|
||||
|
||||
class Request:
|
||||
class User:
|
||||
def has_perm(self, *args, **kwargs):
|
||||
return True
|
||||
|
||||
user = User()
|
||||
|
||||
mock_request = Request()
|
||||
widget = ObjectListWidget(id='2829fd9b-5dee-4c9a-81f2-5bd84c350a27', **config)
|
||||
rendered = widget.render(mock_request)
|
||||
self.assertTrue('Unable to load content. Invalid view name:' in rendered)
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.forms import ValidationError
|
||||
from django.test import TestCase
|
||||
|
||||
from core.models import ObjectType
|
||||
@@ -478,3 +479,30 @@ class ConfigContextTest(TestCase):
|
||||
annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
|
||||
self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 2)
|
||||
self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
|
||||
|
||||
def test_valid_local_context_data(self):
|
||||
device = Device.objects.first()
|
||||
device.local_context_data = None
|
||||
device.clean()
|
||||
|
||||
device.local_context_data = {"foo": "bar"}
|
||||
device.clean()
|
||||
|
||||
def test_invalid_local_context_data(self):
|
||||
device = Device.objects.first()
|
||||
|
||||
device.local_context_data = ""
|
||||
with self.assertRaises(ValidationError):
|
||||
device.clean()
|
||||
|
||||
device.local_context_data = 0
|
||||
with self.assertRaises(ValidationError):
|
||||
device.clean()
|
||||
|
||||
device.local_context_data = False
|
||||
with self.assertRaises(ValidationError):
|
||||
device.clean()
|
||||
|
||||
device.local_context_data = 'foo'
|
||||
with self.assertRaises(ValidationError):
|
||||
device.clean()
|
||||
|
||||
@@ -1098,8 +1098,8 @@ class DashboardWidgetAddView(LoginRequiredMixin, View):
|
||||
if not request.htmx:
|
||||
return redirect('home')
|
||||
|
||||
initial = request.GET or {
|
||||
'widget_class': 'extras.NoteWidget',
|
||||
initial = {
|
||||
'widget_class': request.GET.get('widget_class') or 'extras.NoteWidget',
|
||||
}
|
||||
widget_form = DashboardWidgetAddForm(initial=initial)
|
||||
widget_name = get_field_value(widget_form, 'widget_class')
|
||||
|
||||
@@ -12,7 +12,8 @@ from netaddr.core import AddrFormatError
|
||||
from circuits.models import Provider
|
||||
from dcim.models import Device, Interface, Region, Site, SiteGroup
|
||||
from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet
|
||||
from tenancy.filtersets import TenancyFilterSet
|
||||
from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
|
||||
|
||||
from utilities.filters import (
|
||||
ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter,
|
||||
)
|
||||
@@ -148,7 +149,7 @@ class RIRFilterSet(OrganizationalModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'is_private', 'description')
|
||||
|
||||
|
||||
class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
family = django_filters.NumberFilter(
|
||||
field_name='prefix',
|
||||
lookup_expr='family'
|
||||
@@ -231,6 +232,19 @@ class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
|
||||
to_field_name='slug',
|
||||
label=_('RIR (slug)'),
|
||||
)
|
||||
site_group_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
field_name='sites__group',
|
||||
lookup_expr='in',
|
||||
label=_('Site group (ID)'),
|
||||
)
|
||||
site_group = TreeNodeMultipleChoiceFilter(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
field_name='sites__group',
|
||||
lookup_expr='in',
|
||||
to_field_name='slug',
|
||||
label=_('Site group (slug)'),
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='sites',
|
||||
queryset=Site.objects.all(),
|
||||
@@ -276,7 +290,7 @@ class RoleFilterSet(OrganizationalModelFilterSet):
|
||||
fields = ('id', 'name', 'slug', 'description', 'weight')
|
||||
|
||||
|
||||
class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet):
|
||||
class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
family = django_filters.NumberFilter(
|
||||
field_name='prefix',
|
||||
lookup_expr='family'
|
||||
@@ -337,6 +351,18 @@ class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet):
|
||||
to_field_name='rd',
|
||||
label=_('VRF (RD)'),
|
||||
)
|
||||
vlan_group_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vlan__group',
|
||||
queryset=VLANGroup.objects.all(),
|
||||
to_field_name="id",
|
||||
label=_('VLAN Group (ID)'),
|
||||
)
|
||||
vlan_group = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vlan__group__slug',
|
||||
queryset=VLANGroup.objects.all(),
|
||||
to_field_name="slug",
|
||||
label=_('VLAN Group (slug)'),
|
||||
)
|
||||
vlan_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=VLAN.objects.all(),
|
||||
label=_('VLAN (ID)'),
|
||||
@@ -430,7 +456,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet):
|
||||
).distinct()
|
||||
|
||||
|
||||
class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet, ContactModelFilterSet):
|
||||
family = django_filters.NumberFilter(
|
||||
field_name='start_address',
|
||||
lookup_expr='family'
|
||||
@@ -522,7 +548,7 @@ class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
return queryset.filter(q)
|
||||
|
||||
|
||||
class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
|
||||
family = django_filters.NumberFilter(
|
||||
field_name='address',
|
||||
lookup_expr='family'
|
||||
@@ -1136,7 +1162,7 @@ class ServiceTemplateFilterSet(NetBoxModelFilterSet):
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
class ServiceFilterSet(NetBoxModelFilterSet):
|
||||
class ServiceFilterSet(ContactModelFilterSet, NetBoxModelFilterSet):
|
||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Device.objects.all(),
|
||||
label=_('Device (ID)'),
|
||||
|
||||
@@ -177,6 +177,13 @@ class PrefixImportForm(ScopedImportForm, NetBoxModelImportForm):
|
||||
to_field_name='name',
|
||||
help_text=_("VLAN's group (if any)")
|
||||
)
|
||||
vlan_site = CSVModelChoiceField(
|
||||
label=_('VLAN Site'),
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_("VLAN's site (if any)")
|
||||
)
|
||||
vlan = CSVModelChoiceField(
|
||||
label=_('VLAN'),
|
||||
queryset=VLAN.objects.all(),
|
||||
@@ -200,8 +207,8 @@ class PrefixImportForm(ScopedImportForm, NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = Prefix
|
||||
fields = (
|
||||
'prefix', 'vrf', 'tenant', 'vlan_group', 'vlan', 'status', 'role', 'scope_type', 'scope_id', 'is_pool',
|
||||
'mark_utilized', 'description', 'comments', 'tags',
|
||||
'prefix', 'vrf', 'tenant', 'vlan_group', 'vlan_site', 'vlan', 'status', 'role', 'scope_type', 'scope_id',
|
||||
'is_pool', 'mark_utilized', 'description', 'comments', 'tags',
|
||||
)
|
||||
labels = {
|
||||
'scope_id': _('Scope ID'),
|
||||
@@ -213,19 +220,19 @@ class PrefixImportForm(ScopedImportForm, NetBoxModelImportForm):
|
||||
if not data:
|
||||
return
|
||||
|
||||
site = data.get('site')
|
||||
vlan_site = data.get('vlan_site')
|
||||
vlan_group = data.get('vlan_group')
|
||||
|
||||
# Limit VLAN queryset by assigned site and/or group (if specified)
|
||||
query = Q()
|
||||
|
||||
if site:
|
||||
if vlan_site:
|
||||
query |= Q(**{
|
||||
f"site__{self.fields['site'].to_field_name}": site
|
||||
f"site__{self.fields['vlan_site'].to_field_name}": vlan_site
|
||||
})
|
||||
# Don't Forget to include VLANs without a site in the filter
|
||||
query |= Q(**{
|
||||
f"site__{self.fields['site'].to_field_name}__isnull": True
|
||||
f"site__{self.fields['vlan_site'].to_field_name}__isnull": True
|
||||
})
|
||||
|
||||
if vlan_group:
|
||||
@@ -320,6 +327,13 @@ class IPAddressImportForm(NetBoxModelImportForm):
|
||||
to_field_name='name',
|
||||
help_text=_('Assigned interface')
|
||||
)
|
||||
fhrp_group = CSVModelChoiceField(
|
||||
label=_('FHRP Group'),
|
||||
queryset=FHRPGroup.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_('Assigned FHRP Group name')
|
||||
)
|
||||
is_primary = forms.BooleanField(
|
||||
label=_('Is primary'),
|
||||
help_text=_('Make this the primary IP for the assigned device'),
|
||||
@@ -334,8 +348,8 @@ class IPAddressImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = [
|
||||
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary',
|
||||
'is_oob', 'dns_name', 'description', 'comments', 'tags',
|
||||
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'fhrp_group',
|
||||
'is_primary', 'is_oob', 'dns_name', 'description', 'comments', 'tags',
|
||||
]
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
@@ -391,6 +405,8 @@ class IPAddressImportForm(NetBoxModelImportForm):
|
||||
# Set interface assignment
|
||||
if self.cleaned_data.get('interface'):
|
||||
self.instance.assigned_object = self.cleaned_data['interface']
|
||||
if self.cleaned_data.get('fhrp_group'):
|
||||
self.instance.assigned_object = self.cleaned_data['fhrp_group']
|
||||
|
||||
ipaddress = super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from ipam.choices import *
|
||||
from ipam.constants import *
|
||||
from ipam.models import *
|
||||
from netbox.forms import NetBoxModelFilterSetForm
|
||||
from tenancy.forms import TenancyFilterForm
|
||||
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice
|
||||
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
|
||||
from utilities.forms.rendering import FieldSet
|
||||
@@ -94,12 +94,13 @@ class RIRFilterForm(NetBoxModelFilterSetForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class AggregateFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
class AggregateFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
model = Aggregate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('family', 'rir_id', name=_('Attributes')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
family = forms.ChoiceField(
|
||||
required=False,
|
||||
@@ -141,7 +142,7 @@ class ASNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
model = ASN
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('rir_id', 'site_id', name=_('Assignment')),
|
||||
FieldSet('rir_id', 'site_group_id', 'site_id', name=_('Assignment')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
)
|
||||
rir_id = DynamicModelMultipleChoiceField(
|
||||
@@ -149,6 +150,11 @@ class ASNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
required=False,
|
||||
label=_('RIR')
|
||||
)
|
||||
site_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group')
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
@@ -162,7 +168,7 @@ class RoleFilterForm(NetBoxModelFilterSetForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
class PrefixFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm, ):
|
||||
model = Prefix
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
@@ -170,10 +176,11 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
'within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized',
|
||||
name=_('Addressing')
|
||||
),
|
||||
FieldSet('vlan_id', name=_('VLAN Assignment')),
|
||||
FieldSet('vlan_group_id', 'vlan_id', name=_('VLAN Assignment')),
|
||||
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
mask_length__lte = forms.IntegerField(
|
||||
widget=forms.HiddenInput()
|
||||
@@ -253,6 +260,11 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
vlan_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VLANGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('VLAN Group'),
|
||||
)
|
||||
vlan_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False,
|
||||
@@ -262,12 +274,13 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
class IPRangeFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
model = IPRange
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('family', 'vrf_id', 'status', 'role_id', 'mark_utilized', name=_('Attributes')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
family = forms.ChoiceField(
|
||||
required=False,
|
||||
@@ -301,7 +314,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
class IPAddressFilterForm(ContactModelFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
model = IPAddress
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
@@ -312,6 +325,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('device_id', 'virtual_machine_id', name=_('Device/VM')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'parent', 'status', 'role')
|
||||
parent = forms.CharField(
|
||||
@@ -414,7 +428,7 @@ class FHRPGroupFilterForm(NetBoxModelFilterSetForm):
|
||||
class VLANGroupFilterForm(NetBoxModelFilterSetForm):
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('region', 'sitegroup', 'site', 'location', 'rack', name=_('Location')),
|
||||
FieldSet('region', 'site_group', 'site', 'location', 'rack', name=_('Location')),
|
||||
FieldSet('cluster_group', 'cluster', name=_('Cluster')),
|
||||
FieldSet('contains_vid', name=_('VLANs')),
|
||||
)
|
||||
@@ -424,7 +438,7 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
|
||||
required=False,
|
||||
label=_('Region')
|
||||
)
|
||||
sitegroup = DynamicModelMultipleChoiceField(
|
||||
site_group = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group')
|
||||
@@ -590,12 +604,13 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ServiceFilterForm(ServiceTemplateFilterForm):
|
||||
class ServiceFilterForm(ContactModelFilterForm, ServiceTemplateFilterForm):
|
||||
model = Service
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('protocol', 'port', name=_('Attributes')),
|
||||
FieldSet('device_id', 'virtual_machine_id', name=_('Assignment')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
|
||||
@@ -538,7 +538,6 @@ class FHRPGroupForm(NetBoxModelForm):
|
||||
role=FHRP_PROTOCOL_ROLE_MAPPINGS.get(self.cleaned_data['protocol'], IPAddressRoleChoices.ROLE_VIP),
|
||||
assigned_object=instance
|
||||
)
|
||||
ipaddress.populate_custom_field_defaults()
|
||||
ipaddress.save()
|
||||
|
||||
# Check that the new IPAddress conforms with any assigned object-level permissions
|
||||
|
||||
@@ -5,6 +5,7 @@ import strawberry_django
|
||||
|
||||
from circuits.graphql.types import ProviderType
|
||||
from dcim.graphql.types import SiteType
|
||||
from extras.graphql.mixins import ContactsMixin
|
||||
from ipam import models
|
||||
from netbox.graphql.scalars import BigInt
|
||||
from netbox.graphql.types import BaseObjectType, NetBoxObjectType, OrganizationalObjectType
|
||||
@@ -83,7 +84,7 @@ class ASNRangeType(NetBoxObjectType):
|
||||
fields='__all__',
|
||||
filters=AggregateFilter
|
||||
)
|
||||
class AggregateType(NetBoxObjectType, BaseIPAddressFamilyType):
|
||||
class AggregateType(NetBoxObjectType, ContactsMixin, BaseIPAddressFamilyType):
|
||||
prefix: str
|
||||
rir: Annotated["RIRType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
|
||||
@@ -120,7 +121,7 @@ class FHRPGroupAssignmentType(BaseObjectType):
|
||||
exclude=('assigned_object_type', 'assigned_object_id', 'address'),
|
||||
filters=IPAddressFilter
|
||||
)
|
||||
class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType):
|
||||
class IPAddressType(NetBoxObjectType, ContactsMixin, BaseIPAddressFamilyType):
|
||||
address: str
|
||||
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
|
||||
@@ -144,7 +145,7 @@ class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType):
|
||||
fields='__all__',
|
||||
filters=IPRangeFilter
|
||||
)
|
||||
class IPRangeType(NetBoxObjectType):
|
||||
class IPRangeType(NetBoxObjectType, ContactsMixin):
|
||||
start_address: str
|
||||
end_address: str
|
||||
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
@@ -157,7 +158,7 @@ class IPRangeType(NetBoxObjectType):
|
||||
exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'),
|
||||
filters=PrefixFilter
|
||||
)
|
||||
class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType):
|
||||
class PrefixType(NetBoxObjectType, ContactsMixin, BaseIPAddressFamilyType):
|
||||
prefix: str
|
||||
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
|
||||
@@ -217,7 +218,7 @@ class RouteTargetType(NetBoxObjectType):
|
||||
fields='__all__',
|
||||
filters=ServiceFilter
|
||||
)
|
||||
class ServiceType(NetBoxObjectType):
|
||||
class ServiceType(NetBoxObjectType, ContactsMixin):
|
||||
ports: List[int]
|
||||
device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] | None
|
||||
virtual_machine: Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')] | None
|
||||
|
||||
@@ -133,10 +133,18 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
ASN.objects.bulk_create(asns)
|
||||
|
||||
site_groups = (
|
||||
SiteGroup(name='Site Group 1', slug='site-group-1'),
|
||||
SiteGroup(name='Site Group 2', slug='site-group-2'),
|
||||
SiteGroup(name='Site Group 3', slug='site-group-3'),
|
||||
)
|
||||
for site_group in site_groups:
|
||||
site_group.save()
|
||||
|
||||
sites = [
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2'),
|
||||
Site(name='Site 3', slug='site-3')
|
||||
Site(name='Site 1', slug='site-1', group=site_groups[0]),
|
||||
Site(name='Site 2', slug='site-2', group=site_groups[1]),
|
||||
Site(name='Site 3', slug='site-3', group=site_groups[2]),
|
||||
]
|
||||
Site.objects.bulk_create(sites)
|
||||
asns[0].sites.set([sites[0]])
|
||||
@@ -178,6 +186,13 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'rir': [rirs[0].slug, rirs[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_site_group(self):
|
||||
site_groups = SiteGroup.objects.all()[:2]
|
||||
params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_site(self):
|
||||
sites = Site.objects.all()[:2]
|
||||
params = {'site_id': [sites[0].pk, sites[1].pk]}
|
||||
@@ -630,9 +645,16 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
vrfs[1].export_targets.add(route_targets[1])
|
||||
vrfs[2].export_targets.add(route_targets[2])
|
||||
|
||||
vlan_groups = (
|
||||
VLANGroup(name='VLAN Group 1', slug='vlan-group-1'),
|
||||
VLANGroup(name='VLAN Group 2', slug='vlan-group-2'),
|
||||
)
|
||||
for vlan_group in vlan_groups:
|
||||
vlan_group.save()
|
||||
|
||||
vlans = (
|
||||
VLAN(vid=1, name='VLAN 1'),
|
||||
VLAN(vid=2, name='VLAN 2'),
|
||||
VLAN(vid=1, name='VLAN 1', group=vlan_groups[0]),
|
||||
VLAN(vid=2, name='VLAN 2', group=vlan_groups[1]),
|
||||
VLAN(vid=3, name='VLAN 3'),
|
||||
)
|
||||
VLAN.objects.bulk_create(vlans)
|
||||
@@ -835,6 +857,13 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_vlan_group(self):
|
||||
vlan_groups = VLANGroup.objects.all()[:2]
|
||||
params = {'vlan_group_id': [vlan_groups[0].pk, vlan_groups[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'vlan_group': [vlan_groups[0].slug, vlan_groups[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_vlan(self):
|
||||
vlans = VLAN.objects.all()[:2]
|
||||
params = {'vlan_id': [vlans[0].pk, vlans[1].pk]}
|
||||
|
||||
@@ -666,6 +666,24 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
fhrp_groups = (
|
||||
FHRPGroup(
|
||||
name='FHRP Group 1',
|
||||
protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP,
|
||||
group_id=10
|
||||
),
|
||||
FHRPGroup(
|
||||
name='FHRP Group 2',
|
||||
protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP,
|
||||
group_id=20
|
||||
),
|
||||
FHRPGroup(
|
||||
name='FHRP Group 3',
|
||||
protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP,
|
||||
group_id=30
|
||||
),
|
||||
)
|
||||
FHRPGroup.objects.bulk_create(fhrp_groups)
|
||||
cls.form_data = {
|
||||
'vrf': vrfs[1].pk,
|
||||
'address': IPNetwork('192.0.2.99/24'),
|
||||
@@ -679,10 +697,10 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"vrf,address,status",
|
||||
"VRF 1,192.0.2.4/24,active",
|
||||
"VRF 1,192.0.2.5/24,active",
|
||||
"VRF 1,192.0.2.6/24,active",
|
||||
"vrf,address,status,fhrp_group",
|
||||
"VRF 1,192.0.2.4/24,active,FHRP Group 1",
|
||||
"VRF 1,192.0.2.5/24,active,FHRP Group 2",
|
||||
"VRF 1,192.0.2.6/24,active,FHRP Group 3",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
|
||||
@@ -12,6 +12,7 @@ from rest_framework.viewsets import GenericViewSet
|
||||
|
||||
from utilities.api import get_annotations_for_serializer, get_prefetches_for_serializer
|
||||
from utilities.exceptions import AbortRequest
|
||||
from utilities.query import reapply_model_ordering
|
||||
from . import mixins
|
||||
|
||||
__all__ = (
|
||||
@@ -123,8 +124,7 @@ class NetBoxModelViewSet(
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
ordering = qs.model._meta.ordering
|
||||
return qs.order_by(*ordering)
|
||||
return reapply_model_ordering(qs)
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
# If a list of objects has been provided, initialize the serializer with many=True
|
||||
|
||||
@@ -28,7 +28,7 @@ AUTH_BACKEND_ATTRS = {
|
||||
'bitbucket-oauth2': ('BitBucket', 'bitbucket'),
|
||||
'digitalocean': ('DigitalOcean', 'digital-ocean'),
|
||||
'docker': ('Docker', 'docker'),
|
||||
'github': ('GitHub', 'docker'),
|
||||
'github': ('GitHub', 'github'),
|
||||
'github-app': ('GitHub', 'github'),
|
||||
'github-org': ('GitHub', 'github'),
|
||||
'github-team': ('GitHub', 'github'),
|
||||
|
||||
@@ -221,6 +221,11 @@ SESSION_COOKIE_NAME = 'sessionid'
|
||||
# database access.) Note that the user as which NetBox runs must have read and write permissions to this path.
|
||||
SESSION_FILE_PATH = None
|
||||
|
||||
# By default the memory and disk sizes are displayed using base 10 (e.g. 1000 MB = 1 GB).
|
||||
# If you would like to use base 2 (e.g. 1024 MB = 1 GB) set this to 1024.
|
||||
# DISK_BASE_UNIT = 1024
|
||||
# RAM_BASE_UNIT = 1024
|
||||
|
||||
# By default, uploaded media is stored on the local filesystem. Using Django-storages is also supported. Provide the
|
||||
# class path of the storage driver in STORAGE_BACKEND and any configuration options in STORAGE_CONFIG. For example:
|
||||
# STORAGE_BACKEND = 'storages.backends.s3boto3.S3Boto3Storage'
|
||||
|
||||
@@ -43,7 +43,12 @@ class CoreMiddleware:
|
||||
# Check if language cookie should be renewed
|
||||
if request.user.is_authenticated and settings.SESSION_SAVE_EVERY_REQUEST:
|
||||
if language := request.user.config.get('locale.language'):
|
||||
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
|
||||
response.set_cookie(
|
||||
key=settings.LANGUAGE_COOKIE_NAME,
|
||||
value=language,
|
||||
max_age=request.session.get_expiry_age(),
|
||||
secure=settings.SESSION_COOKIE_SECURE,
|
||||
)
|
||||
|
||||
# Attach the unique request ID as an HTTP header.
|
||||
response['X-Request-ID'] = request.id
|
||||
|
||||
@@ -301,6 +301,14 @@ class CustomFieldsMixin(models.Model):
|
||||
if cf.required and cf.name not in self.custom_field_data:
|
||||
raise ValidationError(_("Missing required custom field '{name}'.").format(name=cf.name))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Populate default values if omitted
|
||||
for cf in self.custom_fields.filter(default__isnull=False):
|
||||
if cf.name not in self.custom_field_data:
|
||||
self.custom_field_data[cf.name] = cf.default
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class CustomLinksMixin(models.Model):
|
||||
"""
|
||||
|
||||
@@ -176,6 +176,12 @@ STORAGE_BACKEND = getattr(configuration, 'STORAGE_BACKEND', None)
|
||||
STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {})
|
||||
TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
|
||||
TRANSLATION_ENABLED = getattr(configuration, 'TRANSLATION_ENABLED', True)
|
||||
DISK_BASE_UNIT = getattr(configuration, 'DISK_BASE_UNIT', 1000)
|
||||
if DISK_BASE_UNIT not in [1000, 1024]:
|
||||
raise ImproperlyConfigured(f"DISK_BASE_UNIT must be 1000 or 1024 (found {DISK_BASE_UNIT})")
|
||||
RAM_BASE_UNIT = getattr(configuration, 'RAM_BASE_UNIT', 1000)
|
||||
if RAM_BASE_UNIT not in [1000, 1024]:
|
||||
raise ImproperlyConfigured(f"RAM_BASE_UNIT must be 1000 or 1024 (found {RAM_BASE_UNIT})")
|
||||
|
||||
# Load any dynamic configuration parameters which have been hard-coded in the configuration file
|
||||
for param in CONFIG_PARAMS:
|
||||
|
||||
@@ -28,6 +28,7 @@ from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fiel
|
||||
from utilities.forms.bulk_import import BulkImportForm
|
||||
from utilities.htmx import htmx_partial
|
||||
from utilities.permissions import get_permission_for_model
|
||||
from utilities.query import reapply_model_ordering
|
||||
from utilities.views import GetReturnURLMixin, get_viewname
|
||||
from .base import BaseMultiObjectView
|
||||
from .mixins import ActionsMixin, TableMixin
|
||||
@@ -127,8 +128,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
ordering = qs.model._meta.ordering
|
||||
return qs.order_by(*ordering)
|
||||
return reapply_model_ordering(qs)
|
||||
|
||||
def get(self, request):
|
||||
"""
|
||||
@@ -666,7 +666,9 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
elif 'virtual_machine' in request.GET:
|
||||
initial_data['virtual_machine'] = request.GET.get('virtual_machine')
|
||||
|
||||
form = self.form(request.POST, initial=initial_data)
|
||||
post_data = request.POST.copy()
|
||||
post_data.setlist('pk', pk_list)
|
||||
form = self.form(post_data, initial=initial_data)
|
||||
restrict_form_fields(form, request.user)
|
||||
|
||||
if '_apply' in request.POST:
|
||||
|
||||
@@ -9,8 +9,7 @@ const options = {
|
||||
outdir: './dist',
|
||||
bundle: true,
|
||||
minify: true,
|
||||
sourcemap: 'external',
|
||||
sourcesContent: false,
|
||||
sourcemap: 'linked',
|
||||
logLevel: 'error',
|
||||
};
|
||||
|
||||
|
||||
2
netbox/project-static/dist/netbox.css
vendored
2
netbox/project-static/dist/netbox.css
vendored
File diff suppressed because one or more lines are too long
17
netbox/project-static/dist/netbox.js
vendored
17
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
5
netbox/project-static/dist/netbox.js.map
vendored
5
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "netbox",
|
||||
"version": "4.1.0",
|
||||
"version": "4.2.8",
|
||||
"main": "dist/netbox.js",
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
@@ -24,13 +24,13 @@
|
||||
"dependencies": {
|
||||
"@mdi/font": "7.4.47",
|
||||
"@tabler/core": "1.0.0-beta21",
|
||||
"bootstrap": "5.3.3",
|
||||
"bootstrap": "5.3.5",
|
||||
"clipboard": "2.0.11",
|
||||
"flatpickr": "4.6.13",
|
||||
"gridstack": "11.3.0",
|
||||
"gridstack": "11.5.0",
|
||||
"htmx.org": "1.9.12",
|
||||
"query-string": "9.1.1",
|
||||
"sass": "1.85.0",
|
||||
"sass": "1.87.0",
|
||||
"tom-select": "2.4.3",
|
||||
"typeface-inter": "3.18.1",
|
||||
"typeface-roboto-mono": "1.1.13"
|
||||
|
||||
@@ -5,11 +5,13 @@ interface PluginConfig {
|
||||
export function getPlugins(element: HTMLSelectElement): object {
|
||||
const plugins: PluginConfig = {};
|
||||
|
||||
// Enable "clear all" button
|
||||
plugins.clear_button = {
|
||||
html: (data: Dict) =>
|
||||
`<i class="mdi mdi-close-circle ${data.className}" title="${data.title}"></i>`,
|
||||
};
|
||||
// Enable "clear all" button for non-required fields
|
||||
if (!element.required) {
|
||||
plugins.clear_button = {
|
||||
html: (data: Dict) =>
|
||||
`<i class="mdi mdi-close-circle ${data.className}" title="${data.title}"></i>`,
|
||||
};
|
||||
}
|
||||
|
||||
// Enable individual "remove" buttons for items on multi-select fields
|
||||
if (element.hasAttribute('multiple')) {
|
||||
|
||||
@@ -38,7 +38,7 @@ span.color-label {
|
||||
.btn-float-group {
|
||||
position: sticky;
|
||||
bottom: 10px;
|
||||
z-index: 2;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.btn-float-group-left {
|
||||
|
||||
@@ -3,6 +3,12 @@ html {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
|
||||
// Remove horizontal padding from highlighted text
|
||||
mark {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
// Prevent dropdown menus from being clipped inside responsive tables
|
||||
.table-responsive {
|
||||
.dropdown, .btn-group, .btn-group-vertical {
|
||||
|
||||
@@ -769,9 +769,9 @@
|
||||
bootstrap "5.3.3"
|
||||
|
||||
"@tabler/icons@^3.14.0":
|
||||
version "3.16.0"
|
||||
resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-3.16.0.tgz#d618670b80163925a31a6c2290e8775f6058d81a"
|
||||
integrity sha512-GU7MSx4uQEr55BmyON6hD/QYTl6k1v0YlRhM91gBWDoKAbyCt6QIYw7rpJ/ecdh5zrHaTOJKPenZ4+luoutwFA==
|
||||
version "3.31.0"
|
||||
resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-3.31.0.tgz#92d39dc336f2e3e312170420b00ffe9ca474925e"
|
||||
integrity sha512-dblAdeKY3+GA1U+Q9eziZ0ooVlZMHsE8dqP0RkwvRtEsAULoKOYaCUOcJ4oW1DjWegdxk++UAt2SlQVnmeHv+g==
|
||||
|
||||
"@tanstack/react-virtual@^3.0.0-beta.60":
|
||||
version "3.5.0"
|
||||
@@ -1066,6 +1066,11 @@ bootstrap@5.3.3:
|
||||
resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-5.3.3.tgz#de35e1a765c897ac940021900fcbb831602bac38"
|
||||
integrity sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==
|
||||
|
||||
bootstrap@5.3.5:
|
||||
version "5.3.5"
|
||||
resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-5.3.5.tgz#be42cfe0d580e97ee1abb7d38ce94f5c393c9bb6"
|
||||
integrity sha512-ct1CHKtiobRimyGzmsSldEtM03E8fcEX4Tb3dGXz1V8faRwM50+vfHwTzOxB3IlKO7m+9vTH3s/3C6T2EAPeTA==
|
||||
|
||||
brace-expansion@^1.1.7:
|
||||
version "1.1.11"
|
||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
|
||||
@@ -1911,10 +1916,10 @@ graphql@16.10.0:
|
||||
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.10.0.tgz#24c01ae0af6b11ea87bf55694429198aaa8e220c"
|
||||
integrity sha512-AjqGKbDGUFRKIRCP9tCKiIGHyriz2oHEbPIbEtcSLSs4YjReZOIPQQWek4+6hjw62H9QShXHyaGivGiYVLeYFQ==
|
||||
|
||||
gridstack@11.3.0:
|
||||
version "11.3.0"
|
||||
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-11.3.0.tgz#b110c66bafc64c920fc54933e2c9df4f7b2cfffe"
|
||||
integrity sha512-Z0eRovKcZTRTs3zetJwjO6CNwrgIy845WfOeZGk8ybpeMCE8fMA8tScyKU72Y2M6uGHkjgwnjflglvPiv+RcBQ==
|
||||
gridstack@11.5.0:
|
||||
version "11.5.0"
|
||||
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-11.5.0.tgz#ecd507776db857f3308d37a8fd67d6a24c7fdd74"
|
||||
integrity sha512-SE1a/aC2K8VKQr5cqV7gSJ+r/xIYghijIjHzkZ3Xo3aS1/4dvwIgPYT7QqgV1z+d7XjKYUPEizcgVQ5HhdFTng==
|
||||
|
||||
has-bigints@^1.0.1, has-bigints@^1.0.2:
|
||||
version "1.0.2"
|
||||
@@ -2673,10 +2678,10 @@ safe-regex-test@^1.0.3:
|
||||
es-errors "^1.3.0"
|
||||
is-regex "^1.1.4"
|
||||
|
||||
sass@1.85.0:
|
||||
version "1.85.0"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.85.0.tgz#0127ef697d83144496401553f0a0e87be83df45d"
|
||||
integrity sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==
|
||||
sass@1.87.0:
|
||||
version "1.87.0"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.87.0.tgz#8cceb36fa63fb48a8d5d7f2f4c13b49c524b723e"
|
||||
integrity sha512-d0NoFH4v6SjEK7BoX810Jsrhj7IQSYHAHLi/iSpgqKc7LaIDshFRlSg5LOymf9FqQhxEHs2W5ZQXlvy0KD45Uw==
|
||||
dependencies:
|
||||
chokidar "^4.0.0"
|
||||
immutable "^5.0.2"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version: "4.2.5"
|
||||
version: "4.2.8"
|
||||
edition: "Community"
|
||||
published: "2025-03-06"
|
||||
published: "2025-04-22"
|
||||
|
||||
@@ -4,30 +4,30 @@
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>{% trans "Server Error" %}</title>
|
||||
<link rel="stylesheet" href="{% static 'netbox-light.css'%}" />
|
||||
<meta charset="UTF-8">
|
||||
<title>{% trans "Server Error" %}</title>
|
||||
<link rel="stylesheet" href="{% static 'netbox.css'%}" />
|
||||
<meta charset="UTF-8">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col col-md-6 offset-md-3">
|
||||
<div class="card border-danger mt-5">
|
||||
<h2 class="card-header">
|
||||
<i class="mdi mdi-alert"></i> {% trans "Server Error" %}
|
||||
</h2>
|
||||
<div class="card-body">
|
||||
{% block message %}
|
||||
<p>
|
||||
{% trans "There was a problem with your request. Please contact an administrator" %}.
|
||||
</p>
|
||||
{% endblock %}
|
||||
<hr />
|
||||
<p>
|
||||
{% trans "The complete exception is provided below" %}:
|
||||
</p>
|
||||
<pre class="block"><strong>{{ exception }}</strong><br />
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col col-md-6 offset-md-3">
|
||||
<div class="card border-danger mt-5">
|
||||
<h2 class="card-header text-bg-danger">
|
||||
<i class="mdi mdi-alert"></i> {% trans "Server Error" %}
|
||||
</h2>
|
||||
<div class="card-body">
|
||||
{% block message %}
|
||||
<p>
|
||||
{% trans "There was a problem with your request. Please contact an administrator" %}.
|
||||
</p>
|
||||
{% endblock %}
|
||||
<hr />
|
||||
<p>
|
||||
{% trans "The complete exception is provided below" %}:
|
||||
</p>
|
||||
<pre class="block"><strong>{{ exception }}</strong><br />
|
||||
{{ error }}
|
||||
|
||||
{% trans "Python version" %}: {{ python_version }}
|
||||
@@ -35,17 +35,17 @@
|
||||
{% trans "Plugins" %}: {% for plugin, version in plugins.items %}
|
||||
{{ plugin }}: {{ version }}{% empty %}{% trans "None installed" %}{% endfor %}
|
||||
</pre>
|
||||
<p>
|
||||
{% trans "If further assistance is required, please post to the" %} <a href="https://github.com/netbox-community/netbox/discussions">{% trans "NetBox discussion forum" %}</a> {% trans "on GitHub" %}.
|
||||
</p>
|
||||
<div class="text-end">
|
||||
<a href="{% url 'home' %}" class="btn btn-primary">{% trans "Home Page" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
{% trans "If further assistance is required, please post to the" %} <a href="https://github.com/netbox-community/netbox/discussions">{% trans "NetBox discussion forum" %}</a> {% trans "on GitHub" %}.
|
||||
</p>
|
||||
<div class="text-end">
|
||||
<a href="{% url 'home' %}" class="btn btn-primary">{% trans "Home Page" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends 'generic/object_edit.html' %}
|
||||
|
||||
{% block form %}
|
||||
{% include 'dcim/htmx/cable_edit.html' %}
|
||||
{% include 'dcim/htmx/cable_edit.html' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -335,6 +335,15 @@
|
||||
</div>
|
||||
{% if object.rack and object.position %}
|
||||
<div class="row" style="margin-bottom: 20px">
|
||||
<div class="text-center">
|
||||
<strong><a href="{% url 'dcim:rack' pk=object.rack.pk %}">{{ object.rack.name }}</a></strong>
|
||||
{% if object.rack.role %}
|
||||
<br /><span class="badge my-3" style="color: {{ object.rack.role.color|fgcolor }}; background-color: #{{ object.rack.role.color }}">{{ object.rack.role }}</span>
|
||||
{% endif %}
|
||||
{% if object.rack.facility_id %}
|
||||
<br /><small class="text-muted">{{ object.rack.facility_id }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col col-md-6 col-sm-6 col-xs-12 text-center">
|
||||
<div style="margin-left: 30px">
|
||||
<h2 class="h4">{% trans "Front" %}</h2>
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% block form %}
|
||||
{% render_errors form %}
|
||||
{% for field in form.hidden_fields %}
|
||||
{{ field }}
|
||||
{% endfor %}
|
||||
|
||||
<div class="field-group my-5">
|
||||
<div class="row">
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
{% load form_helpers %}
|
||||
{% load i18n %}
|
||||
|
||||
{% for field in form.hidden_fields %}
|
||||
{{ field }}
|
||||
{% endfor %}
|
||||
|
||||
{# A side termination #}
|
||||
<div class="field-group mb-5">
|
||||
|
||||
@@ -20,10 +20,15 @@
|
||||
<th scope="row">{{ terminations.0|meta:"verbose_name"|capfirst }}</th>
|
||||
<td>
|
||||
{% for term in terminations %}
|
||||
{{term.device|linkify}}
|
||||
<i class="mdi mdi-chevron-right" aria-hidden="true"></i>
|
||||
{{ term|linkify }}
|
||||
{% if not forloop.last %}<br/>{% endif %}
|
||||
{{ term.device|linkify }}
|
||||
<i class="mdi mdi-chevron-right" aria-hidden="true"></i>
|
||||
{{ term|linkify }}
|
||||
{% with trace_url=term|viewname:"trace" %}
|
||||
<a href="{% url trace_url pk=term.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
|
||||
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endwith %}
|
||||
{% if not forloop.last %}<br/>{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -41,7 +46,13 @@
|
||||
<th scope="row">{{ terminations.0|meta:"verbose_name"|capfirst }}</th>
|
||||
<td>
|
||||
{% for term in terminations %}
|
||||
{{ term|linkify }}{% if not forloop.last %},{% endif %}
|
||||
{{ term|linkify }}
|
||||
{% with trace_url=term|viewname:"trace" %}
|
||||
<a href="{% url trace_url pk=term.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
|
||||
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endwith %}
|
||||
{% if not forloop.last %}<br/>{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -55,7 +66,13 @@
|
||||
<th scope="row">{% trans "Circuit" %}</th>
|
||||
<td>
|
||||
{% for term in terminations %}
|
||||
{{ term.circuit|linkify }} ({{ term }}){% if not forloop.last %},{% endif %}
|
||||
{{ term.circuit|linkify }} ({{ term }})
|
||||
{% with trace_url=term|viewname:"trace" %}
|
||||
<a href="{% url trace_url pk=term.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
|
||||
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endwith %}
|
||||
{% if not forloop.last %}<br/>{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% block form %}
|
||||
{% for field in form.hidden_fields %}
|
||||
{{ field }}
|
||||
{% endfor %}
|
||||
|
||||
<div class="field-group my-5">
|
||||
<div class="row">
|
||||
<h2 class="col-9 offset-3">{% trans "Virtual Chassis" %}</h2>
|
||||
|
||||
@@ -12,11 +12,15 @@
|
||||
{% block content %}
|
||||
<div class="tab-pane show active" id="edit-form" role="tabpanel" aria-labelledby="object-list-tab">
|
||||
<form action="" method="post" enctype="multipart/form-data" class="object-edit">
|
||||
{% render_errors vc_form %}
|
||||
{% for form in formset %}
|
||||
{% render_errors form %}
|
||||
{% endfor %}
|
||||
|
||||
{% csrf_token %}
|
||||
{% for field in vc_form.hidden_fields %}
|
||||
{{ field }}
|
||||
{% endfor %}
|
||||
{{ pk_form.pk }}
|
||||
{{ formset.management_form }}
|
||||
<div class="field-group my-5">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %} class="form-select {% if 'class' in widget.attrs %} {{ widget.attrs.class }}{% endif %}">{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %}
|
||||
<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %} class="form-select{% if 'class' in widget.attrs %} {{ widget.attrs.class }}{% endif %}"{% if widget.required %} required{% endif %}>{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %}
|
||||
<optgroup label="{{ group_name }}">{% endif %}{% for option in group_choices %}
|
||||
{% include option.template_name with widget=option %}{% endfor %}{% if group_name %}
|
||||
</optgroup>{% endif %}{% endfor %}
|
||||
|
||||
@@ -54,11 +54,14 @@
|
||||
<div class="card">
|
||||
<h2 class="card-header d-flex justify-content-between">
|
||||
{% trans "Rendered Config" %}
|
||||
<a href="?export=True" class="btn btn-primary lh-1" role="button">
|
||||
<i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
|
||||
</a>
|
||||
<div>
|
||||
{% copy_content "rendered_config" %}
|
||||
<a href="?export=True" class="btn btn-primary lh-1" role="button">
|
||||
<i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
|
||||
</a>
|
||||
</div>
|
||||
</h2>
|
||||
<pre class="card-body">{{ rendered_config }}</pre>
|
||||
<pre class="card-body" id="rendered_config">{{ rendered_config }}</pre>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning">
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</h2>
|
||||
{% with scripts=module.scripts.all %}
|
||||
{% with scripts=module.ordered_scripts %}
|
||||
{% if scripts %}
|
||||
<table class="table table-hover scripts">
|
||||
<thead>
|
||||
@@ -63,7 +63,7 @@
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ script.python_class.Meta.description|markdown|placeholder }}</td>
|
||||
<td>{{ script.python_class.description|markdown|placeholder }}</td>
|
||||
{% if last_job %}
|
||||
<td>
|
||||
<a href="{% url 'extras:script_result' job_pk=last_job.pk %}">{{ last_job.created|isodatetime }}</a>
|
||||
@@ -79,6 +79,9 @@
|
||||
{% if request.user|can_run:script and script.is_executable %}
|
||||
<div class="float-end d-print-none">
|
||||
<form action="{% url 'extras:script' script.pk %}" method="post">
|
||||
{% if script.python_class.commit_default %}
|
||||
<input type="checkbox" name="_commit" hidden checked>
|
||||
{% endif %}
|
||||
{% csrf_token %}
|
||||
<button type="submit" name="_run" class="btn btn-primary btn-sm">
|
||||
{% if last_job %}
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% block form %}
|
||||
{% for field in form.hidden_fields %}
|
||||
{{ field }}
|
||||
{% endfor %}
|
||||
|
||||
<div class="field-group my-5">
|
||||
<div class="row">
|
||||
<h2 class="col-9 offset-3">{% trans "VLAN" %}</h2>
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
<th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
|
||||
<td>
|
||||
{% if memory_sum %}
|
||||
<span title={{ memory_sum }}>{{ memory_sum|humanize_megabytes }}</span>
|
||||
<span title={{ memory_sum }}>{{ memory_sum|humanize_ram_megabytes }}</span>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
@@ -73,7 +73,7 @@
|
||||
<th scope="row"><i class="mdi mdi-harddisk"></i> {% trans "Disk Space" %}</th>
|
||||
<td>
|
||||
{% if disk_sum %}
|
||||
{{ disk_sum|humanize_megabytes }}
|
||||
{{ disk_sum|humanize_disk_megabytes }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
@@ -81,6 +81,7 @@
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% include 'inc/panels/related_objects.html' %}
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% plugin_right_page object %}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<th scope="row"><i class="mdi mdi-harddisk"></i> {% trans "Size" %}</th>
|
||||
<td>
|
||||
{% if object.size %}
|
||||
{{ object.size|humanize_megabytes }}
|
||||
{{ object.size|humanize_disk_megabytes }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
<th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
|
||||
<td>
|
||||
{% if object.memory %}
|
||||
<span title={{ object.memory }}>{{ object.memory|humanize_megabytes }}</span>
|
||||
<span title={{ object.memory }}>{{ object.memory|humanize_ram_megabytes }}</span>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
@@ -141,7 +141,7 @@
|
||||
</th>
|
||||
<td>
|
||||
{% if object.disk %}
|
||||
{{ object.disk|humanize_megabytes }}
|
||||
{{ object.disk|humanize_disk_megabytes }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user