mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-19 01:58:43 -06:00
Compare commits
14 Commits
20911-drop
...
9b72237ce5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b72237ce5 | ||
|
|
52745e39ee | ||
|
|
b12bb38031 | ||
|
|
16a9e5b924 | ||
|
|
97b9805b94 | ||
|
|
fe52e4cd74 | ||
|
|
2966288fb5 | ||
|
|
f5ec885c3a | ||
|
|
6cc182c7ce | ||
|
|
c1c912d84b | ||
|
|
7d945b24cb | ||
|
|
697fda1be3 | ||
|
|
144f23444b | ||
|
|
15c32d44e7 |
@@ -15,7 +15,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.4.10
|
||||
placeholder: v4.4.8
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
@@ -27,7 +27,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.4.10
|
||||
placeholder: v4.4.8
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
16
.github/ISSUE_TEMPLATE/06-deprecation.yaml
vendored
16
.github/ISSUE_TEMPLATE/06-deprecation.yaml
vendored
@@ -1,26 +1,20 @@
|
||||
---
|
||||
name: ⚠️ Deprecation
|
||||
name: 🗑️ Deprecation
|
||||
type: Deprecation
|
||||
description: Designation of a feature or behavior that will be removed in a future release
|
||||
description: The removal of an existing feature or resource
|
||||
labels: ["netbox", "type: deprecation"]
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Deprecated Functionality
|
||||
label: Proposed Changes
|
||||
description: >
|
||||
Describe the feature(s) and/or behavior that is being flagged for deprecation.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Scheduled removal
|
||||
description: In what future release will the deprecated functionality be removed?
|
||||
Describe in detail the proposed changes. What is being removed?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Justification
|
||||
description: Please provide justification for the deprecation.
|
||||
description: Please provide justification for the proposed change(s).
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
|
||||
20
.github/ISSUE_TEMPLATE/07-feature_removal.yaml
vendored
20
.github/ISSUE_TEMPLATE/07-feature_removal.yaml
vendored
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: 🗑️ Feature Removal
|
||||
type: Removal
|
||||
description: The removal of a deprecated feature or resource
|
||||
labels: ["netbox", "type: removal"]
|
||||
body:
|
||||
- type: input
|
||||
attributes:
|
||||
label: Deprecation Issue
|
||||
description: Specify the issue in which this deprecation was announced.
|
||||
placeholder: "#1234"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Summary of Changes
|
||||
description: >
|
||||
List all changes necessary to remove the deprecated feature or resource.
|
||||
validations:
|
||||
required: true
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.12
|
||||
python-version: 3.11
|
||||
|
||||
- name: Install system dependencies
|
||||
run: sudo apt install -y gettext
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,8 +9,7 @@ yarn-error.log*
|
||||
/netbox/netbox/configuration.py
|
||||
/netbox/netbox/ldap_config.py
|
||||
/netbox/local/*
|
||||
/netbox/media/*
|
||||
!/netbox/media/.gitkeep
|
||||
/netbox/media
|
||||
/netbox/reports/*
|
||||
!/netbox/reports/__init__.py
|
||||
/netbox/scripts/*
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<a href="https://github.com/netbox-community/netbox/blob/main/LICENSE.txt"><img src="https://img.shields.io/badge/license-Apache_2.0-blue.svg" alt="License" /></a>
|
||||
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
|
||||
<a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
|
||||
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-16-blue" alt="Languages supported" /></a>
|
||||
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-15-blue" alt="Languages supported" /></a>
|
||||
<a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/actions/workflows/ci.yml/badge.svg" alt="CI status" /></a>
|
||||
<p>
|
||||
<strong><a href="https://netboxlabs.com/community/">NetBox Community</a></strong> |
|
||||
|
||||
13466
contrib/openapi.json
13466
contrib/openapi.json
File diff suppressed because it is too large
Load Diff
@@ -88,7 +88,7 @@ While permissions are typically assigned to specific groups and/or users, it is
|
||||
|
||||
### Viewing Objects
|
||||
|
||||
Object-based permissions work by filtering the database query generated by a user's request to restrict the set of objects returned. When a request is received, NetBox first determines whether the user is authenticated and has been granted permission to perform the requested action. For example, if the requested URL is `/dcim/devices/`, NetBox will check for the `dcim.view_device` permission. If the user has not been assigned this permission (either directly or via a group assignment), NetBox will return a 403 (forbidden) HTTP response.
|
||||
Object-based permissions work by filtering the database query generated by a user's request to restrict the set of objects returned. When a request is received, NetBox first determines whether the user is authenticated and has been granted to perform the requested action. For example, if the requested URL is `/dcim/devices/`, NetBox will check for the `dcim.view_device` permission. If the user has not been assigned this permission (either directly or via a group assignment), NetBox will return a 403 (forbidden) HTTP response.
|
||||
|
||||
If the permission _has_ been granted, NetBox will compile any specified constraints for the model and action. For example, suppose two permissions have been assigned to the user granting view access to the device model, with the following constraints:
|
||||
|
||||
@@ -102,9 +102,9 @@ If the permission _has_ been granted, NetBox will compile any specified constrai
|
||||
This grants the user access to view any device that is assigned to a site named NYC1 or NYC2, **or** which has a status of "offline" and has no tenant assigned. These constraints are equivalent to the following ORM query:
|
||||
|
||||
```no-highlight
|
||||
Device.objects.filter(
|
||||
Site.objects.filter(
|
||||
Q(site__name__in=['NYC1', 'NYC2']),
|
||||
Q(status='offline', tenant__isnull=True)
|
||||
Q(status='active', tenant__isnull=True)
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -10,11 +10,9 @@ Change records are exposed in the API via the read-only endpoint `/api/extras/ob
|
||||
|
||||
## User Messages
|
||||
|
||||
When creating, modifying, or deleting an object in NetBox, a user has the option of recording an arbitrary message (up to 200 characters) that will appear in the change record. This can be helpful to capture additional context, such as the reason for a change or a reference to an external ticket.
|
||||
!!! info "This feature was introduced in NetBox v4.4."
|
||||
|
||||
When editing an object via the web UI, the "Changelog message" field appears at the bottom of the form. This field is optional. The changelog message field is available in object create forms, object edit forms, delete confirmation dialogs, and bulk operations.
|
||||
|
||||
For information on including changelog messages when making changes via the REST API, see [Changelog Messages](../integrations/rest-api.md#changelog-messages).
|
||||
When creating, modifying, or deleting an object in NetBox, a user has the option of recording an arbitrary message that will appear in the change record. This can be helpful to capture additional context, such as the reason for the change.
|
||||
|
||||
## Correlating Changes by Request
|
||||
|
||||
|
||||
@@ -610,7 +610,9 @@ http://netbox/api/dcim/sites/ \
|
||||
|
||||
## Changelog Messages
|
||||
|
||||
Most objects in NetBox support [change logging](../features/change-logging.md), which generates a detailed record each time an object is created, modified, or deleted. Additionally, users can attach a message to the change record as well. This is accomplished via the REST API by including a `changelog_message` field in the object representation.
|
||||
!!! info "This feature was introduced in NetBox v4.4."
|
||||
|
||||
Most objects in NetBox support [change logging](../features/change-logging.md), which generates a detailed record each time an object is created, modified, or deleted. Beginning in NetBox v4.4, users can attach a message to the change record as well. This is accomplished via the REST API by including a `changelog_message` field in the object representation.
|
||||
|
||||
For example, the following API request will create a new site and record a message in the resulting changelog entry:
|
||||
|
||||
@@ -626,7 +628,7 @@ http://netbox/api/dcim/sites/ \
|
||||
}'
|
||||
```
|
||||
|
||||
This approach works when creating, modifying, or deleting objects, either individually or in bulk. For more information about change logging, see [Change Logging](../features/change-logging.md).
|
||||
This approach works when creating, modifying, or deleting objects, either individually or in bulk.
|
||||
|
||||
## Uploading Files
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ The plugin source directory contains all the actual Python code and other resour
|
||||
|
||||
The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below:
|
||||
|
||||
```python title="__init__.py"
|
||||
```python
|
||||
from netbox.plugins import PluginConfig
|
||||
|
||||
class FooBarConfig(PluginConfig):
|
||||
@@ -151,7 +151,7 @@ Any additional apps must be installed within the same Python environment as NetB
|
||||
|
||||
An example `pyproject.toml` is below:
|
||||
|
||||
```toml title="pyproject.toml"
|
||||
```
|
||||
# See PEP 518 for the spec of this file
|
||||
# https://www.python.org/dev/peps/pep-0518/
|
||||
|
||||
@@ -179,24 +179,11 @@ classifiers=[
|
||||
]
|
||||
|
||||
requires-python = ">=3.12.0"
|
||||
|
||||
```
|
||||
|
||||
Many of these are self-explanatory, but for more information, see the [pyproject.toml documentation](https://packaging.python.org/en/latest/specifications/pyproject-toml/).
|
||||
|
||||
## Compatibility Matrix
|
||||
|
||||
Consider adding a file named `COMPATIBILITY.md` to your plugin project root (alongside `pyproject.toml`). This file should contain a table listing the minimum and maximum supported versions of NetBox (`min_version` and `max_version`) for each release. This serves as a handy reference for users who are upgrading from a previous version of your plugin. An example is shown below:
|
||||
|
||||
```markdown title="COMPATIBILITY.md"
|
||||
# Compatibility Matrix
|
||||
|
||||
| Release | Minimum NetBox Version | Maximum NetBox Version |
|
||||
|---------|------------------------|------------------------|
|
||||
| 0.2.0 | 4.4.0 | 4.5.x |
|
||||
| 0.1.1 | 4.3.0 | 4.4.x |
|
||||
| 0.1.0 | 4.3.0 | 4.4.x |
|
||||
```
|
||||
|
||||
## Create a Virtual Environment
|
||||
|
||||
It is strongly recommended to create a Python [virtual environment](https://docs.python.org/3/tutorial/venv.html) for the development of your plugin, as opposed to using system-wide packages. This will afford you complete control over the installed versions of all dependencies and avoid conflict with system packages. This environment can live wherever you'd like;however, it should be excluded from revision control. (A popular convention is to keep all virtual environments in the user's home directory, e.g. `~/.virtualenvs/`.)
|
||||
|
||||
@@ -10,14 +10,6 @@ Minor releases are published in April, August, and December of each calendar yea
|
||||
|
||||
This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
|
||||
|
||||
#### [Version 4.5](./version-4.5.md) (January 2026)
|
||||
|
||||
* Lookup Modifiers in Filter Forms ([#7604](https://github.com/netbox-community/netbox/issues/7604))
|
||||
* Improved API Authentication Tokens ([#20210](https://github.com/netbox-community/netbox/issues/20210))
|
||||
* Object Ownership ([#20304](https://github.com/netbox-community/netbox/issues/20304))
|
||||
* Advanced Port Mappings ([#20564](https://github.com/netbox-community/netbox/issues/20564))
|
||||
* Cable Profiles ([#20788](https://github.com/netbox-community/netbox/issues/20788))
|
||||
|
||||
#### [Version 4.4](./version-4.4.md) (September 2025)
|
||||
|
||||
* Background Jobs for Bulk Operations ([#19589](https://github.com/netbox-community/netbox/issues/19589), [#19891](https://github.com/netbox-community/netbox/issues/19891))
|
||||
|
||||
@@ -1,51 +1,5 @@
|
||||
# NetBox v4.4
|
||||
|
||||
## v4.4.10 (2026-01-06)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#20953](https://github.com/netbox-community/netbox/issues/20953) - Show reverse bridge relationships on interface detail pages
|
||||
* [#21071](https://github.com/netbox-community/netbox/issues/21071) - Include request method & URL when displaying server errors
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#19506](https://github.com/netbox-community/netbox/issues/19506) - Add filter forms for component templates to ensure object selector support
|
||||
* [#20044](https://github.com/netbox-community/netbox/issues/20044) - Fix dark mode support for rack elevations
|
||||
* [#20320](https://github.com/netbox-community/netbox/issues/20320) - Restore support for selecting related interfaces when bulk editing device interfaces
|
||||
* [#20817](https://github.com/netbox-community/netbox/issues/20817) - Re-enable sync button when disabling scheduled syncing for a data source
|
||||
* [#21045](https://github.com/netbox-community/netbox/issues/21045) - Fix `ValueError` exception when saving a site with an assigned prefix
|
||||
* [#21049](https://github.com/netbox-community/netbox/issues/21049) - Ignore stale custom field data when validating an object
|
||||
* [#21063](https://github.com/netbox-community/netbox/issues/21063) - Check for duplicate choice values when validating a custom field choice set
|
||||
* [#21064](https://github.com/netbox-community/netbox/issues/21064) - Ensures that extra choices in custom field choice sets preserve escaped colons
|
||||
|
||||
---
|
||||
|
||||
## v4.4.9 (2025-12-23)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#20309](https://github.com/netbox-community/netbox/issues/20309) - Support ASDOT notation for ASN ranges
|
||||
* [#20720](https://github.com/netbox-community/netbox/issues/20720) - Add Latvian translations
|
||||
* [#20900](https://github.com/netbox-community/netbox/issues/20900) - Allow filtering custom choice fields by multiple values in the UI
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#17976](https://github.com/netbox-community/netbox/issues/17976) - Remove `devicetype_count` from nested manufacturer to correct OpenAPI schema
|
||||
* [#20011](https://github.com/netbox-community/netbox/issues/20011) - Provide a clear message when encountering duplicate object IDs during bulk import
|
||||
* [#20114](https://github.com/netbox-community/netbox/issues/20114) - Preserve `parent_bay` during device bulk import when tags are present
|
||||
* [#20491](https://github.com/netbox-community/netbox/issues/20491) - Improve handling of numeric ranges in tests
|
||||
* [#20873](https://github.com/netbox-community/netbox/issues/20873) - Fix `AttributeError` exception triggered by event rules associated with an object that supports file attachments
|
||||
* [#20875](https://github.com/netbox-community/netbox/issues/20875) - Ensure that parent object relations are cached (for filtering) on device/module components during instantiation
|
||||
* [#20876](https://github.com/netbox-community/netbox/issues/20876) - Allow editing an IP address that resides within a range marked as populated
|
||||
* [#20912](https://github.com/netbox-community/netbox/issues/20912) - Fix inconsistent clearing of `module` field on ModuleBay
|
||||
* [#20944](https://github.com/netbox-community/netbox/issues/20944) - Ensure cached scope is updated on child objects when a parent region/site/location is changed
|
||||
* [#20948](https://github.com/netbox-community/netbox/issues/20948) - Handle the deletion of related objects with `on_delete=RESTRICT` the same as `CASCADE`
|
||||
* [#20969](https://github.com/netbox-community/netbox/issues/20969) - Fix querying of front port templates by `rear_port_id`
|
||||
* [#21011](https://github.com/netbox-community/netbox/issues/21011) - Avoid writing to the database when loading active ConfigRevision
|
||||
* [#21032](https://github.com/netbox-community/netbox/issues/21032) - Avoid SQL subquery in RestrictedQuerySet where unnecessary
|
||||
|
||||
---
|
||||
|
||||
## v4.4.8 (2025-12-09)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
## v4.5.0 (FUTURE)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
* Python 3.10 and 3.11 are no longer supported. NetBox now requires Python 3.12, 3.13, or 3.14.
|
||||
* GraphQL API queries which filter by object IDs or enums must now specify a filter lookup similar to other fields. For example, `id: 123` becomes `id: {exact: 123 }`.
|
||||
* Rendering a device or virtual machine configuration is now restricted to users with the `render_config` permission for the applicable object type.
|
||||
* Retrieval of API token plaintexts is no longer supported. The `ALLOW_TOKEN_RETRIEVAL` config parameter has been removed.
|
||||
* API tokens can no longer be reassigned from one user to another.
|
||||
* A config context assigned to a platform will now also apply to any children of that platform. (Although this is typically desired behavior, it may introduce unanticipated changes for existing deployments.)
|
||||
* The `/api/dcim/cable-terminations/` REST API endpoint is now read-only. Cable terminations must be set on cables directly via the `/api/dcim/cables/` endpoint.
|
||||
* The UI view dedicated to swapping A/Z circuit terminations has been removed.
|
||||
* The experimental HTMX navigation feature has been removed.
|
||||
* The obsolete boolean field `is_staff` has been removed from the `User` model.
|
||||
* Removal of deprecated behavior
|
||||
* The `/api/extras/object-types/` REST API endpoint has been removed. (Use `/api/core/object-types/` instead.)
|
||||
* Webhooks no longer specify a `model` in payload data. (Reference `object_type` instead, which includes the parent app label.)
|
||||
* The obsolete module `core.models.contenttypes` has been removed (replaced in v4.4 by `core.models.object_types`).
|
||||
* The `load_yaml()` and `load_json()` utility methods have been removed from the base class for custom scripts.
|
||||
|
||||
### New Features
|
||||
|
||||
#### Lookup Modifiers in Filter Forms ([#7604](https://github.com/netbox-community/netbox/issues/7604))
|
||||
|
||||
Most object list filters within the UI have been extended to include optional lookup modifiers to support more complex queries. For instance, filters for numeric values now include a dropdown where a user can select "less than," "greater than," or "not" in addition to the default equivalency match. The specific modifiers available depend on the type of each filter. Plugins can register their own filtersets using the `register_filterset()` decorator to enable this new functionality.
|
||||
|
||||
(Note that this feature does not introduce any new filters. Rather, it makes available in the UI filters which already exist.)
|
||||
|
||||
#### Improved API Authentication Tokens ([#20210](https://github.com/netbox-community/netbox/issues/20210))
|
||||
|
||||
This release introduces a new version of API token (v2) which implements several security improvements. HMAC hashing with a cryptographic pepper is used to authenticate these tokens, obviating the need to store plaintexts. The new tokens also employ a non-sensitive key which can be shared to identify tokens without divulging their plaintexts. We've also adopted the standard "bearer" HTTP header format, as shown below.
|
||||
|
||||
```
|
||||
# v1 token header
|
||||
Authorization: Token <TOKEN>
|
||||
|
||||
# v2 token header
|
||||
Authorization: Bearer nbt_<KEY>.<TOKEN>
|
||||
```
|
||||
|
||||
Note that v2 token keys are prefixed with the fixed string `nbt_`, which can be used to aid in secret detection.
|
||||
|
||||
Backward compatibility with legacy (v1) tokens is retained in this release. However, users are strongly encouraged to begin using only v2 tokens, as support for legacy tokens will be removed in NetBox v4.7.
|
||||
|
||||
#### Object Ownership ([#20304](https://github.com/netbox-community/netbox/issues/20304))
|
||||
|
||||
An optional `owner` foreign key field has been added to most models. This enables the assignment of objects to a new Owner model, which represents a set of users and/or groups. Through this relationship, we can now convey ownership of objects within NetBox natively, without needing to rely on the assignment of tags or custom fields.
|
||||
|
||||
(Note that ownership differs significantly in function from tenancy. Ownership determines the parties responsible for the maintenance of an object, whereas as tenancy conveys an operational dependency.)
|
||||
|
||||
#### Advanced Port Mappings ([#20564](https://github.com/netbox-community/netbox/issues/20564))
|
||||
|
||||
The previous many-to-one mapping of front to rear ports has been expanded to support bidirectional mappings. The `rear_port` and `rear_port_position` fields on the FrontPort model have been replaced with an intermediary PortMapping model, which supports any number of assignments between front port/position pair and a rear port/position pair. This change unlocks the ability to model complex inline devices that swap individual fiber pairs between cables.
|
||||
|
||||
#### Cable Profiles ([#20788](https://github.com/netbox-community/netbox/issues/20788))
|
||||
|
||||
Cables can now be assigned profiles which determine how they are treated for path tracing. A profile indicates the number of discrete parallel channels or lanes carried by the cable among its endpoints. For example, a 1-to-4 breakout cable has four lanes, shared at one end via a common termination and split out at the other end to four separate terminations. Profiles, when assigned, enable NetBox to more accurately trace a specific connection within a cable, rather than the cable as a whole.
|
||||
|
||||
The assignment of cable profiles is optional: Cable tracing will continue to operate as before for cables with no profile assigned.
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#16681](https://github.com/netbox-community/netbox/issues/16681) - Introduce a `render_config` permission, which is now required to render a device or virtual machine configuration
|
||||
* [#18658](https://github.com/netbox-community/netbox/issues/18658) - Add a `start_on_boot` choice field for virtual machines
|
||||
* [#19095](https://github.com/netbox-community/netbox/issues/19095) - Add support for Python 3.13 and 3.14
|
||||
* [#19338](https://github.com/netbox-community/netbox/issues/19338) - Enable filter lookups for object IDs and enums in GraphQL API queries
|
||||
* [#19523](https://github.com/netbox-community/netbox/issues/19523) - Cache the number of instances for device, module, and rack types, and enable filtering by these counts
|
||||
* [#20417](https://github.com/netbox-community/netbox/issues/20417) - Add an optional `color` field for device type power outlets
|
||||
* [#20476](https://github.com/netbox-community/netbox/issues/20476) - Once provisioned, the owner of an API token cannot be changed
|
||||
* [#20492](https://github.com/netbox-community/netbox/issues/20492) - Completely disabled the means to retrieve legacy API token plaintexts (removed the `ALLOW_TOKEN_RETRIEVAL` config parameter)
|
||||
* [#20639](https://github.com/netbox-community/netbox/issues/20639) - Apply config contexts to devices/VMs assigned any child platform of the parent platform
|
||||
* [#20834](https://github.com/netbox-community/netbox/issues/20834) - Add an `enabled` boolean field to API tokens
|
||||
* [#20917](https://github.com/netbox-community/netbox/issues/20917) - Include usage reference on API token views
|
||||
* [#20925](https://github.com/netbox-community/netbox/issues/20925) - Add optional `comments` field to all subclasses of `OrganizationalModel`
|
||||
* [#20929](https://github.com/netbox-community/netbox/issues/20929) - Require the `render_config` permission to view a rendered device/VM configuration in the UI
|
||||
* [#20936](https://github.com/netbox-community/netbox/issues/20936) - Introduce the `/api/authentication-check/` REST API endpoint for validating authentication tokens
|
||||
* [#20959](https://github.com/netbox-community/netbox/issues/20959) - Include a count of related module types for a manufacturer in the REST API
|
||||
|
||||
### Plugins
|
||||
|
||||
* [#13182](https://github.com/netbox-community/netbox/issues/13182) - Added `PrimaryModel`, `OrganizationalModel`, and `NestedGroupModel` to the plugins API, as well as their respective base classes for various resources
|
||||
|
||||
### Other Changes
|
||||
|
||||
* [#16137](https://github.com/netbox-community/netbox/issues/16137) - Remove the obsolete boolean field `is_staff` from the `User` model
|
||||
* [#17571](https://github.com/netbox-community/netbox/issues/17571) - Remove the experimental HTMX navigation feature
|
||||
* [#17936](https://github.com/netbox-community/netbox/issues/17936) - Introduce a dedicated `GFKSerializerField` for representing generic foreign keys in API serializers
|
||||
* [#19889](https://github.com/netbox-community/netbox/issues/19889) - Drop support for Python 3.10 and 3.11
|
||||
* [#19898](https://github.com/netbox-community/netbox/issues/19898) - Remove the obsolete REST API endpoint `/api/extras/object-types/`
|
||||
* [#20088](https://github.com/netbox-community/netbox/issues/20088) - Remove the non-deterministic `model` key from webhook payload data
|
||||
* [#20095](https://github.com/netbox-community/netbox/issues/20095) - Remove the obsolete module `core.models.contenttypes`
|
||||
* [#20096](https://github.com/netbox-community/netbox/issues/20096) - Remove the `load_yaml()` and `load_json()` utility methods from the `BaseScript` class
|
||||
* [#20204](https://github.com/netbox-community/netbox/issues/20204) - Started migrating object views from custom HTML templates to declarative layouts
|
||||
* [#20295](https://github.com/netbox-community/netbox/issues/20295) - Cable terminations may be modified via the REST API only by modifying the cable itself
|
||||
* [#20617](https://github.com/netbox-community/netbox/issues/20617) - Introduce `BaseModel` as the global base class for models
|
||||
* [#20683](https://github.com/netbox-community/netbox/issues/20683) - Remove the UI view dedicated to swapping A/Z circuit terminations
|
||||
* [#20926](https://github.com/netbox-community/netbox/issues/20926) - Standardize naming of GraphQL filters
|
||||
|
||||
### REST API Changes
|
||||
|
||||
* Most objects now include an optional `owner` foreign key field.
|
||||
* The `/api/dcim/cable-terminations` endpoint is now read-only.
|
||||
* Introduced the `/api/authentication-check/` endpoint to test REST API credentials
|
||||
* `circuits.CircuitGroup`
|
||||
* Add optional `comments` field
|
||||
* `circuits.CircuitType`
|
||||
* Add optional `comments` field
|
||||
* `circuits.VirtualCircuitType`
|
||||
* Add optional `comments` field
|
||||
* `dcim.Cable`
|
||||
* Add the optional `profile` choice field
|
||||
* `dcim.FrontPort`
|
||||
* Removed the `rear_port` and `rear_port_position` fields
|
||||
* Add the `positions` integer field
|
||||
* Add the `rear_ports` list for port mappings
|
||||
* `dcim.InventoryItemRole`
|
||||
* Add optional `comments` field
|
||||
* `dcim.Manufacturer`
|
||||
* Add optional `comments` field
|
||||
* Add read-only `moduletype_count` integer field
|
||||
* `dcim.ModuleType`
|
||||
* Add read-only `module_count` integer field
|
||||
* `dcim.PowerOutletTemplate`
|
||||
* Add optional `color` field
|
||||
* `dcim.RackRole`
|
||||
* Add optional `comments` field
|
||||
* `dcim.RackType`
|
||||
* Add read-only `rack_count` integer field
|
||||
* `dcim.RearPort`
|
||||
* Add the `front_ports` list for port mappings
|
||||
* `ipam.ASNRange`
|
||||
* Add optional `comments` field
|
||||
* `ipam.RIR`
|
||||
* Add optional `comments` field
|
||||
* `ipam.Role`
|
||||
* Add optional `comments` field
|
||||
* `ipam.VLANGroup`
|
||||
* Add optional `comments` field
|
||||
* `tenancy.ContactRole`
|
||||
* Add optional `comments` field
|
||||
* `users.Token`
|
||||
* Add `enabled` boolean field
|
||||
* `virtualization.ClusterGroup`
|
||||
* Add optional `comments` field
|
||||
* `virtualization.ClusterType`
|
||||
* Add optional `comments` field
|
||||
* `virtualization.VirtualMachine`
|
||||
* Add optional `start_on_boot` choice field
|
||||
* `vpn.TunnelGroup`
|
||||
* Add optional `comments` field
|
||||
@@ -322,7 +322,6 @@ nav:
|
||||
- git Cheat Sheet: 'development/git-cheat-sheet.md'
|
||||
- Release Notes:
|
||||
- Summary: 'release-notes/index.md'
|
||||
- Version 4.5: 'release-notes/version-4.5.md'
|
||||
- Version 4.4: 'release-notes/version-4.4.md'
|
||||
- Version 4.3: 'release-notes/version-4.3.md'
|
||||
- Version 4.2: 'release-notes/version-4.2.md'
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('circuits', '0055_add_comments_to_organizationalmodel'),
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('dcim', '0224_add_comments_to_organizationalmodel'),
|
||||
('extras', '0134_owner'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name='circuittermination',
|
||||
index=models.Index(fields=['termination_type', 'termination_id'], name='circuits_ci_termina_505dda_idx'),
|
||||
),
|
||||
]
|
||||
@@ -335,9 +335,6 @@ class CircuitTermination(
|
||||
name='%(app_label)s_%(class)s_unique_circuit_term_side'
|
||||
),
|
||||
)
|
||||
indexes = (
|
||||
models.Index(fields=('termination_type', 'termination_id')),
|
||||
)
|
||||
verbose_name = _('circuit termination')
|
||||
verbose_name_plural = _('circuit terminations')
|
||||
|
||||
|
||||
@@ -63,20 +63,16 @@ class ConfigRevision(models.Model):
|
||||
return reverse('core:config') # Default config view
|
||||
return reverse('core:configrevision', args=[self.pk])
|
||||
|
||||
def activate(self, update_db=True):
|
||||
def activate(self):
|
||||
"""
|
||||
Cache the configuration data.
|
||||
|
||||
Parameters:
|
||||
update_db: Mark the ConfigRevision as active in the database (default: True)
|
||||
"""
|
||||
cache.set('config', self.data, None)
|
||||
cache.set('config_version', self.pk, None)
|
||||
|
||||
if update_db:
|
||||
# Set all instances of ConfigRevision to false and set this instance to true
|
||||
ConfigRevision.objects.all().update(active=False)
|
||||
ConfigRevision.objects.filter(pk=self.pk).update(active=True)
|
||||
# Set all instances of ConfigRevision to false and set this instance to true
|
||||
ConfigRevision.objects.all().update(active=False)
|
||||
ConfigRevision.objects.filter(pk=self.pk).update(active=True)
|
||||
|
||||
activate.alters_data = True
|
||||
|
||||
|
||||
@@ -131,19 +131,6 @@ class DataSource(JobsMixin, PrimaryModel):
|
||||
'source_url': "URLs for local sources must start with file:// (or specify no scheme)"
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# If recurring sync is disabled for an existing DataSource, clear any pending sync jobs for it and reset its
|
||||
# "queued" status
|
||||
if not self._state.adding and not self.sync_interval:
|
||||
self.jobs.filter(status=JobStatusChoices.STATUS_PENDING).delete()
|
||||
if self.status == DataSourceStatusChoices.QUEUED and self.last_synced:
|
||||
self.status = DataSourceStatusChoices.COMPLETED
|
||||
elif self.status == DataSourceStatusChoices.QUEUED:
|
||||
self.status = DataSourceStatusChoices.NEW
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def to_objectchange(self, action):
|
||||
objectchange = super().to_objectchange(action)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from threading import local
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.db.models import CASCADE, RESTRICT
|
||||
from django.db.models import CASCADE
|
||||
from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel
|
||||
from django.db.models.signals import m2m_changed, post_migrate, post_save, pre_delete
|
||||
from django.dispatch import receiver, Signal
|
||||
@@ -221,7 +221,7 @@ def handle_deleted_object(sender, instance, **kwargs):
|
||||
obj.snapshot() # Ensure the change record includes the "before" state
|
||||
if type(relation) is ManyToManyRel:
|
||||
getattr(obj, related_field_name).remove(instance)
|
||||
elif type(relation) is ManyToOneRel and relation.null and relation.on_delete not in (CASCADE, RESTRICT):
|
||||
elif type(relation) is ManyToOneRel and relation.null and relation.on_delete is not CASCADE:
|
||||
setattr(obj, related_field_name, None)
|
||||
obj.save()
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext as _
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from rest_framework import serializers
|
||||
|
||||
from dcim.choices import *
|
||||
@@ -14,7 +14,6 @@ from ipam.models import VLAN
|
||||
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
|
||||
from netbox.api.gfk_fields import GFKSerializerField
|
||||
from netbox.api.serializers import NetBoxModelSerializer
|
||||
from users.api.serializers_.mixins import OwnerMixin
|
||||
from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer
|
||||
from wireless.api.serializers_.nested import NestedWirelessLinkSerializer
|
||||
from wireless.api.serializers_.wirelesslans import WirelessLANSerializer
|
||||
@@ -41,12 +40,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class ConsoleServerPortSerializer(
|
||||
OwnerMixin,
|
||||
NetBoxModelSerializer,
|
||||
CabledObjectSerializer,
|
||||
ConnectedEndpointsSerializer
|
||||
):
|
||||
class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
|
||||
device = DeviceSerializer(nested=True)
|
||||
module = ModuleSerializer(
|
||||
nested=True,
|
||||
@@ -70,18 +64,13 @@ class ConsoleServerPortSerializer(
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
|
||||
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints',
|
||||
'connected_endpoints_type', 'connected_endpoints_reachable', 'owner', 'tags', 'custom_fields', 'created',
|
||||
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
|
||||
class ConsolePortSerializer(
|
||||
OwnerMixin,
|
||||
NetBoxModelSerializer,
|
||||
CabledObjectSerializer,
|
||||
ConnectedEndpointsSerializer
|
||||
):
|
||||
class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
|
||||
device = DeviceSerializer(nested=True)
|
||||
module = ModuleSerializer(
|
||||
nested=True,
|
||||
@@ -105,18 +94,13 @@ class ConsolePortSerializer(
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
|
||||
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints',
|
||||
'connected_endpoints_type', 'connected_endpoints_reachable', 'owner', 'tags', 'custom_fields', 'created',
|
||||
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
|
||||
class PowerPortSerializer(
|
||||
OwnerMixin,
|
||||
NetBoxModelSerializer,
|
||||
CabledObjectSerializer,
|
||||
ConnectedEndpointsSerializer
|
||||
):
|
||||
class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
|
||||
device = DeviceSerializer(nested=True)
|
||||
module = ModuleSerializer(
|
||||
nested=True,
|
||||
@@ -136,18 +120,13 @@ class PowerPortSerializer(
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'maximum_draw',
|
||||
'allocated_draw', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
|
||||
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'owner', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', '_occupied',
|
||||
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
|
||||
class PowerOutletSerializer(
|
||||
OwnerMixin,
|
||||
NetBoxModelSerializer,
|
||||
CabledObjectSerializer,
|
||||
ConnectedEndpointsSerializer
|
||||
):
|
||||
class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
|
||||
device = DeviceSerializer(nested=True)
|
||||
module = ModuleSerializer(
|
||||
nested=True,
|
||||
@@ -180,17 +159,12 @@ class PowerOutletSerializer(
|
||||
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'status', 'color',
|
||||
'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
|
||||
'link_peers_type', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable',
|
||||
'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
|
||||
'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
|
||||
class InterfaceSerializer(
|
||||
OwnerMixin,
|
||||
NetBoxModelSerializer,
|
||||
CabledObjectSerializer,
|
||||
ConnectedEndpointsSerializer
|
||||
):
|
||||
class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
|
||||
device = DeviceSerializer(nested=True)
|
||||
vdcs = SerializedPKRelatedField(
|
||||
queryset=VirtualDeviceContext.objects.all(),
|
||||
@@ -208,7 +182,6 @@ class InterfaceSerializer(
|
||||
type = ChoiceField(choices=InterfaceTypeChoices)
|
||||
parent = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||
bridge = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||
bridge_interfaces = NestedInterfaceSerializer(many=True, read_only=True)
|
||||
lag = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||
mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True)
|
||||
duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True, allow_null=True)
|
||||
@@ -248,13 +221,13 @@ class InterfaceSerializer(
|
||||
model = Interface
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled',
|
||||
'parent', 'bridge', 'bridge_interfaces', 'lag', 'mtu', 'mac_address', 'primary_mac_address',
|
||||
'mac_addresses', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
|
||||
'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',
|
||||
'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'mark_connected', 'cable', 'cable_end',
|
||||
'wireless_link', 'link_peers', 'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination',
|
||||
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'owner', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
|
||||
'parent', 'bridge', 'lag', 'mtu', 'mac_address', 'primary_mac_address', 'mac_addresses', 'speed', 'duplex',
|
||||
'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'poe_mode', 'poe_type',
|
||||
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
|
||||
'vlan_translation_policy', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers',
|
||||
'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints',
|
||||
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
@@ -334,7 +307,7 @@ class RearPortMappingSerializer(serializers.ModelSerializer):
|
||||
fields = ('position', 'front_port', 'front_port_position')
|
||||
|
||||
|
||||
class RearPortSerializer(OwnerMixin, NetBoxModelSerializer, CabledObjectSerializer, PortSerializer):
|
||||
class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, PortSerializer):
|
||||
device = DeviceSerializer(nested=True)
|
||||
module = ModuleSerializer(
|
||||
nested=True,
|
||||
@@ -354,7 +327,7 @@ class RearPortSerializer(OwnerMixin, NetBoxModelSerializer, CabledObjectSerializ
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions',
|
||||
'front_ports', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
|
||||
'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
|
||||
'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
@@ -372,7 +345,7 @@ class FrontPortMappingSerializer(serializers.ModelSerializer):
|
||||
fields = ('position', 'rear_port', 'rear_port_position')
|
||||
|
||||
|
||||
class FrontPortSerializer(OwnerMixin, NetBoxModelSerializer, CabledObjectSerializer, PortSerializer):
|
||||
class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, PortSerializer):
|
||||
device = DeviceSerializer(nested=True)
|
||||
module = ModuleSerializer(
|
||||
nested=True,
|
||||
@@ -392,12 +365,12 @@ class FrontPortSerializer(OwnerMixin, NetBoxModelSerializer, CabledObjectSeriali
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions',
|
||||
'rear_ports', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
|
||||
'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
|
||||
'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
|
||||
class ModuleBaySerializer(OwnerMixin, NetBoxModelSerializer):
|
||||
class ModuleBaySerializer(NetBoxModelSerializer):
|
||||
device = DeviceSerializer(nested=True)
|
||||
module = ModuleSerializer(
|
||||
nested=True,
|
||||
@@ -417,12 +390,12 @@ class ModuleBaySerializer(OwnerMixin, NetBoxModelSerializer):
|
||||
model = ModuleBay
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'installed_module', 'label', 'position',
|
||||
'description', 'owner', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'description', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'description')
|
||||
|
||||
|
||||
class DeviceBaySerializer(OwnerMixin, NetBoxModelSerializer):
|
||||
class DeviceBaySerializer(NetBoxModelSerializer):
|
||||
device = DeviceSerializer(nested=True)
|
||||
installed_device = DeviceSerializer(nested=True, required=False, allow_null=True)
|
||||
|
||||
@@ -430,12 +403,12 @@ class DeviceBaySerializer(OwnerMixin, NetBoxModelSerializer):
|
||||
model = DeviceBay
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'device', 'name', 'label', 'description', 'installed_device',
|
||||
'owner', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description')
|
||||
|
||||
|
||||
class InventoryItemSerializer(OwnerMixin, NetBoxModelSerializer):
|
||||
class InventoryItemSerializer(NetBoxModelSerializer):
|
||||
device = DeviceSerializer(nested=True)
|
||||
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
|
||||
role = InventoryItemRoleSerializer(nested=True, required=False, allow_null=True)
|
||||
@@ -454,6 +427,6 @@ class InventoryItemSerializer(OwnerMixin, NetBoxModelSerializer):
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'device', 'parent', 'name', 'label', 'status', 'role',
|
||||
'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', 'component_type',
|
||||
'component_id', 'component', 'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_depth',
|
||||
'component_id', 'component', 'tags', 'custom_fields', 'created', 'last_updated', '_depth',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', '_depth')
|
||||
|
||||
@@ -111,7 +111,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
|
||||
'id', 'url', 'display_url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial',
|
||||
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
|
||||
'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
|
||||
'vc_position', 'vc_priority', 'description', 'owner', 'comments', 'config_template', 'config_context',
|
||||
'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'config_context',
|
||||
'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'console_port_count',
|
||||
'console_server_port_count', 'power_port_count', 'power_outlet_count', 'interface_count',
|
||||
'front_port_count', 'rear_port_count', 'device_bay_count', 'module_bay_count', 'inventory_item_count',
|
||||
|
||||
@@ -22,4 +22,4 @@ class ManufacturerSerializer(OrganizationalModelSerializer):
|
||||
'custom_fields', 'created', 'last_updated', 'devicetype_count', 'moduletype_count', 'inventoryitem_count',
|
||||
'platform_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description')
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'devicetype_count')
|
||||
|
||||
@@ -59,17 +59,11 @@ class BaseCableProfile:
|
||||
"""
|
||||
Given a terminating object, return the peer terminating object (if any) on the opposite end of the cable.
|
||||
"""
|
||||
try:
|
||||
connector, position = self.get_mapped_position(
|
||||
termination.cable_end,
|
||||
termination.cable_connector,
|
||||
position
|
||||
)
|
||||
except TypeError:
|
||||
raise ValueError(
|
||||
f"Could not map connector {termination.cable_connector} position {position} on side "
|
||||
f"{termination.cable_end}"
|
||||
)
|
||||
connector, position = self.get_mapped_position(
|
||||
termination.cable_end,
|
||||
termination.cable_connector,
|
||||
position
|
||||
)
|
||||
try:
|
||||
ct = CableTermination.objects.get(
|
||||
cable=termination.cable,
|
||||
@@ -302,7 +296,7 @@ class Breakout1C6Px6C1PCableProfile(BaseCableProfile):
|
||||
}
|
||||
|
||||
|
||||
class Trunk2C4PShuffleCableProfile(BaseCableProfile):
|
||||
class Shuffle2C4PCableProfile(BaseCableProfile):
|
||||
a_connectors = {
|
||||
1: 4,
|
||||
2: 4,
|
||||
@@ -320,7 +314,7 @@ class Trunk2C4PShuffleCableProfile(BaseCableProfile):
|
||||
}
|
||||
|
||||
|
||||
class Trunk4C4PShuffleCableProfile(BaseCableProfile):
|
||||
class Shuffle4C4PCableProfile(BaseCableProfile):
|
||||
a_connectors = {
|
||||
1: 4,
|
||||
2: 4,
|
||||
@@ -348,7 +342,10 @@ class Trunk4C4PShuffleCableProfile(BaseCableProfile):
|
||||
}
|
||||
|
||||
|
||||
class Breakout2C4Px8C1PShuffleCableProfile(BaseCableProfile):
|
||||
class ShuffleBreakout2x8CableProfile(BaseCableProfile):
|
||||
"""
|
||||
Temporary solution for mapping 2 front/rear ports to 8 discrete interfaces
|
||||
"""
|
||||
a_connectors = {
|
||||
1: 4,
|
||||
2: 4,
|
||||
@@ -385,6 +382,6 @@ class Breakout2C4Px8C1PShuffleCableProfile(BaseCableProfile):
|
||||
}
|
||||
|
||||
def get_mapped_position(self, side, connector, position):
|
||||
if side.upper() == CableEndChoices.SIDE_A:
|
||||
if side.lower() == CableEndChoices.SIDE_A:
|
||||
return self._a_mapping.get((connector, position))
|
||||
return self._b_mapping.get((connector, position))
|
||||
|
||||
@@ -1734,60 +1734,66 @@ class CableProfileChoices(ChoiceSet):
|
||||
TRUNK_2C1P = 'trunk-2c1p'
|
||||
TRUNK_2C2P = 'trunk-2c2p'
|
||||
TRUNK_2C4P = 'trunk-2c4p'
|
||||
TRUNK_2C4P_SHUFFLE = 'trunk-2c4p-shuffle'
|
||||
TRUNK_2C6P = 'trunk-2c6p'
|
||||
TRUNK_2C8P = 'trunk-2c8p'
|
||||
TRUNK_2C12P = 'trunk-2c12p'
|
||||
TRUNK_4C1P = 'trunk-4c1p'
|
||||
TRUNK_4C2P = 'trunk-4c2p'
|
||||
TRUNK_4C4P = 'trunk-4c4p'
|
||||
TRUNK_4C4P_SHUFFLE = 'trunk-4c4p-shuffle'
|
||||
TRUNK_4C6P = 'trunk-4c6p'
|
||||
TRUNK_4C8P = 'trunk-4c8p'
|
||||
TRUNK_8C4P = 'trunk-8c4p'
|
||||
# Breakouts
|
||||
BREAKOUT_1C4P_4C1P = 'breakout-1c4p-4c1p'
|
||||
BREAKOUT_1C6P_6C1P = 'breakout-1c6p-6c1p'
|
||||
BREAKOUT_2C4P_8C1P_SHUFFLE = 'breakout-2c4p-8c1p-shuffle'
|
||||
SHUFFLE_BREAKOUT_2X8 = 'shuffle-breakout-2x8'
|
||||
# Shuffles
|
||||
SHUFFLE_2C4P = 'shuffle-2c4p'
|
||||
SHUFFLE_4C4P = 'shuffle-4c4p'
|
||||
|
||||
CHOICES = (
|
||||
(
|
||||
_('Single'),
|
||||
(
|
||||
(SINGLE_1C1P, _('1C1P')),
|
||||
(SINGLE_1C2P, _('1C2P')),
|
||||
(SINGLE_1C4P, _('1C4P')),
|
||||
(SINGLE_1C6P, _('1C6P')),
|
||||
(SINGLE_1C8P, _('1C8P')),
|
||||
(SINGLE_1C12P, _('1C12P')),
|
||||
(SINGLE_1C16P, _('1C16P')),
|
||||
(SINGLE_1C1P, _('Single (1C1P)')),
|
||||
(SINGLE_1C2P, _('Single (1C2P)')),
|
||||
(SINGLE_1C4P, _('Single (1C4P)')),
|
||||
(SINGLE_1C6P, _('Single (1C6P)')),
|
||||
(SINGLE_1C8P, _('Single (1C8P)')),
|
||||
(SINGLE_1C12P, _('Single (1C12P)')),
|
||||
(SINGLE_1C16P, _('Single (1C16P)')),
|
||||
),
|
||||
),
|
||||
(
|
||||
_('Trunk'),
|
||||
(
|
||||
(TRUNK_2C1P, _('2C1P trunk')),
|
||||
(TRUNK_2C2P, _('2C2P trunk')),
|
||||
(TRUNK_2C4P, _('2C4P trunk')),
|
||||
(TRUNK_2C4P_SHUFFLE, _('2C4P trunk (shuffle)')),
|
||||
(TRUNK_2C6P, _('2C6P trunk')),
|
||||
(TRUNK_2C8P, _('2C8P trunk')),
|
||||
(TRUNK_2C12P, _('2C12P trunk')),
|
||||
(TRUNK_4C1P, _('4C1P trunk')),
|
||||
(TRUNK_4C2P, _('4C2P trunk')),
|
||||
(TRUNK_4C4P, _('4C4P trunk')),
|
||||
(TRUNK_4C4P_SHUFFLE, _('4C4P trunk (shuffle)')),
|
||||
(TRUNK_4C6P, _('4C6P trunk')),
|
||||
(TRUNK_4C8P, _('4C8P trunk')),
|
||||
(TRUNK_8C4P, _('8C4P trunk')),
|
||||
(TRUNK_2C1P, _('Trunk (2C1P)')),
|
||||
(TRUNK_2C2P, _('Trunk (2C2P)')),
|
||||
(TRUNK_2C4P, _('Trunk (2C4P)')),
|
||||
(TRUNK_2C6P, _('Trunk (2C6P)')),
|
||||
(TRUNK_2C8P, _('Trunk (2C8P)')),
|
||||
(TRUNK_2C12P, _('Trunk (2C12P)')),
|
||||
(TRUNK_4C1P, _('Trunk (4C1P)')),
|
||||
(TRUNK_4C2P, _('Trunk (4C2P)')),
|
||||
(TRUNK_4C4P, _('Trunk (4C4P)')),
|
||||
(TRUNK_4C6P, _('Trunk (4C6P)')),
|
||||
(TRUNK_4C8P, _('Trunk (4C8P)')),
|
||||
(TRUNK_8C4P, _('Trunk (8C4P)')),
|
||||
),
|
||||
),
|
||||
(
|
||||
_('Breakout'),
|
||||
(
|
||||
(BREAKOUT_1C4P_4C1P, _('1C4P:4C1P breakout')),
|
||||
(BREAKOUT_1C6P_6C1P, _('1C6P:6C1P breakout')),
|
||||
(BREAKOUT_2C4P_8C1P_SHUFFLE, _('2C4P:8C1P breakout (shuffle)')),
|
||||
(BREAKOUT_1C4P_4C1P, _('Breakout (1C4P/4C1P)')),
|
||||
(BREAKOUT_1C6P_6C1P, _('Breakout (1C6P/6C1P)')),
|
||||
),
|
||||
),
|
||||
(
|
||||
_('Shuffle'),
|
||||
(
|
||||
(SHUFFLE_2C4P, _('Shuffle (2C4P)')),
|
||||
(SHUFFLE_4C4P, _('Shuffle (4C4P)')),
|
||||
(SHUFFLE_BREAKOUT_2X8, _('Shuffle breakout (2x8)')),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -905,7 +905,7 @@ class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
|
||||
)
|
||||
rear_port_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='mappings__rear_port',
|
||||
queryset=RearPortTemplate.objects.all(),
|
||||
queryset=RearPort.objects.all(),
|
||||
to_field_name='rear_port',
|
||||
label=_('Rear port (ID)'),
|
||||
)
|
||||
|
||||
@@ -27,45 +27,35 @@ __all__ = (
|
||||
'CableFilterForm',
|
||||
'ConsoleConnectionFilterForm',
|
||||
'ConsolePortFilterForm',
|
||||
'ConsolePortTemplateFilterForm',
|
||||
'ConsoleServerPortFilterForm',
|
||||
'ConsoleServerPortTemplateFilterForm',
|
||||
'DeviceBayFilterForm',
|
||||
'DeviceBayTemplateFilterForm',
|
||||
'DeviceFilterForm',
|
||||
'DeviceRoleFilterForm',
|
||||
'DeviceTypeFilterForm',
|
||||
'FrontPortFilterForm',
|
||||
'FrontPortTemplateFilterForm',
|
||||
'InterfaceConnectionFilterForm',
|
||||
'InterfaceFilterForm',
|
||||
'InterfaceTemplateFilterForm',
|
||||
'InventoryItemFilterForm',
|
||||
'InventoryItemTemplateFilterForm',
|
||||
'InventoryItemRoleFilterForm',
|
||||
'LocationFilterForm',
|
||||
'MACAddressFilterForm',
|
||||
'ManufacturerFilterForm',
|
||||
'ModuleFilterForm',
|
||||
'ModuleBayFilterForm',
|
||||
'ModuleBayTemplateFilterForm',
|
||||
'ModuleTypeFilterForm',
|
||||
'ModuleTypeProfileFilterForm',
|
||||
'PlatformFilterForm',
|
||||
'PowerConnectionFilterForm',
|
||||
'PowerFeedFilterForm',
|
||||
'PowerOutletFilterForm',
|
||||
'PowerOutletTemplateFilterForm',
|
||||
'PowerPanelFilterForm',
|
||||
'PowerPortFilterForm',
|
||||
'PowerPortTemplateFilterForm',
|
||||
'RackFilterForm',
|
||||
'RackElevationFilterForm',
|
||||
'RackReservationFilterForm',
|
||||
'RackRoleFilterForm',
|
||||
'RackTypeFilterForm',
|
||||
'RearPortFilterForm',
|
||||
'RearPortTemplateFilterForm',
|
||||
'RegionFilterForm',
|
||||
'SiteFilterForm',
|
||||
'SiteGroupFilterForm',
|
||||
@@ -1343,23 +1333,6 @@ class PowerFeedFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
# Device components
|
||||
#
|
||||
|
||||
class DeviceComponentTemplateFilterForm(NetBoxModelFilterSetForm):
|
||||
device_type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DeviceType.objects.all(),
|
||||
required=False,
|
||||
label=_('Device type'),
|
||||
)
|
||||
|
||||
|
||||
class ModularDeviceComponentTemplateFilterForm(DeviceComponentTemplateFilterForm):
|
||||
module_type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ModuleType.objects.all(),
|
||||
required=False,
|
||||
query_params={'manufacturer_id': '$manufacturer_id'},
|
||||
label=_('Module Type'),
|
||||
)
|
||||
|
||||
|
||||
class CabledFilterForm(forms.Form):
|
||||
cabled = forms.NullBooleanField(
|
||||
label=_('Cabled'),
|
||||
@@ -1412,20 +1385,6 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ConsolePortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = ConsolePortTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', name=_('Attributes')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=ConsolePortTypeChoices,
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
model = ConsoleServerPort
|
||||
fieldsets = (
|
||||
@@ -1451,20 +1410,6 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = ConsoleServerPortTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', name=_('Attributes')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=ConsolePortTypeChoices,
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
model = PowerPort
|
||||
fieldsets = (
|
||||
@@ -1485,20 +1430,6 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class PowerPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = PowerPortTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', name=_('Attributes')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=PowerPortTypeChoices,
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
model = PowerOutlet
|
||||
fieldsets = (
|
||||
@@ -1528,20 +1459,6 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
)
|
||||
|
||||
|
||||
class PowerOutletTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = PowerOutletTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', name=_('Attributes')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=PowerOutletTypeChoices,
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
model = Interface
|
||||
fieldsets = (
|
||||
@@ -1669,51 +1586,6 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class InterfaceTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = InterfaceTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', 'enabled', 'mgmt_only', name=_('Attributes')),
|
||||
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
|
||||
FieldSet('rf_role', name=_('Wireless')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=InterfaceTypeChoices,
|
||||
required=False
|
||||
)
|
||||
enabled = forms.NullBooleanField(
|
||||
label=_('Enabled'),
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
mgmt_only = forms.NullBooleanField(
|
||||
label=_('Management only'),
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
poe_mode = forms.MultipleChoiceField(
|
||||
choices=InterfacePoEModeChoices,
|
||||
required=False,
|
||||
label=_('PoE mode')
|
||||
)
|
||||
poe_type = forms.MultipleChoiceField(
|
||||
choices=InterfacePoETypeChoices,
|
||||
required=False,
|
||||
label=_('PoE type')
|
||||
)
|
||||
rf_role = forms.MultipleChoiceField(
|
||||
choices=WirelessRoleChoices,
|
||||
required=False,
|
||||
label=_('Wireless role')
|
||||
)
|
||||
|
||||
|
||||
class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
@@ -1738,24 +1610,6 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class FrontPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = FrontPortTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=PortTypeChoices,
|
||||
required=False
|
||||
)
|
||||
color = ColorField(
|
||||
label=_('Color'),
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
model = RearPort
|
||||
fieldsets = (
|
||||
@@ -1780,24 +1634,6 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class RearPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = RearPortTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=PortTypeChoices,
|
||||
required=False
|
||||
)
|
||||
color = ColorField(
|
||||
label=_('Color'),
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class ModuleBayFilterForm(DeviceComponentFilterForm):
|
||||
model = ModuleBay
|
||||
fieldsets = (
|
||||
@@ -1816,19 +1652,6 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
|
||||
)
|
||||
|
||||
|
||||
class ModuleBayTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = ModuleBayTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'position', name=_('Attributes')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
position = forms.CharField(
|
||||
label=_('Position'),
|
||||
required=False,
|
||||
)
|
||||
|
||||
|
||||
class DeviceBayFilterForm(DeviceComponentFilterForm):
|
||||
model = DeviceBay
|
||||
fieldsets = (
|
||||
@@ -1843,15 +1666,6 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class DeviceBayTemplateFilterForm(DeviceComponentTemplateFilterForm):
|
||||
model = DeviceBayTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', name=_('Attributes')),
|
||||
FieldSet('device_type_id', name=_('Device')),
|
||||
)
|
||||
|
||||
|
||||
class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||
model = InventoryItem
|
||||
fieldsets = (
|
||||
@@ -1899,25 +1713,6 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class InventoryItemTemplateFilterForm(DeviceComponentTemplateFilterForm):
|
||||
model = InventoryItemTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'role_id', 'manufacturer_id', name=_('Attributes')),
|
||||
FieldSet('device_type_id', name=_('Device')),
|
||||
)
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
queryset=InventoryItemRole.objects.all(),
|
||||
required=False,
|
||||
label=_('Role')
|
||||
)
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
label=_('Manufacturer')
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Device component roles
|
||||
#
|
||||
|
||||
@@ -733,10 +733,9 @@ class ModuleForm(ModuleCommonForm, PrimaryModelForm):
|
||||
)
|
||||
module_bay = DynamicModelChoiceField(
|
||||
label=_('Module bay'),
|
||||
queryset=ModuleBay.objects.order_by('name'),
|
||||
queryset=ModuleBay.objects.all(),
|
||||
query_params={
|
||||
'device_id': '$device',
|
||||
'ordering': 'name',
|
||||
'device_id': '$device'
|
||||
},
|
||||
context={
|
||||
'disabled': 'installed_module',
|
||||
|
||||
@@ -38,15 +38,6 @@ class ScopedFilterMixin:
|
||||
|
||||
@dataclass
|
||||
class ComponentModelFilterMixin:
|
||||
_site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='site')
|
||||
)
|
||||
_location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='location')
|
||||
)
|
||||
_rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='rack')
|
||||
)
|
||||
device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
||||
device_id: ID | None = strawberry_django.filter_field()
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('dcim', '0224_add_comments_to_organizationalmodel'),
|
||||
('extras', '0134_owner'),
|
||||
('users', '0015_owner'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name='macaddress',
|
||||
index=models.Index(
|
||||
fields=['assigned_object_type', 'assigned_object_id'], name='dcim_macadd_assigne_54115d_idx'
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -115,9 +115,8 @@ class Cable(PrimaryModel):
|
||||
# A copy of the PK to be used by __str__ in case the object is deleted
|
||||
self._pk = self.__dict__.get('id')
|
||||
|
||||
# Cache the original profile & status so we can check later whether either has been changed
|
||||
# Cache the original status so we can check later if it's been changed
|
||||
self._orig_status = self.__dict__.get('status')
|
||||
self._orig_profile = self.__dict__.get('profile')
|
||||
|
||||
self._terminations_modified = False
|
||||
|
||||
@@ -148,20 +147,20 @@ class Cable(PrimaryModel):
|
||||
CableProfileChoices.TRUNK_2C1P: cable_profiles.Trunk2C1PCableProfile,
|
||||
CableProfileChoices.TRUNK_2C2P: cable_profiles.Trunk2C2PCableProfile,
|
||||
CableProfileChoices.TRUNK_2C4P: cable_profiles.Trunk2C4PCableProfile,
|
||||
CableProfileChoices.TRUNK_2C4P_SHUFFLE: cable_profiles.Trunk2C4PShuffleCableProfile,
|
||||
CableProfileChoices.TRUNK_2C6P: cable_profiles.Trunk2C6PCableProfile,
|
||||
CableProfileChoices.TRUNK_2C8P: cable_profiles.Trunk2C8PCableProfile,
|
||||
CableProfileChoices.TRUNK_2C12P: cable_profiles.Trunk2C12PCableProfile,
|
||||
CableProfileChoices.TRUNK_4C1P: cable_profiles.Trunk4C1PCableProfile,
|
||||
CableProfileChoices.TRUNK_4C2P: cable_profiles.Trunk4C2PCableProfile,
|
||||
CableProfileChoices.TRUNK_4C4P: cable_profiles.Trunk4C4PCableProfile,
|
||||
CableProfileChoices.TRUNK_4C4P_SHUFFLE: cable_profiles.Trunk4C4PShuffleCableProfile,
|
||||
CableProfileChoices.TRUNK_4C6P: cable_profiles.Trunk4C6PCableProfile,
|
||||
CableProfileChoices.TRUNK_4C8P: cable_profiles.Trunk4C8PCableProfile,
|
||||
CableProfileChoices.TRUNK_8C4P: cable_profiles.Trunk8C4PCableProfile,
|
||||
CableProfileChoices.BREAKOUT_1C4P_4C1P: cable_profiles.Breakout1C4Px4C1PCableProfile,
|
||||
CableProfileChoices.BREAKOUT_1C6P_6C1P: cable_profiles.Breakout1C6Px6C1PCableProfile,
|
||||
CableProfileChoices.BREAKOUT_2C4P_8C1P_SHUFFLE: cable_profiles.Breakout2C4Px8C1PShuffleCableProfile,
|
||||
CableProfileChoices.SHUFFLE_2C4P: cable_profiles.Shuffle2C4PCableProfile,
|
||||
CableProfileChoices.SHUFFLE_4C4P: cable_profiles.Shuffle4C4PCableProfile,
|
||||
CableProfileChoices.SHUFFLE_BREAKOUT_2X8: cable_profiles.ShuffleBreakout2x8CableProfile,
|
||||
}.get(self.profile)
|
||||
|
||||
def _get_x_terminations(self, side):
|
||||
@@ -291,10 +290,7 @@ class Cable(PrimaryModel):
|
||||
# Update the private PK used in __str__()
|
||||
self._pk = self.pk
|
||||
|
||||
if self._orig_profile != self.profile:
|
||||
print(f'profile changed from {self._orig_profile} to {self.profile}')
|
||||
self.update_terminations(force=True)
|
||||
elif self._terminations_modified:
|
||||
if self._terminations_modified:
|
||||
self.update_terminations()
|
||||
|
||||
super().save(*args, force_update=True, using=using, update_fields=update_fields)
|
||||
@@ -348,28 +344,24 @@ class Cable(PrimaryModel):
|
||||
|
||||
return a_terminations, b_terminations
|
||||
|
||||
def update_terminations(self, force=False):
|
||||
def update_terminations(self):
|
||||
"""
|
||||
Create/delete CableTerminations for this Cable to reflect its current state.
|
||||
|
||||
Args:
|
||||
force: Force the recreation of all CableTerminations, even if no changes have been made. Needed e.g. when
|
||||
altering a Cable's assigned profile.
|
||||
"""
|
||||
a_terminations, b_terminations = self.get_terminations()
|
||||
|
||||
# Delete any stale CableTerminations
|
||||
for termination, ct in a_terminations.items():
|
||||
if force or (termination.pk and termination not in self.a_terminations):
|
||||
if termination.pk and termination not in self.a_terminations:
|
||||
ct.delete()
|
||||
for termination, ct in b_terminations.items():
|
||||
if force or (termination.pk and termination not in self.b_terminations):
|
||||
if termination.pk and termination not in self.b_terminations:
|
||||
ct.delete()
|
||||
|
||||
# Save any new CableTerminations
|
||||
profile = self.profile_class() if self.profile else None
|
||||
for i, termination in enumerate(self.a_terminations, start=1):
|
||||
if force or not termination.pk or termination not in a_terminations:
|
||||
if not termination.pk or termination not in a_terminations:
|
||||
connector = positions = None
|
||||
if profile:
|
||||
connector = i
|
||||
@@ -382,7 +374,7 @@ class Cable(PrimaryModel):
|
||||
termination=termination
|
||||
).save()
|
||||
for i, termination in enumerate(self.b_terminations, start=1):
|
||||
if force or not termination.pk or termination not in b_terminations:
|
||||
if not termination.pk or termination not in b_terminations:
|
||||
connector = positions = None
|
||||
if profile:
|
||||
connector = i
|
||||
@@ -798,8 +790,7 @@ class CablePath(models.Model):
|
||||
# Profile-based tracing
|
||||
if links[0].profile:
|
||||
cable_profile = links[0].profile_class()
|
||||
position = position_stack.pop()[0] if position_stack else None
|
||||
term, position = cable_profile.get_peer_termination(terminations[0], position)
|
||||
term, position = cable_profile.get_peer_termination(terminations[0], position_stack.pop()[0])
|
||||
remote_terminations = [term]
|
||||
position_stack.append([position])
|
||||
|
||||
|
||||
@@ -1292,8 +1292,6 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
|
||||
def save(self, *args, **kwargs):
|
||||
if self.module:
|
||||
self.parent = self.module.module_bay
|
||||
else:
|
||||
self.parent = None
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
|
||||
@@ -959,11 +959,6 @@ class Device(
|
||||
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
|
||||
for component in components:
|
||||
component.custom_field_data = cf_defaults
|
||||
# Set denormalized references
|
||||
for component in components:
|
||||
component._site = self.site
|
||||
component._location = self.location
|
||||
component._rack = self.rack
|
||||
components = model.objects.bulk_create(components)
|
||||
# Prefetch related objects to minimize queries needed during post_save
|
||||
prefetch_fields = get_prefetchable_fields(model)
|
||||
@@ -1323,10 +1318,7 @@ class MACAddress(PrimaryModel):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('mac_address', 'pk')
|
||||
indexes = (
|
||||
models.Index(fields=('assigned_object_type', 'assigned_object_id')),
|
||||
)
|
||||
ordering = ('mac_address', 'pk',)
|
||||
verbose_name = _('MAC address')
|
||||
verbose_name_plural = _('MAC addresses')
|
||||
|
||||
|
||||
@@ -259,13 +259,11 @@ class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
|
||||
module_bays = []
|
||||
modules = []
|
||||
while module:
|
||||
module_module_bay = getattr(module, "module_bay", None)
|
||||
if module.pk in modules or (module_module_bay and module_module_bay.pk in module_bays):
|
||||
if module.pk in modules or module.module_bay.pk in module_bays:
|
||||
raise ValidationError(_("A module bay cannot belong to a module installed within it."))
|
||||
modules.append(module.pk)
|
||||
if module_module_bay:
|
||||
module_bays.append(module_module_bay.pk)
|
||||
module = module_module_bay.module if module_module_bay else None
|
||||
module_bays.append(module.module_bay.pk)
|
||||
module = module.module_bay.module if module.module_bay else None
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
is_new = self.pk is None
|
||||
@@ -323,12 +321,6 @@ class Module(TrackingModelMixin, PrimaryModel, ConfigContextModel):
|
||||
for component in create_instances:
|
||||
component.custom_field_data = cf_defaults
|
||||
|
||||
# Set denormalized references
|
||||
for component in create_instances:
|
||||
component._site = self.device.site
|
||||
component._location = self.device.location
|
||||
component._rack = self.device.rack
|
||||
|
||||
if component_model is not ModuleBay:
|
||||
component_model.objects.bulk_create(create_instances)
|
||||
# Emit the post_save signal for each newly created object
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import logging
|
||||
|
||||
from django.db.models import Q
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.db.models.signals import post_save, post_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
from dcim.choices import CableEndChoices, LinkStatusChoices
|
||||
from ipam.models import Prefix
|
||||
from virtualization.models import Cluster, VMInterface
|
||||
from wireless.models import WirelessLAN
|
||||
from virtualization.models import VMInterface
|
||||
from .models import (
|
||||
Cable, CablePath, CableTermination, ConsolePort, ConsoleServerPort, Device, DeviceBay, FrontPort, Interface,
|
||||
InventoryItem, Location, ModuleBay, PathEndpoint, PortMapping, PowerOutlet, PowerPanel, PowerPort, Rack, RearPort,
|
||||
Site, VirtualChassis,
|
||||
InventoryItem, ModuleBay, PathEndpoint, PortMapping, PowerOutlet, PowerPanel, PowerPort, Rack, RearPort, Location,
|
||||
VirtualChassis,
|
||||
)
|
||||
from .models.cables import trace_paths
|
||||
from .utils import create_cablepaths, rebuild_paths
|
||||
@@ -47,9 +45,6 @@ def handle_location_site_change(instance, created, **kwargs):
|
||||
Device.objects.filter(location__in=locations).update(site=instance.site)
|
||||
PowerPanel.objects.filter(location__in=locations).update(site=instance.site)
|
||||
CableTermination.objects.filter(_location__in=locations).update(_site=instance.site)
|
||||
# Update component models for devices in these locations
|
||||
for model in COMPONENT_MODELS:
|
||||
model.objects.filter(device__location__in=locations).update(_site=instance.site)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Rack)
|
||||
@@ -59,12 +54,6 @@ def handle_rack_site_change(instance, created, **kwargs):
|
||||
"""
|
||||
if not created:
|
||||
Device.objects.filter(rack=instance).update(site=instance.site, location=instance.location)
|
||||
# Update component models for devices in this rack
|
||||
for model in COMPONENT_MODELS:
|
||||
model.objects.filter(device__rack=instance).update(
|
||||
_site=instance.site,
|
||||
_location=instance.location,
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Device)
|
||||
@@ -183,40 +172,3 @@ def update_mac_address_interface(instance, created, raw, **kwargs):
|
||||
if created and not raw and instance.primary_mac_address:
|
||||
instance.primary_mac_address.assigned_object = instance
|
||||
instance.primary_mac_address.save()
|
||||
|
||||
|
||||
@receiver(post_save, sender=Location)
|
||||
@receiver(post_save, sender=Site)
|
||||
def sync_cached_scope_fields(instance, created, **kwargs):
|
||||
"""
|
||||
Rebuild cached scope fields for all CachedScopeMixin-based models
|
||||
affected by a change in a Region, SiteGroup, Site, or Location.
|
||||
|
||||
This method is safe to run for objects created in the past and does
|
||||
not rely on incremental updates. Cached fields are recomputed from
|
||||
authoritative relationships.
|
||||
"""
|
||||
if created:
|
||||
return
|
||||
|
||||
if isinstance(instance, Location):
|
||||
filters = {'_location': instance}
|
||||
elif isinstance(instance, Site):
|
||||
filters = {'_site': instance}
|
||||
else:
|
||||
return
|
||||
|
||||
# These models are explicitly listed because they all subclass CachedScopeMixin
|
||||
# and therefore require their cached scope fields to be recomputed.
|
||||
for model in (Prefix, Cluster, WirelessLAN):
|
||||
qs = model.objects.filter(**filters)
|
||||
|
||||
for obj in qs:
|
||||
# Recompute cache using the same logic as save()
|
||||
obj.cache_related_objects()
|
||||
obj.save(update_fields=[
|
||||
'_location',
|
||||
'_site',
|
||||
'_site_group',
|
||||
'_region',
|
||||
])
|
||||
|
||||
@@ -532,7 +532,7 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
class ManufacturerTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Manufacturer
|
||||
brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
|
||||
brief_fields = ['description', 'devicetype_count', 'display', 'id', 'name', 'slug', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'Manufacturer 4',
|
||||
|
||||
@@ -710,7 +710,7 @@ class CablePathTests(CablePathTestCase):
|
||||
cable.save()
|
||||
cables.append(cable)
|
||||
shuffle_cable = Cable(
|
||||
profile=CableProfileChoices.TRUNK_2C4P_SHUFFLE,
|
||||
profile=CableProfileChoices.SHUFFLE_2C4P,
|
||||
a_terminations=rear_ports[0:2],
|
||||
b_terminations=rear_ports[2:4],
|
||||
)
|
||||
|
||||
@@ -6,7 +6,6 @@ from core.models import ObjectType
|
||||
from dcim.choices import *
|
||||
from dcim.models import *
|
||||
from extras.models import CustomField
|
||||
from ipam.models import Prefix
|
||||
from netbox.choices import WeightUnitChoices
|
||||
from tenancy.models import Tenant
|
||||
from utilities.data import drange
|
||||
@@ -849,32 +848,6 @@ class ModuleBayTestCase(TestCase):
|
||||
nested_bay = module.modulebays.get(name='SFP A-21')
|
||||
self.assertEqual(nested_bay.label, 'A-21')
|
||||
|
||||
@tag('regression') # #20912
|
||||
def test_module_bay_parent_cleared_when_module_removed(self):
|
||||
"""Test that the parent field is properly cleared when a module bay's module assignment is removed"""
|
||||
device = Device.objects.first()
|
||||
manufacturer = Manufacturer.objects.first()
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Test Module Type')
|
||||
bay1 = ModuleBay.objects.create(device=device, name='Test Bay 1')
|
||||
bay2 = ModuleBay.objects.create(device=device, name='Test Bay 2')
|
||||
|
||||
# Install a module in bay1
|
||||
module1 = Module.objects.create(device=device, module_bay=bay1, module_type=module_type)
|
||||
|
||||
# Assign bay2 to module1 and verify parent is now set to bay1 (module1's bay)
|
||||
bay2.module = module1
|
||||
bay2.save()
|
||||
bay2.refresh_from_db()
|
||||
self.assertEqual(bay2.parent, bay1)
|
||||
self.assertEqual(bay2.module, module1)
|
||||
|
||||
# Clear the module assignment (return bay2 to device level) Verify parent is cleared
|
||||
bay2.module = None
|
||||
bay2.save()
|
||||
bay2.refresh_from_db()
|
||||
self.assertIsNone(bay2.parent)
|
||||
self.assertIsNone(bay2.module)
|
||||
|
||||
|
||||
class CableTestCase(TestCase):
|
||||
|
||||
@@ -1206,14 +1179,3 @@ class VirtualChassisTestCase(TestCase):
|
||||
device2.vc_position = 1
|
||||
with self.assertRaises(ValidationError):
|
||||
device2.full_clean()
|
||||
|
||||
|
||||
class SiteSignalTestCase(TestCase):
|
||||
|
||||
@tag('regression')
|
||||
def test_edit_site_with_prefix_no_vrf(self):
|
||||
site = Site.objects.create(name='Test Site', slug='test-site')
|
||||
Prefix.objects.create(prefix='192.0.2.0/24', scope=site, vrf=None)
|
||||
|
||||
# Regression test for #21045: should not raise ValueError
|
||||
site.save()
|
||||
|
||||
@@ -11,7 +11,6 @@ from core.models import ObjectType
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models import *
|
||||
from extras.models import ConfigTemplate
|
||||
from ipam.models import ASN, RIR, VLAN, VRF
|
||||
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, WeightUnitChoices
|
||||
from tenancy.models import Tenant
|
||||
@@ -2340,54 +2339,6 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
url = reverse('dcim:device_inventory', kwargs={'pk': device.pk})
|
||||
self.assertHttpStatus(self.client.get(url), 200)
|
||||
|
||||
def test_device_renderconfig(self):
|
||||
configtemplate = ConfigTemplate.objects.create(
|
||||
name='Test Config Template',
|
||||
template_code='Config for device {{ device.name }}'
|
||||
)
|
||||
device = Device.objects.first()
|
||||
device.config_template = configtemplate
|
||||
device.save()
|
||||
url = reverse('dcim:device_render-config', kwargs={'pk': device.pk})
|
||||
|
||||
# User with only view permission should NOT be able to render config
|
||||
self.add_permissions('dcim.view_device')
|
||||
self.assertHttpStatus(self.client.get(url), 403)
|
||||
|
||||
# With render_config permission added should be able to render config
|
||||
self.add_permissions('dcim.render_config_device')
|
||||
self.assertHttpStatus(self.client.get(url), 200)
|
||||
|
||||
# With view permission removed should NOT be able to render config
|
||||
self.remove_permissions('dcim.view_device')
|
||||
self.assertHttpStatus(self.client.get(url), 403)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_bulk_import_duplicate_ids_error_message(self):
|
||||
device = Device.objects.first()
|
||||
csv_data = (
|
||||
"id,role",
|
||||
f"{device.pk},Device Role 1",
|
||||
f"{device.pk},Device Role 2",
|
||||
)
|
||||
|
||||
self.add_permissions('dcim.add_device', 'dcim.change_device')
|
||||
response = self.client.post(
|
||||
self._get_url('bulk_import'),
|
||||
{
|
||||
'data': '\n'.join(csv_data),
|
||||
'format': ImportFormatChoices.CSV,
|
||||
'csv_delimiter': CSVDelimiterChoices.AUTO,
|
||||
},
|
||||
follow=True
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(
|
||||
f'Duplicate objects found: Device with ID(s) {device.pk} appears multiple times',
|
||||
response.content.decode('utf-8')
|
||||
)
|
||||
|
||||
|
||||
class ModuleTestCase(
|
||||
# Module does not support bulk renaming (no name field) or
|
||||
|
||||
@@ -16,7 +16,7 @@ from circuits.models import Circuit, CircuitTermination
|
||||
from dcim.ui import panels
|
||||
from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
|
||||
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
|
||||
from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
|
||||
from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN
|
||||
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
|
||||
from netbox.object_actions import *
|
||||
from netbox.ui import actions, layout
|
||||
@@ -2682,7 +2682,6 @@ class DeviceConfigContextView(ObjectConfigContextView):
|
||||
class DeviceRenderConfigView(ObjectRenderConfigView):
|
||||
queryset = Device.objects.all()
|
||||
base_template = 'dcim/device/base.html'
|
||||
additional_permissions = ['dcim.render_config_device']
|
||||
tab = ViewTab(
|
||||
label=_('Render Config'),
|
||||
weight=2100,
|
||||
@@ -2714,12 +2713,11 @@ class DeviceBulkImportView(generic.BulkImportView):
|
||||
model_form = forms.DeviceImportForm
|
||||
|
||||
def save_object(self, object_form, request):
|
||||
parent_bay = getattr(object_form.instance, 'parent_bay', None)
|
||||
obj = object_form.save()
|
||||
|
||||
# For child devices, save the reverse relation to the parent device bay
|
||||
if parent_bay:
|
||||
device_bay = parent_bay
|
||||
if getattr(obj, 'parent_bay', None):
|
||||
device_bay = obj.parent_bay
|
||||
device_bay.installed_device = obj
|
||||
device_bay.save()
|
||||
|
||||
@@ -3160,7 +3158,6 @@ class InterfaceView(generic.ObjectView):
|
||||
|
||||
return {
|
||||
'vdc_table': vdc_table,
|
||||
'bridge_interfaces': bridge_interfaces,
|
||||
'bridge_interfaces_table': bridge_interfaces_table,
|
||||
'child_interfaces_table': child_interfaces_table,
|
||||
'vlan_table': vlan_table,
|
||||
|
||||
@@ -119,9 +119,7 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
|
||||
if snapshots:
|
||||
params["snapshots"] = snapshots
|
||||
if request:
|
||||
# Exclude FILES - webhooks don't need uploaded files,
|
||||
# which can cause pickle errors with Pillow.
|
||||
params["request"] = copy_safe_request(request, include_files=False)
|
||||
params["request"] = copy_safe_request(request)
|
||||
|
||||
# Enqueue the task
|
||||
rq_queue.enqueue(
|
||||
|
||||
@@ -189,22 +189,22 @@ class CustomFieldChoiceSetForm(ChangelogMessageMixin, OwnerMixin, forms.ModelFor
|
||||
# if standardize these, we can simplify this code
|
||||
|
||||
# Convert extra_choices Array Field from model to CharField for form
|
||||
if extra_choices := self.initial.get('extra_choices', None):
|
||||
if 'extra_choices' in self.initial and self.initial['extra_choices']:
|
||||
extra_choices = self.initial['extra_choices']
|
||||
if isinstance(extra_choices, str):
|
||||
extra_choices = [extra_choices]
|
||||
choices = []
|
||||
choices = ""
|
||||
for choice in extra_choices:
|
||||
# Setup choices in Add Another use case
|
||||
if isinstance(choice, str):
|
||||
choice_str = ":".join(choice.replace("'", "").replace(" ", "")[1:-1].split(","))
|
||||
choices.append(choice_str)
|
||||
choices += choice_str + "\n"
|
||||
# Setup choices in Edit use case
|
||||
elif isinstance(choice, list):
|
||||
value = choice[0].replace(':', '\\:')
|
||||
label = choice[1].replace(':', '\\:')
|
||||
choices.append(f'{value}:{label}')
|
||||
choice_str = ":".join(choice)
|
||||
choices += choice_str + "\n"
|
||||
|
||||
self.initial['extra_choices'] = '\n'.join(choices)
|
||||
self.initial['extra_choices'] = choices
|
||||
|
||||
def clean_extra_choices(self):
|
||||
data = []
|
||||
|
||||
@@ -450,14 +450,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
|
||||
return model.objects.filter(pk__in=value)
|
||||
return value
|
||||
|
||||
def to_form_field(
|
||||
self,
|
||||
set_initial=True,
|
||||
enforce_required=True,
|
||||
enforce_visibility=True,
|
||||
for_csv_import=False,
|
||||
for_filterset_form=False,
|
||||
):
|
||||
def to_form_field(self, set_initial=True, enforce_required=True, enforce_visibility=True, for_csv_import=False):
|
||||
"""
|
||||
Return a form field suitable for setting a CustomField's value for an object.
|
||||
|
||||
@@ -465,7 +458,6 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
|
||||
enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
|
||||
enforce_visibility: Honor the value of CustomField.ui_visible. Set to False for filtering.
|
||||
for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
|
||||
for_filterset_form: Return a form field suitable for use in a FilterSet form.
|
||||
"""
|
||||
initial = self.default if set_initial else None
|
||||
required = self.required if enforce_required else False
|
||||
@@ -528,7 +520,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
|
||||
field_class = CSVMultipleChoiceField
|
||||
field = field_class(choices=choices, required=required, initial=initial)
|
||||
else:
|
||||
if self.type == CustomFieldTypeChoices.TYPE_SELECT and not for_filterset_form:
|
||||
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||
field_class = DynamicChoiceField
|
||||
widget_class = APISelect
|
||||
else:
|
||||
@@ -879,16 +871,6 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, OwnerMixin, Chang
|
||||
if not self.base_choices and not self.extra_choices:
|
||||
raise ValidationError(_("Must define base or extra choices."))
|
||||
|
||||
# Check for duplicate values in extra_choices
|
||||
choice_values = [c[0] for c in self.extra_choices] if self.extra_choices else []
|
||||
if len(set(choice_values)) != len(choice_values):
|
||||
# At least one duplicate value is present. Find the first one and raise an error.
|
||||
_seen = []
|
||||
for value in choice_values:
|
||||
if value in _seen:
|
||||
raise ValidationError(_("Duplicate value '{value}' found in extra choices.").format(value=value))
|
||||
_seen.append(value)
|
||||
|
||||
# Check whether any choices have been removed. If so, check whether any of the removed
|
||||
# choices are still set in custom field data for any object.
|
||||
original_choices = set([
|
||||
|
||||
@@ -1506,17 +1506,18 @@ class CustomFieldModelTest(TestCase):
|
||||
|
||||
def test_invalid_data(self):
|
||||
"""
|
||||
Any invalid or stale custom field data should be removed from the instance.
|
||||
Setting custom field data for a non-applicable (or non-existent) CustomField should raise a ValidationError.
|
||||
"""
|
||||
site = Site(name='Test Site', slug='test-site')
|
||||
|
||||
# Set custom field data
|
||||
site.custom_field_data['foo'] = 'abc'
|
||||
site.custom_field_data['bar'] = 'def'
|
||||
site.clean()
|
||||
with self.assertRaises(ValidationError):
|
||||
site.clean()
|
||||
|
||||
self.assertIn('foo', site.custom_field_data)
|
||||
self.assertNotIn('bar', site.custom_field_data)
|
||||
del site.custom_field_data['bar']
|
||||
site.clean()
|
||||
|
||||
def test_missing_required_field(self):
|
||||
"""
|
||||
|
||||
@@ -5,7 +5,6 @@ from dcim.forms import SiteForm
|
||||
from dcim.models import Site
|
||||
from extras.choices import CustomFieldTypeChoices
|
||||
from extras.forms import SavedFilterForm
|
||||
from extras.forms.model_forms import CustomFieldChoiceSetForm
|
||||
from extras.models import CustomField, CustomFieldChoiceSet
|
||||
|
||||
|
||||
@@ -91,31 +90,6 @@ class CustomFieldModelFormTest(TestCase):
|
||||
self.assertIsNone(instance.custom_field_data[field_type])
|
||||
|
||||
|
||||
class CustomFieldChoiceSetFormTest(TestCase):
|
||||
|
||||
def test_escaped_colons_preserved_on_edit(self):
|
||||
choice_set = CustomFieldChoiceSet.objects.create(
|
||||
name='Test Choice Set',
|
||||
extra_choices=[['foo:bar', 'label'], ['value', 'label:with:colons']]
|
||||
)
|
||||
|
||||
form = CustomFieldChoiceSetForm(instance=choice_set)
|
||||
initial_choices = form.initial['extra_choices']
|
||||
|
||||
# colons are re-escaped
|
||||
self.assertEqual(initial_choices, 'foo\\:bar:label\nvalue:label\\:with\\:colons')
|
||||
|
||||
form = CustomFieldChoiceSetForm(
|
||||
{'name': choice_set.name, 'extra_choices': initial_choices},
|
||||
instance=choice_set
|
||||
)
|
||||
self.assertTrue(form.is_valid())
|
||||
updated = form.save()
|
||||
|
||||
# cleaned extra choices are correct, which does actually mean a list of tuples
|
||||
self.assertEqual(updated.extra_choices, [('foo:bar', 'label'), ('value', 'label:with:colons')])
|
||||
|
||||
|
||||
class SavedFilterFormTest(TestCase):
|
||||
|
||||
def test_basic_submit(self):
|
||||
|
||||
@@ -51,14 +51,7 @@ class ImageAttachmentsPanel(panels.ObjectsTablePanel):
|
||||
]
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
'extras.imageattachment',
|
||||
filters={
|
||||
'object_type_id': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
|
||||
'object_id': lambda ctx: ctx['object'].pk,
|
||||
},
|
||||
**kwargs,
|
||||
)
|
||||
super().__init__('extras.imageattachment', **kwargs)
|
||||
|
||||
|
||||
class TagsPanel(panels.ObjectPanel):
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from dcim.models import Site
|
||||
from ipam.models import ASN, ASNRange, RIR
|
||||
from netbox.api.fields import RelatedObjectCountField, SerializedPKRelatedField
|
||||
from netbox.api.fields import RelatedObjectCountField
|
||||
from netbox.api.serializers import OrganizationalModelSerializer, PrimaryModelSerializer
|
||||
from tenancy.api.serializers_.tenants import TenantSerializer
|
||||
|
||||
__all__ = (
|
||||
'ASNRangeSerializer',
|
||||
'ASNSerializer',
|
||||
'ASNSiteSerializer',
|
||||
'AvailableASNSerializer',
|
||||
'RIRSerializer',
|
||||
)
|
||||
@@ -43,27 +41,9 @@ class ASNRangeSerializer(OrganizationalModelSerializer):
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
class ASNSiteSerializer(PrimaryModelSerializer):
|
||||
"""
|
||||
This serializer is meant for inclusion in ASNSerializer and is only used
|
||||
to avoid a circular import of SiteSerializer.
|
||||
"""
|
||||
class Meta:
|
||||
model = Site
|
||||
fields = ('id', 'url', 'display', 'name', 'description', 'slug')
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description', 'slug')
|
||||
|
||||
|
||||
class ASNSerializer(PrimaryModelSerializer):
|
||||
rir = RIRSerializer(nested=True, required=False, allow_null=True)
|
||||
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
|
||||
sites = SerializedPKRelatedField(
|
||||
queryset=Site.objects.all(),
|
||||
serializer=ASNSiteSerializer,
|
||||
nested=True,
|
||||
required=False,
|
||||
many=True
|
||||
)
|
||||
|
||||
# Related object counts
|
||||
site_count = RelatedObjectCountField('sites')
|
||||
@@ -73,7 +53,7 @@ class ASNSerializer(PrimaryModelSerializer):
|
||||
model = ASN
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'asn', 'rir', 'tenant', 'description', 'owner', 'comments', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'site_count', 'provider_count', 'sites',
|
||||
'custom_fields', 'created', 'last_updated', 'site_count', 'provider_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'asn', 'description')
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ __all__ = (
|
||||
# BGP ASN bounds
|
||||
BGP_ASN_MIN = 1
|
||||
BGP_ASN_MAX = 2**32 - 1
|
||||
BGP_ASN_ASDOT_BASE = 2**16
|
||||
|
||||
|
||||
class BaseIPField(models.Field):
|
||||
@@ -127,16 +126,3 @@ class ASNField(models.BigIntegerField):
|
||||
}
|
||||
defaults.update(**kwargs)
|
||||
return super().formfield(**defaults)
|
||||
|
||||
@staticmethod
|
||||
def to_asdot(value) -> str:
|
||||
"""
|
||||
Return ASDOT notation for AS numbers greater than 16 bits.
|
||||
"""
|
||||
if value is None:
|
||||
return ''
|
||||
|
||||
if value >= BGP_ASN_ASDOT_BASE:
|
||||
hi, lo = divmod(value, BGP_ASN_ASDOT_BASE)
|
||||
return f'{hi}.{lo}'
|
||||
return str(value)
|
||||
|
||||
@@ -229,6 +229,10 @@ class PrefixImportForm(ScopedImportForm, PrimaryModelImportForm):
|
||||
query |= Q(**{
|
||||
f"site__{self.fields['vlan_site'].to_field_name}": vlan_site
|
||||
})
|
||||
# Don't Forget to include VLANs without a site in the filter
|
||||
query |= Q(**{
|
||||
f"site__{self.fields['vlan_site'].to_field_name}__isnull": True
|
||||
})
|
||||
|
||||
if vlan_group:
|
||||
query &= Q(**{
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('dcim', '0225_gfk_indexes'),
|
||||
('extras', '0134_owner'),
|
||||
('ipam', '0085_add_comments_to_organizationalmodel'),
|
||||
('tenancy', '0022_add_comments_to_organizationalmodel'),
|
||||
('users', '0015_owner'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name='prefix',
|
||||
index=models.Index(fields=['scope_type', 'scope_id'], name='ipam_prefix_scope_t_fe84a6_idx'),
|
||||
),
|
||||
]
|
||||
@@ -55,6 +55,13 @@ class ASNRange(OrganizationalModel):
|
||||
def __str__(self):
|
||||
return f'{self.name} ({self.range_as_string()})'
|
||||
|
||||
@property
|
||||
def range(self):
|
||||
return range(self.start, self.end + 1)
|
||||
|
||||
def range_as_string(self):
|
||||
return f'{self.start}-{self.end}'
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
@@ -65,45 +72,7 @@ class ASNRange(OrganizationalModel):
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def range(self):
|
||||
"""
|
||||
Return a range of integers representing the ASN range.
|
||||
"""
|
||||
return range(self.start, self.end + 1)
|
||||
|
||||
@property
|
||||
def start_asdot(self):
|
||||
"""
|
||||
Return ASDOT notation for AS numbers greater than 16 bits.
|
||||
"""
|
||||
return ASNField.to_asdot(self.start)
|
||||
|
||||
@property
|
||||
def end_asdot(self):
|
||||
"""
|
||||
Return ASDOT notation for AS numbers greater than 16 bits.
|
||||
"""
|
||||
return ASNField.to_asdot(self.end)
|
||||
|
||||
def range_as_string(self):
|
||||
"""
|
||||
Return a string representation of the ASN range.
|
||||
"""
|
||||
return f'{self.start}-{self.end}'
|
||||
|
||||
def range_as_string_with_asdot(self):
|
||||
"""
|
||||
Return a string representation of the ASN range, including ASDOT notation.
|
||||
"""
|
||||
if self.end >= 65536:
|
||||
return f'{self.range_as_string()} ({self.start_asdot}-{self.end_asdot})'
|
||||
return self.range_as_string()
|
||||
|
||||
def get_child_asns(self):
|
||||
"""
|
||||
Return all child ASNs (ASNs within the range).
|
||||
"""
|
||||
return ASN.objects.filter(
|
||||
asn__gte=self.start,
|
||||
asn__lte=self.end
|
||||
@@ -162,20 +131,20 @@ class ASN(ContactsMixin, PrimaryModel):
|
||||
"""
|
||||
Return ASDOT notation for AS numbers greater than 16 bits.
|
||||
"""
|
||||
return ASNField.to_asdot(self.asn)
|
||||
if self.asn > 65535:
|
||||
return f'{self.asn // 65536}.{self.asn % 65536}'
|
||||
return self.asn
|
||||
|
||||
@property
|
||||
def asn_with_asdot(self):
|
||||
"""
|
||||
Return both plain and ASDOT notation, where applicable.
|
||||
"""
|
||||
if self.asn >= 65536:
|
||||
return f'{self.asn} ({self.asn_asdot})'
|
||||
return str(self.asn)
|
||||
if self.asn > 65535:
|
||||
return f'{self.asn} ({self.asn // 65536}.{self.asn % 65536})'
|
||||
else:
|
||||
return self.asn
|
||||
|
||||
@property
|
||||
def prefixed_name(self):
|
||||
"""
|
||||
Return the ASN with ASDOT notation prefixed with "AS".
|
||||
"""
|
||||
return f'AS{self.asn_with_asdot}'
|
||||
|
||||
@@ -282,10 +282,13 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
|
||||
ordering = (F('vrf').asc(nulls_first=True), 'prefix', 'pk') # (vrf, prefix) may be non-unique
|
||||
verbose_name = _('prefix')
|
||||
verbose_name_plural = _('prefixes')
|
||||
indexes = (
|
||||
models.Index(fields=('scope_type', 'scope_id')),
|
||||
GistIndex(fields=['prefix'], name='ipam_prefix_gist_idx', opclasses=['inet_ops']),
|
||||
)
|
||||
indexes = [
|
||||
GistIndex(
|
||||
fields=['prefix'],
|
||||
name='ipam_prefix_gist_idx',
|
||||
opclasses=['inet_ops'],
|
||||
),
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -907,13 +910,13 @@ class IPAddress(ContactsMixin, PrimaryModel):
|
||||
})
|
||||
|
||||
# Disallow the creation of IPAddresses within an IPRange with mark_populated=True
|
||||
parent_range_qs = IPRange.objects.filter(
|
||||
parent_range = IPRange.objects.filter(
|
||||
start_address__lte=self.address,
|
||||
end_address__gte=self.address,
|
||||
vrf=self.vrf,
|
||||
mark_populated=True
|
||||
)
|
||||
if not self.pk and (parent_range := parent_range_qs.first()):
|
||||
).first()
|
||||
if parent_range:
|
||||
raise ValidationError({
|
||||
'address': _(
|
||||
"Cannot create IP address {ip} inside range {range}."
|
||||
|
||||
@@ -20,16 +20,6 @@ class ASNRangeTable(TenancyColumnsMixin, OrganizationalModelTable):
|
||||
verbose_name=_('RIR'),
|
||||
linkify=True
|
||||
)
|
||||
start_asdot = tables.Column(
|
||||
accessor=tables.A('start_asdot'),
|
||||
order_by=tables.A('start'),
|
||||
verbose_name=_('Start (ASDOT)')
|
||||
)
|
||||
end_asdot = tables.Column(
|
||||
accessor=tables.A('end_asdot'),
|
||||
order_by=tables.A('end'),
|
||||
verbose_name=_('End (ASDOT)')
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='ipam:asnrange_list'
|
||||
)
|
||||
@@ -40,8 +30,8 @@ class ASNRangeTable(TenancyColumnsMixin, OrganizationalModelTable):
|
||||
class Meta(OrganizationalModelTable.Meta):
|
||||
model = ASNRange
|
||||
fields = (
|
||||
'pk', 'name', 'slug', 'rir', 'start', 'start_asdot', 'end', 'end_asdot', 'asn_count', 'tenant',
|
||||
'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated', 'actions',
|
||||
'pk', 'name', 'slug', 'rir', 'start', 'end', 'asn_count', 'tenant', 'tenant_group', 'description',
|
||||
'comments', 'tags', 'created', 'last_updated', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'rir', 'start', 'end', 'tenant', 'asn_count', 'description')
|
||||
|
||||
|
||||
@@ -1071,17 +1071,14 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase):
|
||||
{
|
||||
'name': 'VLAN Group 4',
|
||||
'slug': 'vlan-group-4',
|
||||
'vid_ranges': [[1, 4094]]
|
||||
},
|
||||
{
|
||||
'name': 'VLAN Group 5',
|
||||
'slug': 'vlan-group-5',
|
||||
'vid_ranges': [[1, 4094]]
|
||||
},
|
||||
{
|
||||
'name': 'VLAN Group 6',
|
||||
'slug': 'vlan-group-6',
|
||||
'vid_ranges': [[1, 4094]]
|
||||
},
|
||||
]
|
||||
bulk_update_data = {
|
||||
|
||||
@@ -564,82 +564,6 @@ vlan: 102
|
||||
self.assertEqual(prefix.vlan.vid, 102)
|
||||
self.assertEqual(prefix.scope, site)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_prefix_import_with_vlan_site_multiple_vlans_same_vid(self):
|
||||
"""
|
||||
Test import when multiple VLANs exist with the same vid but different sites.
|
||||
Ref: #20560
|
||||
"""
|
||||
site1 = Site.objects.get(name='Site 1')
|
||||
site2 = Site.objects.get(name='Site 2')
|
||||
|
||||
# Create VLANs with the same vid but different sites
|
||||
vlan1 = VLAN.objects.create(vid=1, name='VLAN1-Site1', site=site1)
|
||||
VLAN.objects.create(vid=1, name='VLAN1-Site2', site=site2) # Create ambiguity
|
||||
|
||||
# Import prefix with vlan_site specified
|
||||
IMPORT_DATA = f"""
|
||||
prefix: 10.11.0.0/22
|
||||
status: active
|
||||
scope_type: dcim.site
|
||||
scope_id: {site1.pk}
|
||||
vlan_site: {site1.name}
|
||||
vlan: 1
|
||||
description: LOC02-MGMT
|
||||
"""
|
||||
|
||||
# Add all required permissions to the test user
|
||||
self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
|
||||
|
||||
form_data = {
|
||||
'data': IMPORT_DATA,
|
||||
'format': 'yaml'
|
||||
}
|
||||
response = self.client.post(reverse('ipam:prefix_bulk_import'), data=form_data, follow=True)
|
||||
self.assertHttpStatus(response, 200)
|
||||
|
||||
# Verify the prefix was created with the correct VLAN
|
||||
prefix = Prefix.objects.get(prefix='10.11.0.0/22')
|
||||
self.assertEqual(prefix.vlan, vlan1)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_prefix_import_with_vlan_site_and_global_vlan(self):
|
||||
"""
|
||||
Test import when a global VLAN (no site) and site-specific VLAN exist with same vid.
|
||||
When vlan_site is specified, should prefer the site-specific VLAN.
|
||||
Ref: #20560
|
||||
"""
|
||||
site1 = Site.objects.get(name='Site 1')
|
||||
|
||||
# Create a global VLAN (no site) and a site-specific VLAN with the same vid
|
||||
VLAN.objects.create(vid=10, name='VLAN10-Global', site=None) # Create ambiguity
|
||||
vlan_site = VLAN.objects.create(vid=10, name='VLAN10-Site1', site=site1)
|
||||
|
||||
# Import prefix with vlan_site specified
|
||||
IMPORT_DATA = f"""
|
||||
prefix: 10.12.0.0/22
|
||||
status: active
|
||||
scope_type: dcim.site
|
||||
scope_id: {site1.pk}
|
||||
vlan_site: {site1.name}
|
||||
vlan: 10
|
||||
description: Test Site-Specific VLAN
|
||||
"""
|
||||
|
||||
# Add all required permissions to the test user
|
||||
self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
|
||||
|
||||
form_data = {
|
||||
'data': IMPORT_DATA,
|
||||
'format': 'yaml'
|
||||
}
|
||||
response = self.client.post(reverse('ipam:prefix_bulk_import'), data=form_data, follow=True)
|
||||
self.assertHttpStatus(response, 200)
|
||||
|
||||
# Verify the prefix was created with the site-specific VLAN (not the global one)
|
||||
prefix = Prefix.objects.get(prefix='10.12.0.0/22')
|
||||
self.assertEqual(prefix.vlan, vlan_site)
|
||||
|
||||
|
||||
class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
model = IPRange
|
||||
|
||||
@@ -80,21 +80,22 @@ class Config:
|
||||
try:
|
||||
# Enforce the creation date as the ordering parameter
|
||||
revision = ConfigRevision.objects.get(active=True)
|
||||
logger.debug(f"Loaded active configuration revision (#{revision.pk})")
|
||||
logger.debug(f"Loaded active configuration revision #{revision.pk}")
|
||||
except (ConfigRevision.DoesNotExist, ConfigRevision.MultipleObjectsReturned):
|
||||
logger.debug("No active configuration revision found - falling back to most recent")
|
||||
revision = ConfigRevision.objects.order_by('-created').first()
|
||||
if revision is None:
|
||||
logger.debug("No configuration found in database; proceeding with default values")
|
||||
logger.debug("No previous configuration found in database; proceeding with default values")
|
||||
return
|
||||
logger.debug(f"No active configuration revision found; falling back to most recent (#{revision.pk})")
|
||||
logger.debug(f"Using fallback configuration revision #{revision.pk}")
|
||||
except DatabaseError:
|
||||
# The database may not be available yet (e.g. when running a management command)
|
||||
logger.warning("Skipping config initialization (database unavailable)")
|
||||
return
|
||||
|
||||
revision.activate(update_db=False)
|
||||
self._populate_from_cache()
|
||||
revision.activate()
|
||||
logger.debug("Filled cache with data from latest ConfigRevision")
|
||||
self._populate_from_cache()
|
||||
|
||||
|
||||
class ConfigItem:
|
||||
|
||||
@@ -42,9 +42,7 @@ class NetBoxModelFilterSetForm(FilterModifierMixin, CustomFieldsMixin, SavedFilt
|
||||
)
|
||||
|
||||
def _get_form_field(self, customfield):
|
||||
return customfield.to_form_field(
|
||||
set_initial=False, enforce_required=False, enforce_visibility=False, for_filterset_form=True
|
||||
)
|
||||
return customfield.to_form_field(set_initial=False, enforce_required=False, enforce_visibility=False)
|
||||
|
||||
|
||||
class OwnerFilterMixin(forms.Form):
|
||||
|
||||
@@ -288,13 +288,12 @@ class CustomFieldsMixin(models.Model):
|
||||
cf.name: cf for cf in CustomField.objects.get_for_model(self)
|
||||
}
|
||||
|
||||
# Remove any stale custom field data
|
||||
self.custom_field_data = {
|
||||
k: v for k, v in self.custom_field_data.items() if k in custom_fields.keys()
|
||||
}
|
||||
|
||||
# Validate all field values
|
||||
for field_name, value in self.custom_field_data.items():
|
||||
if field_name not in custom_fields:
|
||||
raise ValidationError(_("Unknown field name '{name}' in custom field data.").format(
|
||||
name=field_name
|
||||
))
|
||||
try:
|
||||
custom_fields[field_name].validate(value)
|
||||
except ValidationError as e:
|
||||
|
||||
@@ -232,7 +232,7 @@ VPN_MENU = Menu(
|
||||
label=_('L2VPNs'),
|
||||
items=(
|
||||
get_model_item('vpn', 'l2vpn', _('L2VPNs')),
|
||||
get_model_item('vpn', 'l2vpntermination', _('L2VPN Terminations')),
|
||||
get_model_item('vpn', 'l2vpntermination', _('Terminations')),
|
||||
),
|
||||
),
|
||||
MenuGroup(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from django.db.models import ForeignKey
|
||||
from django.template import loader
|
||||
from django.urls.exceptions import NoReverseMatch
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -176,21 +175,6 @@ class BulkEdit(ObjectAction):
|
||||
permissions_required = {'change'}
|
||||
template_name = 'buttons/bulk_edit.html'
|
||||
|
||||
@classmethod
|
||||
def get_context(cls, context, model):
|
||||
url_params = super().get_url_params(context)
|
||||
|
||||
# If this is a child object, pass the parent's PK as a URL parameter
|
||||
if parent := context.get('object'):
|
||||
for field in model._meta.get_fields():
|
||||
if isinstance(field, ForeignKey) and field.remote_field.model == parent.__class__:
|
||||
url_params[field.name] = parent.pk
|
||||
break
|
||||
|
||||
return {
|
||||
'url_params': url_params,
|
||||
}
|
||||
|
||||
|
||||
class BulkRename(ObjectAction):
|
||||
"""
|
||||
|
||||
@@ -37,6 +37,8 @@ class PluginMenuItem:
|
||||
Alternatively, a pre-generated url can be set on the object which will be rendered literally.
|
||||
Buttons are each specified as a list of PluginMenuButton instances.
|
||||
"""
|
||||
permissions = []
|
||||
buttons = []
|
||||
_url = None
|
||||
|
||||
def __init__(
|
||||
@@ -52,14 +54,10 @@ class PluginMenuItem:
|
||||
if type(permissions) not in (list, tuple):
|
||||
raise TypeError(_("Permissions must be passed as a tuple or list."))
|
||||
self.permissions = permissions
|
||||
else:
|
||||
self.permissions = []
|
||||
if buttons is not None:
|
||||
if type(buttons) not in (list, tuple):
|
||||
raise TypeError(_("Buttons must be passed as a tuple or list."))
|
||||
self.buttons = buttons
|
||||
else:
|
||||
self.buttons = []
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
@@ -76,6 +74,7 @@ class PluginMenuButton:
|
||||
ButtonColorChoices.
|
||||
"""
|
||||
color = ButtonColorChoices.DEFAULT
|
||||
permissions = []
|
||||
_url = None
|
||||
|
||||
def __init__(self, link, title, icon_class, color=None, permissions=None):
|
||||
@@ -88,8 +87,6 @@ class PluginMenuButton:
|
||||
if type(permissions) not in (list, tuple):
|
||||
raise TypeError(_("Permissions must be passed as a tuple or list."))
|
||||
self.permissions = permissions
|
||||
else:
|
||||
self.permissions = []
|
||||
if color is not None:
|
||||
if color not in ButtonColorChoices.values():
|
||||
raise ValueError(_("Button color must be a choice within ButtonColorChoices."))
|
||||
|
||||
@@ -833,7 +833,6 @@ LANGUAGES = (
|
||||
('fr', _('French')),
|
||||
('it', _('Italian')),
|
||||
('ja', _('Japanese')),
|
||||
('lv', _('Latvian')),
|
||||
('nl', _('Dutch')),
|
||||
('pl', _('Polish')),
|
||||
('pt', _('Portuguese')),
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from unittest import skipIf
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import RequestFactory, TestCase
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.models import Device, DeviceType, Manufacturer
|
||||
from netbox.object_actions import AddObject, BulkEdit, BulkImport
|
||||
from dcim.models import Device
|
||||
from netbox.object_actions import AddObject, BulkImport
|
||||
from netbox.tests.dummy_plugin.models import DummyNetBoxModel
|
||||
|
||||
|
||||
class ObjectActionTest(TestCase):
|
||||
@@ -19,11 +20,9 @@ class ObjectActionTest(TestCase):
|
||||
url = BulkImport.get_url(obj)
|
||||
self.assertEqual(url, '/dcim/devices/import/')
|
||||
|
||||
@skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, 'dummy_plugin not in settings.PLUGINS')
|
||||
@skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS")
|
||||
def test_get_url_plugin_model(self):
|
||||
"""Test URL generation for plugin models includes plugins: namespace"""
|
||||
from netbox.tests.dummy_plugin.models import DummyNetBoxModel
|
||||
|
||||
obj = DummyNetBoxModel()
|
||||
|
||||
url = AddObject.get_url(obj)
|
||||
@@ -31,29 +30,3 @@ class ObjectActionTest(TestCase):
|
||||
|
||||
url = BulkImport.get_url(obj)
|
||||
self.assertEqual(url, '/plugins/dummy-plugin/netboxmodel/import/')
|
||||
|
||||
def test_bulk_edit_get_context_child_object(self):
|
||||
"""
|
||||
Test that the parent object's PK is included in the context for child objects.
|
||||
|
||||
Ensure that BulkEdit.get_context() correctly identifies and
|
||||
includes the parent object's PK when rendering a child object's
|
||||
action button.
|
||||
"""
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
|
||||
# Mock context containing the parent object (DeviceType)
|
||||
request = RequestFactory().get('/')
|
||||
context = {
|
||||
'request': request,
|
||||
'object': device_type,
|
||||
}
|
||||
|
||||
# Get context for the child model (Device)
|
||||
action_context = BulkEdit.get_context(context, Device)
|
||||
|
||||
# Verify that 'device_type' (the FK field name) is present in
|
||||
# url_params with the parent's PK
|
||||
self.assertIn('url_params', action_context)
|
||||
self.assertEqual(action_context['url_params'].get('device_type'), device_type.pk)
|
||||
|
||||
@@ -11,7 +11,7 @@ from netbox.tests.dummy_plugin import config as dummy_config
|
||||
from netbox.tests.dummy_plugin.data_backends import DummyBackend
|
||||
from netbox.tests.dummy_plugin.jobs import DummySystemJob
|
||||
from netbox.tests.dummy_plugin.webhook_callbacks import set_context
|
||||
from netbox.plugins.navigation import PluginMenu, PluginMenuItem, PluginMenuButton
|
||||
from netbox.plugins.navigation import PluginMenu
|
||||
from netbox.plugins.utils import get_plugin_config
|
||||
from netbox.graphql.schema import Query
|
||||
from netbox.registry import registry
|
||||
@@ -227,46 +227,3 @@ class PluginTest(TestCase):
|
||||
Test the registration of webhook callbacks.
|
||||
"""
|
||||
self.assertIn(set_context, registry['webhook_callbacks'])
|
||||
|
||||
|
||||
class PluginNavigationTest(TestCase):
|
||||
|
||||
def test_plugin_menu_item_independent_permissions(self):
|
||||
item1 = PluginMenuItem(link='test1', link_text='Test 1')
|
||||
item1.permissions.append('leaked_permission')
|
||||
|
||||
item2 = PluginMenuItem(link='test2', link_text='Test 2')
|
||||
|
||||
self.assertIsNot(item1.permissions, item2.permissions)
|
||||
self.assertEqual(item1.permissions, ['leaked_permission'])
|
||||
self.assertEqual(item2.permissions, [])
|
||||
|
||||
def test_plugin_menu_item_independent_buttons(self):
|
||||
item1 = PluginMenuItem(link='test1', link_text='Test 1')
|
||||
button = PluginMenuButton(link='button1', title='Button 1', icon_class='mdi-test')
|
||||
item1.buttons.append(button)
|
||||
|
||||
item2 = PluginMenuItem(link='test2', link_text='Test 2')
|
||||
|
||||
self.assertIsNot(item1.buttons, item2.buttons)
|
||||
self.assertEqual(len(item1.buttons), 1)
|
||||
self.assertEqual(item1.buttons[0], button)
|
||||
self.assertEqual(item2.buttons, [])
|
||||
|
||||
def test_plugin_menu_button_independent_permissions(self):
|
||||
button1 = PluginMenuButton(link='button1', title='Button 1', icon_class='mdi-test')
|
||||
button1.permissions.append('leaked_permission')
|
||||
|
||||
button2 = PluginMenuButton(link='button2', title='Button 2', icon_class='mdi-test')
|
||||
|
||||
self.assertIsNot(button1.permissions, button2.permissions)
|
||||
self.assertEqual(button1.permissions, ['leaked_permission'])
|
||||
self.assertEqual(button2.permissions, [])
|
||||
|
||||
def test_explicit_permissions_remain_independent(self):
|
||||
item1 = PluginMenuItem(link='test1', link_text='Test 1', permissions=['explicit_permission'])
|
||||
item2 = PluginMenuItem(link='test2', link_text='Test 2', permissions=['different_permission'])
|
||||
|
||||
self.assertIsNot(item1.permissions, item2.permissions)
|
||||
self.assertEqual(item1.permissions, ['explicit_permission'])
|
||||
self.assertEqual(item2.permissions, ['different_permission'])
|
||||
|
||||
@@ -52,7 +52,6 @@ def handler_500(request, template_name=ERROR_500_TEMPLATE_NAME):
|
||||
type_, error = sys.exc_info()[:2]
|
||||
|
||||
return HttpResponseServerError(template.render({
|
||||
'request': request,
|
||||
'error': error,
|
||||
'exception': str(type_),
|
||||
'netbox_version': settings.RELEASE.full_version,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import logging
|
||||
import re
|
||||
from collections import Counter
|
||||
from copy import deepcopy
|
||||
|
||||
from django.contrib import messages
|
||||
@@ -34,7 +33,6 @@ from utilities.jobs import is_background_request, process_request_as_job
|
||||
from utilities.permissions import get_permission_for_model
|
||||
from utilities.query import reapply_model_ordering
|
||||
from utilities.request import safe_for_redirect
|
||||
from utilities.string import title
|
||||
from utilities.tables import get_table_configs
|
||||
from utilities.views import GetReturnURLMixin, get_action_url
|
||||
from .base import BaseMultiObjectView
|
||||
@@ -445,18 +443,6 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
|
||||
# Prefetch objects to be updated, if any
|
||||
prefetch_ids = [int(record['id']) for record in records if record.get('id')]
|
||||
|
||||
# check for duplicate IDs
|
||||
duplicate_pks = [pk for pk, count in Counter(prefetch_ids).items() if count > 1]
|
||||
if duplicate_pks:
|
||||
error_msg = _(
|
||||
"Duplicate objects found: {model} with ID(s) {ids} appears multiple times"
|
||||
).format(
|
||||
model=title(self.queryset.model._meta.verbose_name),
|
||||
ids=', '.join(str(pk) for pk in sorted(duplicate_pks))
|
||||
)
|
||||
raise ValidationError(error_msg)
|
||||
|
||||
prefetched_objects = {
|
||||
obj.pk: obj
|
||||
for obj in self.queryset.model.objects.filter(id__in=prefetch_ids)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
.docExplorerWrap{height:unset!important;min-width:unset!important;width:unset!important}.docExplorerWrap svg{display:unset}.doc-explorer-title{font-size:var(--font-size-h2);font-weight:var(--font-weight-medium)}.doc-explorer-rhs{display:none}.graphiql-explorer-root{font-family:var(--font-family-mono)!important;font-size:var(--font-size-body)!important;padding:0!important}.graphiql-explorer-root>div>div{padding-top:var(--px-16);border-color:hsla(var(--color-neutral),var(--alpha-background-heavy))!important}.graphiql-explorer-root>div{overflow:auto!important}.graphiql-explorer-root input{background:unset}.graphiql-explorer-root select{border:1px solid hsla(var(--color-neutral),var(--alpha-secondary));border-radius:var(--border-radius-4);margin:0 var(--px-8);padding:var(--px-4)var(--px-6);background:hsl(var(--color-base))!important;color:hsl(var(--color-neutral))!important}.toolbar-button{all:unset;cursor:pointer;margin-left:var(--px-6);color:hsl(var(--color-primary));line-height:0!important;font-size:var(--font-size-h3)!important}.graphiql-explorer-slug .toolbar-button,.graphiql-explorer-graphql-arguments .toolbar-button{font-size:inherit!important}.graphiql-explorer-graphql-arguments input{min-width:2rem;line-height:0}.graphiql-explorer-actions{border-color:hsla(var(--color-neutral),var(--alpha-background-heavy))!important}
|
||||
.docExplorerWrap{height:unset!important;min-width:unset!important;width:unset!important}.docExplorerWrap svg{display:unset}.doc-explorer-title{font-size:var(--font-size-h2);font-weight:var(--font-weight-medium)}.doc-explorer-rhs{display:none}.graphiql-explorer-root{font-family:var(--font-family-mono)!important;font-size:var(--font-size-body)!important;padding:0!important}.graphiql-explorer-root>div>div{border-color:hsla(var(--color-neutral),var(--alpha-background-heavy))!important;padding-top:var(--px-16)}.graphiql-explorer-root input{background:unset}.graphiql-explorer-root select{background:hsl(var(--color-base))!important;border:1px solid hsla(var(--color-neutral),var(--alpha-secondary));border-radius:var(--border-radius-4);color:hsl(var(--color-neutral))!important;margin:0 var(--px-8);padding:var(--px-4) var(--px-6)}.graphiql-operation-title-bar .toolbar-button{line-height:0;margin-left:var(--px-8);color:hsla(var(--color-neutral),var(--alpha-secondary, .6));font-size:var(--font-size-h3);vertical-align:middle}.graphiql-explorer-graphql-arguments input{line-height:0}.graphiql-explorer-actions{border-color:hsla(var(--color-neutral),var(--alpha-background-heavy))!important}
|
||||
|
||||
2
netbox/project-static/dist/netbox.css
vendored
2
netbox/project-static/dist/netbox.css
vendored
File diff suppressed because one or more lines are too long
10
netbox/project-static/dist/netbox.js
vendored
10
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
6
netbox/project-static/dist/netbox.js.map
vendored
6
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -6,7 +6,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@graphiql/plugin-explorer": "4.0.6",
|
||||
"@graphiql/plugin-explorer": "3.2.6",
|
||||
"graphiql": "4.1.2",
|
||||
"graphql": "16.12.0",
|
||||
"js-cookie": "3.0.5",
|
||||
|
||||
@@ -28,10 +28,10 @@
|
||||
"bootstrap": "5.3.8",
|
||||
"clipboard": "2.0.11",
|
||||
"flatpickr": "4.6.13",
|
||||
"gridstack": "12.4.2",
|
||||
"gridstack": "12.3.3",
|
||||
"htmx.org": "2.0.8",
|
||||
"query-string": "9.3.1",
|
||||
"sass": "1.97.2",
|
||||
"sass": "1.95.0",
|
||||
"tom-select": "2.4.3",
|
||||
"typeface-inter": "3.18.1",
|
||||
"typeface-roboto-mono": "1.1.13"
|
||||
@@ -39,21 +39,21 @@
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.0.0",
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/bootstrap": "5.2.10",
|
||||
"@types/cookie": "^1.0.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.48.1",
|
||||
"@typescript-eslint/parser": "^8.48.1",
|
||||
"esbuild": "^0.27.1",
|
||||
"esbuild": "^0.27.0",
|
||||
"esbuild-sass-plugin": "^3.3.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-prettier": "^5.5.1",
|
||||
"globals": "^16.5.0",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier": "^3.7.3",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"resolutions": {
|
||||
|
||||
@@ -28,27 +28,13 @@ function updateElements(targetMode: ColorMode): void {
|
||||
}
|
||||
|
||||
for (const elevation of getElements<HTMLObjectElement>('.rack_elevation')) {
|
||||
const svg = elevation.firstElementChild ?? null;
|
||||
if (svg !== null && svg.nodeName == 'svg') {
|
||||
const svg = elevation.contentDocument?.querySelector('svg') ?? null;
|
||||
if (svg !== null) {
|
||||
svg.setAttribute(`data-bs-theme`, targetMode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the color mode to light of elevations after an htmx call.
|
||||
* Pulls current color mode from document
|
||||
*
|
||||
* @param event htmx listener event details. See: https://htmx.org/events/#htmx:afterSwap
|
||||
*/
|
||||
function updateElevations(evt: CustomEvent, ): void {
|
||||
const swappedElement = evt.detail.elt
|
||||
if (swappedElement.nodeName == 'svg') {
|
||||
const currentMode = localStorage.getItem(COLOR_MODE_KEY);
|
||||
swappedElement.setAttribute('data-bs-theme', currentMode)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call all functions necessary to update the color mode across the UI.
|
||||
*
|
||||
@@ -129,7 +115,6 @@ function initColorModeToggle(): void {
|
||||
*/
|
||||
export function initColorMode(): void {
|
||||
window.addEventListener('load', defaultColorMode);
|
||||
window.addEventListener('htmx:afterSwap', updateElevations as EventListener); // Uses a custom event from HTMX
|
||||
for (const func of [initColorModeToggle]) {
|
||||
func();
|
||||
}
|
||||
|
||||
@@ -75,16 +75,10 @@ export class DynamicTomSelect extends TomSelect {
|
||||
load(value: string) {
|
||||
const self = this;
|
||||
|
||||
// Save current selection before clearing
|
||||
const currentValue = self.getValue();
|
||||
|
||||
// Automatically clear any cached options. (Only options included
|
||||
// in the API response should be present.)
|
||||
self.clearOptions();
|
||||
|
||||
// Clear user_options to prevent the pre-selected option from being treated specially
|
||||
(self as any).user_options = {};
|
||||
|
||||
// Populate the null option (if any) if not searching
|
||||
if (self.nullOption && !value) {
|
||||
self.addOption(self.nullOption);
|
||||
@@ -104,29 +98,16 @@ export class DynamicTomSelect extends TomSelect {
|
||||
.then(response => response.json())
|
||||
.then(apiData => {
|
||||
const results: Dict[] = apiData.results;
|
||||
|
||||
// Add options and manually set $order to ensure correct sorting
|
||||
results.forEach((result, index) => {
|
||||
const options: Dict[] = [];
|
||||
for (const result of results) {
|
||||
const option = self.getOptionFromData(result);
|
||||
self.addOption(option);
|
||||
// Set $order after addOption() to override any special handling of pre-selected items
|
||||
const key = option[self.settings.valueField as string] as string;
|
||||
if (self.options[key]) {
|
||||
(self.options[key] as any).$order = index;
|
||||
}
|
||||
});
|
||||
|
||||
self.loading--;
|
||||
if (self.loading === 0) {
|
||||
self.wrapper.classList.remove(self.settings.loadingClass as string);
|
||||
options.push(option);
|
||||
}
|
||||
|
||||
// Restore the current selection
|
||||
if (currentValue && !self.items.includes(currentValue as string)) {
|
||||
self.items.push(currentValue as string);
|
||||
}
|
||||
|
||||
self.refreshOptions(false);
|
||||
return options;
|
||||
})
|
||||
// Pass the options to the callback function
|
||||
.then(options => {
|
||||
self.loadCallback(options, []);
|
||||
})
|
||||
.catch(() => {
|
||||
self.loadCallback([], []);
|
||||
|
||||
@@ -49,9 +49,6 @@ export function initDynamicSelects(): void {
|
||||
labelField: LABEL_FIELD,
|
||||
maxOptions: MAX_OPTIONS,
|
||||
|
||||
// Preserve API response order
|
||||
sortField: '$order',
|
||||
|
||||
// Disable local search (search is performed on the backend)
|
||||
searchField: [],
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ form.object-edit {
|
||||
// Make optgroup labels sticky when scrolling through select elements
|
||||
select[multiple] {
|
||||
optgroup {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: var(--bs-body-bg);
|
||||
font-style: normal;
|
||||
|
||||
@@ -24,135 +24,135 @@
|
||||
dependencies:
|
||||
tslib "^2.4.0"
|
||||
|
||||
"@esbuild/aix-ppc64@0.27.1":
|
||||
version "0.27.1"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz#116edcd62c639ed8ab551e57b38251bb28384de4"
|
||||
integrity sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==
|
||||
"@esbuild/aix-ppc64@0.27.0":
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz#1d8be43489a961615d49e037f1bfa0f52a773737"
|
||||
integrity sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==
|
||||
|
||||
"@esbuild/android-arm64@0.27.1":
|
||||
version "0.27.1"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.1.tgz#31c00d864c80f6de1900a11de8a506dbfbb27349"
|
||||
integrity sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==
|
||||
"@esbuild/android-arm64@0.27.0":
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz#bd1763194aad60753fa3338b1ba9bda974b58724"
|
||||
integrity sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==
|
||||
|
||||
"@esbuild/android-arm@0.27.1":
|
||||
version "0.27.1"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.1.tgz#d2b73ab0ba894923a1d1378fd4b15cc20985f436"
|
||||
integrity sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==
|
||||
"@esbuild/android-arm@0.27.0":
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.0.tgz#69c7b57f02d3b3618a5ba4f82d127b57665dc397"
|
||||
integrity sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==
|
||||
|
||||
"@esbuild/android-x64@0.27.1":
|
||||
version "0.27.1"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.1.tgz#d9f74d8278191317250cfe0c15a13f410540b122"
|
||||
integrity sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==
|
||||
"@esbuild/android-x64@0.27.0":
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.0.tgz#6ea22b5843acb23243d0126c052d7d3b6a11ca90"
|
||||
integrity sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==
|
||||
|
||||
"@esbuild/darwin-arm64@0.27.1":
|
||||
version "0.27.1"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.1.tgz#baf6914b8c57ed9d41f9de54023aa3ff9b084680"
|
||||
integrity sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==
|
||||
"@esbuild/darwin-arm64@0.27.0":
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz#5ad7c02bc1b1a937a420f919afe40665ba14ad1e"
|
||||
integrity sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==
|
||||
|
||||
"@esbuild/darwin-x64@0.27.1":
|
||||
version "0.27.1"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.1.tgz#64e37400795f780a76c858a118ff19681a64b4e0"
|
||||
integrity sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==
|
||||
"@esbuild/darwin-x64@0.27.0":
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz#48470c83c5fd6d1fc7c823c2c603aeee96e101c9"
|
||||
integrity sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==
|
||||
|
||||
"@esbuild/freebsd-arm64@0.27.1":
|
||||
version "0.27.1"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.1.tgz#6572f2f235933eee906e070dfaae54488ee60acd"
|
||||
integrity sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==
|
||||
"@esbuild/freebsd-arm64@0.27.0":
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz#d5a8effd8b0be7be613cd1009da34d629d4c2457"
|
||||
integrity sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==
|
||||
|
||||
"@esbuild/freebsd-x64@0.27.1":
|
||||
version "0.27.1"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.1.tgz#83105dba9cf6ac4f44336799446d7f75c8c3a1e1"
|
||||
integrity sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==
|
||||
"@esbuild/freebsd-x64@0.27.0":
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz#9bde638bda31aa244d6d64dbafafb41e6e799bcc"
|
||||
integrity sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==
|
||||
|
||||
"@esbuild/linux-arm64@0.27.1":
|
||||
version "0.27.1"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.1.tgz#035ff647d4498bdf16eb2d82801f73b366477dfa"
|
||||
integrity sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==
|
||||
"@esbuild/linux-arm64@0.27.0":
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz#96008c3a207d8ca495708db714c475ea5bf7e2af"
|
||||
integrity sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==
|
||||
|
||||
"@esbuild/linux-arm@0.27.1":
|
||||
version "0.27.1"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.1.tgz#3516c74d2afbe305582dbb546d60f7978a8ece7f"
|
||||
integrity sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==
|
||||
"@esbuild/linux-arm@0.27.0":
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz#9b47cb0f222e567af316e978c7f35307db97bc0e"
|
||||
integrity sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==
|
||||
|
||||
"@esbuild/linux-ia32@0.27.1":
|
||||
version "0.27.1"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.1.tgz#788db5db8ecd3d75dd41c42de0fe8f1fd967a4a7"
|
||||
integrity sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==
|
||||
"@esbuild/linux-ia32@0.27.0":
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz#d1e1e38d406cbdfb8a49f4eca0c25bbc344e18cc"
|
||||
integrity sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==
|
||||
|
||||
"@esbuild/linux-loong64@0.27.1":
|
||||
version "0.27.1"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.1.tgz#8211f08b146916a6302ec2b8f87ec0cc4b62c49e"
|
||||
integrity sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==
|
||||
"@esbuild/linux-loong64@0.27.0":
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz#c13bc6a53e3b69b76f248065bebee8415b44dfce"
|
||||
integrity sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==
|
||||
|
||||
"@esbuild/linux-mips64el@0.27.1":
|
||||
version "0.27.1"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.1.tgz#cc58586ea83b3f171e727a624e7883a1c3eb4c04"
|
||||
integrity sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==
|
||||
"@esbuild/linux-mips64el@0.27.0":
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz#05f8322eb0a96ce1bfbc59691abe788f71e2d217"
|
||||
integrity sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==
|
||||
|
||||
"@esbuild/linux-ppc64@0.27.1":
|
||||
version "0.27.1"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.1.tgz#632477bbd98175cf8e53a7c9952d17fb2d6d4115"
|
||||
integrity sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==
|
||||
"@esbuild/linux-ppc64@0.27.0":
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz#6fc5e7af98b4fb0c6a7f0b73ba837ce44dc54980"
|
||||
integrity sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==
|
||||
|
||||
"@esbuild/linux-riscv64@0.27.1":
|
||||
version "0.27.1"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.1.tgz#35435a82435a8a750edf433b83ac0d10239ac3fe"
|
||||
integrity sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==
|
||||
"@esbuild/linux-riscv64@0.27.0":
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz#508afa9f69a3f97368c0bf07dd894a04af39d86e"
|
||||
integrity sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==
|
||||
|
||||
"@esbuild/linux-s390x@0.27.1":
|
||||
version "0.27.1"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.1.tgz#172edd7086438edacd86c0e2ea25ac9dbb62aac5"
|
||||
integrity sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==
|
||||
"@esbuild/linux-s390x@0.27.0":
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz#21fda656110ee242fc64f87a9e0b0276d4e4ec5b"
|
||||
integrity sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==
|
||||
|
||||
"@esbuild/linux-x64@0.27.1":
|
||||
version "0.27.1"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.1.tgz#09c771de9e2d8169d5969adf298ae21581f08c7f"
|
||||
integrity sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==
|
||||
"@esbuild/linux-x64@0.27.0":
|
||||
version "0.27.0"
|
||||
resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz"
|
||||
integrity sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==
|
||||
|
||||
"@esbuild/netbsd-arm64@0.27.1":
|
||||
version "0.27.1"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.1.tgz#475ac0ce7edf109a358b1669f67759de4bcbb7c4"
|
||||
integrity sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==
|
||||
"@esbuild/netbsd-arm64@0.27.0":
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz#a0131159f4db6e490da35cc4bb51ef0d03b7848a"
|
||||
integrity sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==
|
||||
|
||||
"@esbuild/netbsd-x64@0.27.1":
|
||||
version "0.27.1"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.1.tgz#3c31603d592477dc43b63df1ae100000f7fb59d7"
|
||||
integrity sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==
|
||||
"@esbuild/netbsd-x64@0.27.0":
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz#6f4877d7c2ba425a2b80e4330594e0b43caa2d7d"
|
||||
integrity sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==
|
||||
|
||||
"@esbuild/openbsd-arm64@0.27.1":
|
||||
version "0.27.1"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.1.tgz#482067c847665b10d66431e936d4bc5fa8025abf"
|
||||
integrity sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==
|
||||
"@esbuild/openbsd-arm64@0.27.0":
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz#cbefbd4c2f375cebeb4f965945be6cf81331bd01"
|
||||
integrity sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==
|
||||
|
||||
"@esbuild/openbsd-x64@0.27.1":
|
||||
version "0.27.1"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.1.tgz#687a188c2b184e5b671c5f74a6cd6247c0718c52"
|
||||
integrity sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==
|
||||
"@esbuild/openbsd-x64@0.27.0":
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz#31fa9e8649fc750d7c2302c8b9d0e1547f57bc84"
|
||||
integrity sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==
|
||||
|
||||
"@esbuild/openharmony-arm64@0.27.1":
|
||||
version "0.27.1"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.1.tgz#9929ee7fa8c1db2f33ef4d86198018dac9c1744f"
|
||||
integrity sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==
|
||||
"@esbuild/openharmony-arm64@0.27.0":
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz#03727780f1fdf606e7b56193693a715d9f1ee001"
|
||||
integrity sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==
|
||||
|
||||
"@esbuild/sunos-x64@0.27.1":
|
||||
version "0.27.1"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.1.tgz#94071a146f313e7394c6424af07b2b564f1f994d"
|
||||
integrity sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==
|
||||
"@esbuild/sunos-x64@0.27.0":
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz#866a35f387234a867ced35af8906dfffb073b9ff"
|
||||
integrity sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==
|
||||
|
||||
"@esbuild/win32-arm64@0.27.1":
|
||||
version "0.27.1"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.1.tgz#869fde72a3576fdf48824085d05493fceebe395d"
|
||||
integrity sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==
|
||||
"@esbuild/win32-arm64@0.27.0":
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz#53de43a9629b8a34678f28cd56cc104db1b67abb"
|
||||
integrity sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==
|
||||
|
||||
"@esbuild/win32-ia32@0.27.1":
|
||||
version "0.27.1"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.1.tgz#31d7585893ed7b54483d0b8d87a4bfeba0ecfff5"
|
||||
integrity sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==
|
||||
"@esbuild/win32-ia32@0.27.0":
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz#924d2aed8692fea5d27bfb6500f9b8b9c1a34af4"
|
||||
integrity sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==
|
||||
|
||||
"@esbuild/win32-x64@0.27.1":
|
||||
version "0.27.1"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.1.tgz#5efe5a112938b1180e98c76685ff9185cfa4f16e"
|
||||
integrity sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==
|
||||
"@esbuild/win32-x64@0.27.0":
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz#64995295227e001f2940258617c6674efb3ac48d"
|
||||
integrity sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==
|
||||
|
||||
"@eslint-community/eslint-utils@^4.7.0":
|
||||
version "4.7.0"
|
||||
@@ -230,10 +230,10 @@
|
||||
minimatch "^3.1.2"
|
||||
strip-json-comments "^3.1.1"
|
||||
|
||||
"@eslint/js@9.39.2", "@eslint/js@^9.39.2":
|
||||
version "9.39.2"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.2.tgz#2d4b8ec4c3ea13c1b3748e0c97ecd766bdd80599"
|
||||
integrity sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==
|
||||
"@eslint/js@9.39.1", "@eslint/js@^9.39.1":
|
||||
version "9.39.1"
|
||||
resolved "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz"
|
||||
integrity sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==
|
||||
|
||||
"@eslint/object-schema@^2.1.7":
|
||||
version "2.1.7"
|
||||
@@ -294,10 +294,10 @@
|
||||
react-compiler-runtime "19.1.0-rc.1"
|
||||
zustand "^5"
|
||||
|
||||
"@graphiql/plugin-explorer@4.0.6":
|
||||
version "4.0.6"
|
||||
resolved "https://registry.yarnpkg.com/@graphiql/plugin-explorer/-/plugin-explorer-4.0.6.tgz#bec1207dc27334914590ab31f46c2e944bbf4ebf"
|
||||
integrity sha512-TppIi92YPER3v70nlF01KTQrq9AiYqkZicSd1hpU7aqGmbqw/pLwBNLUEcfENBoJtw574Qxjswb01+GaYK0Tzw==
|
||||
"@graphiql/plugin-explorer@3.2.6":
|
||||
version "3.2.6"
|
||||
resolved "https://registry.npmjs.org/@graphiql/plugin-explorer/-/plugin-explorer-3.2.6.tgz"
|
||||
integrity sha512-MXzG/zVNzZfes4Em253bHyAbD/lwwAZkPKvxCAQkjz0i3dtcv4uF3D8iqJ7214iu3SCphbORYZZUC93fik1yew==
|
||||
dependencies:
|
||||
graphiql-explorer "^0.9.0"
|
||||
|
||||
@@ -1814,37 +1814,37 @@ esbuild-sass-plugin@^3.3.1:
|
||||
safe-identifier "^0.4.2"
|
||||
sass "^1.71.1"
|
||||
|
||||
esbuild@^0.27.1:
|
||||
version "0.27.1"
|
||||
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.1.tgz#56bf43e6a4b4d2004642ec7c091b78de02b0831a"
|
||||
integrity sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==
|
||||
esbuild@^0.27.0:
|
||||
version "0.27.0"
|
||||
resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz"
|
||||
integrity sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==
|
||||
optionalDependencies:
|
||||
"@esbuild/aix-ppc64" "0.27.1"
|
||||
"@esbuild/android-arm" "0.27.1"
|
||||
"@esbuild/android-arm64" "0.27.1"
|
||||
"@esbuild/android-x64" "0.27.1"
|
||||
"@esbuild/darwin-arm64" "0.27.1"
|
||||
"@esbuild/darwin-x64" "0.27.1"
|
||||
"@esbuild/freebsd-arm64" "0.27.1"
|
||||
"@esbuild/freebsd-x64" "0.27.1"
|
||||
"@esbuild/linux-arm" "0.27.1"
|
||||
"@esbuild/linux-arm64" "0.27.1"
|
||||
"@esbuild/linux-ia32" "0.27.1"
|
||||
"@esbuild/linux-loong64" "0.27.1"
|
||||
"@esbuild/linux-mips64el" "0.27.1"
|
||||
"@esbuild/linux-ppc64" "0.27.1"
|
||||
"@esbuild/linux-riscv64" "0.27.1"
|
||||
"@esbuild/linux-s390x" "0.27.1"
|
||||
"@esbuild/linux-x64" "0.27.1"
|
||||
"@esbuild/netbsd-arm64" "0.27.1"
|
||||
"@esbuild/netbsd-x64" "0.27.1"
|
||||
"@esbuild/openbsd-arm64" "0.27.1"
|
||||
"@esbuild/openbsd-x64" "0.27.1"
|
||||
"@esbuild/openharmony-arm64" "0.27.1"
|
||||
"@esbuild/sunos-x64" "0.27.1"
|
||||
"@esbuild/win32-arm64" "0.27.1"
|
||||
"@esbuild/win32-ia32" "0.27.1"
|
||||
"@esbuild/win32-x64" "0.27.1"
|
||||
"@esbuild/aix-ppc64" "0.27.0"
|
||||
"@esbuild/android-arm" "0.27.0"
|
||||
"@esbuild/android-arm64" "0.27.0"
|
||||
"@esbuild/android-x64" "0.27.0"
|
||||
"@esbuild/darwin-arm64" "0.27.0"
|
||||
"@esbuild/darwin-x64" "0.27.0"
|
||||
"@esbuild/freebsd-arm64" "0.27.0"
|
||||
"@esbuild/freebsd-x64" "0.27.0"
|
||||
"@esbuild/linux-arm" "0.27.0"
|
||||
"@esbuild/linux-arm64" "0.27.0"
|
||||
"@esbuild/linux-ia32" "0.27.0"
|
||||
"@esbuild/linux-loong64" "0.27.0"
|
||||
"@esbuild/linux-mips64el" "0.27.0"
|
||||
"@esbuild/linux-ppc64" "0.27.0"
|
||||
"@esbuild/linux-riscv64" "0.27.0"
|
||||
"@esbuild/linux-s390x" "0.27.0"
|
||||
"@esbuild/linux-x64" "0.27.0"
|
||||
"@esbuild/netbsd-arm64" "0.27.0"
|
||||
"@esbuild/netbsd-x64" "0.27.0"
|
||||
"@esbuild/openbsd-arm64" "0.27.0"
|
||||
"@esbuild/openbsd-x64" "0.27.0"
|
||||
"@esbuild/openharmony-arm64" "0.27.0"
|
||||
"@esbuild/sunos-x64" "0.27.0"
|
||||
"@esbuild/win32-arm64" "0.27.0"
|
||||
"@esbuild/win32-ia32" "0.27.0"
|
||||
"@esbuild/win32-x64" "0.27.0"
|
||||
|
||||
escape-string-regexp@^4.0.0:
|
||||
version "4.0.0"
|
||||
@@ -1944,10 +1944,10 @@ eslint-visitor-keys@^4.2.1:
|
||||
resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz"
|
||||
integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==
|
||||
|
||||
eslint@^9.39.2:
|
||||
version "9.39.2"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.39.2.tgz#cb60e6d16ab234c0f8369a3fe7cc87967faf4b6c"
|
||||
integrity sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==
|
||||
eslint@^9.39.1:
|
||||
version "9.39.1"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.39.1.tgz#be8bf7c6de77dcc4252b5a8dcb31c2efff74a6e5"
|
||||
integrity sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.8.0"
|
||||
"@eslint-community/regexpp" "^4.12.1"
|
||||
@@ -1955,7 +1955,7 @@ eslint@^9.39.2:
|
||||
"@eslint/config-helpers" "^0.4.2"
|
||||
"@eslint/core" "^0.17.0"
|
||||
"@eslint/eslintrc" "^3.3.1"
|
||||
"@eslint/js" "9.39.2"
|
||||
"@eslint/js" "9.39.1"
|
||||
"@eslint/plugin-kit" "^0.4.1"
|
||||
"@humanfs/node" "^0.16.6"
|
||||
"@humanwhocodes/module-importer" "^1.0.1"
|
||||
@@ -2304,10 +2304,10 @@ graphql@16.12.0:
|
||||
resolved "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz"
|
||||
integrity sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==
|
||||
|
||||
gridstack@12.4.2:
|
||||
version "12.4.2"
|
||||
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-12.4.2.tgz#188de180b6cda77e48b1414aac1d778a38f48f04"
|
||||
integrity sha512-aXbJrQpi3LwpYXYOr4UriPM5uc/dPcjK01SdOE5PDpx2vi8tnLhU7yBg/1i4T59UhNkG/RBfabdFUObuN+gMnw==
|
||||
gridstack@12.3.3:
|
||||
version "12.3.3"
|
||||
resolved "https://registry.npmjs.org/gridstack/-/gridstack-12.3.3.tgz"
|
||||
integrity sha512-Bboi4gj7HXGnx1VFXQNde4Nwi5srdUSuCCnOSszKhFjBs8EtMEWhsKX02BjIKkErq/FjQUkNUbXUYeQaVMQ0jQ==
|
||||
|
||||
has-bigints@^1.0.1, has-bigints@^1.0.2:
|
||||
version "1.0.2"
|
||||
@@ -3061,10 +3061,10 @@ prettier-linter-helpers@^1.0.0:
|
||||
dependencies:
|
||||
fast-diff "^1.1.2"
|
||||
|
||||
prettier@^3.7.4:
|
||||
version "3.7.4"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.7.4.tgz#d2f8335d4b1cec47e1c8098645411b0c9dff9c0f"
|
||||
integrity sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==
|
||||
prettier@^3.7.3:
|
||||
version "3.7.3"
|
||||
resolved "https://registry.npmjs.org/prettier/-/prettier-3.7.3.tgz"
|
||||
integrity sha512-QgODejq9K3OzoBbuyobZlUhznP5SKwPqp+6Q6xw6o8gnhr4O85L2U915iM2IDcfF2NPXVaM9zlo9tdwipnYwzg==
|
||||
|
||||
punycode.js@^2.3.1:
|
||||
version "2.3.1"
|
||||
@@ -3251,10 +3251,10 @@ safe-regex-test@^1.1.0:
|
||||
es-errors "^1.3.0"
|
||||
is-regex "^1.2.1"
|
||||
|
||||
sass@1.97.2:
|
||||
version "1.97.2"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.97.2.tgz#e515a319092fd2c3b015228e3094b40198bff0da"
|
||||
integrity sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==
|
||||
sass@1.95.0:
|
||||
version "1.95.0"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.95.0.tgz#3a3a4d4d954313ab50eaf16f6e2548a2f6ec0811"
|
||||
integrity sha512-9QMjhLq+UkOg/4bb8Lt8A+hJZvY3t+9xeZMKSBtBEgxrXA3ed5Ts4NDreUkYgJP1BTmrscQE/xYhf7iShow6lw==
|
||||
dependencies:
|
||||
chokidar "^4.0.0"
|
||||
immutable "^5.0.2"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version: "4.5.0"
|
||||
version: "4.4.8"
|
||||
edition: "Community"
|
||||
published: "2026-01-06"
|
||||
published: "2025-12-09"
|
||||
|
||||
@@ -35,12 +35,6 @@
|
||||
{% trans "Plugins" %}: {% for plugin, version in plugins.items %}
|
||||
{{ plugin }}: {{ version }}{% empty %}{% trans "None installed" %}{% endfor %}
|
||||
</pre>
|
||||
<p>
|
||||
{% trans "The request which yielded the above error is shown below:" %}
|
||||
</p>
|
||||
<p>
|
||||
<code>{{ request.method }} {{ request.build_absolute_uri }}</code>
|
||||
</p>
|
||||
<p>
|
||||
{% trans "If further assistance is required, please post to the" %} <a href="https://github.com/netbox-community/netbox/discussions">{% trans "NetBox discussion forum" %}</a> {% trans "on GitHub" %}.
|
||||
</p>
|
||||
|
||||
@@ -112,19 +112,6 @@
|
||||
<th scope="row">{% trans "Bridge" %}</th>
|
||||
<td>{{ object.bridge|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Bridged Interfaces" %}</th>
|
||||
<td>
|
||||
{% if bridge_interfaces %}
|
||||
{% for interface in bridge_interfaces %}
|
||||
{{ interface|linkify }}
|
||||
{% if not forloop.last %}<br />{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "LAG" %}</th>
|
||||
<td>{{ object.lag|linkify|placeholder }}</td>
|
||||
@@ -448,11 +435,13 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-12">
|
||||
{% include 'inc/panel_table.html' with table=bridge_interfaces_table heading="Bridged Interfaces" %}
|
||||
{% if object.is_bridge %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-12">
|
||||
{% include 'inc/panel_table.html' with table=bridge_interfaces_table heading="Bridge Interfaces" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-12">
|
||||
{% include 'inc/panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Range" %}</th>
|
||||
<td>{{ object.range_as_string_with_asdot }}</td>
|
||||
<td>{{ object.range_as_string }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Tenant" %}</th>
|
||||
|
||||
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.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user