mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-27 02:48:38 -06:00
Merge branch 'main' into 18955-conditional-clear-button
This commit is contained in:
commit
63e37c5e54
@ -15,7 +15,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v4.2.5
|
placeholder: v4.2.6
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- 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:
|
attributes:
|
||||||
label: NetBox Version
|
label: NetBox Version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v4.2.5
|
placeholder: v4.2.6
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
@ -8,7 +8,10 @@ django-cors-headers
|
|||||||
|
|
||||||
# Runtime UI tool for debugging Django
|
# Runtime UI tool for debugging Django
|
||||||
# https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst
|
# https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst
|
||||||
django-debug-toolbar
|
# See: https://django-debug-toolbar.readthedocs.io/en/latest/changes.html#id1
|
||||||
|
# "Wrap SHOW_TOOLBAR_CALLBACK function with sync_to_async or async_to_sync to allow sync/async
|
||||||
|
# compatibility." breaks stawberry-graphql-django at version 0.52.0 (current)
|
||||||
|
django-debug-toolbar==5.0.1
|
||||||
|
|
||||||
# Library for writing reusable URL query filters
|
# Library for writing reusable URL query filters
|
||||||
# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
|
# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
|
||||||
@ -88,8 +91,7 @@ mkdocs-material
|
|||||||
|
|
||||||
# Introspection for embedded code
|
# Introspection for embedded code
|
||||||
# https://github.com/mkdocstrings/mkdocstrings/blob/main/CHANGELOG.md
|
# https://github.com/mkdocstrings/mkdocstrings/blob/main/CHANGELOG.md
|
||||||
# See #18568
|
mkdocstrings[python]
|
||||||
mkdocstrings[python-legacy]==0.27.0
|
|
||||||
|
|
||||||
# Library for manipulating IP prefixes and addresses
|
# Library for manipulating IP prefixes and addresses
|
||||||
# https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst
|
# https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst
|
||||||
|
@ -500,6 +500,9 @@
|
|||||||
"n",
|
"n",
|
||||||
"mrj21",
|
"mrj21",
|
||||||
"fc",
|
"fc",
|
||||||
|
"fc-pc",
|
||||||
|
"fc-upc",
|
||||||
|
"fc-apc",
|
||||||
"lc",
|
"lc",
|
||||||
"lc-pc",
|
"lc-pc",
|
||||||
"lc-upc",
|
"lc-upc",
|
||||||
@ -565,6 +568,9 @@
|
|||||||
"n",
|
"n",
|
||||||
"mrj21",
|
"mrj21",
|
||||||
"fc",
|
"fc",
|
||||||
|
"fc-pc",
|
||||||
|
"fc-upc",
|
||||||
|
"fc-apc",
|
||||||
"lc",
|
"lc",
|
||||||
"lc-pc",
|
"lc-pc",
|
||||||
"lc-upc",
|
"lc-upc",
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
# Release Checklist
|
# 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)
|
* Major release (e.g. v3.7.8 to v4.0.0)
|
||||||
* Minor release (e.g. v4.0.10 to v4.1.0)
|
* Minor release (e.g. v4.0.10 to v4.1.0)
|
||||||
* Patch release (e.g. v4.1.0 to v4.1.1)
|
* 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.
|
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`.
|
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
|
### Manually Perform a New Install
|
||||||
|
|
||||||
Start the documentation server and navigate to the current version of the installation docs:
|
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
|
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
|
### 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
|
### 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)
|
### 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
|
### 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
|
git checkout main
|
||||||
|
@ -17,12 +17,53 @@ Prior to upgrading your NetBox instance, be sure to carefully review all [releas
|
|||||||
|
|
||||||
NetBox requires the following dependencies:
|
NetBox requires the following dependencies:
|
||||||
|
|
||||||
|
=== "Current Version"
|
||||||
|
|
||||||
| Dependency | Supported Versions |
|
| Dependency | Supported Versions |
|
||||||
|------------|--------------------|
|
|------------|--------------------|
|
||||||
| Python | 3.10, 3.11, 3.12 |
|
| Python | 3.10, 3.11, 3.12 |
|
||||||
| PostgreSQL | 13+ |
|
| PostgreSQL | 13+ |
|
||||||
| Redis | 4.0+ |
|
| 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
|
## 3. Install the Latest Release
|
||||||
|
|
||||||
As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by checking out the latest production release from the git repository.
|
As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by checking out the latest production release from the git repository.
|
||||||
|
@ -61,6 +61,11 @@ class MyModelViewSet(...):
|
|||||||
|
|
||||||
The `TagFilter` class is available for all models which support tag assignment (those which inherit from `NetBoxModel` or `TagsMixin`). This filter subclasses django-filter's `ModelMultipleChoiceFilter` to work with NetBox's `TaggedItem` class.
|
The `TagFilter` class is available for all models which support tag assignment (those which inherit from `NetBoxModel` or `TagsMixin`). This filter subclasses django-filter's `ModelMultipleChoiceFilter` to work with NetBox's `TaggedItem` class.
|
||||||
|
|
||||||
|
This class filters `tags` using the `slug` field. For example:
|
||||||
|
|
||||||
|
`GET /api/dcim/sites/?tag=alpha&tag=bravo`
|
||||||
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from django_filters import FilterSet
|
from django_filters import FilterSet
|
||||||
from extras.filters import TagFilter
|
from extras.filters import TagFilter
|
||||||
@ -68,3 +73,19 @@ from extras.filters import TagFilter
|
|||||||
class MyModelFilterSet(FilterSet):
|
class MyModelFilterSet(FilterSet):
|
||||||
tag = TagFilter()
|
tag = TagFilter()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### TagIDFilter
|
||||||
|
|
||||||
|
The `TagIDFilter` class is available for all models which support tag assignment (those which inherit from `NetBoxModel` or `TagsMixin`). This filter subclasses django-filter's `ModelMultipleChoiceFilter` to work with NetBox's `TaggedItem` class.
|
||||||
|
|
||||||
|
This class filters `tags` using the `id` field. For example:
|
||||||
|
|
||||||
|
`GET /api/dcim/sites/?tag_id=100&tag_id=200`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django_filters import FilterSet
|
||||||
|
from extras.filters import TagIDFilter
|
||||||
|
|
||||||
|
class MyModelFilterSet(FilterSet):
|
||||||
|
tag_id = TagIDFilter()
|
||||||
|
```
|
||||||
|
@ -1,5 +1,40 @@
|
|||||||
# NetBox v4.2
|
# NetBox v4.2
|
||||||
|
|
||||||
|
## 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)
|
## v4.2.5 (2025-03-06)
|
||||||
|
|
||||||
### Enhancements
|
### Enhancements
|
||||||
|
@ -43,7 +43,7 @@ class ProviderType(NetBoxObjectType, ContactsMixin):
|
|||||||
fields='__all__',
|
fields='__all__',
|
||||||
filters=ProviderAccountFilter
|
filters=ProviderAccountFilter
|
||||||
)
|
)
|
||||||
class ProviderAccountType(NetBoxObjectType):
|
class ProviderAccountType(ContactsMixin, NetBoxObjectType):
|
||||||
provider: Annotated["ProviderType", strawberry.lazy('circuits.graphql.types')]
|
provider: Annotated["ProviderType", strawberry.lazy('circuits.graphql.types')]
|
||||||
|
|
||||||
circuits: List[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]]
|
circuits: List[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]]
|
||||||
|
@ -3,7 +3,10 @@ from django.conf import settings
|
|||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.migrations.operations import AlterModelOptions
|
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
|
from utilities.migration import custom_deconstruct
|
||||||
|
|
||||||
# Ignore verbose_name & verbose_name_plural Meta options when calculating model migrations
|
# Ignore verbose_name & verbose_name_plural Meta options when calculating model migrations
|
||||||
@ -26,6 +29,15 @@ class CoreConfig(AppConfig):
|
|||||||
# Register models
|
# Register models
|
||||||
register_models(*self.get_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
|
# Clear Redis cache on startup in development mode
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
try:
|
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__ = (
|
__all__ = (
|
||||||
'JOB_COMPLETED',
|
'JOB_COMPLETED',
|
||||||
'JOB_ERRORED',
|
'JOB_ERRORED',
|
||||||
@ -22,12 +18,3 @@ JOB_STARTED = 'job_started'
|
|||||||
JOB_COMPLETED = 'job_completed'
|
JOB_COMPLETED = 'job_completed'
|
||||||
JOB_FAILED = 'job_failed'
|
JOB_FAILED = 'job_failed'
|
||||||
JOB_ERRORED = 'job_errored'
|
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()
|
|
||||||
|
@ -986,6 +986,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
|||||||
|
|
||||||
# Coaxial
|
# Coaxial
|
||||||
TYPE_DOCSIS = 'docsis'
|
TYPE_DOCSIS = 'docsis'
|
||||||
|
TYPE_MOCA = 'moca'
|
||||||
|
|
||||||
# PON
|
# PON
|
||||||
TYPE_BPON = 'bpon'
|
TYPE_BPON = 'bpon'
|
||||||
@ -1182,6 +1183,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
|||||||
_('Coaxial'),
|
_('Coaxial'),
|
||||||
(
|
(
|
||||||
(TYPE_DOCSIS, 'DOCSIS'),
|
(TYPE_DOCSIS, 'DOCSIS'),
|
||||||
|
(TYPE_MOCA, 'MoCA'),
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
@ -153,6 +153,7 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
|
|||||||
self.fields['rear_port'].choices = choices
|
self.fields['rear_port'].choices = choices
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
|
||||||
# Check that the number of FrontPortTemplates to be created matches the selected number of RearPortTemplate
|
# Check that the number of FrontPortTemplates to be created matches the selected number of RearPortTemplate
|
||||||
# positions
|
# positions
|
||||||
@ -302,6 +303,7 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
|
|||||||
self.fields['rear_port'].choices = choices
|
self.fields['rear_port'].choices = choices
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
|
super().clean()
|
||||||
|
|
||||||
# Check that the number of FrontPorts to be created matches the selected number of RearPort positions
|
# Check that the number of FrontPorts to be created matches the selected number of RearPort positions
|
||||||
frontport_count = len(self.cleaned_data['name'])
|
frontport_count = len(self.cleaned_data['name'])
|
||||||
|
@ -30,6 +30,7 @@ class PathEndpointMixin:
|
|||||||
|
|
||||||
connected_endpoints: List[Annotated[Union[
|
connected_endpoints: List[Annotated[Union[
|
||||||
Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')], # noqa: F821
|
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["ConsolePortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
|
||||||
Annotated["ConsoleServerPortType", 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
|
Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], # noqa: F821
|
||||||
|
@ -429,7 +429,7 @@ class InterfaceTemplateType(ModularComponentTemplateType):
|
|||||||
)
|
)
|
||||||
class InventoryItemType(ComponentType):
|
class InventoryItemType(ComponentType):
|
||||||
role: Annotated["InventoryItemRoleType", strawberry.lazy('dcim.graphql.types')] | None
|
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')]]
|
child_items: List[Annotated["InventoryItemType", strawberry.lazy('dcim.graphql.types')]]
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from django.test import override_settings
|
from django.test import override_settings, tag
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from rest_framework import status
|
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):
|
class RearPortTest(APIViewTestCases.APIViewTestCase):
|
||||||
model = RearPort
|
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):
|
class ModuleBayTest(APIViewTestCases.APIViewTestCase):
|
||||||
model = ModuleBay
|
model = ModuleBay
|
||||||
|
@ -1,6 +1,12 @@
|
|||||||
from django.test import TestCase
|
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.forms import *
|
||||||
from dcim.models import *
|
from dcim.models import *
|
||||||
from ipam.models import VLAN
|
from ipam.models import VLAN
|
||||||
@ -118,6 +124,51 @@ class DeviceTestCase(TestCase):
|
|||||||
self.assertIn('position', form.errors)
|
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):
|
class InterfaceTestCase(TestCase):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -4,6 +4,7 @@ from .models import Tag
|
|||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'TagFilter',
|
'TagFilter',
|
||||||
|
'TagIDFilter',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -20,3 +21,18 @@ class TagFilter(django_filters.ModelMultipleChoiceFilter):
|
|||||||
kwargs.setdefault('queryset', Tag.objects.all())
|
kwargs.setdefault('queryset', Tag.objects.all())
|
||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class TagIDFilter(django_filters.ModelMultipleChoiceFilter):
|
||||||
|
"""
|
||||||
|
Match on one or more assigned tags. If multiple tags are specified (e.g. ?tag=1&tag=2), the queryset is filtered
|
||||||
|
to objects matching all tags.
|
||||||
|
"""
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
||||||
|
kwargs.setdefault('field_name', 'tags__id')
|
||||||
|
kwargs.setdefault('to_field_name', 'id')
|
||||||
|
kwargs.setdefault('conjoined', True)
|
||||||
|
kwargs.setdefault('queryset', Tag.objects.all())
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
@ -11,7 +11,7 @@ from users.models import Group, User
|
|||||||
from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
|
from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
|
||||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||||
from .choices import *
|
from .choices import *
|
||||||
from .filters import TagFilter
|
from .filters import TagFilter, TagIDFilter
|
||||||
from .models import *
|
from .models import *
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -665,6 +665,7 @@ class ConfigTemplateFilterSet(ChangeLoggedModelFilterSet):
|
|||||||
label=_('Data file (ID)'),
|
label=_('Data file (ID)'),
|
||||||
)
|
)
|
||||||
tag = TagFilter()
|
tag = TagFilter()
|
||||||
|
tag_id = TagIDFilter()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ConfigTemplate
|
model = ConfigTemplate
|
||||||
|
@ -9,6 +9,8 @@ from django.conf import settings
|
|||||||
from django.contrib.postgres.fields import ArrayField
|
from django.contrib.postgres.fields import ArrayField
|
||||||
from django.core.validators import RegexValidator, ValidationError
|
from django.core.validators import RegexValidator, ValidationError
|
||||||
from django.db import models
|
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.urls import reverse
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
@ -281,12 +283,20 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
Populate initial custom field data upon either a) the creation of a new CustomField, or
|
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.
|
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:
|
for ct in content_types:
|
||||||
model = ct.model_class()
|
ct.model_class().objects.update(
|
||||||
instances = model.objects.exclude(**{'custom_field_data__contains': self.name})
|
custom_field_data=Func(
|
||||||
for instance in instances:
|
F('custom_field_data'),
|
||||||
instance.custom_field_data[self.name] = self.default
|
Value([self.name]),
|
||||||
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
|
value,
|
||||||
|
function='jsonb_set'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def remove_stale_data(self, content_types):
|
def remove_stale_data(self, content_types):
|
||||||
"""
|
"""
|
||||||
@ -295,22 +305,27 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
"""
|
"""
|
||||||
for ct in content_types:
|
for ct in content_types:
|
||||||
if model := ct.model_class():
|
if model := ct.model_class():
|
||||||
instances = model.objects.filter(custom_field_data__has_key=self.name)
|
model.objects.update(
|
||||||
for instance in instances:
|
custom_field_data=F('custom_field_data') - self.name
|
||||||
del instance.custom_field_data[self.name]
|
)
|
||||||
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
|
|
||||||
|
|
||||||
def rename_object_data(self, old_name, new_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():
|
for ct in self.object_types.all():
|
||||||
model = ct.model_class()
|
ct.model_class().objects.update(
|
||||||
params = {f'custom_field_data__{old_name}__isnull': False}
|
custom_field_data=Func(
|
||||||
instances = model.objects.filter(**params)
|
F('custom_field_data') - old_name,
|
||||||
for instance in instances:
|
Value([new_name]),
|
||||||
instance.custom_field_data[new_name] = instance.custom_field_data.pop(old_name)
|
Func(
|
||||||
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
|
F('custom_field_data'),
|
||||||
|
function='jsonb_extract_path_text',
|
||||||
|
template=f"to_jsonb(%(expressions)s -> '{old_name}')"
|
||||||
|
),
|
||||||
|
function='jsonb_set')
|
||||||
|
)
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
@ -532,6 +547,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
}
|
}
|
||||||
if not for_csv_import:
|
if not for_csv_import:
|
||||||
kwargs['query_params'] = self.related_object_filter
|
kwargs['query_params'] = self.related_object_filter
|
||||||
|
kwargs['selector'] = True
|
||||||
|
|
||||||
field = field_class(**kwargs)
|
field = field_class(**kwargs)
|
||||||
|
|
||||||
@ -546,6 +562,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
}
|
}
|
||||||
if not for_csv_import:
|
if not for_csv_import:
|
||||||
kwargs['query_params'] = self.related_object_filter
|
kwargs['query_params'] = self.related_object_filter
|
||||||
|
kwargs['selector'] = True
|
||||||
|
|
||||||
field = field_class(**kwargs)
|
field = field_class(**kwargs)
|
||||||
|
|
||||||
|
@ -117,6 +117,15 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.python_name
|
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
|
@property
|
||||||
def module_scripts(self):
|
def module_scripts(self):
|
||||||
|
|
||||||
|
@ -498,13 +498,16 @@ class ConfigContextTable(NetBoxTable):
|
|||||||
orderable=False,
|
orderable=False,
|
||||||
verbose_name=_('Synced')
|
verbose_name=_('Synced')
|
||||||
)
|
)
|
||||||
|
tags = columns.TagColumn(
|
||||||
|
url_name='extras:configcontext_list'
|
||||||
|
)
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = ConfigContext
|
model = ConfigContext
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'weight', 'is_active', 'is_synced', 'description', 'regions', 'sites', 'locations',
|
'pk', 'id', 'name', 'weight', 'is_active', 'is_synced', 'description', 'regions', 'sites', 'locations',
|
||||||
'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants',
|
'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')
|
default_columns = ('pk', 'name', 'weight', 'is_active', 'is_synced', 'description')
|
||||||
|
|
||||||
|
@ -327,6 +327,13 @@ class IPAddressImportForm(NetBoxModelImportForm):
|
|||||||
to_field_name='name',
|
to_field_name='name',
|
||||||
help_text=_('Assigned interface')
|
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(
|
is_primary = forms.BooleanField(
|
||||||
label=_('Is primary'),
|
label=_('Is primary'),
|
||||||
help_text=_('Make this the primary IP for the assigned device'),
|
help_text=_('Make this the primary IP for the assigned device'),
|
||||||
@ -341,8 +348,8 @@ class IPAddressImportForm(NetBoxModelImportForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = IPAddress
|
model = IPAddress
|
||||||
fields = [
|
fields = [
|
||||||
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary',
|
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'fhrp_group',
|
||||||
'is_oob', 'dns_name', 'description', 'comments', 'tags',
|
'is_primary', 'is_oob', 'dns_name', 'description', 'comments', 'tags',
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, data=None, *args, **kwargs):
|
def __init__(self, data=None, *args, **kwargs):
|
||||||
@ -398,6 +405,8 @@ class IPAddressImportForm(NetBoxModelImportForm):
|
|||||||
# Set interface assignment
|
# Set interface assignment
|
||||||
if self.cleaned_data.get('interface'):
|
if self.cleaned_data.get('interface'):
|
||||||
self.instance.assigned_object = self.cleaned_data['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)
|
ipaddress = super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
@ -5,6 +5,7 @@ import strawberry_django
|
|||||||
|
|
||||||
from circuits.graphql.types import ProviderType
|
from circuits.graphql.types import ProviderType
|
||||||
from dcim.graphql.types import SiteType
|
from dcim.graphql.types import SiteType
|
||||||
|
from extras.graphql.mixins import ContactsMixin
|
||||||
from ipam import models
|
from ipam import models
|
||||||
from netbox.graphql.scalars import BigInt
|
from netbox.graphql.scalars import BigInt
|
||||||
from netbox.graphql.types import BaseObjectType, NetBoxObjectType, OrganizationalObjectType
|
from netbox.graphql.types import BaseObjectType, NetBoxObjectType, OrganizationalObjectType
|
||||||
@ -83,7 +84,7 @@ class ASNRangeType(NetBoxObjectType):
|
|||||||
fields='__all__',
|
fields='__all__',
|
||||||
filters=AggregateFilter
|
filters=AggregateFilter
|
||||||
)
|
)
|
||||||
class AggregateType(NetBoxObjectType, BaseIPAddressFamilyType):
|
class AggregateType(NetBoxObjectType, ContactsMixin, BaseIPAddressFamilyType):
|
||||||
prefix: str
|
prefix: str
|
||||||
rir: Annotated["RIRType", strawberry.lazy('ipam.graphql.types')] | None
|
rir: Annotated["RIRType", strawberry.lazy('ipam.graphql.types')] | None
|
||||||
tenant: Annotated["TenantType", strawberry.lazy('tenancy.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'),
|
exclude=('assigned_object_type', 'assigned_object_id', 'address'),
|
||||||
filters=IPAddressFilter
|
filters=IPAddressFilter
|
||||||
)
|
)
|
||||||
class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType):
|
class IPAddressType(NetBoxObjectType, ContactsMixin, BaseIPAddressFamilyType):
|
||||||
address: str
|
address: str
|
||||||
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
|
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
|
||||||
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
|
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
|
||||||
@ -144,7 +145,7 @@ class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType):
|
|||||||
fields='__all__',
|
fields='__all__',
|
||||||
filters=IPRangeFilter
|
filters=IPRangeFilter
|
||||||
)
|
)
|
||||||
class IPRangeType(NetBoxObjectType):
|
class IPRangeType(NetBoxObjectType, ContactsMixin):
|
||||||
start_address: str
|
start_address: str
|
||||||
end_address: str
|
end_address: str
|
||||||
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
|
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'),
|
exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'),
|
||||||
filters=PrefixFilter
|
filters=PrefixFilter
|
||||||
)
|
)
|
||||||
class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType):
|
class PrefixType(NetBoxObjectType, ContactsMixin, BaseIPAddressFamilyType):
|
||||||
prefix: str
|
prefix: str
|
||||||
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
|
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
|
||||||
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
|
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
|
||||||
@ -217,7 +218,7 @@ class RouteTargetType(NetBoxObjectType):
|
|||||||
fields='__all__',
|
fields='__all__',
|
||||||
filters=ServiceFilter
|
filters=ServiceFilter
|
||||||
)
|
)
|
||||||
class ServiceType(NetBoxObjectType):
|
class ServiceType(NetBoxObjectType, ContactsMixin):
|
||||||
ports: List[int]
|
ports: List[int]
|
||||||
device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] | None
|
device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] | None
|
||||||
virtual_machine: Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')] | None
|
virtual_machine: Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')] | None
|
||||||
|
@ -666,6 +666,24 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
|
|
||||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
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 = {
|
cls.form_data = {
|
||||||
'vrf': vrfs[1].pk,
|
'vrf': vrfs[1].pk,
|
||||||
'address': IPNetwork('192.0.2.99/24'),
|
'address': IPNetwork('192.0.2.99/24'),
|
||||||
@ -679,10 +697,10 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
cls.csv_data = (
|
cls.csv_data = (
|
||||||
"vrf,address,status",
|
"vrf,address,status,fhrp_group",
|
||||||
"VRF 1,192.0.2.4/24,active",
|
"VRF 1,192.0.2.4/24,active,FHRP Group 1",
|
||||||
"VRF 1,192.0.2.5/24,active",
|
"VRF 1,192.0.2.5/24,active,FHRP Group 2",
|
||||||
"VRF 1,192.0.2.6/24,active",
|
"VRF 1,192.0.2.6/24,active,FHRP Group 3",
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.csv_update_data = (
|
cls.csv_update_data = (
|
||||||
|
@ -10,7 +10,7 @@ from django.utils.translation import gettext as _
|
|||||||
from core.choices import ObjectChangeActionChoices
|
from core.choices import ObjectChangeActionChoices
|
||||||
from core.models import ObjectChange
|
from core.models import ObjectChange
|
||||||
from extras.choices import CustomFieldFilterLogicChoices
|
from extras.choices import CustomFieldFilterLogicChoices
|
||||||
from extras.filters import TagFilter
|
from extras.filters import TagFilter, TagIDFilter
|
||||||
from extras.models import CustomField, SavedFilter
|
from extras.models import CustomField, SavedFilter
|
||||||
from utilities.constants import (
|
from utilities.constants import (
|
||||||
FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP,
|
FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP,
|
||||||
@ -286,6 +286,7 @@ class NetBoxModelFilterSet(ChangeLoggedModelFilterSet):
|
|||||||
label=_('Search'),
|
label=_('Search'),
|
||||||
)
|
)
|
||||||
tag = TagFilter()
|
tag = TagFilter()
|
||||||
|
tag_id = TagIDFilter()
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
@ -700,6 +700,10 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
|||||||
else:
|
else:
|
||||||
logger.debug("Form validation failed")
|
logger.debug("Form validation failed")
|
||||||
|
|
||||||
|
else:
|
||||||
|
form = self.form(initial=initial_data)
|
||||||
|
restrict_form_fields(form, request.user)
|
||||||
|
|
||||||
# Retrieve objects being edited
|
# Retrieve objects being edited
|
||||||
table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False)
|
table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False)
|
||||||
if not table.rows:
|
if not table.rows:
|
||||||
|
BIN
netbox/project-static/dist/netbox.css
vendored
BIN
netbox/project-static/dist/netbox.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
@ -27,10 +27,10 @@
|
|||||||
"bootstrap": "5.3.3",
|
"bootstrap": "5.3.3",
|
||||||
"clipboard": "2.0.11",
|
"clipboard": "2.0.11",
|
||||||
"flatpickr": "4.6.13",
|
"flatpickr": "4.6.13",
|
||||||
"gridstack": "11.3.0",
|
"gridstack": "11.5.0",
|
||||||
"htmx.org": "1.9.12",
|
"htmx.org": "1.9.12",
|
||||||
"query-string": "9.1.1",
|
"query-string": "9.1.1",
|
||||||
"sass": "1.85.0",
|
"sass": "1.86.0",
|
||||||
"tom-select": "2.4.3",
|
"tom-select": "2.4.3",
|
||||||
"typeface-inter": "3.18.1",
|
"typeface-inter": "3.18.1",
|
||||||
"typeface-roboto-mono": "1.1.13"
|
"typeface-roboto-mono": "1.1.13"
|
||||||
|
@ -38,7 +38,7 @@ span.color-label {
|
|||||||
.btn-float-group {
|
.btn-float-group {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 10px;
|
bottom: 10px;
|
||||||
z-index: 2;
|
z-index: 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-float-group-left {
|
.btn-float-group-left {
|
||||||
|
@ -769,9 +769,9 @@
|
|||||||
bootstrap "5.3.3"
|
bootstrap "5.3.3"
|
||||||
|
|
||||||
"@tabler/icons@^3.14.0":
|
"@tabler/icons@^3.14.0":
|
||||||
version "3.16.0"
|
version "3.31.0"
|
||||||
resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-3.16.0.tgz#d618670b80163925a31a6c2290e8775f6058d81a"
|
resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-3.31.0.tgz#92d39dc336f2e3e312170420b00ffe9ca474925e"
|
||||||
integrity sha512-GU7MSx4uQEr55BmyON6hD/QYTl6k1v0YlRhM91gBWDoKAbyCt6QIYw7rpJ/ecdh5zrHaTOJKPenZ4+luoutwFA==
|
integrity sha512-dblAdeKY3+GA1U+Q9eziZ0ooVlZMHsE8dqP0RkwvRtEsAULoKOYaCUOcJ4oW1DjWegdxk++UAt2SlQVnmeHv+g==
|
||||||
|
|
||||||
"@tanstack/react-virtual@^3.0.0-beta.60":
|
"@tanstack/react-virtual@^3.0.0-beta.60":
|
||||||
version "3.5.0"
|
version "3.5.0"
|
||||||
@ -1911,10 +1911,10 @@ graphql@16.10.0:
|
|||||||
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.10.0.tgz#24c01ae0af6b11ea87bf55694429198aaa8e220c"
|
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.10.0.tgz#24c01ae0af6b11ea87bf55694429198aaa8e220c"
|
||||||
integrity sha512-AjqGKbDGUFRKIRCP9tCKiIGHyriz2oHEbPIbEtcSLSs4YjReZOIPQQWek4+6hjw62H9QShXHyaGivGiYVLeYFQ==
|
integrity sha512-AjqGKbDGUFRKIRCP9tCKiIGHyriz2oHEbPIbEtcSLSs4YjReZOIPQQWek4+6hjw62H9QShXHyaGivGiYVLeYFQ==
|
||||||
|
|
||||||
gridstack@11.3.0:
|
gridstack@11.5.0:
|
||||||
version "11.3.0"
|
version "11.5.0"
|
||||||
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-11.3.0.tgz#b110c66bafc64c920fc54933e2c9df4f7b2cfffe"
|
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-11.5.0.tgz#ecd507776db857f3308d37a8fd67d6a24c7fdd74"
|
||||||
integrity sha512-Z0eRovKcZTRTs3zetJwjO6CNwrgIy845WfOeZGk8ybpeMCE8fMA8tScyKU72Y2M6uGHkjgwnjflglvPiv+RcBQ==
|
integrity sha512-SE1a/aC2K8VKQr5cqV7gSJ+r/xIYghijIjHzkZ3Xo3aS1/4dvwIgPYT7QqgV1z+d7XjKYUPEizcgVQ5HhdFTng==
|
||||||
|
|
||||||
has-bigints@^1.0.1, has-bigints@^1.0.2:
|
has-bigints@^1.0.1, has-bigints@^1.0.2:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
@ -2673,10 +2673,10 @@ safe-regex-test@^1.0.3:
|
|||||||
es-errors "^1.3.0"
|
es-errors "^1.3.0"
|
||||||
is-regex "^1.1.4"
|
is-regex "^1.1.4"
|
||||||
|
|
||||||
sass@1.85.0:
|
sass@1.86.0:
|
||||||
version "1.85.0"
|
version "1.86.0"
|
||||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.85.0.tgz#0127ef697d83144496401553f0a0e87be83df45d"
|
resolved "https://registry.yarnpkg.com/sass/-/sass-1.86.0.tgz#f49464fb6237a903a93f4e8760ef6e37a5030114"
|
||||||
integrity sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==
|
integrity sha512-zV8vGUld/+mP4KbMLJMX7TyGCuUp7hnkOScgCMsWuHtns8CWBoz+vmEhoGMXsaJrbUP8gj+F1dLvVe79sK8UdA==
|
||||||
dependencies:
|
dependencies:
|
||||||
chokidar "^4.0.0"
|
chokidar "^4.0.0"
|
||||||
immutable "^5.0.2"
|
immutable "^5.0.2"
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
version: "4.2.5"
|
version: "4.2.6"
|
||||||
edition: "Community"
|
edition: "Community"
|
||||||
published: "2025-03-06"
|
published: "2025-03-21"
|
||||||
|
@ -37,7 +37,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</h2>
|
</h2>
|
||||||
{% with scripts=module.scripts.all %}
|
{% with scripts=module.ordered_scripts %}
|
||||||
{% if scripts %}
|
{% if scripts %}
|
||||||
<table class="table table-hover scripts">
|
<table class="table table-hover scripts">
|
||||||
<thead>
|
<thead>
|
||||||
@ -63,7 +63,7 @@
|
|||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ script.python_class.Meta.description|markdown|placeholder }}</td>
|
<td>{{ script.python_class.description|markdown|placeholder }}</td>
|
||||||
{% if last_job %}
|
{% if last_job %}
|
||||||
<td>
|
<td>
|
||||||
<a href="{% url 'extras:script_result' job_pk=last_job.pk %}">{{ last_job.created|isodatetime }}</a>
|
<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 %}
|
{% if request.user|can_run:script and script.is_executable %}
|
||||||
<div class="float-end d-print-none">
|
<div class="float-end d-print-none">
|
||||||
<form action="{% url 'extras:script' script.pk %}" method="post">
|
<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 %}
|
{% csrf_token %}
|
||||||
<button type="submit" name="_run" class="btn btn-primary btn-sm">
|
<button type="submit" name="_run" class="btn btn-primary btn-sm">
|
||||||
{% if last_job %}
|
{% if last_job %}
|
||||||
|
@ -3,7 +3,7 @@ from typing import Annotated, List
|
|||||||
import strawberry
|
import strawberry
|
||||||
import strawberry_django
|
import strawberry_django
|
||||||
|
|
||||||
from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
|
from extras.graphql.mixins import CustomFieldsMixin, TagsMixin, ContactsMixin
|
||||||
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
|
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
|
||||||
from tenancy import models
|
from tenancy import models
|
||||||
from .mixins import ContactAssignmentsMixin
|
from .mixins import ContactAssignmentsMixin
|
||||||
@ -28,7 +28,7 @@ __all__ = (
|
|||||||
fields='__all__',
|
fields='__all__',
|
||||||
filters=TenantFilter
|
filters=TenantFilter
|
||||||
)
|
)
|
||||||
class TenantType(NetBoxObjectType):
|
class TenantType(ContactsMixin, NetBoxObjectType):
|
||||||
group: Annotated["TenantGroupType", strawberry.lazy('tenancy.graphql.types')] | None
|
group: Annotated["TenantGroupType", strawberry.lazy('tenancy.graphql.types')] | None
|
||||||
|
|
||||||
asns: List[Annotated["ASNType", strawberry.lazy('ipam.graphql.types')]]
|
asns: List[Annotated["ASNType", strawberry.lazy('ipam.graphql.types')]]
|
||||||
|
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@ -198,9 +198,9 @@ class GenericArrayForeignKey(FieldCacheMixin, models.Field):
|
|||||||
Provide a generic many-to-many relation through an 2d array field
|
Provide a generic many-to-many relation through an 2d array field
|
||||||
"""
|
"""
|
||||||
|
|
||||||
many_to_many = True
|
many_to_many = False
|
||||||
many_to_one = False
|
many_to_one = False
|
||||||
one_to_many = False
|
one_to_many = True
|
||||||
one_to_one = False
|
one_to_one = False
|
||||||
|
|
||||||
def __init__(self, field, for_concrete_model=True):
|
def __init__(self, field, for_concrete_model=True):
|
||||||
|
@ -97,10 +97,11 @@ class JSONField(_JSONField):
|
|||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
if not self.help_text:
|
|
||||||
self.help_text = _('Enter context data in <a href="https://json.org/">JSON</a> format.')
|
|
||||||
self.widget.attrs['placeholder'] = ''
|
self.widget.attrs['placeholder'] = ''
|
||||||
self.widget.attrs['class'] = 'font-monospace'
|
self.widget.attrs['class'] = 'font-monospace'
|
||||||
|
if not self.help_text:
|
||||||
|
self.help_text = _('Enter context data in <a href="https://json.org/">JSON</a> format.')
|
||||||
|
|
||||||
def prepare_value(self, value):
|
def prepare_value(self, value):
|
||||||
if isinstance(value, InvalidJSONInput):
|
if isinstance(value, InvalidJSONInput):
|
||||||
|
@ -136,9 +136,11 @@ def get_field_value(form, field_name):
|
|||||||
"""
|
"""
|
||||||
field = form.fields[field_name]
|
field = form.fields[field_name]
|
||||||
|
|
||||||
if form.is_bound and (data := form.data.get(field_name)):
|
if form.is_bound and field_name in form.data:
|
||||||
if hasattr(field, 'valid_value') and field.valid_value(data):
|
if (value := form.data[field_name]) is None:
|
||||||
return data
|
return
|
||||||
|
if hasattr(field, 'valid_value') and field.valid_value(value):
|
||||||
|
return value
|
||||||
|
|
||||||
return form.get_initial_for_field(field, field_name)
|
return form.get_initial_for_field(field, field_name)
|
||||||
|
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from dcim.models import Site
|
||||||
from netbox.choices import ImportFormatChoices
|
from netbox.choices import ImportFormatChoices
|
||||||
from utilities.forms.bulk_import import BulkImportForm
|
from utilities.forms.bulk_import import BulkImportForm
|
||||||
from utilities.forms.forms import BulkRenameForm
|
from utilities.forms.forms import BulkRenameForm
|
||||||
from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipaddress_pattern
|
from utilities.forms.utils import get_field_value, expand_alphanumeric_pattern, expand_ipaddress_pattern
|
||||||
|
|
||||||
|
|
||||||
class ExpandIPAddress(TestCase):
|
class ExpandIPAddress(TestCase):
|
||||||
@ -387,3 +388,63 @@ class BulkRenameFormTest(TestCase):
|
|||||||
self.assertTrue(form.is_valid())
|
self.assertTrue(form.is_valid())
|
||||||
self.assertEqual(form.cleaned_data["find"], " hello ")
|
self.assertEqual(form.cleaned_data["find"], " hello ")
|
||||||
self.assertEqual(form.cleaned_data["replace"], " world ")
|
self.assertEqual(form.cleaned_data["replace"], " world ")
|
||||||
|
|
||||||
|
|
||||||
|
class GetFieldValueTest(TestCase):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
class TestForm(forms.Form):
|
||||||
|
site = forms.ModelChoiceField(
|
||||||
|
queryset=Site.objects.all(),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
cls.form_class = TestForm
|
||||||
|
|
||||||
|
cls.sites = (
|
||||||
|
Site(name='Test Site 1', slug='test-site-1'),
|
||||||
|
Site(name='Test Site 2', slug='test-site-2'),
|
||||||
|
)
|
||||||
|
Site.objects.bulk_create(cls.sites)
|
||||||
|
|
||||||
|
def test_unbound_without_initial(self):
|
||||||
|
form = self.form_class()
|
||||||
|
self.assertEqual(
|
||||||
|
get_field_value(form, 'site'),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unbound_with_initial(self):
|
||||||
|
form = self.form_class(initial={'site': self.sites[0].pk})
|
||||||
|
self.assertEqual(
|
||||||
|
get_field_value(form, 'site'),
|
||||||
|
self.sites[0].pk
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_bound_value_without_initial(self):
|
||||||
|
form = self.form_class({'site': self.sites[0].pk})
|
||||||
|
self.assertEqual(
|
||||||
|
get_field_value(form, 'site'),
|
||||||
|
self.sites[0].pk
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_bound_value_with_initial(self):
|
||||||
|
form = self.form_class({'site': self.sites[0].pk}, initial={'site': self.sites[1].pk})
|
||||||
|
self.assertEqual(
|
||||||
|
get_field_value(form, 'site'),
|
||||||
|
self.sites[0].pk
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_bound_null_without_initial(self):
|
||||||
|
form = self.form_class({'site': None})
|
||||||
|
self.assertEqual(
|
||||||
|
get_field_value(form, 'site'),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_bound_null_with_initial(self):
|
||||||
|
form = self.form_class({'site': None}, initial={'site': self.sites[1].pk})
|
||||||
|
self.assertEqual(
|
||||||
|
get_field_value(form, 'site'),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
@ -149,9 +149,8 @@ class GetReturnURLMixin:
|
|||||||
|
|
||||||
# Attempt to dynamically resolve the list view for the object
|
# Attempt to dynamically resolve the list view for the object
|
||||||
if hasattr(self, 'queryset'):
|
if hasattr(self, 'queryset'):
|
||||||
model_opts = self.queryset.model._meta
|
|
||||||
try:
|
try:
|
||||||
return reverse(f'{model_opts.app_label}:{model_opts.model_name}_list')
|
return reverse(get_viewname(self.queryset.model, 'list'))
|
||||||
except NoReverseMatch:
|
except NoReverseMatch:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@ -112,10 +112,27 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
|
|||||||
brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description')
|
brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description')
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
|
|
||||||
# Validate many-to-many VLAN assignments
|
# Validate many-to-many VLAN assignments
|
||||||
|
virtual_machine = None
|
||||||
|
tagged_vlans = []
|
||||||
|
|
||||||
|
# #18887
|
||||||
|
# There seem to be multiple code paths coming through here. Previously, we might either get
|
||||||
|
# the VirtualMachine instance from self.instance or from incoming data. However, #18887
|
||||||
|
# illustrated that this is also being called when a custom field pointing to an object_type
|
||||||
|
# of VMInterface is on the right side of a custom-field assignment coming in from an API
|
||||||
|
# request. As such, we need to check a third way to access the VirtualMachine
|
||||||
|
# instance--where `data` is the VMInterface instance itself and we can get the associated
|
||||||
|
# VirtualMachine via attribute access.
|
||||||
|
if isinstance(data, dict):
|
||||||
virtual_machine = self.instance.virtual_machine if self.instance else data.get('virtual_machine')
|
virtual_machine = self.instance.virtual_machine if self.instance else data.get('virtual_machine')
|
||||||
for vlan in data.get('tagged_vlans', []):
|
tagged_vlans = data.get('tagged_vlans', [])
|
||||||
|
elif isinstance(data, VMInterface):
|
||||||
|
virtual_machine = data.virtual_machine
|
||||||
|
tagged_vlans = data.tagged_vlans.all()
|
||||||
|
|
||||||
|
if virtual_machine:
|
||||||
|
for vlan in tagged_vlans:
|
||||||
if vlan.site not in [virtual_machine.site, None]:
|
if vlan.site not in [virtual_machine.site, None]:
|
||||||
raise serializers.ValidationError({
|
raise serializers.ValidationError({
|
||||||
'tagged_vlans': f"VLAN {vlan} must belong to the same site as the interface's parent virtual "
|
'tagged_vlans': f"VLAN {vlan} must belong to the same site as the interface's parent virtual "
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
from netbox import denormalized
|
|
||||||
|
|
||||||
|
|
||||||
class VirtualizationConfig(AppConfig):
|
class VirtualizationConfig(AppConfig):
|
||||||
name = 'virtualization'
|
name = 'virtualization'
|
||||||
@ -15,10 +13,5 @@ class VirtualizationConfig(AppConfig):
|
|||||||
# Register models
|
# Register models
|
||||||
register_models(*self.get_models())
|
register_models(*self.get_models())
|
||||||
|
|
||||||
# Register denormalized fields
|
|
||||||
denormalized.register(VirtualMachine, 'cluster', {
|
|
||||||
'site': '_site',
|
|
||||||
})
|
|
||||||
|
|
||||||
# Register counters
|
# Register counters
|
||||||
connect_counters(VirtualMachine)
|
connect_counters(VirtualMachine)
|
||||||
|
@ -33,7 +33,7 @@ class ComponentType(NetBoxObjectType):
|
|||||||
exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'),
|
exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'),
|
||||||
filters=ClusterFilter
|
filters=ClusterFilter
|
||||||
)
|
)
|
||||||
class ClusterType(VLANGroupsMixin, NetBoxObjectType):
|
class ClusterType(ContactsMixin, VLANGroupsMixin, NetBoxObjectType):
|
||||||
type: Annotated["ClusterTypeType", strawberry.lazy('virtualization.graphql.types')] | None
|
type: Annotated["ClusterTypeType", strawberry.lazy('virtualization.graphql.types')] | None
|
||||||
group: Annotated["ClusterGroupType", strawberry.lazy('virtualization.graphql.types')] | None
|
group: Annotated["ClusterGroupType", strawberry.lazy('virtualization.graphql.types')] | None
|
||||||
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
|
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
|
||||||
@ -55,7 +55,7 @@ class ClusterType(VLANGroupsMixin, NetBoxObjectType):
|
|||||||
fields='__all__',
|
fields='__all__',
|
||||||
filters=ClusterGroupFilter
|
filters=ClusterGroupFilter
|
||||||
)
|
)
|
||||||
class ClusterGroupType(VLANGroupsMixin, OrganizationalObjectType):
|
class ClusterGroupType(ContactsMixin, VLANGroupsMixin, OrganizationalObjectType):
|
||||||
|
|
||||||
clusters: List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]
|
clusters: List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@ from django.db.models import Sum
|
|||||||
from django.db.models.signals import post_delete, post_save
|
from django.db.models.signals import post_delete, post_save
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
|
||||||
from .models import VirtualDisk, VirtualMachine
|
from .models import Cluster, VirtualDisk, VirtualMachine
|
||||||
|
|
||||||
|
|
||||||
@receiver((post_delete, post_save), sender=VirtualDisk)
|
@receiver((post_delete, post_save), sender=VirtualDisk)
|
||||||
@ -14,3 +14,12 @@ def update_virtualmachine_disk(instance, **kwargs):
|
|||||||
VirtualMachine.objects.filter(pk=vm.pk).update(
|
VirtualMachine.objects.filter(pk=vm.pk).update(
|
||||||
disk=vm.virtualdisks.aggregate(Sum('size'))['size__sum']
|
disk=vm.virtualdisks.aggregate(Sum('size'))['size__sum']
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=Cluster)
|
||||||
|
def update_virtualmachine_site(instance, **kwargs):
|
||||||
|
"""
|
||||||
|
Update the assigned site for all VMs to match that of the Cluster (if any).
|
||||||
|
"""
|
||||||
|
if instance._site:
|
||||||
|
VirtualMachine.objects.filter(cluster=instance).update(site=instance._site)
|
||||||
|
@ -1,11 +1,15 @@
|
|||||||
|
from django.test import tag
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from netaddr import IPNetwork
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
|
from core.models import ObjectType
|
||||||
from dcim.choices import InterfaceModeChoices
|
from dcim.choices import InterfaceModeChoices
|
||||||
from dcim.models import Site
|
from dcim.models import Site
|
||||||
from extras.models import ConfigTemplate
|
from extras.choices import CustomFieldTypeChoices
|
||||||
|
from extras.models import ConfigTemplate, CustomField
|
||||||
from ipam.choices import VLANQinQRoleChoices
|
from ipam.choices import VLANQinQRoleChoices
|
||||||
from ipam.models import VLAN, VRF
|
from ipam.models import Prefix, VLAN, VRF
|
||||||
from utilities.testing import APITestCase, APIViewTestCases, create_test_device, create_test_virtualmachine
|
from utilities.testing import APITestCase, APIViewTestCases, create_test_device, create_test_virtualmachine
|
||||||
from virtualization.choices import *
|
from virtualization.choices import *
|
||||||
from virtualization.models import *
|
from virtualization.models import *
|
||||||
@ -350,6 +354,39 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@tag('regression')
|
||||||
|
def test_set_vminterface_as_object_in_custom_field(self):
|
||||||
|
cf = CustomField.objects.create(
|
||||||
|
name='associated_interface',
|
||||||
|
type=CustomFieldTypeChoices.TYPE_OBJECT,
|
||||||
|
related_object_type=ObjectType.objects.get_for_model(VMInterface),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
cf.object_types.set([ObjectType.objects.get_for_model(Prefix)])
|
||||||
|
cf.save()
|
||||||
|
|
||||||
|
prefix = Prefix.objects.create(prefix=IPNetwork('10.0.0.0/12'))
|
||||||
|
vmi = VMInterface.objects.first()
|
||||||
|
|
||||||
|
url = reverse('ipam-api:prefix-detail', kwargs={'pk': prefix.pk})
|
||||||
|
data = {
|
||||||
|
'custom_fields': {
|
||||||
|
'associated_interface': vmi.id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
self.add_permissions('ipam.change_prefix')
|
||||||
|
|
||||||
|
response = self.client.patch(url, data, format='json', **self.header)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
prefix_data = response.json()
|
||||||
|
self.assertEqual(prefix_data['custom_fields']['associated_interface']['id'], vmi.id)
|
||||||
|
|
||||||
|
reloaded_prefix = Prefix.objects.get(pk=prefix.pk)
|
||||||
|
self.assertEqual(prefix.pk, reloaded_prefix.pk)
|
||||||
|
self.assertNotEqual(reloaded_prefix.cf['associated_interface'], None)
|
||||||
|
|
||||||
def test_bulk_delete_child_interfaces(self):
|
def test_bulk_delete_child_interfaces(self):
|
||||||
interface1 = VMInterface.objects.get(name='Interface 1')
|
interface1 = VMInterface.objects.get(name='Interface 1')
|
||||||
virtual_machine = interface1.virtual_machine
|
virtual_machine = interface1.virtual_machine
|
||||||
|
@ -27,7 +27,7 @@ __all__ = (
|
|||||||
fields='__all__',
|
fields='__all__',
|
||||||
filters=TunnelGroupFilter
|
filters=TunnelGroupFilter
|
||||||
)
|
)
|
||||||
class TunnelGroupType(OrganizationalObjectType):
|
class TunnelGroupType(ContactsMixin, OrganizationalObjectType):
|
||||||
|
|
||||||
tunnels: List[Annotated["TunnelType", strawberry.lazy('vpn.graphql.types')]]
|
tunnels: List[Annotated["TunnelType", strawberry.lazy('vpn.graphql.types')]]
|
||||||
|
|
||||||
@ -48,7 +48,7 @@ class TunnelTerminationType(CustomFieldsMixin, TagsMixin, ObjectType):
|
|||||||
fields='__all__',
|
fields='__all__',
|
||||||
filters=TunnelFilter
|
filters=TunnelFilter
|
||||||
)
|
)
|
||||||
class TunnelType(NetBoxObjectType):
|
class TunnelType(ContactsMixin, NetBoxObjectType):
|
||||||
group: Annotated["TunnelGroupType", strawberry.lazy('vpn.graphql.types')] | None
|
group: Annotated["TunnelGroupType", strawberry.lazy('vpn.graphql.types')] | None
|
||||||
ipsec_profile: Annotated["IPSecProfileType", strawberry.lazy('vpn.graphql.types')] | None
|
ipsec_profile: Annotated["IPSecProfileType", strawberry.lazy('vpn.graphql.types')] | None
|
||||||
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
|
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
|
||||||
|
@ -2,7 +2,7 @@ Django==5.1.7
|
|||||||
django-cors-headers==4.7.0
|
django-cors-headers==4.7.0
|
||||||
django-debug-toolbar==5.0.1
|
django-debug-toolbar==5.0.1
|
||||||
django-filter==25.1
|
django-filter==25.1
|
||||||
django-htmx==1.22.0
|
django-htmx==1.23.0
|
||||||
django-graphiql-debug-toolbar==0.2.0
|
django-graphiql-debug-toolbar==0.2.0
|
||||||
django-mptt==0.16.0
|
django-mptt==0.16.0
|
||||||
django-pglocks==1.0.4
|
django-pglocks==1.0.4
|
||||||
@ -20,18 +20,18 @@ feedparser==6.0.11
|
|||||||
gunicorn==23.0.0
|
gunicorn==23.0.0
|
||||||
Jinja2==3.1.6
|
Jinja2==3.1.6
|
||||||
Markdown==3.7
|
Markdown==3.7
|
||||||
mkdocs-material==9.6.7
|
mkdocs-material==9.6.9
|
||||||
mkdocstrings[python]==0.28.2
|
mkdocstrings[python]==0.29.0
|
||||||
netaddr==1.3.0
|
netaddr==1.3.0
|
||||||
nh3==0.2.21
|
nh3==0.2.21
|
||||||
Pillow==11.1.0
|
Pillow==11.1.0
|
||||||
psycopg[c,pool]==3.2.5
|
psycopg[c,pool]==3.2.6
|
||||||
PyYAML==6.0.2
|
PyYAML==6.0.2
|
||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
rq==2.1.0
|
rq==2.1.0
|
||||||
social-auth-app-django==5.4.3
|
social-auth-app-django==5.4.3
|
||||||
social-auth-core==4.5.6
|
social-auth-core==4.5.6
|
||||||
strawberry-graphql==0.262.0
|
strawberry-graphql==0.262.5
|
||||||
strawberry-graphql-django==0.52.0
|
strawberry-graphql-django==0.52.0
|
||||||
svgwrite==1.4.3
|
svgwrite==1.4.3
|
||||||
tablib==3.8.0
|
tablib==3.8.0
|
||||||
|
Loading…
Reference in New Issue
Block a user