Merge branch 'main' into 18955-conditional-clear-button

This commit is contained in:
Jeremy Stretch 2025-04-09 14:04:55 -04:00
commit 63e37c5e54
78 changed files with 15133 additions and 13658 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

View File

@ -1,3 +1,3 @@
version: "4.2.5" version: "4.2.6"
edition: "Community" edition: "Community"
published: "2025-03-06" published: "2025-03-21"

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@ exclude = [
"netbox/project-static/**" "netbox/project-static/**"
] ]
line-length = 120 line-length = 120
target-version = "py310"
[lint] [lint]
extend-select = ["E1", "E2", "E3", "E501", "W"] extend-select = ["E1", "E2", "E3", "E501", "W"]