Compare commits

..

1 Commits

Author SHA1 Message Date
Jeremy Stretch
6211a21423 Draft v4.5 release notes 2025-12-08 16:50:10 -05:00
324 changed files with 69043 additions and 106684 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v4.5.1 placeholder: v4.4.7
validations: validations:
required: true required: true
- type: dropdown - type: dropdown
+1 -1
View File
@@ -27,7 +27,7 @@ body:
attributes: attributes:
label: NetBox Version label: NetBox Version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v4.5.1 placeholder: v4.4.7
validations: validations:
required: true required: true
- type: dropdown - type: dropdown
-43
View File
@@ -1,43 +0,0 @@
---
name: 🏁 Performance
type: Performance
description: An opportunity to improve application performance
labels: ["netbox", "type: performance", "status: needs triage"]
body:
- type: input
attributes:
label: NetBox Version
description: What version of NetBox are you currently running?
placeholder: v4.5.1
validations:
required: true
- type: dropdown
attributes:
label: Python Version
description: What version of Python are you currently running?
options:
- "3.12"
- "3.13"
- "3.14"
validations:
required: true
- type: checkboxes
attributes:
label: Area(s) of Concern
description: Which application interface(s) are affected?
options:
- label: User Interface
- label: REST API
- label: GraphQL API
- label: Python ORM
- label: Other
validations:
required: true
- type: textarea
attributes:
label: Details
description: >
Describe in detail the operations being performed and the indications of a performance issue.
Include any relevant testing parameters, benchmarks, and expected results.
validations:
required: true
+25
View File
@@ -0,0 +1,25 @@
---
name: 🗑️ Deprecation
type: Deprecation
description: The removal of an existing feature or resource
labels: ["netbox", "type: deprecation"]
body:
- type: textarea
attributes:
label: Proposed Changes
description: >
Describe in detail the proposed changes. What is being removed?
validations:
required: true
- type: textarea
attributes:
label: Justification
description: Please provide justification for the proposed change(s).
validations:
required: true
- type: textarea
attributes:
label: Impact
description: List all areas of the application that will be affected by this change.
validations:
required: true
-31
View File
@@ -1,31 +0,0 @@
---
name: ⚠️ Deprecation
type: Deprecation
description: Designation of a feature or behavior that will be removed in a future release
labels: ["netbox", "type: deprecation"]
body:
- type: textarea
attributes:
label: Deprecated Functionality
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?
validations:
required: true
- type: textarea
attributes:
label: Justification
description: Please provide justification for the deprecation.
validations:
required: true
- type: textarea
attributes:
label: Impact
description: List all areas of the application that will be affected by this change.
validations:
required: true
-20
View File
@@ -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
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
NETBOX_CONFIGURATION: netbox.configuration_testing NETBOX_CONFIGURATION: netbox.configuration_testing
strategy: strategy:
matrix: matrix:
python-version: ['3.12', '3.13', '3.14'] python-version: ['3.12', '3.13']
node-version: ['20.x'] node-version: ['20.x']
services: services:
redis: redis:
+2 -2
View File
@@ -30,13 +30,13 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v4 uses: github/codeql-action/init@v3
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }} build-mode: ${{ matrix.build-mode }}
config-file: .github/codeql/codeql-config.yml config-file: .github/codeql/codeql-config.yml
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4 uses: github/codeql-action/analyze@v3
with: with:
category: "/language:${{matrix.language}}" category: "/language:${{matrix.language}}"
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: 3.12 python-version: 3.11
- name: Install system dependencies - name: Install system dependencies
run: sudo apt install -y gettext run: sudo apt install -y gettext
Vendored
+1 -2
View File
@@ -9,8 +9,7 @@ yarn-error.log*
/netbox/netbox/configuration.py /netbox/netbox/configuration.py
/netbox/netbox/ldap_config.py /netbox/netbox/ldap_config.py
/netbox/local/* /netbox/local/*
/netbox/media/* /netbox/media
!/netbox/media/.gitkeep
/netbox/reports/* /netbox/reports/*
!/netbox/reports/__init__.py !/netbox/reports/__init__.py
/netbox/scripts/* /netbox/scripts/*
+1 -1
View File
@@ -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/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/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://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> <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> <p>
<strong><a href="https://netboxlabs.com/community/">NetBox Community</a></strong> | <strong><a href="https://netboxlabs.com/community/">NetBox Community</a></strong> |
+1323 -9619
View File
File diff suppressed because it is too large Load Diff
+11 -23
View File
@@ -3,43 +3,31 @@
NetBox includes a Python management shell within which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command: NetBox includes a Python management shell within which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command:
``` ```
cd /opt/netbox ./manage.py nbshell
source /opt/netbox/venv/bin/activate
python3 netbox/manage.py nbshell
``` ```
This will launch a lightly customized version of [the built-in Django shell](https://docs.djangoproject.com/en/stable/ref/django-admin/#shell) with all relevant NetBox models preloaded. (If desired, the stock Django shell is also available by executing `./manage.py shell`.) This will launch a lightly customized version of [the built-in Django shell](https://docs.djangoproject.com/en/stable/ref/django-admin/#shell) with all relevant NetBox models pre-loaded. (If desired, the stock Django shell is also available by executing `./manage.py shell`.)
``` ```
(venv) $ python3 netbox/manage.py nbshell $ ./manage.py nbshell
### NetBox interactive shell (localhost) ### NetBox interactive shell (localhost)
### Python v3.12.3 | Django v5.2.10 | NetBox Community v4.5.1 ### Python 3.7.10 | Django 3.2.5 | NetBox 3.0
### lsapps() & lsmodels() will show available models. Use help(<model>) for more info. ### lsmodels() will show available models. Use help(<model>) for more info.
``` ```
The function `lsmodels()` will print a list of all available NetBox models: The function `lsmodels()` will print a list of all available NetBox models:
``` ```
>>> lsmodels() >>> lsmodels()
...
DCIM: DCIM:
dcim.Cable ConsolePort
dcim.CableTermination ConsolePortTemplate
dcim.ConsolePort ConsoleServerPort
dcim.ConsolePortTemplate ConsoleServerPortTemplate
dcim.ConsoleServerPort Device
dcim.ConsoleServerPortTemplate
dcim.Device
... ...
``` ```
To exit the NetBox shell, type `exit()` or press `Ctrl+D`.
```
>>> exit()
(venv) $
```
!!! warning !!! warning
The NetBox shell affords direct access to NetBox data and function with very little validation in place. As such, it is crucial to ensure that only authorized, knowledgeable users are ever granted access to it. Never perform any action in the management shell without having a full backup in place. The NetBox shell affords direct access to NetBox data and function with very little validation in place. As such, it is crucial to ensure that only authorized, knowledgeable users are ever granted access to it. Never perform any action in the management shell without having a full backup in place.
@@ -126,7 +114,7 @@ Reverse relationships can be traversed as well. For example, the following will
>>> Device.objects.filter(interfaces__name="em0") >>> Device.objects.filter(interfaces__name="em0")
``` ```
Character fields can be filtered against partial matches using the `contains` or `icontains` field lookup (the latter of which is case-insensitive). Character fields can be filtered against partial matches using the `contains` or `icontains` field lookup (the later of which is case-insensitive).
``` ```
>>> Device.objects.filter(name__icontains="testdevice") >>> Device.objects.filter(name__icontains="testdevice")
+3 -3
View File
@@ -88,7 +88,7 @@ While permissions are typically assigned to specific groups and/or users, it is
### Viewing Objects ### 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: 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: 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 ```no-highlight
Device.objects.filter( Site.objects.filter(
Q(site__name__in=['NYC1', 'NYC2']), Q(site__name__in=['NYC1', 'NYC2']),
Q(status='offline', tenant__isnull=True) Q(status='active', tenant__isnull=True)
) )
``` ```
+3 -12
View File
@@ -8,7 +8,7 @@ This is a mapping of models to [custom validators](../customization/custom-valid
```python ```python
CUSTOM_VALIDATORS = { CUSTOM_VALIDATORS = {
"dcim.Site": [ "dcim.site": [
{ {
"name": { "name": {
"min_length": 5, "min_length": 5,
@@ -17,15 +17,12 @@ CUSTOM_VALIDATORS = {
}, },
"my_plugin.validators.Validator1" "my_plugin.validators.Validator1"
], ],
"dcim.Device": [ "dcim.device": [
"my_plugin.validators.Validator1" "my_plugin.validators.Validator1"
] ]
} }
``` ```
!!! info "Case-Insensitive Model Names"
Model identifiers are case-insensitive. Both `dcim.site` and `dcim.Site` are valid and equivalent.
--- ---
## FIELD_CHOICES ## FIELD_CHOICES
@@ -56,9 +53,6 @@ FIELD_CHOICES = {
} }
``` ```
!!! info "Case-Insensitive Field Identifiers"
Field identifiers are case-insensitive. Both `dcim.Site.status` and `dcim.site.status` are valid and equivalent.
The following model fields support configurable choices: The following model fields support configurable choices:
* `circuits.Circuit.status` * `circuits.Circuit.status`
@@ -104,7 +98,7 @@ This is a mapping of models to [custom validators](../customization/custom-valid
```python ```python
PROTECTION_RULES = { PROTECTION_RULES = {
"dcim.Site": [ "dcim.site": [
{ {
"status": { "status": {
"eq": "decommissioning" "eq": "decommissioning"
@@ -114,6 +108,3 @@ PROTECTION_RULES = {
] ]
} }
``` ```
!!! info "Case-Insensitive Model Names"
Model identifiers are case-insensitive. Both `dcim.site` and `dcim.Site` are valid and equivalent.
+1 -1
View File
@@ -15,7 +15,7 @@ Some configuration parameters may alternatively be defined either in `configurat
## Dynamic Configuration Parameters ## Dynamic Configuration Parameters
Some configuration parameters are primarily controlled via NetBox's admin interface (under Admin > System > Configuration History). These are noted where applicable in the documentation. These settings may also be overridden in `configuration.py` to prevent them from being modified via the UI. A complete list of supported parameters is provided below: Some configuration parameters are primarily controlled via NetBox's admin interface (under Admin > Extras > Configuration Revisions). These are noted where applicable in the documentation. These settings may also be overridden in `configuration.py` to prevent them from being modified via the UI. A complete list of supported parameters is provided below:
* [`ALLOWED_URL_SCHEMES`](./security.md#allowed_url_schemes) * [`ALLOWED_URL_SCHEMES`](./security.md#allowed_url_schemes)
* [`BANNER_BOTTOM`](./miscellaneous.md#banner_bottom) * [`BANNER_BOTTOM`](./miscellaneous.md#banner_bottom)
+1 -1
View File
@@ -12,7 +12,7 @@ Depending on its classification, each NetBox model may support various features
| Feature | Feature Mixin | Registry Key | Description | | Feature | Feature Mixin | Registry Key | Description |
|------------------------------------------------------------|-------------------------|---------------------|-----------------------------------------------------------------------------------------| |------------------------------------------------------------|-------------------------|---------------------|-----------------------------------------------------------------------------------------|
| [Bookmarks](../features/user-preferences.md#bookmarks) | `BookmarksMixin` | `bookmarks` | These models can be bookmarked natively in the user interface | | [Bookmarks](../features/customization.md#bookmarks) | `BookmarksMixin` | `bookmarks` | These models can be bookmarked natively in the user interface |
| [Change logging](../features/change-logging.md) | `ChangeLoggingMixin` | `change_logging` | Changes to these objects are automatically recorded in the change log | | [Change logging](../features/change-logging.md) | `ChangeLoggingMixin` | `change_logging` | Changes to these objects are automatically recorded in the change log |
| Cloning | `CloningMixin` | `cloning` | Provides the `clone()` method to prepare a copy | | Cloning | `CloningMixin` | `cloning` | Provides the `clone()` method to prepare a copy |
| [Contacts](../features/contacts.md) | `ContactsMixin` | `contacts` | Contacts can be associated with these models | | [Contacts](../features/contacts.md) | `ContactsMixin` | `contacts` | Contacts can be associated with these models |
+2 -4
View File
@@ -10,11 +10,9 @@ Change records are exposed in the API via the read-only endpoint `/api/extras/ob
## User Messages ## 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. 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.
For information on including changelog messages when making changes via the REST API, see [Changelog Messages](../integrations/rest-api.md#changelog-messages).
## Correlating Changes by Request ## Correlating Changes by Request
+4 -4
View File
@@ -51,14 +51,14 @@ You can verify that authentication works by executing the `psql` command and pas
```no-highlight ```no-highlight
$ psql --username netbox --password --host localhost netbox $ psql --username netbox --password --host localhost netbox
Password: Password for user netbox:
psql (16.11 (Ubuntu 16.11-0ubuntu0.24.04.1)) psql (12.5 (Ubuntu 12.5-0ubuntu0.20.04.1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off) SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
Type "help" for help. Type "help" for help.
netbox=> \conninfo netbox=> \conninfo
You are connected to database "netbox" as user "netbox" on host "localhost" (address "127.0.0.1") at port "5432". You are connected to database "netbox" as user "netbox" on host "localhost" (address "127.0.0.1") at port "5432".
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off) SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
netbox=> \q netbox=> \q
``` ```
+14 -14
View File
@@ -36,7 +36,7 @@ sudo ln -s /opt/netbox-X.Y.Z/ /opt/netbox
``` ```
!!! note !!! note
It is recommended to install NetBox in a directory named for its version number. For example, NetBox v4.0.0 would be installed into `/opt/netbox-4.0.0`, and a symlink from `/opt/netbox/` would point to this location. (You can verify this configuration with the command `ls -l /opt | grep netbox`.) This allows for future releases to be installed in parallel without interrupting the current installation. When changing to the new release, only the symlink needs to be updated. It is recommended to install NetBox in a directory named for its version number. For example, NetBox v3.0.0 would be installed into `/opt/netbox-3.0.0`, and a symlink from `/opt/netbox/` would point to this location. (You can verify this configuration with the command `ls -l /opt | grep netbox`.) This allows for future releases to be installed in parallel without interrupting the current installation. When changing to the new release, only the symlink needs to be updated.
### Option B: Clone the Git Repository ### Option B: Clone the Git Repository
@@ -63,12 +63,12 @@ This command should generate output similar to the following:
``` ```
Cloning into '.'... Cloning into '.'...
remote: Enumerating objects: 148317, done. remote: Enumerating objects: 996, done.
remote: Counting objects: 100% (183/183), done. remote: Counting objects: 100% (996/996), done.
remote: Compressing objects: 100% (115/115), done. remote: Compressing objects: 100% (935/935), done.
remote: Total 148317 (delta 127), reused 68 (delta 68), pack-reused 148134 (from 3) remote: Total 996 (delta 148), reused 386 (delta 34), pack-reused 0
Receiving objects: 100% (148317/148317), 165.12 MiB | 28.71 MiB/s, done. Receiving objects: 100% (996/996), 4.26 MiB | 9.81 MiB/s, done.
Resolving deltas: 100% (116428/116428), done. Resolving deltas: 100% (148/148), done.
``` ```
Finally, check out the tag for the desired release. You can find these on our [releases page](https://github.com/netbox-community/netbox/releases). Replace `vX.Y.Z` with your selected release tag below. Finally, check out the tag for the desired release. You can find these on our [releases page](https://github.com/netbox-community/netbox/releases). Replace `vX.Y.Z` with your selected release tag below.
@@ -102,8 +102,7 @@ sudo cp configuration_example.py configuration.py
Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](../configuration/index.md), but only the following four are required for new installations: Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](../configuration/index.md), but only the following four are required for new installations:
* `ALLOWED_HOSTS` * `ALLOWED_HOSTS`
* `API_TOKEN_PEPPERS` * `DATABASES` (or `DATABASE`)
* `DATABASES`
* `REDIS` * `REDIS`
* `SECRET_KEY` * `SECRET_KEY`
@@ -159,7 +158,7 @@ DATABASES = {
### REDIS ### REDIS
Redis is an in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](../configuration/required-parameters.md#redis) for more detail on individual parameters. Redis is a in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](../configuration/required-parameters.md#redis) for more detail on individual parameters.
Note that NetBox requires the specification of two separate Redis databases: `tasks` and `caching`. These may both be provided by the same Redis service, however each should have a unique numeric database ID. Note that NetBox requires the specification of two separate Redis databases: `tasks` and `caching`. These may both be provided by the same Redis service, however each should have a unique numeric database ID.
@@ -253,7 +252,7 @@ Once NetBox has been configured, we're ready to proceed with the actual installa
sudo /opt/netbox/upgrade.sh sudo /opt/netbox/upgrade.sh
``` ```
Note that **Python 3.12 or later is required** for NetBox v4.5 and later releases. If the default Python installation on your server is set to a lesser version, pass the path to the supported installation as an environment variable named `PYTHON`. (Note that the environment variable must be passed _after_ the `sudo` command.) Note that **Python 3.12 or later is required** for NetBox v4.5 and later releases. If the default Python installation on your server is set to a lesser version, pass the path to the supported installation as an environment variable named `PYTHON`. (Note that the environment variable must be passed _after_ the `sudo` command.)
```no-highlight ```no-highlight
sudo PYTHON=/usr/bin/python3.12 /opt/netbox/upgrade.sh sudo PYTHON=/usr/bin/python3.12 /opt/netbox/upgrade.sh
@@ -296,12 +295,13 @@ python3 manage.py runserver 0.0.0.0:8000 --insecure
If successful, you should see output similar to the following: If successful, you should see output similar to the following:
```no-highlight ```no-highlight
Watching for file changes with StatReloader
Performing system checks... Performing system checks...
System check identified no issues (0 silenced). System check identified no issues (0 silenced).
January 26, 2026 - 17:00:00 August 30, 2021 - 18:02:23
Django version 5.2.10, using settings 'netbox.settings' Django version 3.2.6, using settings 'netbox.settings'
Starting development server at http://0.0.0.0:8000/ Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C. Quit the server with CONTROL-C.
``` ```
+8 -14
View File
@@ -43,22 +43,16 @@ You should see output similar to the following:
```no-highlight ```no-highlight
● netbox.service - NetBox WSGI Service ● netbox.service - NetBox WSGI Service
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; preset: enabled) Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled)
Active: active (running) since Mon 2026-01-26 11:00:00 CST; 7s ago Active: active (running) since Mon 2021-08-30 04:02:36 UTC; 14h ago
Docs: https://docs.netbox.dev/ Docs: https://docs.netbox.dev/
Main PID: 7283 (gunicorn) Main PID: 1140492 (gunicorn)
Tasks: 6 (limit: 4545) Tasks: 19 (limit: 4683)
Memory: 556.1M (peak: 556.3M) Memory: 666.2M
CPU: 3.387s
CGroup: /system.slice/netbox.service CGroup: /system.slice/netbox.service
├─7283 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox> ├─1140492 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /va>
├─7285 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox> ├─1140513 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /va>
├─7286 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox> ├─1140514 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /va>
├─7287 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
├─7288 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
└─7289 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
Jan 26 11:00:00 netbox systemd[1]: Started netbox.service - NetBox WSGI Service.
... ...
``` ```
+1 -1
View File
@@ -3,7 +3,7 @@
This documentation provides example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](https://httpd.apache.org/docs/current/), though any HTTP server which supports WSGI should be compatible. This documentation provides example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](https://httpd.apache.org/docs/current/), though any HTTP server which supports WSGI should be compatible.
!!! info !!! info
For the sake of brevity, only Ubuntu 24.04 instructions are provided here. These tasks are not unique to NetBox and should carry over to other distributions with minimal changes. Please consult your distribution's documentation for assistance if needed. For the sake of brevity, only Ubuntu 20.04 instructions are provided here. These tasks are not unique to NetBox and should carry over to other distributions with minimal changes. Please consult your distribution's documentation for assistance if needed.
## Obtain an SSL Certificate ## Obtain an SSL Certificate
+2 -2
View File
@@ -12,12 +12,12 @@
</div> </div>
The installation instructions provided here have been tested to work on Ubuntu 24.04. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors. The installation instructions provided here have been tested to work on Ubuntu 22.04. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
The following sections detail how to set up a new instance of NetBox: The following sections detail how to set up a new instance of NetBox:
1. [PostgreSQL database](1-postgresql.md) 1. [PostgreSQL database](1-postgresql.md)
2. [Redis](2-redis.md) 1. [Redis](2-redis.md)
3. [NetBox components](3-netbox.md) 3. [NetBox components](3-netbox.md)
4. [Gunicorn](4a-gunicorn.md) or [uWSGI](4b-uwsgi.md) 4. [Gunicorn](4a-gunicorn.md) or [uWSGI](4b-uwsgi.md)
5. [HTTP server](5-http-server.md) 5. [HTTP server](5-http-server.md)
+4 -4
View File
@@ -65,7 +65,7 @@ Download and extract the latest version:
```no-highlight ```no-highlight
# Set $NEWVER to the NetBox version being installed # Set $NEWVER to the NetBox version being installed
NEWVER=4.5.0 NEWVER=3.5.0
wget https://github.com/netbox-community/netbox/archive/v$NEWVER.tar.gz wget https://github.com/netbox-community/netbox/archive/v$NEWVER.tar.gz
sudo tar -xzf v$NEWVER.tar.gz -C /opt sudo tar -xzf v$NEWVER.tar.gz -C /opt
sudo ln -sfn /opt/netbox-$NEWVER/ /opt/netbox sudo ln -sfn /opt/netbox-$NEWVER/ /opt/netbox
@@ -75,7 +75,7 @@ Copy `local_requirements.txt`, `configuration.py`, and `ldap_config.py` (if pres
```no-highlight ```no-highlight
# Set $OLDVER to the NetBox version currently installed # Set $OLDVER to the NetBox version currently installed
OLDVER=4.4.10 OLDVER=3.4.9
sudo cp /opt/netbox-$OLDVER/local_requirements.txt /opt/netbox/ sudo cp /opt/netbox-$OLDVER/local_requirements.txt /opt/netbox/
sudo cp /opt/netbox-$OLDVER/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/ sudo cp /opt/netbox-$OLDVER/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/
sudo cp /opt/netbox-$OLDVER/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/ sudo cp /opt/netbox-$OLDVER/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/
@@ -116,7 +116,7 @@ Check out the desired release by specifying its tag. For example:
``` ```
cd /opt/netbox && \ cd /opt/netbox && \
sudo git fetch --tags && \ sudo git fetch --tags && \
sudo git checkout v4.5.0 sudo git checkout v4.2.7
``` ```
## 4. Run the Upgrade Script ## 4. Run the Upgrade Script
@@ -128,7 +128,7 @@ sudo ./upgrade.sh
``` ```
!!! warning !!! warning
If the default version of Python is not **at least 3.12**, you'll need to pass the path to a supported Python version as an environment variable when calling the upgrade script. For example: If the default version of Python is not at least 3.10, you'll need to pass the path to a supported Python version as an environment variable when calling the upgrade script. For example:
```no-highlight ```no-highlight
sudo PYTHON=/usr/bin/python3.12 ./upgrade.sh sudo PYTHON=/usr/bin/python3.12 ./upgrade.sh
+8 -48
View File
@@ -215,51 +215,9 @@ http://netbox/api/ipam/ip-addresses/ \
If we wanted to assign this IP address to a virtual machine interface instead, we would have set `assigned_object_type` to `virtualization.vminterface` and updated the object ID appropriately. If we wanted to assign this IP address to a virtual machine interface instead, we would have set `assigned_object_type` to `virtualization.vminterface` and updated the object ID appropriately.
### Specifying Fields ### Brief Format
A REST API response will include all available fields for the object type by default. If you wish to return only a subset of the available fields, you can append `?fields=` to the URL followed by a comma-separated list of field names. For example, the following request will return only the `id`, `name`, `status`, and `region` fields for each site in the response. Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of available objects without any related data, such as when populating a drop-down list in a form. As an example, the default (complete) format of a prefix looks like this:
```
GET /api/dcim/sites/?fields=id,name,status,region
```
```json
{
"id": 1,
"name": "DM-NYC",
"status": {
"value": "active",
"label": "Active"
},
"region": {
"id": 43,
"url": "http://netbox:8000/api/dcim/regions/43/",
"display": "New York",
"name": "New York",
"slug": "us-ny",
"description": "",
"site_count": 0,
"_depth": 2
}
}
```
Similarly, you can opt to omit only specific fields by passing the `omit` parameter:
```
GET /api/dcim/sites/?omit=circuit_count,device_count,virtualmachine_count
```
!!! note "The `omit` parameter was introduced in NetBox v4.5.2."
Strategic use of the `fields` and `omit` parameters can drastically improve REST API performance, as the exclusion of fields which reference related objects reduces the number and complexity of underlying database queries needed to generate the response.
!!! note
The `fields` and `omit` parameters should be considered mutually exclusive. If both are passed, `fields` takes precedence.
#### Brief Format
Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of available objects without any related data, such as when populating a drop-down list in a form. It's also more convenient than listing out individual fields via the `fields` or `omit` parameters. As an example, the default (complete) format of a prefix looks like this:
```no-highlight ```no-highlight
GET /api/ipam/prefixes/13980/ GET /api/ipam/prefixes/13980/
@@ -312,10 +270,10 @@ GET /api/ipam/prefixes/13980/
} }
``` ```
The brief format includes only a few fields: The brief format is much more terse:
```no-highlight ```no-highlight
GET /api/ipam/prefixes/13980/?brief=true GET /api/ipam/prefixes/13980/?brief=1
``` ```
```json ```json
@@ -652,7 +610,9 @@ http://netbox/api/dcim/sites/ \
## Changelog Messages ## 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: For example, the following API request will create a new site and record a message in the resulting changelog entry:
@@ -668,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 ## Uploading Files
-8
View File
@@ -32,14 +32,6 @@ class MyFilterSet(NetBoxModelFilterSet):
fields = ('some', 'other', 'fields') fields = ('some', 'other', 'fields')
``` ```
In addition to the base NetBoxModelFilterSet class, the following filterset classes are also available for subclasses of standard base models.
| Model Class | FilterSet Class |
|-----------------------|--------------------------------------------------|
| `PrimaryModel` | `netbox.filtersets.PrimaryModelFilterSet` |
| `OrganizationalModel` | `netbox.filtersets.OrganizationalModelFilterSet` |
| `NestedGroupModel` | `netbox.filtersets.NestedGroupModelFilterSet` |
### Declaring Filter Sets ### Declaring Filter Sets
To utilize a filter set in a subclass of one of NetBox's generic views (such as `ObjectListView` or `BulkEditView`), define the `filterset` attribute on the view class: To utilize a filter set in a subclass of one of NetBox's generic views (such as `ObjectListView` or `BulkEditView`), define the `filterset` attribute on the view class:
+6 -46
View File
@@ -2,7 +2,7 @@
## Form Classes ## Form Classes
NetBox provides several base form classes for use by plugins. Additional form classes are also available for other standard base model classes (PrimaryModel, OrganizationalModel, and NestedGroupModel). NetBox provides several base form classes for use by plugins.
| Form Class | Purpose | | Form Class | Purpose |
|----------------------------|--------------------------------------| |----------------------------|--------------------------------------|
@@ -19,17 +19,7 @@ This is the base form for creating and editing NetBox models. It extends Django'
|-------------|---------------------------------------------------------------------------------------| |-------------|---------------------------------------------------------------------------------------|
| `fieldsets` | A tuple of `FieldSet` instances which control how form fields are rendered (optional) | | `fieldsets` | A tuple of `FieldSet` instances which control how form fields are rendered (optional) |
#### Subclasses **Example**
The corresponding model-specific subclasses of `NetBoxModelForm` are documented below.
| Model Class | Form Class |
|-----------------------|---------------------------|
| `PrimaryModel` | `PrimaryModelForm` |
| `OrganizationalModel` | `OrganizationalModelForm` |
| `NestedGroupModel` | `NestedGroupModelForm` |
#### Example
```python ```python
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -59,19 +49,9 @@ class MyModelForm(NetBoxModelForm):
### `NetBoxModelImportForm` ### `NetBoxModelImportForm`
This form facilitates the bulk import of new objects from CSV, JSON, or YAML data. As with model forms, you'll need to declare a `Meta` subclass specifying the associated `model` and `fields`. NetBox also provides several form fields suitable for importing various types of CSV data, listed [below](#csv-import-fields). This form facilitates the bulk import of new objects from CSV, JSON, or YAML data. As with model forms, you'll need to declare a `Meta` subclass specifying the associated `model` and `fields`. NetBox also provides several form fields suitable for import various types of CSV data, listed below.
#### Subclasses **Example**
The corresponding model-specific subclasses of `NetBoxModelImportForm` are documented below.
| Model Class | Form Class |
|-----------------------|---------------------------------|
| `PrimaryModel` | `PrimaryModelImportForm` |
| `OrganizationalModel` | `OrganizationalModelImportForm` |
| `NestedGroupModel` | `NestedGroupModelImportForm` |
#### Example
```python ```python
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -103,17 +83,7 @@ This form facilitates editing multiple objects in bulk. Unlike a model form, thi
| `fieldsets` | A tuple of `FieldSet` instances which control how form fields are rendered (optional) | | `fieldsets` | A tuple of `FieldSet` instances which control how form fields are rendered (optional) |
| `nullable_fields` | A tuple of fields which can be nullified (set to empty) using the bulk edit form (optional) | | `nullable_fields` | A tuple of fields which can be nullified (set to empty) using the bulk edit form (optional) |
#### Subclasses **Example**
The corresponding model-specific subclasses of `NetBoxModelBulkEditForm` are documented below.
| Model Class | Form Class |
|-----------------------|-----------------------------------|
| `PrimaryModel` | `PrimaryModelBulkEditForm` |
| `OrganizationalModel` | `OrganizationalModelBulkEditForm` |
| `NestedGroupModel` | `NestedGroupModelBulkEditForm` |
#### Example
```python ```python
from django import forms from django import forms
@@ -155,17 +125,7 @@ This form class is used to render a form expressly for filtering a list of objec
| `model` | The model of object being edited | | `model` | The model of object being edited |
| `fieldsets` | A tuple of `FieldSet` instances which control how form fields are rendered (optional) | | `fieldsets` | A tuple of `FieldSet` instances which control how form fields are rendered (optional) |
#### Subclasses **Example**
The corresponding model-specific subclasses of `NetBoxModelFilterSetForm` are documented below.
| Model Class | Form Class |
|-----------------------|------------------------------------|
| `PrimaryModel` | `PrimaryModelFilterSetForm` |
| `OrganizationalModel` | `OrganizationalModelFilterSetForm` |
| `NestedGroupModel` | `NestedGroupModelFilterSetForm` |
#### Example
```python ```python
from dcim.models import Site from dcim.models import Site
-16
View File
@@ -46,19 +46,3 @@ NetBox provides two object type classes for use by plugins.
::: netbox.graphql.types.NetBoxObjectType ::: netbox.graphql.types.NetBoxObjectType
options: options:
members: false members: false
## GraphQL Filters
NetBox provides a base filter class for use by plugins which employ subclasseses of `NetBoxModel`.
::: netbox.graphql.filters.NetBoxModelFilter
options:
members: false
Additionally, the following filter classes are available for subclasses of standard base models.
| Model Class | FilterSet Class |
|-----------------------|----------------------------------------------------|
| `PrimaryModel` | `netbox.graphql.filters.PrimaryModelFilter` |
| `OrganizationalModel` | `netbox.graphql.filters.OrganizationalModelFilter` |
| `NestedGroupModel` | `netbox.graphql.filters.NestedGroupModelFilter` |
+3 -16
View File
@@ -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: 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 from netbox.plugins import PluginConfig
class FooBarConfig(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: An example `pyproject.toml` is below:
```toml title="pyproject.toml" ```
# See PEP 518 for the spec of this file # See PEP 518 for the spec of this file
# https://www.python.org/dev/peps/pep-0518/ # https://www.python.org/dev/peps/pep-0518/
@@ -179,24 +179,11 @@ classifiers=[
] ]
requires-python = ">=3.12.0" 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/). 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 ## 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/`.) 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/`.)
-40
View File
@@ -67,46 +67,6 @@ class MyModel(ExportTemplatesMixin, TagsMixin, models.Model):
... ...
``` ```
### Additional Models
In addition to the base NetBoxModel class, the following additional classes are provided for convenience.
!!! info "These model classes were added to the plugins API in NetBox v4.5."
#### PrimaryModel
PrimaryModel is the go-to class for most object types. It extends NetBoxModel with `description` and `comments` fields, and it introduces support for ownership assignment.
| Field | Required | Unique | Description |
|---------------|----------|--------|---------------------------------------------|
| `owner` | No | No | The object's owner |
| `description` | No | No | A human-friendly description for the object |
| `comments` | No | No | General comments |
#### OrganizationalModel
OrganizationalModel is used by object types whose function is primarily the organization of other objects.
| Field | Required | Unique | Description |
|---------------|----------|--------|---------------------------------------------|
| `name` | Yes | Yes | The name of the object |
| `slug` | Yes | Yes | A unique URL-friendly identifier |
| `owner` | No | No | The object's owner |
| `description` | No | No | A human-friendly description for the object |
#### NestedGroupModel
NestedGroupModel is used for objects which arrange into a recursive hierarchy (like regions and locations) via its self-referential `parent` foreign key.
| Field | Required | Unique | Description |
|---------------|----------|--------|-----------------------------------------------------------------|
| `name` | Yes | Yes | The name of the object |
| `slug` | Yes | Yes | A unique URL-friendly identifier |
| `parent` | No | No | The object (of the same type) under which this object is nested |
| `owner` | No | No | The object's owner |
| `description` | No | No | A human-friendly description for the object |
| `comments` | No | No | General comments |
## Database Migrations ## Database Migrations
Once you have completed defining the model(s) for your plugin, you'll need to create the database schema migrations. A migration file is essentially a set of instructions for manipulating the PostgreSQL database to support your new model, or to alter existing models. Creating migrations can usually be done automatically using Django's `makemigrations` management command. (Ensure that your plugin has been installed and enabled first, otherwise it won't be found.) Once you have completed defining the model(s) for your plugin, you'll need to create the database schema migrations. A migration file is essentially a set of instructions for manipulating the PostgreSQL database to support your new model, or to alter existing models. Creating migrations can usually be done automatically using Django's `makemigrations` management command. (Ensure that your plugin has been installed and enabled first, otherwise it won't be found.)
-8
View File
@@ -27,14 +27,6 @@ Serializers are responsible for converting Python objects to JSON data suitable
The default nested representation of an object is defined by the `brief_fields` attributes under the serializer's `Meta` class. (Older versions of NetBox required the definition of a separate nested serializer.) The default nested representation of an object is defined by the `brief_fields` attributes under the serializer's `Meta` class. (Older versions of NetBox required the definition of a separate nested serializer.)
In addition to the base NetBoxModelSerializer class, the following serializer classes are also available for subclasses of standard base models.
| Model Class | Serializer Class |
|-----------------------|--------------------------------------------------------|
| `PrimaryModel` | `netbox.api.serializers.PrimaryModelSerializer` |
| `OrganizationalModel` | `netbox.api.serializers.OrganizationalModelSerializer` |
| `NestedGroupModel` | `netbox.api.serializers.NestedGroupModelSerializer` |
#### Example #### Example
To create a serializer for a plugin model, subclass `NetBoxModelSerializer` in `api/serializers.py`. Specify the model class and the fields to include within the serializer's `Meta` class. To create a serializer for a plugin model, subclass `NetBoxModelSerializer` in `api/serializers.py`. Specify the model class and the fields to include within the serializer's `Meta` class.
-8
View File
@@ -36,14 +36,6 @@ class MyModelTable(NetBoxTable):
default_columns = ('pk', 'name', ...) default_columns = ('pk', 'name', ...)
``` ```
In addition to the base NetBoxTable class, the following table classes are also available for subclasses of standard base models.
| Model Class | Table Class |
|-----------------------|------------------------------------------|
| `PrimaryModel` | `netbox.tables.PrimaryModelTable` |
| `OrganizationalModel` | `netbox.tables.OrganizationalModelTable` |
| `NestedGroupModel` | `netbox.tables.NestedGroupModelTable` |
### Table Configuration ### Table Configuration
The NetBoxTable class features dynamic configuration to allow users to change their column display and ordering preferences. To configure a table for a specific request, simply call its `configure()` method and pass the current HTTPRequest object. For example: The NetBoxTable class features dynamic configuration to allow users to change their column display and ordering preferences. To configure a table for a specific request, simply call its `configure()` method and pass the current HTTPRequest object. For example:
-8
View File
@@ -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. 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) #### [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)) * Background Jobs for Bulk Operations ([#19589](https://github.com/netbox-community/netbox/issues/19589), [#19891](https://github.com/netbox-community/netbox/issues/19891))
-64
View File
@@ -1,69 +1,5 @@
# NetBox v4.4 # 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`
* [#20966](https://github.com/netbox-community/netbox/issues/20966) - Fix UI rendering issue when scrolling list of object types in permissions form
* [#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
* [#20068](https://github.com/netbox-community/netbox/issues/20068) - Support the assignment of module type profile attributes via bulk import
* [#20914](https://github.com/netbox-community/netbox/issues/20914) - Enable filtering device components by tenant assigned to device
### Bug Fixes
* [#19918](https://github.com/netbox-community/netbox/issues/19918) - Fix support for `{module}` resolution of components of child modules
* [#20759](https://github.com/netbox-community/netbox/issues/20759) - Improve legibility of object types in permissions form
* [#20860](https://github.com/netbox-community/netbox/issues/20860) - Ensure user-provided changelog message is recorded when creating device components via the UI
* [#20878](https://github.com/netbox-community/netbox/issues/20878) - Use the active database connection when executing custom scripts
* [#20888](https://github.com/netbox-community/netbox/issues/20888) - Resolve warnings about non-decimal values for min/max latitude & longitude fields
---
## v4.4.7 (2025-11-25) ## v4.4.7 (2025-11-25)
### Enhancements ### Enhancements
+35 -111
View File
@@ -1,103 +1,37 @@
# NetBox v4.5 ## v4.5.0 (FUTURE)
## v4.5.1 (2026-01-20)
### Enhancements
* [#21018](https://github.com/netbox-community/netbox/issues/21018) - Enable filtering prefixes by location/site/site group/region directly via GraphQL API
* [#21142](https://github.com/netbox-community/netbox/issues/21142) - Enable filtering device components by site/location/rack directly via GraphQL API
* [#21144](https://github.com/netbox-community/netbox/issues/21144) - Enable specifying a prefix length for IP addresses when utilizing the `/api/ipam/prefixes/<id>/available-ips/` REST API endpoint
* [#21165](https://github.com/netbox-community/netbox/issues/21165) - VLAN selector should default to group (instead of site)
* [#21178](https://github.com/netbox-community/netbox/issues/21178) - Improve consistency of rack measurements in UI
### Bug Fixes
* [#19901](https://github.com/netbox-community/netbox/issues/19901) - Fix `RelatedObjectDoesNotExist` exception when importing modules into unnamed devices
* [#20239](https://github.com/netbox-community/netbox/issues/20239) - Prevent shared mutable state in PluginMenuItem & PluginMenuButton
* [#20933](https://github.com/netbox-community/netbox/issues/20933) - Fix writable `data_file` assignment for ConfigContext and ConfigContextProfile via the REST API
* [#21039](https://github.com/netbox-community/netbox/issues/21039) - Fix support for AVIF image uploads
* [#21050](https://github.com/netbox-community/netbox/issues/21050) - Clear device OOB IP assignments when reassigning IP addresses
* [#21051](https://github.com/netbox-community/netbox/issues/21051) - Remove irrelevant object types from permissions form
* [#21097](https://github.com/netbox-community/netbox/issues/21097) - Fix comparison lookups for ID filters in GraphQL API
* [#21102](https://github.com/netbox-community/netbox/issues/21102) - Fix GraphiQL explorer UI
* [#21117](https://github.com/netbox-community/netbox/issues/21117) - Avoid `ValueError` exception when `API_TOKEN_PEPPERS` is not defined
* [#21118](https://github.com/netbox-community/netbox/issues/21118) - Address performance issue when saving sites with many assigned objects
* [#21124](https://github.com/netbox-community/netbox/issues/21124) - Fix front/rear port mapping for module types
* [#21134](https://github.com/netbox-community/netbox/issues/21134) - Fix bulk renaming for module types
* [#21139](https://github.com/netbox-community/netbox/issues/21139) - Support `fields` parameter for job, object change, and object type REST API endpoints
* [#21140](https://github.com/netbox-community/netbox/issues/21140) - Restore translation for object attribute labels on several UI views
* [#21160](https://github.com/netbox-community/netbox/issues/21160) - Fix performance issue loading UI views caused by unintended `APISelect` choices resolution
* [#21166](https://github.com/netbox-community/netbox/issues/21166) - Fix support for 32-bit ASN filtering in GraphQL API
* [#21175](https://github.com/netbox-community/netbox/issues/21175) - Fix pending migrations warning when `DEFAULT_LANGUAGE` is set
* [#21181](https://github.com/netbox-community/netbox/issues/21181) - Handle `AuthenticationFailed` exception when using an invalid API token to fetch media files
* [#21213](https://github.com/netbox-community/netbox/issues/21213) - Tag weight field should be marked as required in UI forms
* [#21231](https://github.com/netbox-community/netbox/issues/21231) - Presence of object types table should be checked only during migration (performance improvement)
---
## v4.5.0 (2026-01-06)
### Breaking Changes ### Breaking Changes
* Python 3.10 and 3.11 are no longer supported. NetBox now requires Python 3.12, 3.13, or 3.14. * Python 3.10 and 3.11 are no longer supported. NetBox now requires Python 3.12 or later.
* 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 }`. * 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. * 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. * 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. * The owner of an API token can no longer be changed once it has been created.
* 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.) * Config contexts now apply to all child platforms of a parent platform.
* 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 `/api/extras/object-types/` REST API endpoint has been removed. (Use `/api/core/object-types/` instead.)
* The UI view dedicated to swapping A/Z circuit terminations has been removed. * The `/api/dcim/cable-terminations/` REST API endpoint is now read-only. Cable terminations must be set on cables directly.
* The UI view dedicated to swaping A/Z circuit terminations has been removed.
* 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.
* The experimental HTMX navigation feature has been removed. * The experimental HTMX navigation feature has been removed.
* The obsolete boolean field `is_staff` has been removed from the `User` model. * The obsolete 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 ### New Features
#### Lookup Modifiers in Filter Forms ([#7604](https://github.com/netbox-community/netbox/issues/7604)) #### 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)) #### 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)) #### 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)) #### 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)) #### 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 ### 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 * [#16681](https://github.com/netbox-community/netbox/issues/16681) - Introduce a `render_config` permission, which is noq 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 * [#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 * [#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 * [#19338](https://github.com/netbox-community/netbox/issues/19338) - Enable filter lookups for object IDs and enums in GraphQL API queries
@@ -109,9 +43,7 @@ The assignment of cable profiles is optional: Cable tracing will continue to ope
* [#20834](https://github.com/netbox-community/netbox/issues/20834) - Add an `enabled` boolean field to API tokens * [#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 * [#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` * [#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 * [#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 ### Plugins
@@ -128,60 +60,52 @@ The assignment of cable profiles is optional: Cable tracing will continue to ope
* [#20095](https://github.com/netbox-community/netbox/issues/20095) - Remove the obsolete module `core.models.contenttypes` * [#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 * [#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 * [#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 * [#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 * [#20683](https://github.com/netbox-community/netbox/issues/20683) - Remove the UI view dedicated to swaping A/Z circuit terminations
* [#20926](https://github.com/netbox-community/netbox/issues/20926) - Standardize naming of GraphQL filters * [#20926](https://github.com/netbox-community/netbox/issues/20926) - Standardize naming of GraphQL filters
### REST API Changes ### REST API Changes
* Most objects now include an optional `owner` foreign key field. * Most objects now include an optional `owner` foreign key field.
* The `/api/dcim/cable-terminations` endpoint is now read-only. * The `/api/dcim/cable-terminations` endpoint is now read-only.
* Introduced the `/api/authentication-check/` endpoint to test REST API credentials * Introduced the `/api/authentication-check/` endpoint.
* `circuits.CircuitGroup` * `circuits.CircuitGroup`
* Add optional `comments` field * Add optional `comments` field
* `circuits.CircuitType` * `circuits.CircuitType`
* Add optional `comments` field * Add optional `comments` field
* `circuits.VirtualCircuitType` * `circuits.VirtualCircuitType`
* Add optional `comments` field * Add optional `comments` field
* `dcim.Cable` * `dcim.Cable`
* Add the optional `profile` choice field * 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` * `dcim.InventoryItemRole`
* Add optional `comments` field * Add optional `comments` field
* `dcim.Manufacturer` * `dcim.Manufacturer`
* Add optional `comments` field * Add optional `comments` field
* Add read-only `moduletype_count` integer field
* `dcim.ModuleType` * `dcim.ModuleType`
* Add read-only `module_count` integer field * Add read-only `module_count` integer field
* `dcim.PowerOutletTemplate` * `dcim.PowerOutletTemplate`
* Add optional `color` field * Add optional `color` field
* `dcim.RackRole` * `dcim.RackRole`
* Add optional `comments` field * Add optional `comments` field
* `dcim.RackType` * `dcim.RackType`
* Add read-only `rack_count` integer field * Add read-only `rack_count` integer field
* `dcim.RearPort`
* Add the `front_ports` list for port mappings
* `ipam.ASNRange` * `ipam.ASNRange`
* Add optional `comments` field * Add optional `comments` field
* `ipam.RIR` * `ipam.RIR`
* Add optional `comments` field * Add optional `comments` field
* `ipam.Role` * `ipam.Role`
* Add optional `comments` field * Add optional `comments` field
* `ipam.VLANGroup` * `ipam.VLANGroup`
* Add optional `comments` field * Add optional `comments` field
* `tenancy.ContactRole` * `tenancy.ContactRole`
* Add optional `comments` field * Add optional `comments` field
* `users.Token` * `users.Token`
* Add `enabled` boolean field * Add `enabled` boolean field
* `virtualization.ClusterGroup` * `virtualization.ClusterGroup`
* Add optional `comments` field * Add optional `comments` field
* `virtualization.ClusterType` * `virtualization.ClusterType`
* Add optional `comments` field * Add optional `comments` field
* `virtualization.VirtualMachine` * `virtualization.VirtualMachine`
* Add optional `start_on_boot` choice field * Add optional `start_on_boot` choice field
* `vpn.TunnelGroup` * `vpn.TunnelGroup`
* Add optional `comments` field * Add optional `comments` field
-1
View File
@@ -322,7 +322,6 @@ nav:
- git Cheat Sheet: 'development/git-cheat-sheet.md' - git Cheat Sheet: 'development/git-cheat-sheet.md'
- Release Notes: - Release Notes:
- Summary: 'release-notes/index.md' - 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.4: 'release-notes/version-4.4.md'
- Version 4.3: 'release-notes/version-4.3.md' - Version 4.3: 'release-notes/version-4.3.md'
- Version 4.2: 'release-notes/version-4.2.md' - Version 4.2: 'release-notes/version-4.2.md'
+3 -3
View File
@@ -38,7 +38,7 @@ class CircuitTypeSerializer(OrganizationalModelSerializer):
class Meta: class Meta:
model = CircuitType model = CircuitType
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'owner', 'comments', 'tags', 'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'owner', 'tags',
'custom_fields', 'created', 'last_updated', 'circuit_count', 'custom_fields', 'created', 'last_updated', 'circuit_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count') brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count')
@@ -71,7 +71,7 @@ class CircuitGroupSerializer(OrganizationalModelSerializer):
class Meta: class Meta:
model = CircuitGroup model = CircuitGroup
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'tenant', 'owner', 'comments', 'tags', 'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'tenant', 'owner', 'tags',
'custom_fields', 'created', 'last_updated', 'circuit_count' 'custom_fields', 'created', 'last_updated', 'circuit_count'
] ]
brief_fields = ('id', 'url', 'display', 'name') brief_fields = ('id', 'url', 'display', 'name')
@@ -161,7 +161,7 @@ class VirtualCircuitTypeSerializer(OrganizationalModelSerializer):
class Meta: class Meta:
model = VirtualCircuitType model = VirtualCircuitType
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'owner', 'comments', 'tags', 'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'owner', 'tags',
'custom_fields', 'created', 'last_updated', 'virtual_circuit_count', 'custom_fields', 'created', 'last_updated', 'virtual_circuit_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'virtual_circuit_count') brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'virtual_circuit_count')
+1 -1
View File
@@ -353,7 +353,7 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
model = CircuitTermination model = CircuitTermination
fields = ( fields = (
'id', 'termination_id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description', 'id', 'termination_id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description',
'mark_connected', 'pp_info', 'cable_end', 'cable_connector', 'mark_connected', 'pp_info', 'cable_end', 'cable_position',
) )
def search(self, queryset, name, value): def search(self, queryset, name, value):
+3 -3
View File
@@ -99,7 +99,7 @@ class CircuitTypeBulkEditForm(OrganizationalModelBulkEditForm):
fieldsets = ( fieldsets = (
FieldSet('color', 'description'), FieldSet('color', 'description'),
) )
nullable_fields = ('color', 'description', 'comments') nullable_fields = ('color', 'description')
class CircuitBulkEditForm(PrimaryModelBulkEditForm): class CircuitBulkEditForm(PrimaryModelBulkEditForm):
@@ -241,7 +241,7 @@ class CircuitGroupBulkEditForm(OrganizationalModelBulkEditForm):
model = CircuitGroup model = CircuitGroup
nullable_fields = ( nullable_fields = (
'description', 'tenant', 'comments', 'description', 'tenant',
) )
@@ -274,7 +274,7 @@ class VirtualCircuitTypeBulkEditForm(OrganizationalModelBulkEditForm):
fieldsets = ( fieldsets = (
FieldSet('color', 'description'), FieldSet('color', 'description'),
) )
nullable_fields = ('color', 'description', 'comments') nullable_fields = ('color', 'description')
class VirtualCircuitBulkEditForm(PrimaryModelBulkEditForm): class VirtualCircuitBulkEditForm(PrimaryModelBulkEditForm):
+3 -3
View File
@@ -73,7 +73,7 @@ class CircuitTypeImportForm(OrganizationalModelImportForm):
class Meta: class Meta:
model = CircuitType model = CircuitType
fields = ('name', 'slug', 'color', 'description', 'owner', 'comments', 'tags') fields = ('name', 'slug', 'color', 'description', 'owner', 'tags')
class CircuitImportForm(PrimaryModelImportForm): class CircuitImportForm(PrimaryModelImportForm):
@@ -176,7 +176,7 @@ class CircuitGroupImportForm(OrganizationalModelImportForm):
class Meta: class Meta:
model = CircuitGroup model = CircuitGroup
fields = ('name', 'slug', 'description', 'tenant', 'owner', 'comments', 'tags') fields = ('name', 'slug', 'description', 'tenant', 'owner', 'tags')
class CircuitGroupAssignmentImportForm(NetBoxModelImportForm): class CircuitGroupAssignmentImportForm(NetBoxModelImportForm):
@@ -199,7 +199,7 @@ class VirtualCircuitTypeImportForm(OrganizationalModelImportForm):
class Meta: class Meta:
model = VirtualCircuitType model = VirtualCircuitType
fields = ('name', 'slug', 'color', 'description', 'owner', 'comments', 'tags') fields = ('name', 'slug', 'color', 'description', 'owner', 'tags')
class VirtualCircuitImportForm(PrimaryModelImportForm): class VirtualCircuitImportForm(PrimaryModelImportForm):
+8 -16
View File
@@ -34,10 +34,9 @@ __all__ = (
class ProviderFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm): class ProviderFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
model = Provider model = Provider
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
FieldSet('asn_id', name=_('ASN')), FieldSet('asn_id', name=_('ASN')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
@@ -70,9 +69,8 @@ class ProviderFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
class ProviderAccountFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm): class ProviderAccountFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
model = ProviderAccount model = ProviderAccount
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('provider_id', 'account', name=_('Attributes')), FieldSet('provider_id', 'account', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
provider_id = DynamicModelMultipleChoiceField( provider_id = DynamicModelMultipleChoiceField(
@@ -90,9 +88,8 @@ class ProviderAccountFilterForm(ContactModelFilterForm, PrimaryModelFilterSetFor
class ProviderNetworkFilterForm(PrimaryModelFilterSetForm): class ProviderNetworkFilterForm(PrimaryModelFilterSetForm):
model = ProviderNetwork model = ProviderNetwork
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('provider_id', 'service_id', name=_('Attributes')), FieldSet('provider_id', 'service_id', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
provider_id = DynamicModelMultipleChoiceField( provider_id = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
@@ -110,9 +107,8 @@ class ProviderNetworkFilterForm(PrimaryModelFilterSetForm):
class CircuitTypeFilterForm(OrganizationalModelFilterSetForm): class CircuitTypeFilterForm(OrganizationalModelFilterSetForm):
model = CircuitType model = CircuitType
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('color', name=_('Attributes')), FieldSet('color', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@@ -125,7 +121,7 @@ class CircuitTypeFilterForm(OrganizationalModelFilterSetForm):
class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm): class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
model = Circuit model = Circuit
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')), FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
FieldSet( FieldSet(
'type_id', 'status', 'install_date', 'termination_date', 'commit_rate', 'distance', 'distance_unit', 'type_id', 'status', 'install_date', 'termination_date', 'commit_rate', 'distance', 'distance_unit',
@@ -133,7 +129,6 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelF
), ),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'provider_id', 'provider_network_id') selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'provider_id', 'provider_network_id')
@@ -279,9 +274,8 @@ class CircuitTerminationFilterForm(NetBoxModelFilterSetForm):
class CircuitGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm): class CircuitGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
model = CircuitGroup model = CircuitGroup
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@@ -318,9 +312,8 @@ class CircuitGroupAssignmentFilterForm(NetBoxModelFilterSetForm):
class VirtualCircuitTypeFilterForm(OrganizationalModelFilterSetForm): class VirtualCircuitTypeFilterForm(OrganizationalModelFilterSetForm):
model = VirtualCircuitType model = VirtualCircuitType
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('color', name=_('Attributes')), FieldSet('color', name=_('Attributes')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@@ -333,11 +326,10 @@ class VirtualCircuitTypeFilterForm(OrganizationalModelFilterSetForm):
class VirtualCircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm): class VirtualCircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
model = VirtualCircuit model = VirtualCircuit
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')), FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
FieldSet('type_id', 'status', name=_('Attributes')), FieldSet('type_id', 'status', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
selector_fields = ('filter_id', 'q', 'provider_id', 'provider_network_id') selector_fields = ('filter_id', 'q', 'provider_id', 'provider_network_id')
provider_id = DynamicModelMultipleChoiceField( provider_id = DynamicModelMultipleChoiceField(
+3 -3
View File
@@ -97,7 +97,7 @@ class CircuitTypeForm(OrganizationalModelForm):
class Meta: class Meta:
model = CircuitType model = CircuitType
fields = [ fields = [
'name', 'slug', 'color', 'description', 'comments', 'tags', 'name', 'slug', 'color', 'description', 'tags',
] ]
@@ -236,7 +236,7 @@ class CircuitGroupForm(TenancyForm, OrganizationalModelForm):
class Meta: class Meta:
model = CircuitGroup model = CircuitGroup
fields = [ fields = [
'name', 'slug', 'description', 'tenant_group', 'tenant', 'owner', 'comments', 'tags', 'name', 'slug', 'description', 'tenant_group', 'tenant', 'owner', 'tags',
] ]
@@ -307,7 +307,7 @@ class VirtualCircuitTypeForm(OrganizationalModelForm):
class Meta: class Meta:
model = VirtualCircuitType model = VirtualCircuitType
fields = [ fields = [
'name', 'slug', 'color', 'description', 'owner', 'comments', 'tags', 'name', 'slug', 'color', 'description', 'owner', 'tags',
] ]
@@ -1,39 +0,0 @@
import django.contrib.postgres.fields
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0053_owner'),
]
operations = [
migrations.AddField(
model_name='circuittermination',
name='cable_connector',
field=models.PositiveSmallIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(256)
],
),
),
migrations.AddField(
model_name='circuittermination',
name='cable_positions',
field=django.contrib.postgres.fields.ArrayField(
base_field=models.PositiveSmallIntegerField(
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024),
]
),
blank=True,
null=True,
size=None,
),
),
]
@@ -0,0 +1,23 @@
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0053_owner'),
]
operations = [
migrations.AddField(
model_name='circuittermination',
name='cable_position',
field=models.PositiveIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024),
],
),
),
]
@@ -1,28 +0,0 @@
# Generated by Django 5.2.8 on 2025-12-08 17:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0054_cable_connector_positions'),
]
operations = [
migrations.AddField(
model_name='circuitgroup',
name='comments',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='circuittype',
name='comments',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='virtualcircuittype',
name='comments',
field=models.TextField(blank=True),
),
]
@@ -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'),
),
]
-3
View File
@@ -335,9 +335,6 @@ class CircuitTermination(
name='%(app_label)s_%(class)s_unique_circuit_term_side' name='%(app_label)s_%(class)s_unique_circuit_term_side'
), ),
) )
indexes = (
models.Index(fields=('termination_type', 'termination_id')),
)
verbose_name = _('circuit termination') verbose_name = _('circuit termination')
verbose_name_plural = _('circuit terminations') verbose_name_plural = _('circuit terminations')
-3
View File
@@ -20,7 +20,6 @@ class CircuitGroupIndex(SearchIndex):
('name', 100), ('name', 100),
('slug', 110), ('slug', 110),
('description', 500), ('description', 500),
('comments', 5000),
) )
display_attrs = ('description',) display_attrs = ('description',)
@@ -45,7 +44,6 @@ class CircuitTypeIndex(SearchIndex):
('name', 100), ('name', 100),
('slug', 110), ('slug', 110),
('description', 500), ('description', 500),
('comments', 5000),
) )
display_attrs = ('description',) display_attrs = ('description',)
@@ -111,6 +109,5 @@ class VirtualCircuitTypeIndex(SearchIndex):
('name', 100), ('name', 100),
('slug', 110), ('slug', 110),
('description', 500), ('description', 500),
('comments', 5000),
) )
display_attrs = ('description',) display_attrs = ('description',)
+3 -3
View File
@@ -40,8 +40,8 @@ class CircuitTypeTable(OrganizationalModelTable):
class Meta(OrganizationalModelTable.Meta): class Meta(OrganizationalModelTable.Meta):
model = CircuitType model = CircuitType
fields = ( fields = (
'pk', 'id', 'name', 'circuit_count', 'color', 'description', 'slug', 'comments', 'tags', 'created', 'pk', 'id', 'name', 'circuit_count', 'color', 'description', 'slug', 'tags', 'created', 'last_updated',
'last_updated', 'actions', 'actions',
) )
default_columns = ('pk', 'name', 'circuit_count', 'color', 'description') default_columns = ('pk', 'name', 'circuit_count', 'color', 'description')
@@ -175,7 +175,7 @@ class CircuitGroupTable(OrganizationalModelTable):
class Meta(OrganizationalModelTable.Meta): class Meta(OrganizationalModelTable.Meta):
model = CircuitGroup model = CircuitGroup
fields = ( fields = (
'pk', 'name', 'description', 'circuit_group_assignment_count', 'comments', 'tags', 'pk', 'name', 'description', 'circuit_group_assignment_count', 'tags',
'created', 'last_updated', 'actions', 'created', 'last_updated', 'actions',
) )
default_columns = ('pk', 'name', 'description', 'circuit_group_assignment_count') default_columns = ('pk', 'name', 'description', 'circuit_group_assignment_count')
+1 -1
View File
@@ -433,7 +433,7 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = CircuitTermination.objects.all() queryset = CircuitTermination.objects.all()
filterset = CircuitTerminationFilterSet filterset = CircuitTerminationFilterSet
ignore_fields = ('cable', 'cable_positions') ignore_fields = ('cable',)
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
-1
View File
@@ -44,4 +44,3 @@ class DataFileSerializer(NetBoxModelSerializer):
'id', 'url', 'display_url', 'display', 'source', 'path', 'last_updated', 'size', 'hash', 'id', 'url', 'display_url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
] ]
brief_fields = ('id', 'url', 'display', 'path') brief_fields = ('id', 'url', 'display', 'path')
read_only_fields = ['path', 'last_updated', 'size', 'hash']
+1 -2
View File
@@ -31,8 +31,7 @@ class JobSerializer(BaseModelSerializer):
model = Job model = Job
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'object', 'name', 'status', 'created', 'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'object', 'name', 'status', 'created',
'scheduled', 'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'queue_name', 'scheduled', 'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'log_entries',
'log_entries',
] ]
brief_fields = ('url', 'created', 'completed', 'user', 'status') brief_fields = ('url', 'created', 'completed', 'user', 'status')
+5 -15
View File
@@ -11,6 +11,7 @@ from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from rest_framework.viewsets import ReadOnlyModelViewSet
from rq.job import Job as RQ_Job from rq.job import Job as RQ_Job
from rq.worker import Worker from rq.worker import Worker
@@ -63,7 +64,7 @@ class DataFileViewSet(NetBoxReadOnlyModelViewSet):
filterset_class = filtersets.DataFileFilterSet filterset_class = filtersets.DataFileFilterSet
class JobViewSet(NetBoxReadOnlyModelViewSet): class JobViewSet(ReadOnlyModelViewSet):
""" """
Retrieve a list of job results Retrieve a list of job results
""" """
@@ -72,20 +73,19 @@ class JobViewSet(NetBoxReadOnlyModelViewSet):
filterset_class = filtersets.JobFilterSet filterset_class = filtersets.JobFilterSet
class ObjectChangeViewSet(NetBoxReadOnlyModelViewSet): class ObjectChangeViewSet(ReadOnlyModelViewSet):
""" """
Retrieve a list of recent changes. Retrieve a list of recent changes.
""" """
metadata_class = ContentTypeMetadata metadata_class = ContentTypeMetadata
queryset = ObjectChange.objects.all()
serializer_class = serializers.ObjectChangeSerializer serializer_class = serializers.ObjectChangeSerializer
filterset_class = filtersets.ObjectChangeFilterSet filterset_class = filtersets.ObjectChangeFilterSet
def get_queryset(self): def get_queryset(self):
return super().get_queryset().valid_models() return ObjectChange.objects.valid_models()
class ObjectTypeViewSet(NetBoxReadOnlyModelViewSet): class ObjectTypeViewSet(ReadOnlyModelViewSet):
""" """
Read-only list of ObjectTypes. Read-only list of ObjectTypes.
""" """
@@ -94,16 +94,6 @@ class ObjectTypeViewSet(NetBoxReadOnlyModelViewSet):
serializer_class = serializers.ObjectTypeSerializer serializer_class = serializers.ObjectTypeSerializer
filterset_class = filtersets.ObjectTypeFilterSet filterset_class = filtersets.ObjectTypeFilterSet
def initial(self, request, *args, **kwargs):
"""
Override initial() to skip the restrict() call since ObjectType (a ContentType proxy)
doesn't use RestrictedQuerySet and is publicly accessible metadata.
"""
# Call GenericViewSet.initial() directly, skipping BaseViewSet.initial()
# which would try to call restrict() on the queryset
from rest_framework.viewsets import GenericViewSet
GenericViewSet.initial(self, request, *args, **kwargs)
class BaseRQViewSet(viewsets.ViewSet): class BaseRQViewSet(viewsets.ViewSet):
""" """
+1 -5
View File
@@ -129,14 +129,10 @@ class JobFilterSet(BaseFilterSet):
choices=JobStatusChoices, choices=JobStatusChoices,
null_value=None null_value=None
) )
queue_name = django_filters.CharFilter()
class Meta: class Meta:
model = Job model = Job
fields = ( fields = ('id', 'object_type', 'object_type_id', 'object_id', 'name', 'interval', 'status', 'user', 'job_id')
'id', 'object_type', 'object_type_id', 'object_id', 'name', 'interval', 'status', 'user', 'job_id',
'queue_name',
)
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
+2 -7
View File
@@ -26,9 +26,8 @@ __all__ = (
class DataSourceFilterForm(PrimaryModelFilterSetForm): class DataSourceFilterForm(PrimaryModelFilterSetForm):
model = DataSource model = DataSource
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('type', 'status', 'enabled', 'sync_interval', name=_('Data Source')), FieldSet('type', 'status', 'enabled', 'sync_interval', name=_('Data Source')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
label=_('Type'), label=_('Type'),
@@ -72,7 +71,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
model = Job model = Job
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id'), FieldSet('q', 'filter_id'),
FieldSet('object_type_id', 'status', 'queue_name', name=_('Attributes')), FieldSet('object_type_id', 'status', name=_('Attributes')),
FieldSet( FieldSet(
'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before', 'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before',
'started__after', 'completed__before', 'completed__after', 'user', name=_('Creation') 'started__after', 'completed__before', 'completed__after', 'user', name=_('Creation')
@@ -88,10 +87,6 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
choices=JobStatusChoices, choices=JobStatusChoices,
required=False required=False
) )
queue_name = forms.CharField(
label=_('Queue'),
required=False
)
created__after = forms.DateTimeField( created__after = forms.DateTimeField(
label=_('Created after'), label=_('Created after'),
required=False, required=False,
@@ -1,18 +0,0 @@
# Generated by Django 5.2.9 on 2026-01-27 00:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0020_owner'),
]
operations = [
migrations.AddField(
model_name='job',
name='queue_name',
field=models.CharField(blank=True, max_length=100),
),
]
+4 -8
View File
@@ -63,20 +63,16 @@ class ConfigRevision(models.Model):
return reverse('core:config') # Default config view return reverse('core:config') # Default config view
return reverse('core:configrevision', args=[self.pk]) return reverse('core:configrevision', args=[self.pk])
def activate(self, update_db=True): def activate(self):
""" """
Cache the configuration data. 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', self.data, None)
cache.set('config_version', self.pk, None) cache.set('config_version', self.pk, None)
if update_db: # Set all instances of ConfigRevision to false and set this instance to true
# Set all instances of ConfigRevision to false and set this instance to true ConfigRevision.objects.all().update(active=False)
ConfigRevision.objects.all().update(active=False) ConfigRevision.objects.filter(pk=self.pk).update(active=True)
ConfigRevision.objects.filter(pk=self.pk).update(active=True)
activate.alters_data = True activate.alters_data = True
+2 -17
View File
@@ -12,7 +12,7 @@ from django.core.validators import RegexValidator
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext as _
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
from netbox.models import PrimaryModel from netbox.models import PrimaryModel
@@ -128,24 +128,9 @@ class DataSource(JobsMixin, PrimaryModel):
# Ensure URL scheme matches selected type # Ensure URL scheme matches selected type
if self.backend_class.is_local and self.url_scheme not in ('file', ''): if self.backend_class.is_local and self.url_scheme not in ('file', ''):
raise ValidationError({ raise ValidationError({
'source_url': _("URLs for local sources must start with {scheme} (or specify no scheme)").format( 'source_url': "URLs for local sources must start with file:// (or specify no scheme)"
scheme='file://'
)
}) })
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): def to_objectchange(self, action):
objectchange = super().to_objectchange(action) objectchange = super().to_objectchange(action)
+3 -14
View File
@@ -112,12 +112,6 @@ class Job(models.Model):
verbose_name=_('job ID'), verbose_name=_('job ID'),
unique=True unique=True
) )
queue_name = models.CharField(
verbose_name=_('queue name'),
max_length=100,
blank=True,
help_text=_('Name of the queue in which this job was enqueued')
)
log_entries = ArrayField( log_entries = ArrayField(
verbose_name=_('log entries'), verbose_name=_('log entries'),
base_field=models.JSONField( base_field=models.JSONField(
@@ -185,15 +179,11 @@ class Job(models.Model):
return f"{int(minutes)} minutes, {seconds:.2f} seconds" return f"{int(minutes)} minutes, {seconds:.2f} seconds"
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
# Use the stored queue name, or fall back to get_queue_for_model for legacy jobs
rq_queue_name = self.queue_name or get_queue_for_model(self.object_type.model if self.object_type else None)
rq_job_id = str(self.job_id)
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
# Cancel the RQ job using the stored queue name rq_queue_name = get_queue_for_model(self.object_type.model if self.object_type else None)
queue = django_rq.get_queue(rq_queue_name) queue = django_rq.get_queue(rq_queue_name)
job = queue.fetch_job(rq_job_id) job = queue.fetch_job(str(self.job_id))
if job: if job:
try: try:
@@ -298,8 +288,7 @@ class Job(models.Model):
scheduled=schedule_at, scheduled=schedule_at,
interval=interval, interval=interval,
user=user, user=user,
job_id=uuid.uuid4(), job_id=uuid.uuid4()
queue_name=rq_queue_name
) )
job.full_clean() job.full_clean()
job.save() job.save()
+4 -21
View File
@@ -9,7 +9,6 @@ from django.db import connection, models
from django.db.models import Q from django.db.models import Q
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from netbox.context import query_cache
from netbox.plugins import PluginConfig from netbox.plugins import PluginConfig
from netbox.registry import registry from netbox.registry import registry
from utilities.string import title from utilities.string import title
@@ -36,10 +35,6 @@ class ObjectTypeQuerySet(models.QuerySet):
class ObjectTypeManager(models.Manager): class ObjectTypeManager(models.Manager):
# TODO: Remove this in NetBox v5.0
# Cache the result of introspection to avoid repeated queries.
_table_exists = False
def get_queryset(self): def get_queryset(self):
return ObjectTypeQuerySet(self.model, using=self._db) return ObjectTypeQuerySet(self.model, using=self._db)
@@ -71,21 +66,13 @@ class ObjectTypeManager(models.Manager):
""" """
from netbox.models.features import get_model_features, model_is_public from netbox.models.features import get_model_features, model_is_public
# Check the request cache before hitting the database
cache = query_cache.get()
if cache is not None:
if ot := cache['object_types'].get((model._meta.model, for_concrete_model)):
return ot
# TODO: Remove this in NetBox v5.0 # TODO: Remove this in NetBox v5.0
# If the ObjectType table has not yet been provisioned (e.g. because we're in a pre-v4.4 migration), # If the ObjectType table has not yet been provisioned (e.g. because we're in a pre-v4.4 migration),
# fall back to ContentType. # fall back to ContentType.
if not ObjectTypeManager._table_exists: if 'core_objecttype' not in connection.introspection.table_names():
if 'core_objecttype' not in connection.introspection.table_names(): ct = ContentType.objects.get_for_model(model, for_concrete_model=for_concrete_model)
ct = ContentType.objects.get_for_model(model, for_concrete_model=for_concrete_model) ct.features = get_model_features(ct.model_class())
ct.features = get_model_features(ct.model_class()) return ct
return ct
ObjectTypeManager._table_exists = True
if not inspect.isclass(model): if not inspect.isclass(model):
model = model.__class__ model = model.__class__
@@ -103,10 +90,6 @@ class ObjectTypeManager(models.Manager):
features=get_model_features(model), features=get_model_features(model),
)[0] )[0]
# Populate the request cache to avoid redundant lookups
if cache is not None:
cache['object_types'][(model._meta.model, for_concrete_model)] = ot
return ot return ot
def get_for_models(self, *models, for_concrete_models=True): def get_for_models(self, *models, for_concrete_models=True):
+3 -4
View File
@@ -3,7 +3,7 @@ from threading import local
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist, ValidationError 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.fields.reverse_related import ManyToManyRel, ManyToOneRel
from django.db.models.signals import m2m_changed, post_migrate, post_save, pre_delete from django.db.models.signals import m2m_changed, post_migrate, post_save, pre_delete
from django.dispatch import receiver, Signal from django.dispatch import receiver, Signal
@@ -18,7 +18,6 @@ from extras.events import enqueue_event
from extras.models import Tag from extras.models import Tag
from extras.utils import run_validators from extras.utils import run_validators
from netbox.config import get_config from netbox.config import get_config
from utilities.data import get_config_value_ci
from netbox.context import current_request, events_queue from netbox.context import current_request, events_queue
from netbox.models.features import ChangeLoggingMixin, get_model_features, model_is_public from netbox.models.features import ChangeLoggingMixin, get_model_features, model_is_public
from utilities.exceptions import AbortRequest from utilities.exceptions import AbortRequest
@@ -169,7 +168,7 @@ def handle_deleted_object(sender, instance, **kwargs):
# to queueing any events for the object being deleted, in case a validation error is # to queueing any events for the object being deleted, in case a validation error is
# raised, causing the deletion to fail. # raised, causing the deletion to fail.
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}' model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
validators = get_config_value_ci(get_config().PROTECTION_RULES, model_name, default=[]) validators = get_config().PROTECTION_RULES.get(model_name, [])
try: try:
run_validators(instance, validators) run_validators(instance, validators)
except ValidationError as e: except ValidationError as e:
@@ -222,7 +221,7 @@ def handle_deleted_object(sender, instance, **kwargs):
obj.snapshot() # Ensure the change record includes the "before" state obj.snapshot() # Ensure the change record includes the "before" state
if type(relation) is ManyToManyRel: if type(relation) is ManyToManyRel:
getattr(obj, related_field_name).remove(instance) 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) setattr(obj, related_field_name, None)
obj.save() obj.save()
+1 -4
View File
@@ -42,9 +42,6 @@ class JobTable(NetBoxTable):
completed = columns.DateTimeColumn( completed = columns.DateTimeColumn(
verbose_name=_('Completed'), verbose_name=_('Completed'),
) )
queue_name = tables.Column(
verbose_name=_('Queue'),
)
log_entries = tables.Column( log_entries = tables.Column(
verbose_name=_('Log Entries'), verbose_name=_('Log Entries'),
) )
@@ -56,7 +53,7 @@ class JobTable(NetBoxTable):
model = Job model = Job
fields = ( fields = (
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started', 'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started',
'completed', 'user', 'queue_name', 'log_entries', 'error', 'job_id', 'completed', 'user', 'error', 'job_id',
) )
default_columns = ( default_columns = (
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user', 'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
+1 -38
View File
@@ -1,10 +1,8 @@
from unittest.mock import patch, MagicMock
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.test import TestCase from django.test import TestCase
from core.models import DataSource, Job, ObjectType from core.models import DataSource, ObjectType
from core.choices import ObjectChangeActionChoices from core.choices import ObjectChangeActionChoices
from dcim.models import Site, Location, Device from dcim.models import Site, Location, Device
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
@@ -202,38 +200,3 @@ class ObjectTypeTest(TestCase):
bookmarks_ots = ObjectType.objects.with_feature('bookmarks') bookmarks_ots = ObjectType.objects.with_feature('bookmarks')
self.assertIn(ObjectType.objects.get_by_natural_key('dcim', 'site'), bookmarks_ots) self.assertIn(ObjectType.objects.get_by_natural_key('dcim', 'site'), bookmarks_ots)
self.assertNotIn(ObjectType.objects.get_by_natural_key('dcim', 'cabletermination'), bookmarks_ots) self.assertNotIn(ObjectType.objects.get_by_natural_key('dcim', 'cabletermination'), bookmarks_ots)
class JobTest(TestCase):
@patch('core.models.jobs.django_rq.get_queue')
def test_delete_cancels_job_from_correct_queue(self, mock_get_queue):
"""
Test that when a job is deleted, it's canceled from the correct queue.
"""
mock_queue = MagicMock()
mock_rq_job = MagicMock()
mock_queue.fetch_job.return_value = mock_rq_job
mock_get_queue.return_value = mock_queue
def dummy_func(**kwargs):
pass
# Enqueue a job with a custom queue name
custom_queue = 'my_custom_queue'
job = Job.enqueue(
func=dummy_func,
name='Test Job',
queue_name=custom_queue
)
# Reset mock to clear enqueue call
mock_get_queue.reset_mock()
# Delete the job
job.delete()
# Verify the correct queue was used for cancellation
mock_get_queue.assert_called_with(custom_queue)
mock_queue.fetch_job.assert_called_with(str(job.job_id))
mock_rq_job.cancel.assert_called_once()
-52
View File
@@ -2,12 +2,10 @@ from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from dcim.models import FrontPort, FrontPortTemplate, PortMapping, PortTemplateMapping, RearPort, RearPortTemplate
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
__all__ = ( __all__ = (
'ConnectedEndpointsSerializer', 'ConnectedEndpointsSerializer',
'PortSerializer',
) )
@@ -37,53 +35,3 @@ class ConnectedEndpointsSerializer(serializers.ModelSerializer):
@extend_schema_field(serializers.BooleanField) @extend_schema_field(serializers.BooleanField)
def get_connected_endpoints_reachable(self, obj): def get_connected_endpoints_reachable(self, obj):
return obj._path and obj._path.is_complete and obj._path.is_active return obj._path and obj._path.is_complete and obj._path.is_active
class PortSerializer(serializers.ModelSerializer):
"""
Base serializer for front & rear port and port templates.
"""
@property
def _mapper(self):
"""
Return the model and ForeignKey field name used to track port mappings for this model.
"""
if self.Meta.model is FrontPort:
return PortMapping, 'front_port'
if self.Meta.model is RearPort:
return PortMapping, 'rear_port'
if self.Meta.model is FrontPortTemplate:
return PortTemplateMapping, 'front_port'
if self.Meta.model is RearPortTemplate:
return PortTemplateMapping, 'rear_port'
raise ValueError(f"Could not determine mapping details for {self.__class__}")
def create(self, validated_data):
mappings = validated_data.pop('mappings', [])
instance = super().create(validated_data)
# Create port mappings
mapping_model, fk_name = self._mapper
for attrs in mappings:
mapping_model.objects.create(**{
fk_name: instance,
**attrs,
})
return instance
def update(self, instance, validated_data):
mappings = validated_data.pop('mappings', None)
instance = super().update(instance, validated_data)
if mappings is not None:
# Update port mappings
mapping_model, fk_name = self._mapper
mapping_model.objects.filter(**{fk_name: instance}).delete()
for attrs in mappings:
mapping_model.objects.create(**{
fk_name: instance,
**attrs,
})
return instance
+2 -3
View File
@@ -61,12 +61,11 @@ class CableTerminationSerializer(NetBoxModelSerializer):
model = CableTermination model = CableTermination
fields = [ fields = [
'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id', 'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id',
'termination', 'connector', 'positions', 'created', 'last_updated', 'termination', 'position', 'created', 'last_updated',
] ]
read_only_fields = fields read_only_fields = fields
brief_fields = ( brief_fields = (
'id', 'url', 'display', 'cable', 'cable_end', 'connector', 'positions', 'termination_type', 'id', 'url', 'display', 'cable', 'cable_end', 'position', 'termination_type', 'termination_id',
'termination_id',
) )
@@ -1,26 +1,25 @@
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.contrib.contenttypes.models import ContentType
from rest_framework import serializers from rest_framework import serializers
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models import ( from dcim.models import (
ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PortMapping, ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort,
PowerOutlet, PowerPort, RearPort, VirtualDeviceContext, RearPort, VirtualDeviceContext,
) )
from ipam.api.serializers_.vlans import VLANSerializer, VLANTranslationPolicySerializer from ipam.api.serializers_.vlans import VLANSerializer, VLANTranslationPolicySerializer
from ipam.api.serializers_.vrfs import VRFSerializer from ipam.api.serializers_.vrfs import VRFSerializer
from ipam.models import VLAN from ipam.models import VLAN
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.gfk_fields import GFKSerializerField from netbox.api.gfk_fields import GFKSerializerField
from netbox.api.serializers import NetBoxModelSerializer from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
from users.api.serializers_.mixins import OwnerMixin
from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer
from wireless.api.serializers_.nested import NestedWirelessLinkSerializer from wireless.api.serializers_.nested import NestedWirelessLinkSerializer
from wireless.api.serializers_.wirelesslans import WirelessLANSerializer from wireless.api.serializers_.wirelesslans import WirelessLANSerializer
from wireless.choices import * from wireless.choices import *
from wireless.models import WirelessLAN from wireless.models import WirelessLAN
from .base import ConnectedEndpointsSerializer, PortSerializer from .base import ConnectedEndpointsSerializer
from .cables import CabledObjectSerializer from .cables import CabledObjectSerializer
from .devices import DeviceSerializer, MACAddressSerializer, ModuleSerializer, VirtualDeviceContextSerializer from .devices import DeviceSerializer, MACAddressSerializer, ModuleSerializer, VirtualDeviceContextSerializer
from .manufacturers import ManufacturerSerializer from .manufacturers import ManufacturerSerializer
@@ -41,12 +40,7 @@ __all__ = (
) )
class ConsoleServerPortSerializer( class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
OwnerMixin,
NetBoxModelSerializer,
CabledObjectSerializer,
ConnectedEndpointsSerializer
):
device = DeviceSerializer(nested=True) device = DeviceSerializer(nested=True)
module = ModuleSerializer( module = ModuleSerializer(
nested=True, nested=True,
@@ -70,18 +64,13 @@ class ConsoleServerPortSerializer(
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description', 'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints', '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', 'last_updated', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied') brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class ConsolePortSerializer( class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
OwnerMixin,
NetBoxModelSerializer,
CabledObjectSerializer,
ConnectedEndpointsSerializer
):
device = DeviceSerializer(nested=True) device = DeviceSerializer(nested=True)
module = ModuleSerializer( module = ModuleSerializer(
nested=True, nested=True,
@@ -105,18 +94,13 @@ class ConsolePortSerializer(
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description', 'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints', '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', 'last_updated', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied') brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class PowerPortSerializer( class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
OwnerMixin,
NetBoxModelSerializer,
CabledObjectSerializer,
ConnectedEndpointsSerializer
):
device = DeviceSerializer(nested=True) device = DeviceSerializer(nested=True)
module = ModuleSerializer( module = ModuleSerializer(
nested=True, nested=True,
@@ -136,18 +120,13 @@ class PowerPortSerializer(
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'maximum_draw', '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', 'allocated_draw', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'owner', 'tags', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
'custom_fields', 'created', 'last_updated', '_occupied', 'created', 'last_updated', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied') brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class PowerOutletSerializer( class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
OwnerMixin,
NetBoxModelSerializer,
CabledObjectSerializer,
ConnectedEndpointsSerializer
):
device = DeviceSerializer(nested=True) device = DeviceSerializer(nested=True)
module = ModuleSerializer( module = ModuleSerializer(
nested=True, nested=True,
@@ -180,17 +159,12 @@ class PowerOutletSerializer(
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'status', 'color', 'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'status', 'color',
'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
'link_peers_type', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', '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') brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class InterfaceSerializer( class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
OwnerMixin,
NetBoxModelSerializer,
CabledObjectSerializer,
ConnectedEndpointsSerializer
):
device = DeviceSerializer(nested=True) device = DeviceSerializer(nested=True)
vdcs = SerializedPKRelatedField( vdcs = SerializedPKRelatedField(
queryset=VirtualDeviceContext.objects.all(), queryset=VirtualDeviceContext.objects.all(),
@@ -208,7 +182,6 @@ class InterfaceSerializer(
type = ChoiceField(choices=InterfaceTypeChoices) type = ChoiceField(choices=InterfaceTypeChoices)
parent = NestedInterfaceSerializer(required=False, allow_null=True) parent = NestedInterfaceSerializer(required=False, allow_null=True)
bridge = 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) lag = NestedInterfaceSerializer(required=False, allow_null=True)
mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True) mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True)
duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True, allow_null=True) duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True, allow_null=True)
@@ -248,13 +221,13 @@ class InterfaceSerializer(
model = Interface model = Interface
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled', 'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled',
'parent', 'bridge', 'bridge_interfaces', 'lag', 'mtu', 'mac_address', 'primary_mac_address', 'parent', 'bridge', 'lag', 'mtu', 'mac_address', 'primary_mac_address', 'mac_addresses', 'speed', 'duplex',
'mac_addresses', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'poe_mode', 'poe_type',
'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'mark_connected', 'cable', 'cable_end', 'vlan_translation_policy', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers',
'wireless_link', 'link_peers', 'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints',
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'owner', 'tags', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied') brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
@@ -321,20 +294,7 @@ class InterfaceSerializer(
return super().validate(data) return super().validate(data)
class RearPortMappingSerializer(serializers.ModelSerializer): class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
position = serializers.IntegerField(
source='rear_port_position'
)
front_port = serializers.PrimaryKeyRelatedField(
queryset=FrontPort.objects.all(),
)
class Meta:
model = PortMapping
fields = ('position', 'front_port', 'front_port_position')
class RearPortSerializer(OwnerMixin, NetBoxModelSerializer, CabledObjectSerializer, PortSerializer):
device = DeviceSerializer(nested=True) device = DeviceSerializer(nested=True)
module = ModuleSerializer( module = ModuleSerializer(
nested=True, nested=True,
@@ -343,36 +303,28 @@ class RearPortSerializer(OwnerMixin, NetBoxModelSerializer, CabledObjectSerializ
allow_null=True allow_null=True
) )
type = ChoiceField(choices=PortTypeChoices) type = ChoiceField(choices=PortTypeChoices)
front_ports = RearPortMappingSerializer(
source='mappings',
many=True,
required=False,
)
class Meta: class Meta:
model = RearPort model = RearPort
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions', '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', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags',
'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', 'custom_fields', 'created', 'last_updated', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied') brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class FrontPortMappingSerializer(serializers.ModelSerializer): class FrontPortRearPortSerializer(WritableNestedSerializer):
position = serializers.IntegerField( """
source='front_port_position' NestedRearPortSerializer but with parent device omitted (since front and rear ports must belong to same device)
) """
rear_port = serializers.PrimaryKeyRelatedField(
queryset=RearPort.objects.all(),
)
class Meta: class Meta:
model = PortMapping model = RearPort
fields = ('position', 'rear_port', 'rear_port_position') fields = ['id', 'url', 'display_url', 'display', 'name', 'label', 'description']
class FrontPortSerializer(OwnerMixin, NetBoxModelSerializer, CabledObjectSerializer, PortSerializer): class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
device = DeviceSerializer(nested=True) device = DeviceSerializer(nested=True)
module = ModuleSerializer( module = ModuleSerializer(
nested=True, nested=True,
@@ -381,23 +333,19 @@ class FrontPortSerializer(OwnerMixin, NetBoxModelSerializer, CabledObjectSeriali
allow_null=True allow_null=True
) )
type = ChoiceField(choices=PortTypeChoices) type = ChoiceField(choices=PortTypeChoices)
rear_ports = FrontPortMappingSerializer( rear_port = FrontPortRearPortSerializer()
source='mappings',
many=True,
required=False,
)
class Meta: class Meta:
model = FrontPort model = FrontPort
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port',
'rear_ports', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', 'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
] ]
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied') brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
class ModuleBaySerializer(OwnerMixin, NetBoxModelSerializer): class ModuleBaySerializer(NetBoxModelSerializer):
device = DeviceSerializer(nested=True) device = DeviceSerializer(nested=True)
module = ModuleSerializer( module = ModuleSerializer(
nested=True, nested=True,
@@ -417,12 +365,12 @@ class ModuleBaySerializer(OwnerMixin, NetBoxModelSerializer):
model = ModuleBay model = ModuleBay
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'installed_module', 'label', 'position', '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') brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'description')
class DeviceBaySerializer(OwnerMixin, NetBoxModelSerializer): class DeviceBaySerializer(NetBoxModelSerializer):
device = DeviceSerializer(nested=True) device = DeviceSerializer(nested=True)
installed_device = DeviceSerializer(nested=True, required=False, allow_null=True) installed_device = DeviceSerializer(nested=True, required=False, allow_null=True)
@@ -430,12 +378,12 @@ class DeviceBaySerializer(OwnerMixin, NetBoxModelSerializer):
model = DeviceBay model = DeviceBay
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'device', 'name', 'label', 'description', 'installed_device', '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') brief_fields = ('id', 'url', 'display', 'device', 'name', 'description')
class InventoryItemSerializer(OwnerMixin, NetBoxModelSerializer): class InventoryItemSerializer(NetBoxModelSerializer):
device = DeviceSerializer(nested=True) device = DeviceSerializer(nested=True)
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None) parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
role = InventoryItemRoleSerializer(nested=True, required=False, allow_null=True) role = InventoryItemRoleSerializer(nested=True, required=False, allow_null=True)
@@ -454,6 +402,6 @@ class InventoryItemSerializer(OwnerMixin, NetBoxModelSerializer):
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'device', 'parent', 'name', 'label', 'status', 'role', 'id', 'url', 'display_url', 'display', 'device', 'parent', 'name', 'label', 'status', 'role',
'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', 'component_type', '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') brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', '_depth')
+1 -1
View File
@@ -111,7 +111,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
'id', 'url', 'display_url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial', 'id', 'url', 'display_url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', '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', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'console_port_count',
'console_server_port_count', 'power_port_count', 'power_outlet_count', 'interface_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', 'front_port_count', 'rear_port_count', 'device_bay_count', 'module_bay_count', 'inventory_item_count',
@@ -5,14 +5,12 @@ from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models import ( from dcim.models import (
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate, ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate,
InventoryItemTemplate, ModuleBayTemplate, PortTemplateMapping, PowerOutletTemplate, PowerPortTemplate, InventoryItemTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
RearPortTemplate,
) )
from netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.fields import ChoiceField, ContentTypeField
from netbox.api.gfk_fields import GFKSerializerField from netbox.api.gfk_fields import GFKSerializerField
from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer
from wireless.choices import * from wireless.choices import *
from .base import PortSerializer
from .devicetypes import DeviceTypeSerializer, ModuleTypeSerializer from .devicetypes import DeviceTypeSerializer, ModuleTypeSerializer
from .manufacturers import ManufacturerSerializer from .manufacturers import ManufacturerSerializer
from .nested import NestedInterfaceTemplateSerializer from .nested import NestedInterfaceTemplateSerializer
@@ -207,20 +205,7 @@ class InterfaceTemplateSerializer(ComponentTemplateSerializer):
brief_fields = ('id', 'url', 'display', 'name', 'description') brief_fields = ('id', 'url', 'display', 'name', 'description')
class RearPortTemplateMappingSerializer(serializers.ModelSerializer): class RearPortTemplateSerializer(ComponentTemplateSerializer):
position = serializers.IntegerField(
source='rear_port_position'
)
front_port = serializers.PrimaryKeyRelatedField(
queryset=FrontPortTemplate.objects.all(),
)
class Meta:
model = PortTemplateMapping
fields = ('position', 'front_port', 'front_port_position')
class RearPortTemplateSerializer(ComponentTemplateSerializer, PortSerializer):
device_type = DeviceTypeSerializer( device_type = DeviceTypeSerializer(
required=False, required=False,
nested=True, nested=True,
@@ -234,35 +219,17 @@ class RearPortTemplateSerializer(ComponentTemplateSerializer, PortSerializer):
default=None default=None
) )
type = ChoiceField(choices=PortTypeChoices) type = ChoiceField(choices=PortTypeChoices)
front_ports = RearPortTemplateMappingSerializer(
source='mappings',
many=True,
required=False,
)
class Meta: class Meta:
model = RearPortTemplate model = RearPortTemplate
fields = [ fields = [
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color',
'front_ports', 'description', 'created', 'last_updated', 'positions', 'description', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description') brief_fields = ('id', 'url', 'display', 'name', 'description')
class FrontPortTemplateMappingSerializer(serializers.ModelSerializer): class FrontPortTemplateSerializer(ComponentTemplateSerializer):
position = serializers.IntegerField(
source='front_port_position'
)
rear_port = serializers.PrimaryKeyRelatedField(
queryset=RearPortTemplate.objects.all(),
)
class Meta:
model = PortTemplateMapping
fields = ('position', 'rear_port', 'rear_port_position')
class FrontPortTemplateSerializer(ComponentTemplateSerializer, PortSerializer):
device_type = DeviceTypeSerializer( device_type = DeviceTypeSerializer(
nested=True, nested=True,
required=False, required=False,
@@ -276,17 +243,13 @@ class FrontPortTemplateSerializer(ComponentTemplateSerializer, PortSerializer):
default=None default=None
) )
type = ChoiceField(choices=PortTypeChoices) type = ChoiceField(choices=PortTypeChoices)
rear_ports = FrontPortTemplateMappingSerializer( rear_port = RearPortTemplateSerializer(nested=True)
source='mappings',
many=True,
required=False,
)
class Meta: class Meta:
model = FrontPortTemplate model = FrontPortTemplate
fields = [ fields = [
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color',
'rear_ports', 'description', 'created', 'last_updated', 'rear_port', 'rear_port_position', 'description', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description') brief_fields = ('id', 'url', 'display', 'name', 'description')
@@ -11,15 +11,13 @@ class ManufacturerSerializer(OrganizationalModelSerializer):
# Related object counts # Related object counts
devicetype_count = RelatedObjectCountField('device_types') devicetype_count = RelatedObjectCountField('device_types')
moduletype_count = RelatedObjectCountField('module_types')
inventoryitem_count = RelatedObjectCountField('inventory_items') inventoryitem_count = RelatedObjectCountField('inventory_items')
platform_count = RelatedObjectCountField('platforms') platform_count = RelatedObjectCountField('platforms')
class Meta: class Meta:
model = Manufacturer model = Manufacturer
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'owner', 'comments', 'tags', 'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'owner', 'tags', 'custom_fields',
'custom_fields', 'created', 'last_updated', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', 'created', 'last_updated', 'devicetype_count', 'inventoryitem_count', 'platform_count',
'platform_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description') brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'devicetype_count')
+1 -1
View File
@@ -30,7 +30,7 @@ class RackRoleSerializer(OrganizationalModelSerializer):
class Meta: class Meta:
model = RackRole model = RackRole
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'owner', 'comments', 'tags', 'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'owner', 'tags',
'custom_fields', 'created', 'last_updated', 'rack_count', 'custom_fields', 'created', 'last_updated', 'rack_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count') brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count')
+1 -1
View File
@@ -38,7 +38,7 @@ class InventoryItemRoleSerializer(OrganizationalModelSerializer):
class Meta: class Meta:
model = InventoryItemRole model = InventoryItemRole
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'owner', 'comments', 'tags', 'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'owner', 'tags',
'custom_fields', 'created', 'last_updated', 'inventoryitem_count', 'custom_fields', 'created', 'last_updated', 'inventoryitem_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'inventoryitem_count') brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'inventoryitem_count')
+68 -350
View File
@@ -1,390 +1,108 @@
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from dcim.choices import CableEndChoices
from dcim.models import CableTermination from dcim.models import CableTermination
class BaseCableProfile: class BaseCableProfile:
"""Base class for representing a cable profile.""" # Maximum number of terminations allowed per side
a_max_connections = None
# Mappings of connectors to the number of positions presented by each, at either end of the cable. For example, a b_max_connections = None
# 12-strand MPO fiber cable would have one connector at either end with six positions (six bidirectional fiber
# pairs).
a_connectors = {}
b_connectors = {}
# Defined a mapping of A/B connector & position pairings. If not defined, all positions are presumed to be
# symmetrical (i.e. 1:1 on side A maps to 1:1 on side B). If defined, it must be constructed as a dictionary of
# two-item tuples, e.g. {(1, 1): (1, 1)}.
_mapping = None
def clean(self, cable): def clean(self, cable):
# Enforce maximum terminations limits # Enforce maximum connection limits
a_terminations_count = len(cable.a_terminations) if self.a_max_connections and len(cable.a_terminations) > self.a_max_connections:
b_terminations_count = len(cable.b_terminations)
max_a_terminations = len(self.a_connectors)
max_b_terminations = len(self.b_connectors)
if a_terminations_count > max_a_terminations:
raise ValidationError({ raise ValidationError({
'a_terminations': _( 'a_terminations': _(
'A side of cable has {count} terminations but only {max} are permitted for profile {profile}' 'Maximum A side connections for profile {profile}: {max}'
).format( ).format(
count=a_terminations_count,
profile=cable.get_profile_display(), profile=cable.get_profile_display(),
max=max_a_terminations, max=self.a_max_connections,
) )
}) })
if b_terminations_count > max_b_terminations: if self.b_max_connections and len(cable.b_terminations) > self.b_max_connections:
raise ValidationError({ raise ValidationError({
'b_terminations': _( 'b_terminations': _(
'B side of cable has {count} terminations but only {max} are permitted for profile {profile}' 'Maximum B side connections for profile {profile}: {max}'
).format( ).format(
count=b_terminations_count,
profile=cable.get_profile_display(), profile=cable.get_profile_display(),
max=max_b_terminations, max=self.b_max_connections,
) )
}) })
def get_mapped_position(self, side, connector, position): def get_mapped_position(self, side, position):
""" """
Return the mapped far-end connector & position for a given cable end the local connector & position. Return the mapped position for a given cable end and position.
By default, assume all positions are symmetrical.
""" """
# By default, assume all positions are symmetrical. return position
if self._mapping:
return self._mapping.get((connector, position))
return connector, position
def get_peer_termination(self, termination, position): def get_peer_terminations(self, terminations, position_stack):
""" local_end = terminations[0].cable_end
Given a terminating object, return the peer terminating object (if any) on the opposite end of the cable. qs = CableTermination.objects.filter(
""" cable=terminations[0].cable,
try: cable_end=terminations[0].opposite_cable_end
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}"
)
try:
ct = CableTermination.objects.get(
cable=termination.cable,
cable_end=termination.opposite_cable_end,
connector=connector,
positions__contains=[position],
)
return ct.termination, position
except CableTermination.DoesNotExist:
return None, None
@staticmethod # TODO: Optimize this to use a single query under any condition
def get_position_list(n): if position_stack:
"""Return a list of integers from 1 to n, inclusive.""" # Attempt to find a peer termination at the same position currently in the stack. Pop the stack only if
return list(range(1, n + 1)) # we find one. Otherwise, return any peer terminations with a null position.
position = self.get_mapped_position(local_end, position_stack[-1][0])
if peers := qs.filter(position=position):
position_stack.pop()
return peers
return qs.filter(position=None)
# Profile naming: class StraightSingleCableProfile(BaseCableProfile):
# - Single: One connector per side, with one or more positions a_max_connections = 1
# - Trunk: Two or more connectors per side, with one or more positions per connector b_max_connections = 1
# - Breakout: One or more connectors on the A side which map to a greater number of B side connectors
# - Shuffle: A cable with nonlinear position mappings between sides
class Single1C1PCableProfile(BaseCableProfile):
a_connectors = { class StraightMultiCableProfile(BaseCableProfile):
a_max_connections = None
b_max_connections = None
class Shuffle2x2MPO8CableProfile(BaseCableProfile):
a_max_connections = 8
b_max_connections = 8
_mapping = {
1: 1, 1: 1,
}
b_connectors = a_connectors
class Single1C2PCableProfile(BaseCableProfile):
a_connectors = {
1: 2,
}
b_connectors = a_connectors
class Single1C4PCableProfile(BaseCableProfile):
a_connectors = {
1: 4,
}
b_connectors = a_connectors
class Single1C6PCableProfile(BaseCableProfile):
a_connectors = {
1: 6,
}
b_connectors = a_connectors
class Single1C8PCableProfile(BaseCableProfile):
a_connectors = {
1: 8,
}
b_connectors = a_connectors
class Single1C12PCableProfile(BaseCableProfile):
a_connectors = {
1: 12,
}
b_connectors = a_connectors
class Single1C16PCableProfile(BaseCableProfile):
a_connectors = {
1: 16,
}
b_connectors = a_connectors
class Trunk2C1PCableProfile(BaseCableProfile):
a_connectors = {
1: 1,
2: 1,
}
b_connectors = a_connectors
class Trunk2C2PCableProfile(BaseCableProfile):
a_connectors = {
1: 2,
2: 2, 2: 2,
} 3: 5,
b_connectors = a_connectors
class Trunk2C4PCableProfile(BaseCableProfile):
a_connectors = {
1: 4,
2: 4,
}
b_connectors = a_connectors
class Trunk2C6PCableProfile(BaseCableProfile):
a_connectors = {
1: 6,
2: 6,
}
b_connectors = a_connectors
class Trunk2C8PCableProfile(BaseCableProfile):
a_connectors = {
1: 8,
2: 8,
}
b_connectors = a_connectors
class Trunk2C12PCableProfile(BaseCableProfile):
a_connectors = {
1: 12,
2: 12,
}
b_connectors = a_connectors
class Trunk4C1PCableProfile(BaseCableProfile):
a_connectors = {
1: 1,
2: 1,
3: 1,
4: 1,
}
b_connectors = a_connectors
class Trunk4C2PCableProfile(BaseCableProfile):
a_connectors = {
1: 2,
2: 2,
3: 2,
4: 2,
}
b_connectors = a_connectors
class Trunk4C4PCableProfile(BaseCableProfile):
a_connectors = {
1: 4,
2: 4,
3: 4,
4: 4,
}
b_connectors = a_connectors
class Trunk4C6PCableProfile(BaseCableProfile):
a_connectors = {
1: 6,
2: 6,
3: 6,
4: 6, 4: 6,
} 5: 3,
b_connectors = a_connectors
class Trunk4C8PCableProfile(BaseCableProfile):
a_connectors = {
1: 8,
2: 8,
3: 8,
4: 8,
}
b_connectors = a_connectors
class Trunk8C4PCableProfile(BaseCableProfile):
a_connectors = {
1: 4,
2: 4,
3: 4,
4: 4,
5: 4,
6: 4, 6: 4,
7: 4, 7: 7,
8: 4, 8: 8,
}
b_connectors = a_connectors
class Breakout1C4Px4C1PCableProfile(BaseCableProfile):
a_connectors = {
1: 4,
}
b_connectors = {
1: 1,
2: 1,
3: 1,
4: 1,
}
_mapping = {
(1, 1): (1, 1),
(1, 2): (2, 1),
(1, 3): (3, 1),
(1, 4): (4, 1),
(2, 1): (1, 2),
(3, 1): (1, 3),
(4, 1): (1, 4),
} }
def get_mapped_position(self, side, position):
class Breakout1C6Px6C1PCableProfile(BaseCableProfile): return self._mapping.get(position)
a_connectors = {
1: 6,
}
b_connectors = {
1: 1,
2: 1,
3: 1,
4: 1,
5: 1,
6: 1,
}
_mapping = {
(1, 1): (1, 1),
(1, 2): (2, 1),
(1, 3): (3, 1),
(1, 4): (4, 1),
(1, 5): (5, 1),
(1, 6): (6, 1),
(2, 1): (1, 2),
(3, 1): (1, 3),
(4, 1): (1, 4),
(5, 1): (1, 5),
(6, 1): (1, 6),
}
class Trunk2C4PShuffleCableProfile(BaseCableProfile): class Shuffle4x4MPO8CableProfile(BaseCableProfile):
a_connectors = { a_max_connections = 8
1: 4, b_max_connections = 8
2: 4, # A side to B side position mapping
}
b_connectors = a_connectors
_mapping = {
(1, 1): (1, 1),
(1, 2): (1, 2),
(1, 3): (2, 1),
(1, 4): (2, 2),
(2, 1): (1, 3),
(2, 2): (1, 4),
(2, 3): (2, 3),
(2, 4): (2, 4),
}
class Trunk4C4PShuffleCableProfile(BaseCableProfile):
a_connectors = {
1: 4,
2: 4,
3: 4,
4: 4,
}
b_connectors = a_connectors
_mapping = {
(1, 1): (1, 1),
(1, 2): (2, 1),
(1, 3): (3, 1),
(1, 4): (4, 1),
(2, 1): (1, 2),
(2, 2): (2, 2),
(2, 3): (3, 2),
(2, 4): (4, 2),
(3, 1): (1, 3),
(3, 2): (2, 3),
(3, 3): (3, 3),
(3, 4): (4, 3),
(4, 1): (1, 4),
(4, 2): (2, 4),
(4, 3): (3, 4),
(4, 4): (4, 4),
}
class Breakout2C4Px8C1PShuffleCableProfile(BaseCableProfile):
a_connectors = {
1: 4,
2: 4,
}
b_connectors = {
1: 1,
2: 1,
3: 1,
4: 1,
5: 1,
6: 1,
7: 1,
8: 1,
}
_a_mapping = { _a_mapping = {
(1, 1): (1, 1), 1: 1,
(1, 2): (2, 1), 2: 3,
(1, 3): (5, 1), 3: 5,
(1, 4): (6, 1), 4: 7,
(2, 1): (3, 1), 5: 2,
(2, 2): (4, 1), 6: 4,
(2, 3): (7, 1), 7: 6,
(2, 4): (8, 1), 8: 8,
}
_b_mapping = {
(1, 1): (1, 1),
(2, 1): (1, 2),
(3, 1): (2, 1),
(4, 1): (2, 2),
(5, 1): (1, 3),
(6, 1): (1, 4),
(7, 1): (2, 3),
(8, 1): (2, 4),
} }
# B side to A side position mapping (reverse of _a_mapping)
_b_mapping = {v: k for k, v in _a_mapping.items()}
def get_mapped_position(self, side, connector, position): def get_mapped_position(self, side, position):
if side.upper() == CableEndChoices.SIDE_A: if side.lower() == 'b':
return self._a_mapping.get((connector, position)) return self._b_mapping.get(position)
return self._b_mapping.get((connector, position)) return self._a_mapping.get(position)
+8 -66
View File
@@ -1722,74 +1722,16 @@ class PortTypeChoices(ChoiceSet):
# #
class CableProfileChoices(ChoiceSet): class CableProfileChoices(ChoiceSet):
# Singles STRAIGHT_SINGLE = 'straight-single'
SINGLE_1C1P = 'single-1c1p' STRAIGHT_MULTI = 'straight-multi'
SINGLE_1C2P = 'single-1c2p' SHUFFLE_2X2_MPO8 = 'shuffle-2x2-mpo8'
SINGLE_1C4P = 'single-1c4p' SHUFFLE_4X4_MPO8 = 'shuffle-4x4-mpo8'
SINGLE_1C6P = 'single-1c6p'
SINGLE_1C8P = 'single-1c8p'
SINGLE_1C12P = 'single-1c12p'
SINGLE_1C16P = 'single-1c16p'
# Trunks
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'
CHOICES = ( CHOICES = (
( (STRAIGHT_SINGLE, _('Straight (single position)')),
_('Single'), (STRAIGHT_MULTI, _('Straight (multi-position)')),
( (SHUFFLE_2X2_MPO8, _('Shuffle (2x2 MPO8)')),
(SINGLE_1C1P, _('1C1P')), (SHUFFLE_4X4_MPO8, _('Shuffle (4x4 MPO8)')),
(SINGLE_1C2P, _('1C2P')),
(SINGLE_1C4P, _('1C4P')),
(SINGLE_1C6P, _('1C6P')),
(SINGLE_1C8P, _('1C8P')),
(SINGLE_1C12P, _('1C12P')),
(SINGLE_1C16P, _('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')),
),
),
(
_('Breakout'),
(
(BREAKOUT_1C4P_4C1P, _('1C4P:4C1P breakout')),
(BREAKOUT_1C6P_6C1P, _('1C6P:6C1P breakout')),
(BREAKOUT_2C4P_8C1P_SHUFFLE, _('2C4P:8C1P breakout (shuffle)')),
),
),
) )
+2 -5
View File
@@ -24,9 +24,6 @@ RACK_STARTING_UNIT_DEFAULT = 1
# Cables # Cables
# #
CABLE_CONNECTOR_MIN = 1
CABLE_CONNECTOR_MAX = 256
CABLE_POSITION_MIN = 1 CABLE_POSITION_MIN = 1
CABLE_POSITION_MAX = 1024 CABLE_POSITION_MAX = 1024
@@ -35,8 +32,8 @@ CABLE_POSITION_MAX = 1024
# RearPorts # RearPorts
# #
PORT_POSITION_MIN = 1 REARPORT_POSITIONS_MIN = 1
PORT_POSITION_MAX = 1024 REARPORT_POSITIONS_MAX = 1024
# #
+13 -46
View File
@@ -904,15 +904,12 @@ class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
null_value=None null_value=None
) )
rear_port_id = django_filters.ModelMultipleChoiceFilter( rear_port_id = django_filters.ModelMultipleChoiceFilter(
field_name='mappings__rear_port', queryset=RearPort.objects.all()
queryset=RearPortTemplate.objects.all(),
to_field_name='rear_port',
label=_('Rear port (ID)'),
) )
class Meta: class Meta:
model = FrontPortTemplate model = FrontPortTemplate
fields = ('id', 'name', 'label', 'type', 'color', 'positions', 'description') fields = ('id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description')
@register_filterset @register_filterset
@@ -921,12 +918,6 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCom
choices=PortTypeChoices, choices=PortTypeChoices,
null_value=None null_value=None
) )
front_port_id = django_filters.ModelMultipleChoiceFilter(
field_name='mappings__front_port',
queryset=FrontPort.objects.all(),
to_field_name='front_port',
label=_('Front port (ID)'),
)
class Meta: class Meta:
model = RearPortTemplate model = RearPortTemplate
@@ -1673,17 +1664,6 @@ class DeviceComponentFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
choices=DeviceStatusChoices, choices=DeviceStatusChoices,
field_name='device__status', field_name='device__status',
) )
tenant_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__tenant',
queryset=Tenant.objects.all(),
label=_('Tenant (ID)'),
)
tenant = django_filters.ModelMultipleChoiceFilter(
field_name='device__tenant__slug',
queryset=Tenant.objects.all(),
to_field_name='slug',
label=_('Tenant (slug)'),
)
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@@ -1748,9 +1728,7 @@ class ConsolePortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSe
class Meta: class Meta:
model = ConsolePort model = ConsolePort
fields = ( fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end', 'cable_position')
'id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end', 'cable_connector',
)
@register_filterset @register_filterset
@@ -1762,9 +1740,7 @@ class ConsoleServerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFi
class Meta: class Meta:
model = ConsoleServerPort model = ConsoleServerPort
fields = ( fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end', 'cable_position')
'id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end', 'cable_connector',
)
@register_filterset @register_filterset
@@ -1778,7 +1754,7 @@ class PowerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet,
model = PowerPort model = PowerPort
fields = ( fields = (
'id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable_end', 'id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable_end',
'cable_connector', 'cable_position',
) )
@@ -1805,7 +1781,7 @@ class PowerOutletFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSe
model = PowerOutlet model = PowerOutlet
fields = ( fields = (
'id', 'name', 'status', 'label', 'feed_leg', 'description', 'color', 'mark_connected', 'cable_end', 'id', 'name', 'status', 'label', 'feed_leg', 'description', 'color', 'mark_connected', 'cable_end',
'cable_connector', 'cable_position',
) )
@@ -2115,7 +2091,7 @@ class InterfaceFilterSet(
fields = ( fields = (
'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role', 'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role',
'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected',
'cable_id', 'cable_end', 'cable_connector', 'cable_id', 'cable_end', 'cable_position',
) )
def filter_virtual_chassis_member_or_master(self, queryset, name, value): def filter_virtual_chassis_member_or_master(self, queryset, name, value):
@@ -2161,17 +2137,14 @@ class FrontPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet)
null_value=None null_value=None
) )
rear_port_id = django_filters.ModelMultipleChoiceFilter( rear_port_id = django_filters.ModelMultipleChoiceFilter(
field_name='mappings__rear_port', queryset=RearPort.objects.all()
queryset=RearPort.objects.all(),
to_field_name='rear_port',
label=_('Rear port (ID)'),
) )
class Meta: class Meta:
model = FrontPort model = FrontPort
fields = ( fields = (
'id', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable_end', 'id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description', 'mark_connected', 'cable_end',
'cable_connector', 'cable_position',
) )
@@ -2181,18 +2154,12 @@ class RearPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet):
choices=PortTypeChoices, choices=PortTypeChoices,
null_value=None null_value=None
) )
front_port_id = django_filters.ModelMultipleChoiceFilter(
field_name='mappings__front_port',
queryset=FrontPort.objects.all(),
to_field_name='front_port',
label=_('Front port (ID)'),
)
class Meta: class Meta:
model = RearPort model = RearPort
fields = ( fields = (
'id', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable_end', 'id', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable_end',
'cable_connector', 'cable_position',
) )
@@ -2548,7 +2515,7 @@ class CableTerminationFilterSet(ChangeLoggedModelFilterSet):
class Meta: class Meta:
model = CableTermination model = CableTermination
fields = ('id', 'cable', 'cable_end', 'termination_type', 'termination_id') fields = ('id', 'cable', 'cable_end', 'position', 'termination_type', 'termination_id')
@register_filterset @register_filterset
@@ -2667,7 +2634,7 @@ class PowerFeedFilterSet(PrimaryModelFilterSet, CabledObjectFilterSet, PathEndpo
model = PowerFeed model = PowerFeed
fields = ( fields = (
'id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization',
'available_power', 'mark_connected', 'cable_end', 'cable_connector', 'description', 'available_power', 'mark_connected', 'cable_end', 'cable_position', 'description',
) )
def search(self, queryset, name, value): def search(self, queryset, name, value):
+3 -3
View File
@@ -208,7 +208,7 @@ class RackRoleBulkEditForm(OrganizationalModelBulkEditForm):
fieldsets = ( fieldsets = (
FieldSet('color', 'description'), FieldSet('color', 'description'),
) )
nullable_fields = ('color', 'description', 'comments') nullable_fields = ('color', 'description')
class RackTypeBulkEditForm(PrimaryModelBulkEditForm): class RackTypeBulkEditForm(PrimaryModelBulkEditForm):
@@ -474,7 +474,7 @@ class ManufacturerBulkEditForm(OrganizationalModelBulkEditForm):
fieldsets = ( fieldsets = (
FieldSet('description'), FieldSet('description'),
) )
nullable_fields = ('description', 'comments') nullable_fields = ('description',)
class DeviceTypeBulkEditForm(PrimaryModelBulkEditForm): class DeviceTypeBulkEditForm(PrimaryModelBulkEditForm):
@@ -1719,7 +1719,7 @@ class InventoryItemRoleBulkEditForm(OrganizationalModelBulkEditForm):
fieldsets = ( fieldsets = (
FieldSet('color', 'description'), FieldSet('color', 'description'),
) )
nullable_fields = ('color', 'description', 'comments') nullable_fields = ('color', 'description')
class VirtualDeviceContextBulkEditForm(PrimaryModelBulkEditForm): class VirtualDeviceContextBulkEditForm(PrimaryModelBulkEditForm):
+34 -21
View File
@@ -183,7 +183,7 @@ class RackRoleImportForm(OrganizationalModelImportForm):
class Meta: class Meta:
model = RackRole model = RackRole
fields = ('name', 'slug', 'color', 'description', 'owner', 'comments', 'tags') fields = ('name', 'slug', 'color', 'description', 'owner', 'tags')
class RackTypeImportForm(PrimaryModelImportForm): class RackTypeImportForm(PrimaryModelImportForm):
@@ -400,7 +400,7 @@ class ManufacturerImportForm(OrganizationalModelImportForm):
class Meta: class Meta:
model = Manufacturer model = Manufacturer
fields = ('name', 'slug', 'description', 'owner', 'comments', 'tags') fields = ('name', 'slug', 'description', 'owner', 'tags')
class DeviceTypeImportForm(PrimaryModelImportForm): class DeviceTypeImportForm(PrimaryModelImportForm):
@@ -476,30 +476,14 @@ class ModuleTypeImportForm(PrimaryModelImportForm):
required=False, required=False,
help_text=_('Unit for module weight') help_text=_('Unit for module weight')
) )
attribute_data = forms.JSONField(
label=_('Attributes'),
required=False,
help_text=_('Attribute values for the assigned profile, passed as a dictionary')
)
class Meta: class Meta:
model = ModuleType model = ModuleType
fields = [ fields = [
'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'profile', 'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'profile',
'attribute_data', 'owner', 'comments', 'tags', 'owner', 'comments', 'tags'
] ]
def clean(self):
super().clean()
# Attribute data may be included only if a profile is specified
if self.cleaned_data.get('attribute_data') and not self.cleaned_data.get('profile'):
raise forms.ValidationError(_("Profile must be specified if attribute data is provided."))
# Default attribute_data to an empty dictionary if a profile is specified (to enforce schema validation)
if self.cleaned_data.get('profile') and not self.cleaned_data.get('attribute_data'):
self.cleaned_data['attribute_data'] = {}
class DeviceRoleImportForm(NestedGroupModelImportForm): class DeviceRoleImportForm(NestedGroupModelImportForm):
parent = CSVModelChoiceField( parent = CSVModelChoiceField(
@@ -1091,6 +1075,12 @@ class FrontPortImportForm(OwnerCSVMixin, NetBoxModelImportForm):
queryset=Device.objects.all(), queryset=Device.objects.all(),
to_field_name='name' to_field_name='name'
) )
rear_port = CSVModelChoiceField(
label=_('Rear port'),
queryset=RearPort.objects.all(),
to_field_name='name',
help_text=_('Corresponding rear port')
)
type = CSVChoiceField( type = CSVChoiceField(
label=_('Type'), label=_('Type'),
choices=PortTypeChoices, choices=PortTypeChoices,
@@ -1100,9 +1090,32 @@ class FrontPortImportForm(OwnerCSVMixin, NetBoxModelImportForm):
class Meta: class Meta:
model = FrontPort model = FrontPort
fields = ( fields = (
'device', 'name', 'label', 'type', 'color', 'mark_connected', 'positions', 'description', 'owner', 'tags' 'device', 'name', 'label', 'type', 'color', 'mark_connected', 'rear_port', 'rear_port_position',
'description', 'owner', 'tags'
) )
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit RearPort choices to those belonging to this device (or VC master)
if self.is_bound and 'device' in self.data:
try:
device = self.fields['device'].to_python(self.data['device'])
except forms.ValidationError:
device = None
else:
try:
device = self.instance.device
except Device.DoesNotExist:
device = None
if device:
self.fields['rear_port'].queryset = RearPort.objects.filter(
device__in=[device, device.get_vc_master()]
)
else:
self.fields['rear_port'].queryset = RearPort.objects.none()
class RearPortImportForm(OwnerCSVMixin, NetBoxModelImportForm): class RearPortImportForm(OwnerCSVMixin, NetBoxModelImportForm):
device = CSVModelChoiceField( device = CSVModelChoiceField(
@@ -1298,7 +1311,7 @@ class InventoryItemRoleImportForm(OrganizationalModelImportForm):
class Meta: class Meta:
model = InventoryItemRole model = InventoryItemRole
fields = ('name', 'slug', 'color', 'description', 'owner', 'comments') fields = ('name', 'slug', 'color', 'description')
# #
+59 -305
View File
@@ -12,12 +12,10 @@ from netbox.forms import (
NestedGroupModelFilterSetForm, NetBoxModelFilterSetForm, OrganizationalModelFilterSetForm, NestedGroupModelFilterSetForm, NetBoxModelFilterSetForm, OrganizationalModelFilterSetForm,
PrimaryModelFilterSetForm, PrimaryModelFilterSetForm,
) )
from netbox.forms.mixins import OwnerFilterMixin
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
from tenancy.models import Tenant from users.models import Owner, User
from users.models import User
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField from utilities.forms.fields import ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import NumberWithOptions from utilities.forms.widgets import NumberWithOptions
from virtualization.models import Cluster, ClusterGroup, VirtualMachine from virtualization.models import Cluster, ClusterGroup, VirtualMachine
@@ -28,54 +26,44 @@ __all__ = (
'CableFilterForm', 'CableFilterForm',
'ConsoleConnectionFilterForm', 'ConsoleConnectionFilterForm',
'ConsolePortFilterForm', 'ConsolePortFilterForm',
'ConsolePortTemplateFilterForm',
'ConsoleServerPortFilterForm', 'ConsoleServerPortFilterForm',
'ConsoleServerPortTemplateFilterForm',
'DeviceBayFilterForm', 'DeviceBayFilterForm',
'DeviceBayTemplateFilterForm',
'DeviceFilterForm', 'DeviceFilterForm',
'DeviceRoleFilterForm', 'DeviceRoleFilterForm',
'DeviceTypeFilterForm', 'DeviceTypeFilterForm',
'FrontPortFilterForm', 'FrontPortFilterForm',
'FrontPortTemplateFilterForm',
'InterfaceConnectionFilterForm', 'InterfaceConnectionFilterForm',
'InterfaceFilterForm', 'InterfaceFilterForm',
'InterfaceTemplateFilterForm',
'InventoryItemFilterForm', 'InventoryItemFilterForm',
'InventoryItemTemplateFilterForm',
'InventoryItemRoleFilterForm', 'InventoryItemRoleFilterForm',
'LocationFilterForm', 'LocationFilterForm',
'MACAddressFilterForm', 'MACAddressFilterForm',
'ManufacturerFilterForm', 'ManufacturerFilterForm',
'ModuleFilterForm', 'ModuleFilterForm',
'ModuleBayFilterForm', 'ModuleBayFilterForm',
'ModuleBayTemplateFilterForm',
'ModuleTypeFilterForm', 'ModuleTypeFilterForm',
'ModuleTypeProfileFilterForm', 'ModuleTypeProfileFilterForm',
'PlatformFilterForm', 'PlatformFilterForm',
'PowerConnectionFilterForm', 'PowerConnectionFilterForm',
'PowerFeedFilterForm', 'PowerFeedFilterForm',
'PowerOutletFilterForm', 'PowerOutletFilterForm',
'PowerOutletTemplateFilterForm',
'PowerPanelFilterForm', 'PowerPanelFilterForm',
'PowerPortFilterForm', 'PowerPortFilterForm',
'PowerPortTemplateFilterForm',
'RackFilterForm', 'RackFilterForm',
'RackElevationFilterForm', 'RackElevationFilterForm',
'RackReservationFilterForm', 'RackReservationFilterForm',
'RackRoleFilterForm', 'RackRoleFilterForm',
'RackTypeFilterForm', 'RackTypeFilterForm',
'RearPortFilterForm', 'RearPortFilterForm',
'RearPortTemplateFilterForm',
'RegionFilterForm', 'RegionFilterForm',
'SiteFilterForm', 'SiteFilterForm',
'SiteGroupFilterForm', 'SiteGroupFilterForm',
'VirtualChassisFilterForm', 'VirtualChassisFilterForm',
'VirtualDeviceContextFilterForm', 'VirtualDeviceContextFilterForm'
) )
class DeviceComponentFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm): class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
name = forms.CharField( name = forms.CharField(
label=_('Name'), label=_('Name'),
required=False required=False
@@ -135,11 +123,6 @@ class DeviceComponentFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
required=False, required=False,
label=_('Device role') label=_('Device role')
) )
tenant_id = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
required=False,
label=_('Tenant')
)
device_id = DynamicModelMultipleChoiceField( device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
required=False, required=False,
@@ -148,8 +131,7 @@ class DeviceComponentFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
'location_id': '$location_id', 'location_id': '$location_id',
'virtual_chassis_id': '$virtual_chassis_id', 'virtual_chassis_id': '$virtual_chassis_id',
'device_type_id': '$device_type_id', 'device_type_id': '$device_type_id',
'role_id': '$role_id', 'role_id': '$role_id'
'tenant_id': '$tenant_id'
}, },
label=_('Device') label=_('Device')
) )
@@ -158,14 +140,18 @@ class DeviceComponentFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
required=False, required=False,
label=_('Device Status'), label=_('Device Status'),
) )
owner_id = DynamicModelChoiceField(
queryset=Owner.objects.all(),
required=False,
label=_('Owner'),
)
class RegionFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm): class RegionFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm):
model = Region model = Region
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('parent_id', name=_('Region')), FieldSet('parent_id', name=_('Region')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')) FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
) )
parent_id = DynamicModelMultipleChoiceField( parent_id = DynamicModelMultipleChoiceField(
@@ -179,9 +165,8 @@ class RegionFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm):
class SiteGroupFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm): class SiteGroupFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm):
model = SiteGroup model = SiteGroup
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('parent_id', name=_('Site Group')), FieldSet('parent_id', name=_('Site Group')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')) FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
) )
parent_id = DynamicModelMultipleChoiceField( parent_id = DynamicModelMultipleChoiceField(
@@ -195,10 +180,9 @@ class SiteGroupFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm)
class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm): class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
model = Site model = Site
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('status', 'region_id', 'group_id', 'asn_id', name=_('Attributes')), FieldSet('status', 'region_id', 'group_id', 'asn_id', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
selector_fields = ('filter_id', 'q', 'region_id', 'group_id') selector_fields = ('filter_id', 'q', 'region_id', 'group_id')
@@ -228,10 +212,9 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilt
class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NestedGroupModelFilterSetForm): class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NestedGroupModelFilterSetForm):
model = Location model = Location
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('region_id', 'site_group_id', 'site_id', 'parent_id', 'status', 'facility', name=_('Attributes')), FieldSet('region_id', 'site_group_id', 'site_id', 'parent_id', 'status', 'facility', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
@@ -277,8 +260,7 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NestedGroupM
class RackRoleFilterForm(OrganizationalModelFilterSetForm): class RackRoleFilterForm(OrganizationalModelFilterSetForm):
model = RackRole model = RackRole
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@@ -329,11 +311,10 @@ class RackBaseFilterForm(PrimaryModelFilterSetForm):
class RackTypeFilterForm(RackBaseFilterForm): class RackTypeFilterForm(RackBaseFilterForm):
model = RackType model = RackType
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('manufacturer_id', 'form_factor', 'width', 'u_height', 'rack_count', name=_('Rack Type')), FieldSet('manufacturer_id', 'form_factor', 'width', 'u_height', 'rack_count', name=_('Rack Type')),
FieldSet('starting_unit', 'desc_units', name=_('Numbering')), FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')), FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
selector_fields = ('filter_id', 'q', 'manufacturer_id') selector_fields = ('filter_id', 'q', 'manufacturer_id')
manufacturer_id = DynamicModelMultipleChoiceField( manufacturer_id = DynamicModelMultipleChoiceField(
@@ -352,14 +333,13 @@ class RackTypeFilterForm(RackBaseFilterForm):
class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterForm): class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterForm):
model = Rack model = Rack
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('status', 'role_id', 'manufacturer_id', 'rack_type_id', 'serial', 'asset_tag', name=_('Rack')), FieldSet('status', 'role_id', 'manufacturer_id', 'rack_type_id', 'serial', 'asset_tag', name=_('Rack')),
FieldSet('form_factor', 'width', 'u_height', 'airflow', name=_('Hardware')), FieldSet('form_factor', 'width', 'u_height', 'airflow', name=_('Hardware')),
FieldSet('starting_unit', 'desc_units', name=_('Numbering')), FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')), FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id') selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id')
@@ -436,10 +416,9 @@ class RackElevationFilterForm(RackFilterForm):
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'id', name=_('Location')),
FieldSet('status', 'role_id', name=_('Function')), FieldSet('status', 'role_id', name=_('Function')),
FieldSet('type', 'width', 'serial', 'asset_tag', name=_('Hardware')), FieldSet('type', 'width', 'serial', 'asset_tag', name=_('Hardware')),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
) )
id = DynamicModelMultipleChoiceField( id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
@@ -455,11 +434,10 @@ class RackElevationFilterForm(RackFilterForm):
class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm): class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = RackReservation model = RackReservation
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('status', 'user_id', name=_('Reservation')), FieldSet('status', 'user_id', name=_('Reservation')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Rack')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Rack')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@@ -514,8 +492,7 @@ class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class ManufacturerFilterForm(ContactModelFilterForm, OrganizationalModelFilterSetForm): class ManufacturerFilterForm(ContactModelFilterForm, OrganizationalModelFilterSetForm):
model = Manufacturer model = Manufacturer
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')) FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@@ -524,7 +501,7 @@ class ManufacturerFilterForm(ContactModelFilterForm, OrganizationalModelFilterSe
class DeviceTypeFilterForm(PrimaryModelFilterSetForm): class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
model = DeviceType model = DeviceType
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet( FieldSet(
'manufacturer_id', 'default_platform_id', 'part_number', 'device_count', 'manufacturer_id', 'default_platform_id', 'part_number', 'device_count',
'subdevice_role', 'airflow', name=_('Hardware') 'subdevice_role', 'airflow', name=_('Hardware')
@@ -535,7 +512,6 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', name=_('Components') 'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', name=_('Components')
), ),
FieldSet('weight', 'weight_unit', name=_('Weight')), FieldSet('weight', 'weight_unit', name=_('Weight')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
selector_fields = ('filter_id', 'q', 'manufacturer_id') selector_fields = ('filter_id', 'q', 'manufacturer_id')
manufacturer_id = DynamicModelMultipleChoiceField( manufacturer_id = DynamicModelMultipleChoiceField(
@@ -659,8 +635,7 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
class ModuleTypeProfileFilterForm(PrimaryModelFilterSetForm): class ModuleTypeProfileFilterForm(PrimaryModelFilterSetForm):
model = ModuleTypeProfile model = ModuleTypeProfile
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
selector_fields = ('filter_id', 'q') selector_fields = ('filter_id', 'q')
tag = TagFilterField(model) tag = TagFilterField(model)
@@ -669,7 +644,7 @@ class ModuleTypeProfileFilterForm(PrimaryModelFilterSetForm):
class ModuleTypeFilterForm(PrimaryModelFilterSetForm): class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
model = ModuleType model = ModuleType
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet( FieldSet(
'profile_id', 'manufacturer_id', 'part_number', 'module_count', 'profile_id', 'manufacturer_id', 'part_number', 'module_count',
'airflow', name=_('Hardware') 'airflow', name=_('Hardware')
@@ -679,7 +654,6 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
'pass_through_ports', name=_('Components') 'pass_through_ports', name=_('Components')
), ),
FieldSet('weight', 'weight_unit', name=_('Weight')), FieldSet('weight', 'weight_unit', name=_('Weight')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
selector_fields = ('filter_id', 'q', 'manufacturer_id') selector_fields = ('filter_id', 'q', 'manufacturer_id')
profile_id = DynamicModelMultipleChoiceField( profile_id = DynamicModelMultipleChoiceField(
@@ -763,9 +737,8 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
class DeviceRoleFilterForm(NestedGroupModelFilterSetForm): class DeviceRoleFilterForm(NestedGroupModelFilterSetForm):
model = DeviceRole model = DeviceRole
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('parent_id', 'config_template_id', name=_('Device Role')), FieldSet('parent_id', 'config_template_id', name=_('Device Role'))
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
config_template_id = DynamicModelMultipleChoiceField( config_template_id = DynamicModelMultipleChoiceField(
queryset=ConfigTemplate.objects.all(), queryset=ConfigTemplate.objects.all(),
@@ -783,9 +756,8 @@ class DeviceRoleFilterForm(NestedGroupModelFilterSetForm):
class PlatformFilterForm(NestedGroupModelFilterSetForm): class PlatformFilterForm(NestedGroupModelFilterSetForm):
model = Platform model = Platform
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('manufacturer_id', 'parent_id', 'config_template_id', name=_('Platform')), FieldSet('manufacturer_id', 'parent_id', 'config_template_id', name=_('Platform'))
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
selector_fields = ('filter_id', 'q', 'manufacturer_id') selector_fields = ('filter_id', 'q', 'manufacturer_id')
parent_id = DynamicModelMultipleChoiceField( parent_id = DynamicModelMultipleChoiceField(
@@ -814,12 +786,11 @@ class DeviceFilterForm(
): ):
model = Device model = Device
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address', name=_('Operation')), FieldSet('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address', name=_('Operation')),
FieldSet('manufacturer_id', 'device_type_id', 'platform_id', name=_('Hardware')), FieldSet('manufacturer_id', 'device_type_id', 'platform_id', name=_('Hardware')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
FieldSet( FieldSet(
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
@@ -1008,10 +979,9 @@ class DeviceFilterForm(
class VirtualDeviceContextFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm): class VirtualDeviceContextFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = VirtualDeviceContext model = VirtualDeviceContext
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('device', 'status', 'has_primary_ip', name=_('Attributes')), FieldSet('device', 'status', 'has_primary_ip', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
device = DynamicModelMultipleChoiceField( device = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
@@ -1036,10 +1006,9 @@ class VirtualDeviceContextFilterForm(TenancyFilterForm, PrimaryModelFilterSetFor
class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm): class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
model = Module model = Module
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')),
FieldSet('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag', name=_('Hardware')), FieldSet('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag', name=_('Hardware')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
device_id = DynamicModelMultipleChoiceField( device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
@@ -1120,10 +1089,9 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, PrimaryM
class VirtualChassisFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm): class VirtualChassisFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = VirtualChassis model = VirtualChassis
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
FieldSet('tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@@ -1150,11 +1118,10 @@ class VirtualChassisFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm): class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = Cable model = Cable
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')), FieldSet('site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')),
FieldSet('type', 'status', 'profile', 'color', 'length', 'length_unit', 'unterminated', name=_('Attributes')), FieldSet('type', 'status', 'profile', 'color', 'length', 'length_unit', 'unterminated', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@@ -1240,9 +1207,8 @@ class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
class PowerPanelFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm): class PowerPanelFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
model = PowerPanel model = PowerPanel
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
selector_fields = ('filter_id', 'q', 'site_id', 'location_id') selector_fields = ('filter_id', 'q', 'site_id', 'location_id')
@@ -1280,11 +1246,10 @@ class PowerPanelFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
class PowerFeedFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm): class PowerFeedFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
model = PowerFeed model = PowerFeed
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id', name=_('Location')),
FieldSet('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')), FieldSet('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', name=_('Attributes')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@@ -1361,23 +1326,6 @@ class PowerFeedFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
# Device components # 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): class CabledFilterForm(forms.Form):
cabled = forms.NullBooleanField( cabled = forms.NullBooleanField(
label=_('Cabled'), label=_('Cabled'),
@@ -1408,15 +1356,13 @@ class PathEndpointFilterForm(CabledFilterForm):
class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = ConsolePort model = ConsolePort
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')), FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet( FieldSet(
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
name=_('Device')
), ),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')), FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
label=_('Type'), label=_('Type'),
@@ -1431,32 +1377,17 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
tag = TagFilterField(model) 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): class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = ConsoleServerPort model = ConsoleServerPort
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')), FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet( FieldSet(
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device') name=_('Device')
), ),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')), FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
label=_('Type'), label=_('Type'),
@@ -1471,32 +1402,16 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
tag = TagFilterField(model) 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): class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = PowerPort model = PowerPort
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('name', 'label', 'type', name=_('Attributes')), FieldSet('name', 'label', 'type', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet( FieldSet(
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
name=_('Device')
), ),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')), FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
label=_('Type'), label=_('Type'),
@@ -1506,32 +1421,17 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
tag = TagFilterField(model) 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): class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = PowerOutlet model = PowerOutlet
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('name', 'label', 'type', 'color', 'status', name=_('Attributes')), FieldSet('name', 'label', 'type', 'color', 'status', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet( FieldSet(
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device') name=_('Device')
), ),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')), FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
label=_('Type'), label=_('Type'),
@@ -1550,24 +1450,10 @@ 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): class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = Interface model = Interface
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only', name=_('Attributes')), FieldSet('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only', name=_('Attributes')),
FieldSet('vrf_id', 'l2vpn_id', 'mac_address', 'wwn', name=_('Addressing')), FieldSet('vrf_id', 'l2vpn_id', 'mac_address', 'wwn', name=_('Addressing')),
FieldSet('poe_mode', 'poe_type', name=_('PoE')), FieldSet('poe_mode', 'poe_type', name=_('PoE')),
@@ -1575,12 +1461,10 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
FieldSet('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', name=_('Wireless')), FieldSet('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', name=_('Wireless')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet( FieldSet(
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', 'vdc_id',
'vdc_id',
name=_('Device') name=_('Device')
), ),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')), FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
selector_fields = ('filter_id', 'q', 'device_id') selector_fields = ('filter_id', 'q', 'device_id')
vdc_id = DynamicModelMultipleChoiceField( vdc_id = DynamicModelMultipleChoiceField(
@@ -1692,62 +1576,15 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
tag = TagFilterField(model) 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): class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')), FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet( FieldSet(
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
name=_('Device')
), ),
FieldSet('cabled', 'occupied', name=_('Cable')), FieldSet('cabled', 'occupied', name=_('Cable')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
model = FrontPort model = FrontPort
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
@@ -1762,36 +1599,17 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
tag = TagFilterField(model) 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): class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
model = RearPort model = RearPort
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')), FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet( FieldSet(
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device') name=_('Device')
), ),
FieldSet('cabled', 'occupied', name=_('Cable')), FieldSet('cabled', 'occupied', name=_('Cable')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
type = forms.MultipleChoiceField( type = forms.MultipleChoiceField(
label=_('Type'), label=_('Type'),
@@ -1805,35 +1623,16 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
tag = TagFilterField(model) 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): class ModuleBayFilterForm(DeviceComponentFilterForm):
model = ModuleBay model = ModuleBay
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('name', 'label', 'position', name=_('Attributes')), FieldSet('name', 'label', 'position', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet( FieldSet(
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device') name=_('Device')
), ),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
tag = TagFilterField(model) tag = TagFilterField(model)
position = forms.CharField( position = forms.CharField(
@@ -1842,57 +1641,33 @@ 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): class DeviceBayFilterForm(DeviceComponentFilterForm):
model = DeviceBay model = DeviceBay
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('name', 'label', name=_('Attributes')), FieldSet('name', 'label', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet( FieldSet(
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device') name=_('Device')
), ),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
tag = TagFilterField(model) 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): class InventoryItemFilterForm(DeviceComponentFilterForm):
model = InventoryItem model = InventoryItem
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet( FieldSet(
'name', 'label', 'status', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered', 'name', 'label', 'status', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered',
name=_('Attributes') name=_('Attributes')
), ),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet( FieldSet(
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device') name=_('Device')
), ),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
role_id = DynamicModelMultipleChoiceField( role_id = DynamicModelMultipleChoiceField(
queryset=InventoryItemRole.objects.all(), queryset=InventoryItemRole.objects.all(),
@@ -1927,25 +1702,6 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
tag = TagFilterField(model) 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 # Device component roles
# #
@@ -1953,8 +1709,7 @@ class InventoryItemTemplateFilterForm(DeviceComponentTemplateFilterForm):
class InventoryItemRoleFilterForm(OrganizationalModelFilterSetForm): class InventoryItemRoleFilterForm(OrganizationalModelFilterSetForm):
model = InventoryItemRole model = InventoryItemRole
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
tag = TagFilterField(model) tag = TagFilterField(model)
@@ -1966,10 +1721,9 @@ class InventoryItemRoleFilterForm(OrganizationalModelFilterSetForm):
class MACAddressFilterForm(PrimaryModelFilterSetForm): class MACAddressFilterForm(PrimaryModelFilterSetForm):
model = MACAddress model = MACAddress
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag', 'owner_id'),
FieldSet('mac_address', name=_('Attributes')), FieldSet('mac_address', name=_('Attributes')),
FieldSet('device_id', 'virtual_machine_id', 'assigned', 'primary', name=_('Assignments')), FieldSet('device_id', 'virtual_machine_id', 'assigned', 'primary', name=_('Assignments')),
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
) )
selector_fields = ('filter_id', 'q', 'device_id', 'virtual_machine_id') selector_fields = ('filter_id', 'q', 'device_id', 'virtual_machine_id')
mac_address = forms.CharField( mac_address = forms.CharField(
+2 -93
View File
@@ -1,12 +1,10 @@
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import connection
from django.db.models.signals import post_save
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from dcim.constants import LOCATION_SCOPE_TYPES from dcim.constants import LOCATION_SCOPE_TYPES
from dcim.models import PortMapping, PortTemplateMapping, Site from dcim.models import Site
from utilities.forms import get_field_value from utilities.forms import get_field_value
from utilities.forms.fields import ( from utilities.forms.fields import (
ContentTypeChoiceField, CSVContentTypeField, DynamicModelChoiceField, ContentTypeChoiceField, CSVContentTypeField, DynamicModelChoiceField,
@@ -15,7 +13,6 @@ from utilities.templatetags.builtins.filters import bettertitle
from utilities.forms.widgets import HTMXSelect from utilities.forms.widgets import HTMXSelect
__all__ = ( __all__ = (
'FrontPortFormMixin',
'ScopedBulkEditForm', 'ScopedBulkEditForm',
'ScopedForm', 'ScopedForm',
'ScopedImportForm', 'ScopedImportForm',
@@ -75,7 +72,7 @@ class ScopedForm(forms.Form):
except ObjectDoesNotExist: except ObjectDoesNotExist:
pass pass
if self.instance and self.instance.pk and scope_type_id != self.instance.scope_type_id: if self.instance and scope_type_id != self.instance.scope_type_id:
self.initial['scope'] = None self.initial['scope'] = None
else: else:
@@ -131,91 +128,3 @@ class ScopedImportForm(forms.Form):
"Please select a {scope_type}." "Please select a {scope_type}."
).format(scope_type=scope_type.model_class()._meta.model_name) ).format(scope_type=scope_type.model_class()._meta.model_name)
}) })
class FrontPortFormMixin(forms.Form):
rear_ports = forms.MultipleChoiceField(
choices=[],
label=_('Rear ports'),
widget=forms.SelectMultiple(attrs={'size': 8})
)
def clean(self):
super().clean()
# Check that the total number of FrontPorts and positions matches the selected number of RearPort:position
# mappings. Note that `name` will be a list under FrontPortCreateForm, in which cases we multiply the number of
# FrontPorts being creation by the number of positions.
positions = self.cleaned_data['positions']
frontport_count = len(self.cleaned_data['name']) if type(self.cleaned_data['name']) is list else 1
rearport_count = len(self.cleaned_data['rear_ports'])
if frontport_count * positions != rearport_count:
raise forms.ValidationError({
'rear_ports': _(
"The total number of front port positions ({frontport_count}) must match the selected number of "
"rear port positions ({rearport_count})."
).format(
frontport_count=frontport_count,
rearport_count=rearport_count
)
})
def _save_m2m(self):
super()._save_m2m()
# TODO: Can this be made more efficient?
# Delete existing rear port mappings
self.port_mapping_model.objects.filter(front_port_id=self.instance.pk).delete()
# Create new rear port mappings
mappings = []
if self.port_mapping_model is PortTemplateMapping:
params = {
'device_type_id': self.instance.device_type_id,
'module_type_id': self.instance.module_type_id,
}
else:
params = {
'device_id': self.instance.device_id,
}
for i, rp_position in enumerate(self.cleaned_data['rear_ports'], start=1):
rear_port_id, rear_port_position = rp_position.split(':')
mappings.append(
self.port_mapping_model(**{
**params,
'front_port_id': self.instance.pk,
'front_port_position': i,
'rear_port_id': rear_port_id,
'rear_port_position': rear_port_position,
})
)
self.port_mapping_model.objects.bulk_create(mappings)
# Send post_save signals
for mapping in mappings:
post_save.send(
sender=PortMapping,
instance=mapping,
created=True,
raw=False,
using=connection,
update_fields=None
)
def _get_rear_port_choices(self, parent_filter, front_port):
"""
Return a list of choices representing each available rear port & position pair on the parent object (identified
by a Q filter), excluding those assigned to the specified instance.
"""
occupied_rear_port_positions = [
f'{mapping.rear_port_id}:{mapping.rear_port_position}'
for mapping in self.port_mapping_model.objects.filter(parent_filter).exclude(front_port=front_port.pk)
]
choices = []
for rear_port in self.rear_port_model.objects.filter(parent_filter):
for i in range(1, rear_port.positions + 1):
pair_id = f'{rear_port.pk}:{i}'
if pair_id not in occupied_rear_port_positions:
pair_label = f'{rear_port.name}:{i}'
choices.append((pair_id, pair_label))
return choices
+29 -64
View File
@@ -6,7 +6,6 @@ from timezone_field import TimeZoneFormField
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.forms.mixins import FrontPortFormMixin
from dcim.models import * from dcim.models import *
from extras.models import ConfigTemplate from extras.models import ConfigTemplate
from ipam.choices import VLANQinQRoleChoices from ipam.choices import VLANQinQRoleChoices
@@ -20,9 +19,7 @@ from utilities.forms.fields import (
DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SlugField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SlugField,
) )
from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
from utilities.forms.widgets import ( from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK
APISelect, ClearableFileInput, ClearableSelect, HTMXSelect, NumberWithOptions, SelectWithPK,
)
from utilities.jsonschema import JSONSchemaProperty from utilities.jsonschema import JSONSchemaProperty
from virtualization.models import Cluster, VMInterface from virtualization.models import Cluster, VMInterface
from wireless.models import WirelessLAN, WirelessLANGroup from wireless.models import WirelessLAN, WirelessLANGroup
@@ -204,7 +201,7 @@ class RackRoleForm(OrganizationalModelForm):
class Meta: class Meta:
model = RackRole model = RackRole
fields = [ fields = [
'name', 'slug', 'color', 'description', 'owner', 'comments', 'tags', 'name', 'slug', 'color', 'description', 'owner', 'tags',
] ]
@@ -347,7 +344,7 @@ class ManufacturerForm(OrganizationalModelForm):
class Meta: class Meta:
model = Manufacturer model = Manufacturer
fields = [ fields = [
'name', 'slug', 'description', 'owner', 'comments', 'tags', 'name', 'slug', 'description', 'owner', 'tags',
] ]
@@ -594,14 +591,6 @@ class DeviceForm(TenancyForm, PrimaryModelForm):
}, },
) )
) )
face = forms.ChoiceField(
label=_('Face'),
choices=add_blank_choice(DeviceFaceChoices),
required=False,
widget=ClearableSelect(
requires_fields=['rack']
)
)
device_type = DynamicModelChoiceField( device_type = DynamicModelChoiceField(
label=_('Device type'), label=_('Device type'),
queryset=DeviceType.objects.all(), queryset=DeviceType.objects.all(),
@@ -1123,45 +1112,34 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
] ]
class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm): class FrontPortTemplateForm(ModularComponentTemplateForm):
rear_port = DynamicModelChoiceField(
label=_('Rear port'),
queryset=RearPortTemplate.objects.all(),
required=False,
query_params={
'device_type_id': '$device_type',
'module_type_id': '$module_type',
}
)
fieldsets = ( fieldsets = (
FieldSet( FieldSet(
TabbedGroups( TabbedGroups(
FieldSet('device_type', name=_('Device Type')), FieldSet('device_type', name=_('Device Type')),
FieldSet('module_type', name=_('Module Type')), FieldSet('module_type', name=_('Module Type')),
), ),
'name', 'label', 'type', 'positions', 'rear_ports', 'description', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
), ),
) )
port_mapping_model = PortTemplateMapping
rear_port_model = RearPortTemplate
class Meta: class Meta:
model = FrontPortTemplate model = FrontPortTemplate
fields = [ fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position',
'description',
] ]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Populate rear port choices based on parent DeviceType or ModuleType
if device_type_id := self.data.get('device_type') or self.initial.get('device_type'):
parent_filter = Q(device_type=device_type_id)
elif module_type_id := self.data.get('module_type') or self.initial.get('module_type'):
parent_filter = Q(module_type=module_type_id)
else:
return
self.fields['rear_ports'].choices = self._get_rear_port_choices(parent_filter, self.instance)
# Set initial rear port mappings
if self.instance.pk:
self.initial['rear_ports'] = [
f'{mapping.rear_port_id}:{mapping.rear_port_position}'
for mapping in PortTemplateMapping.objects.filter(front_port_id=self.instance.pk)
]
class RearPortTemplateForm(ModularComponentTemplateForm): class RearPortTemplateForm(ModularComponentTemplateForm):
fieldsets = ( fieldsets = (
@@ -1600,41 +1578,28 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
} }
class FrontPortForm(FrontPortFormMixin, ModularDeviceComponentForm): class FrontPortForm(ModularDeviceComponentForm):
rear_port = DynamicModelChoiceField(
queryset=RearPort.objects.all(),
query_params={
'device_id': '$device',
}
)
fieldsets = ( fieldsets = (
FieldSet( FieldSet(
'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'rear_ports', 'mark_connected', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected',
'description', 'tags', 'description', 'tags',
), ),
) )
port_mapping_model = PortMapping
rear_port_model = RearPort
class Meta: class Meta:
model = FrontPort model = FrontPort
fields = [ fields = [
'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'owner', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected',
'tags', 'description', 'owner', 'tags',
] ]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Populate rear port choices
if device_id := self.data.get('device') or self.initial.get('device'):
parent_filter = Q(device=device_id)
else:
return
self.fields['rear_ports'].choices = self._get_rear_port_choices(parent_filter, self.instance)
# Set initial rear port mappings
if self.instance.pk:
self.initial['rear_ports'] = [
f'{mapping.rear_port_id}:{mapping.rear_port_position}'
for mapping in PortMapping.objects.filter(front_port_id=self.instance.pk)
]
class RearPortForm(ModularDeviceComponentForm): class RearPortForm(ModularDeviceComponentForm):
fieldsets = ( fieldsets = (
@@ -1850,7 +1815,7 @@ class InventoryItemRoleForm(OrganizationalModelForm):
class Meta: class Meta:
model = InventoryItemRole model = InventoryItemRole
fields = [ fields = [
'name', 'slug', 'color', 'description', 'owner', 'comments', 'tags', 'name', 'slug', 'color', 'description', 'owner', 'tags',
] ]
+123 -21
View File
@@ -3,7 +3,6 @@ from django.utils.translation import gettext_lazy as _
from dcim.models import * from dcim.models import *
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from netbox.forms.mixins import OwnerMixin
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
from utilities.forms.rendering import FieldSet, TabbedGroups from utilities.forms.rendering import FieldSet, TabbedGroups
from utilities.forms.widgets import APISelect from utilities.forms.widgets import APISelect
@@ -110,30 +109,85 @@ class InterfaceTemplateCreateForm(ComponentCreateForm, model_forms.InterfaceTemp
class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemplateForm): class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemplateForm):
rear_port = forms.MultipleChoiceField(
choices=[],
label=_('Rear ports'),
help_text=_('Select one rear port assignment for each front port being created.'),
widget=forms.SelectMultiple(attrs={'size': 6})
)
# Override fieldsets from FrontPortTemplateForm # Override fieldsets from FrontPortTemplateForm to omit rear_port_position
fieldsets = ( fieldsets = (
FieldSet( FieldSet(
TabbedGroups( TabbedGroups(
FieldSet('device_type', name=_('Device Type')), FieldSet('device_type', name=_('Device Type')),
FieldSet('module_type', name=_('Module Type')), FieldSet('module_type', name=_('Module Type')),
), ),
'name', 'label', 'type', 'color', 'positions', 'rear_ports', 'description', 'name', 'label', 'type', 'color', 'rear_port', 'description',
), ),
) )
class Meta: class Meta(model_forms.FrontPortTemplateForm.Meta):
model = FrontPortTemplate exclude = ('name', 'label', 'rear_port', 'rear_port_position')
fields = (
'device_type', 'module_type', 'type', 'color', 'positions', 'description', def __init__(self, *args, **kwargs):
) super().__init__(*args, **kwargs)
# TODO: This needs better validation
if 'device_type' in self.initial or self.data.get('device_type'):
parent = DeviceType.objects.get(
pk=self.initial.get('device_type') or self.data.get('device_type')
)
elif 'module_type' in self.initial or self.data.get('module_type'):
parent = ModuleType.objects.get(
pk=self.initial.get('module_type') or self.data.get('module_type')
)
else:
return
# Determine which rear port positions are occupied. These will be excluded from the list of available mappings.
occupied_port_positions = [
(front_port.rear_port_id, front_port.rear_port_position)
for front_port in parent.frontporttemplates.all()
]
# Populate rear port choices
choices = []
rear_ports = parent.rearporttemplates.all()
for rear_port in rear_ports:
for i in range(1, rear_port.positions + 1):
if (rear_port.pk, i) not in occupied_port_positions:
choices.append(
('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i))
)
self.fields['rear_port'].choices = choices
def clean(self):
super().clean()
# Check that the number of FrontPortTemplates to be created matches the selected number of RearPortTemplate
# positions
frontport_count = len(self.cleaned_data['name'])
rearport_count = len(self.cleaned_data['rear_port'])
if frontport_count != rearport_count:
raise forms.ValidationError({
'rear_port': _(
"The number of front port templates to be created ({frontport_count}) must match the selected "
"number of rear port positions ({rearport_count})."
).format(
frontport_count=frontport_count,
rearport_count=rearport_count
)
})
def get_iterative_data(self, iteration): def get_iterative_data(self, iteration):
positions = self.cleaned_data['positions']
offset = positions * iteration # Assign rear port and position from selected set
rear_port, position = self.cleaned_data['rear_port'][iteration].split(':')
return { return {
'rear_ports': self.cleaned_data['rear_ports'][offset:offset + positions] 'rear_port': int(rear_port),
'rear_port_position': int(position),
} }
@@ -215,26 +269,74 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
} }
) )
) )
rear_port = forms.MultipleChoiceField(
choices=[],
label=_('Rear ports'),
help_text=_('Select one rear port assignment for each front port being created.'),
widget=forms.SelectMultiple(attrs={'size': 6})
)
# Override fieldsets from FrontPortForm to omit rear_port_position # Override fieldsets from FrontPortForm to omit rear_port_position
fieldsets = ( fieldsets = (
FieldSet( FieldSet(
'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'rear_ports', 'mark_connected', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'mark_connected', 'description', 'tags',
'description', 'tags',
), ),
) )
class Meta: class Meta(model_forms.FrontPortForm.Meta):
model = FrontPort exclude = ('name', 'label', 'rear_port', 'rear_port_position')
fields = [
'device', 'module', 'type', 'color', 'positions', 'mark_connected', 'description', 'owner', 'tags', def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if device_id := self.data.get('device') or self.initial.get('device'):
device = Device.objects.get(pk=device_id)
else:
return
# Determine which rear port positions are occupied. These will be excluded from the list of available
# mappings.
occupied_port_positions = [
(front_port.rear_port_id, front_port.rear_port_position)
for front_port in device.frontports.all()
] ]
# Populate rear port choices
choices = []
rear_ports = RearPort.objects.filter(device=device)
for rear_port in rear_ports:
for i in range(1, rear_port.positions + 1):
if (rear_port.pk, i) not in occupied_port_positions:
choices.append(
('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i))
)
self.fields['rear_port'].choices = choices
def clean(self):
super().clean()
# Check that the number of FrontPorts to be created matches the selected number of RearPort positions
frontport_count = len(self.cleaned_data['name'])
rearport_count = len(self.cleaned_data['rear_port'])
if frontport_count != rearport_count:
raise forms.ValidationError({
'rear_port': _(
"The number of front ports to be created ({frontport_count}) must match the selected number of "
"rear port positions ({rearport_count})."
).format(
frontport_count=frontport_count,
rearport_count=rearport_count
)
})
def get_iterative_data(self, iteration): def get_iterative_data(self, iteration):
positions = self.cleaned_data['positions']
offset = positions * iteration # Assign rear port and position from selected set
rear_port, position = self.cleaned_data['rear_port'][iteration].split(':')
return { return {
'rear_ports': self.cleaned_data['rear_ports'][offset:offset + positions] 'rear_port': int(rear_port),
'rear_port_position': int(position),
} }
@@ -272,7 +374,7 @@ class InventoryItemCreateForm(ComponentCreateForm, model_forms.InventoryItemForm
# Virtual chassis # Virtual chassis
# #
class VirtualChassisCreateForm(OwnerMixin, NetBoxModelForm): class VirtualChassisCreateForm(NetBoxModelForm):
region = DynamicModelChoiceField( region = DynamicModelChoiceField(
label=_('Region'), label=_('Region'),
queryset=Region.objects.all(), queryset=Region.objects.all(),
+21 -21
View File
@@ -13,7 +13,6 @@ __all__ = (
'InterfaceTemplateImportForm', 'InterfaceTemplateImportForm',
'InventoryItemTemplateImportForm', 'InventoryItemTemplateImportForm',
'ModuleBayTemplateImportForm', 'ModuleBayTemplateImportForm',
'PortTemplateMappingImportForm',
'PowerOutletTemplateImportForm', 'PowerOutletTemplateImportForm',
'PowerPortTemplateImportForm', 'PowerPortTemplateImportForm',
'RearPortTemplateImportForm', 'RearPortTemplateImportForm',
@@ -114,11 +113,31 @@ class FrontPortTemplateImportForm(forms.ModelForm):
label=_('Type'), label=_('Type'),
choices=PortTypeChoices.CHOICES choices=PortTypeChoices.CHOICES
) )
rear_port = forms.ModelChoiceField(
label=_('Rear port'),
queryset=RearPortTemplate.objects.all(),
to_field_name='name'
)
def clean_device_type(self):
if device_type := self.cleaned_data['device_type']:
rear_port = self.fields['rear_port']
rear_port.queryset = rear_port.queryset.filter(device_type=device_type)
return device_type
def clean_module_type(self):
if module_type := self.cleaned_data['module_type']:
rear_port = self.fields['rear_port']
rear_port.queryset = rear_port.queryset.filter(module_type=module_type)
return module_type
class Meta: class Meta:
model = FrontPortTemplate model = FrontPortTemplate
fields = [ fields = [
'device_type', 'module_type', 'name', 'type', 'color', 'positions', 'label', 'description', 'device_type', 'module_type', 'name', 'type', 'color', 'rear_port', 'rear_port_position', 'label',
'description',
] ]
@@ -135,25 +154,6 @@ class RearPortTemplateImportForm(forms.ModelForm):
] ]
class PortTemplateMappingImportForm(forms.ModelForm):
front_port = forms.ModelChoiceField(
label=_('Front port'),
queryset=FrontPortTemplate.objects.all(),
to_field_name='name',
)
rear_port = forms.ModelChoiceField(
label=_('Rear port'),
queryset=RearPortTemplate.objects.all(),
to_field_name='name',
)
class Meta:
model = PortTemplateMapping
fields = [
'front_port', 'front_port_position', 'rear_port', 'rear_port_position',
]
class ModuleBayTemplateImportForm(forms.ModelForm): class ModuleBayTemplateImportForm(forms.ModelForm):
class Meta: class Meta:
-24
View File
@@ -13,7 +13,6 @@ if TYPE_CHECKING:
from netbox.graphql.filter_lookups import IntegerLookup from netbox.graphql.filter_lookups import IntegerLookup
from extras.graphql.filters import ConfigTemplateFilter from extras.graphql.filters import ConfigTemplateFilter
from ipam.graphql.filters import VLANFilter, VLANTranslationPolicyFilter from ipam.graphql.filters import VLANFilter, VLANTranslationPolicyFilter
from dcim.graphql.filters import LocationFilter, RegionFilter, SiteFilter, SiteGroupFilter
from .filters import * from .filters import *
__all__ = ( __all__ = (
@@ -36,32 +35,9 @@ class ScopedFilterMixin:
) )
scope_id: ID | None = strawberry_django.filter_field() scope_id: ID | None = strawberry_django.filter_field()
# Cached relations
_location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='location')
)
_region: Annotated['RegionFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='region')
)
_site_group: Annotated['SiteGroupFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='site_group')
)
_site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field(name='site')
)
@dataclass @dataclass
class ComponentModelFilterMixin: 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: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
device_id: ID | None = strawberry_django.filter_field() device_id: ID | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field() name: FilterLookup[str] | None = strawberry_django.filter_field()
+13 -37
View File
@@ -16,8 +16,7 @@ from dcim.graphql.filter_mixins import (
from extras.graphql.filter_mixins import ConfigContextFilterMixin from extras.graphql.filter_mixins import ConfigContextFilterMixin
from netbox.graphql.filter_mixins import ImageAttachmentFilterMixin, WeightFilterMixin from netbox.graphql.filter_mixins import ImageAttachmentFilterMixin, WeightFilterMixin
from netbox.graphql.filters import ( from netbox.graphql.filters import (
BaseModelFilter, ChangeLoggedModelFilter, NestedGroupModelFilter, OrganizationalModelFilter, PrimaryModelFilter, ChangeLoggedModelFilter, NestedGroupModelFilter, OrganizationalModelFilter, PrimaryModelFilter, NetBoxModelFilter,
NetBoxModelFilter,
) )
from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
from virtualization.models import VMInterface from virtualization.models import VMInterface
@@ -71,8 +70,6 @@ __all__ = (
'ModuleTypeFilter', 'ModuleTypeFilter',
'ModuleTypeProfileFilter', 'ModuleTypeProfileFilter',
'PlatformFilter', 'PlatformFilter',
'PortMappingFilter',
'PortTemplateMappingFilter',
'PowerFeedFilter', 'PowerFeedFilter',
'PowerOutletFilter', 'PowerOutletFilter',
'PowerOutletTemplateFilter', 'PowerOutletTemplateFilter',
@@ -407,6 +404,13 @@ class FrontPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin,
color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
) )
rear_port: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
rear_port_id: ID | None = strawberry_django.filter_field()
rear_port_position: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
@strawberry_django.filter_type(models.FrontPortTemplate, lookups=True) @strawberry_django.filter_type(models.FrontPortTemplate, lookups=True)
@@ -417,37 +421,13 @@ class FrontPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM
color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = ( color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
) )
@strawberry_django.filter_type(models.PortMapping, lookups=True)
class PortMappingFilter(BaseModelFilter):
device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
front_port: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
rear_port: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
front_port_position: FilterLookup[int] | None = strawberry_django.filter_field()
rear_port_position: FilterLookup[int] | None = strawberry_django.filter_field()
@strawberry_django.filter_type(models.PortTemplateMapping, lookups=True)
class PortTemplateMappingFilter(BaseModelFilter):
device_type: Annotated['DeviceTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
module_type: Annotated['ModuleTypeFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
front_port: Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field()
)
rear_port: Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( rear_port: Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
) )
front_port_position: FilterLookup[int] | None = strawberry_django.filter_field() rear_port_id: ID | None = strawberry_django.filter_field()
rear_port_position: FilterLookup[int] | None = strawberry_django.filter_field() rear_port_position: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
)
@strawberry_django.filter_type(models.MACAddress, lookups=True) @strawberry_django.filter_type(models.MACAddress, lookups=True)
@@ -550,10 +530,6 @@ class InterfaceFilter(
strawberry_django.filter_field() strawberry_django.filter_field()
) )
@strawberry_django.filter_field
def cabled(self, value: bool, prefix: str):
return Q(**{f'{prefix}cable__isnull': (not value)})
@strawberry_django.filter_field @strawberry_django.filter_field
def connected(self, queryset, value: bool, prefix: str): def connected(self, queryset, value: bool, prefix: str):
if value is True: if value is True:
@@ -893,7 +869,7 @@ class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM
@strawberry_django.filter_type(models.RackType, lookups=True) @strawberry_django.filter_type(models.RackType, lookups=True)
class RackTypeFilter(ImageAttachmentFilterMixin, RackFilterMixin, WeightFilterMixin, PrimaryModelFilter): class RackTypeFilter(RackFilterMixin, WeightFilterMixin, PrimaryModelFilter):
form_factor: BaseFilterLookup[Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')]] | None = ( form_factor: BaseFilterLookup[Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
) )
+5 -29
View File
@@ -385,8 +385,7 @@ class DeviceTypeType(PrimaryObjectType):
) )
class FrontPortType(ModularComponentType, CabledObjectMixin): class FrontPortType(ModularComponentType, CabledObjectMixin):
color: str color: str
rear_port: Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')]
mappings: List[Annotated["PortMappingType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type( @strawberry_django.type(
@@ -397,8 +396,7 @@ class FrontPortType(ModularComponentType, CabledObjectMixin):
) )
class FrontPortTemplateType(ModularComponentTemplateType): class FrontPortTemplateType(ModularComponentTemplateType):
color: str color: str
rear_port: Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')]
mappings: List[Annotated["PortMappingTemplateType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type( @strawberry_django.type(
@@ -638,28 +636,6 @@ class PlatformType(NestedGroupObjectType):
devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
models.PortMapping,
fields='__all__',
filters=PortMappingFilter,
pagination=True
)
class PortMappingType(ModularComponentTemplateType):
front_port: Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')]
rear_port: Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')]
@strawberry_django.type(
models.PortTemplateMapping,
fields='__all__',
filters=PortTemplateMappingFilter,
pagination=True
)
class PortMappingTemplateType(ModularComponentTemplateType):
front_port: Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]
rear_port: Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')]
@strawberry_django.type( @strawberry_django.type(
models.PowerFeed, models.PowerFeed,
exclude=['_path'], exclude=['_path'],
@@ -734,7 +710,7 @@ class PowerPortTemplateType(ModularComponentTemplateType):
filters=RackTypeFilter, filters=RackTypeFilter,
pagination=True pagination=True
) )
class RackTypeType(ImageAttachmentsMixin, PrimaryObjectType): class RackTypeType(PrimaryObjectType):
rack_count: BigInt rack_count: BigInt
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
@@ -792,7 +768,7 @@ class RackRoleType(OrganizationalObjectType):
class RearPortType(ModularComponentType, CabledObjectMixin): class RearPortType(ModularComponentType, CabledObjectMixin):
color: str color: str
mappings: List[Annotated["PortMappingType", strawberry.lazy('dcim.graphql.types')]] frontports: List[Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type( @strawberry_django.type(
@@ -804,7 +780,7 @@ class RearPortType(ModularComponentType, CabledObjectMixin):
class RearPortTemplateType(ModularComponentTemplateType): class RearPortTemplateType(ModularComponentTemplateType):
color: str color: str
mappings: List[Annotated["PortMappingTemplateType", strawberry.lazy('dcim.graphql.types')]] frontport_templates: List[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type( @strawberry_django.type(
@@ -1,5 +1,3 @@
import decimal
import django.core.validators import django.core.validators
from django.db import migrations, models from django.db import migrations, models
@@ -19,8 +17,8 @@ class Migration(migrations.Migration):
max_digits=8, max_digits=8,
null=True, null=True,
validators=[ validators=[
django.core.validators.MinValueValidator(decimal.Decimal('-90.0')), django.core.validators.MinValueValidator(-90.0),
django.core.validators.MaxValueValidator(decimal.Decimal('90.0')) django.core.validators.MaxValueValidator(90.0),
], ],
), ),
), ),
@@ -33,8 +31,8 @@ class Migration(migrations.Migration):
max_digits=9, max_digits=9,
null=True, null=True,
validators=[ validators=[
django.core.validators.MinValueValidator(decimal.Decimal('-180.0')), django.core.validators.MinValueValidator(-180.0),
django.core.validators.MaxValueValidator(decimal.Decimal('180.0')) django.core.validators.MaxValueValidator(180.0),
], ],
), ),
), ),
@@ -47,8 +45,8 @@ class Migration(migrations.Migration):
max_digits=8, max_digits=8,
null=True, null=True,
validators=[ validators=[
django.core.validators.MinValueValidator(decimal.Decimal('-90.0')), django.core.validators.MinValueValidator(-90.0),
django.core.validators.MaxValueValidator(decimal.Decimal('90.0')) django.core.validators.MaxValueValidator(90.0),
], ],
), ),
), ),
@@ -61,8 +59,8 @@ class Migration(migrations.Migration):
max_digits=9, max_digits=9,
null=True, null=True,
validators=[ validators=[
django.core.validators.MinValueValidator(decimal.Decimal('-180.0')), django.core.validators.MinValueValidator(-180.0),
django.core.validators.MaxValueValidator(decimal.Decimal('180.0')) django.core.validators.MaxValueValidator(180.0),
], ],
), ),
), ),
+7 -23
View File
@@ -1,4 +1,3 @@
import django.contrib.postgres.fields
import django.core.validators import django.core.validators
from django.db import migrations, models from django.db import migrations, models
@@ -17,40 +16,25 @@ class Migration(migrations.Migration):
), ),
migrations.AddField( migrations.AddField(
model_name='cabletermination', model_name='cabletermination',
name='connector', name='position',
field=models.PositiveSmallIntegerField( field=models.PositiveIntegerField(
blank=True, blank=True,
null=True, null=True,
validators=[ validators=[
django.core.validators.MinValueValidator(1), django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(256) django.core.validators.MaxValueValidator(1024),
] ],
),
),
migrations.AddField(
model_name='cabletermination',
name='positions',
field=django.contrib.postgres.fields.ArrayField(
base_field=models.PositiveSmallIntegerField(
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024)
]
),
blank=True,
null=True,
size=None
), ),
), ),
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='cabletermination', name='cabletermination',
options={'ordering': ('cable', 'cable_end', 'connector', 'pk')}, # connector may be null options={'ordering': ('cable', 'cable_end', 'position', 'pk')},
), ),
migrations.AddConstraint( migrations.AddConstraint(
model_name='cabletermination', model_name='cabletermination',
constraint=models.UniqueConstraint( constraint=models.UniqueConstraint(
fields=('cable', 'cable_end', 'connector'), fields=('cable', 'cable_end', 'position'),
name='dcim_cabletermination_unique_connector' name='dcim_cabletermination_unique_position'
), ),
), ),
] ]
@@ -1,228 +0,0 @@
import django.contrib.postgres.fields
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0220_cable_profile'),
]
operations = [
migrations.AddField(
model_name='consoleport',
name='cable_connector',
field=models.PositiveSmallIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(256)
],
),
),
migrations.AddField(
model_name='consoleport',
name='cable_positions',
field=django.contrib.postgres.fields.ArrayField(
base_field=models.PositiveSmallIntegerField(
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024),
]
),
blank=True,
null=True,
size=None,
),
),
migrations.AddField(
model_name='consoleserverport',
name='cable_connector',
field=models.PositiveSmallIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(256)
],
),
),
migrations.AddField(
model_name='consoleserverport',
name='cable_positions',
field=django.contrib.postgres.fields.ArrayField(
base_field=models.PositiveSmallIntegerField(
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024),
]
),
blank=True,
null=True,
size=None,
),
),
migrations.AddField(
model_name='frontport',
name='cable_connector',
field=models.PositiveSmallIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(256)
],
),
),
migrations.AddField(
model_name='frontport',
name='cable_positions',
field=django.contrib.postgres.fields.ArrayField(
base_field=models.PositiveSmallIntegerField(
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024),
]
),
blank=True,
null=True,
size=None,
),
),
migrations.AddField(
model_name='interface',
name='cable_connector',
field=models.PositiveSmallIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(256)
],
),
),
migrations.AddField(
model_name='interface',
name='cable_positions',
field=django.contrib.postgres.fields.ArrayField(
base_field=models.PositiveSmallIntegerField(
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024),
]
),
blank=True,
null=True,
size=None,
),
),
migrations.AddField(
model_name='powerfeed',
name='cable_connector',
field=models.PositiveSmallIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(256)
],
),
),
migrations.AddField(
model_name='powerfeed',
name='cable_positions',
field=django.contrib.postgres.fields.ArrayField(
base_field=models.PositiveSmallIntegerField(
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024),
]
),
blank=True,
null=True,
size=None,
),
),
migrations.AddField(
model_name='poweroutlet',
name='cable_connector',
field=models.PositiveSmallIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(256)
],
),
),
migrations.AddField(
model_name='poweroutlet',
name='cable_positions',
field=django.contrib.postgres.fields.ArrayField(
base_field=models.PositiveSmallIntegerField(
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024),
]
),
blank=True,
null=True,
size=None,
),
),
migrations.AddField(
model_name='powerport',
name='cable_connector',
field=models.PositiveSmallIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(256)
],
),
),
migrations.AddField(
model_name='powerport',
name='cable_positions',
field=django.contrib.postgres.fields.ArrayField(
base_field=models.PositiveSmallIntegerField(
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024),
]
),
blank=True,
null=True,
size=None,
),
),
migrations.AddField(
model_name='rearport',
name='cable_connector',
field=models.PositiveSmallIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(256)
],
),
),
migrations.AddField(
model_name='rearport',
name='cable_positions',
field=django.contrib.postgres.fields.ArrayField(
base_field=models.PositiveSmallIntegerField(
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024),
]
),
blank=True,
null=True,
size=None,
),
),
]
@@ -0,0 +1,107 @@
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0220_cable_profile'),
]
operations = [
migrations.AddField(
model_name='consoleport',
name='cable_position',
field=models.PositiveIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024),
],
),
),
migrations.AddField(
model_name='consoleserverport',
name='cable_position',
field=models.PositiveIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024),
],
),
),
migrations.AddField(
model_name='frontport',
name='cable_position',
field=models.PositiveIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024),
],
),
),
migrations.AddField(
model_name='interface',
name='cable_position',
field=models.PositiveIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024),
],
),
),
migrations.AddField(
model_name='powerfeed',
name='cable_position',
field=models.PositiveIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024),
],
),
),
migrations.AddField(
model_name='poweroutlet',
name='cable_position',
field=models.PositiveIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024),
],
),
),
migrations.AddField(
model_name='powerport',
name='cable_position',
field=models.PositiveIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024),
],
),
),
migrations.AddField(
model_name='rearport',
name='cable_position',
field=models.PositiveIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024),
],
),
),
]
@@ -1,219 +0,0 @@
import django.core.validators
import django.db.models.deletion
from django.db import migrations
from django.db import models
from itertools import islice
def chunked(iterable, size):
"""
Yield successive chunks of a given size from an iterator.
"""
iterator = iter(iterable)
while chunk := list(islice(iterator, size)):
yield chunk
def populate_port_template_mappings(apps, schema_editor):
FrontPortTemplate = apps.get_model('dcim', 'FrontPortTemplate')
PortTemplateMapping = apps.get_model('dcim', 'PortTemplateMapping')
front_ports = FrontPortTemplate.objects.iterator(chunk_size=1000)
def generate_copies():
for front_port in front_ports:
yield PortTemplateMapping(
device_type_id=front_port.device_type_id,
module_type_id=front_port.module_type_id,
front_port_id=front_port.pk,
front_port_position=1,
rear_port_id=front_port.rear_port_id,
rear_port_position=front_port.rear_port_position,
)
# Bulk insert in streaming batches
for chunk in chunked(generate_copies(), 1000):
PortTemplateMapping.objects.bulk_create(chunk, batch_size=1000)
def populate_port_mappings(apps, schema_editor):
FrontPort = apps.get_model('dcim', 'FrontPort')
PortMapping = apps.get_model('dcim', 'PortMapping')
front_ports = FrontPort.objects.iterator(chunk_size=1000)
def generate_copies():
for front_port in front_ports:
yield PortMapping(
device_id=front_port.device_id,
front_port_id=front_port.pk,
front_port_position=1,
rear_port_id=front_port.rear_port_id,
rear_port_position=front_port.rear_port_position,
)
# Bulk insert in streaming batches
for chunk in chunked(generate_copies(), 1000):
PortMapping.objects.bulk_create(chunk, batch_size=1000)
class Migration(migrations.Migration):
dependencies = [
('dcim', '0221_cable_connector_positions'),
]
operations = [
# Create PortTemplateMapping model (for DeviceTypes)
migrations.CreateModel(
name='PortTemplateMapping',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
(
'front_port_position',
models.PositiveSmallIntegerField(
default=1,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024)
]
)
),
(
'rear_port_position',
models.PositiveSmallIntegerField(
default=1,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024)
]
)
),
(
'device_type',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to='dcim.devicetype',
related_name='port_mappings',
blank=True,
null=True
)
),
(
'module_type',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to='dcim.moduletype',
related_name='port_mappings',
blank=True,
null=True
)
),
(
'front_port',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to='dcim.frontporttemplate',
related_name='mappings'
)
),
(
'rear_port',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to='dcim.rearporttemplate',
related_name='mappings'
)
),
],
),
migrations.AddConstraint(
model_name='porttemplatemapping',
constraint=models.UniqueConstraint(
fields=('front_port', 'front_port_position'),
name='dcim_porttemplatemapping_unique_front_port_position'
),
),
migrations.AddConstraint(
model_name='porttemplatemapping',
constraint=models.UniqueConstraint(
fields=('rear_port', 'rear_port_position'),
name='dcim_porttemplatemapping_unique_rear_port_position'
),
),
# Create PortMapping model (for Devices)
migrations.CreateModel(
name='PortMapping',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
(
'front_port_position',
models.PositiveSmallIntegerField(
default=1,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024)
]
),
),
(
'rear_port_position',
models.PositiveSmallIntegerField(
default=1,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024),
]
),
),
(
'device',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to='dcim.device',
related_name='port_mappings'
)
),
(
'front_port',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to='dcim.frontport',
related_name='mappings'
)
),
(
'rear_port',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to='dcim.rearport',
related_name='mappings'
)
),
],
),
migrations.AddConstraint(
model_name='portmapping',
constraint=models.UniqueConstraint(
fields=('front_port', 'front_port_position'),
name='dcim_portmapping_unique_front_port_position'
),
),
migrations.AddConstraint(
model_name='portmapping',
constraint=models.UniqueConstraint(
fields=('rear_port', 'rear_port_position'),
name='dcim_portmapping_unique_rear_port_position'
),
),
# Data migration
migrations.RunPython(
code=populate_port_template_mappings,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=populate_port_mappings,
reverse_code=migrations.RunPython.noop
),
]
@@ -1,65 +0,0 @@
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0222_port_mappings'),
]
operations = [
# Remove rear_port & rear_port_position from FrontPortTemplate
migrations.RemoveConstraint(
model_name='frontporttemplate',
name='dcim_frontporttemplate_unique_rear_port_position',
),
migrations.RemoveField(
model_name='frontporttemplate',
name='rear_port',
),
migrations.RemoveField(
model_name='frontporttemplate',
name='rear_port_position',
),
# Add positions on FrontPortTemplate
migrations.AddField(
model_name='frontporttemplate',
name='positions',
field=models.PositiveSmallIntegerField(
default=1,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024)
]
),
),
# Remove rear_port & rear_port_position from FrontPort
migrations.RemoveConstraint(
model_name='frontport',
name='dcim_frontport_unique_rear_port_position',
),
migrations.RemoveField(
model_name='frontport',
name='rear_port',
),
migrations.RemoveField(
model_name='frontport',
name='rear_port_position',
),
# Add positions on FrontPort
migrations.AddField(
model_name='frontport',
name='positions',
field=models.PositiveSmallIntegerField(
default=1,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(1024)
]
),
),
]
@@ -1,28 +0,0 @@
# Generated by Django 5.2.8 on 2025-12-08 17:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0223_frontport_positions'),
]
operations = [
migrations.AddField(
model_name='inventoryitemrole',
name='comments',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='manufacturer',
name='comments',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='rackrole',
name='comments',
field=models.TextField(blank=True),
),
]
@@ -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'
),
),
]
-61
View File
@@ -1,61 +0,0 @@
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.utils.translation import gettext_lazy as _
from dcim.constants import PORT_POSITION_MAX, PORT_POSITION_MIN
__all__ = (
'PortMappingBase',
)
class PortMappingBase(models.Model):
"""
Base class for PortMapping and PortTemplateMapping
"""
front_port_position = models.PositiveSmallIntegerField(
default=1,
validators=(
MinValueValidator(PORT_POSITION_MIN),
MaxValueValidator(PORT_POSITION_MAX),
),
)
rear_port_position = models.PositiveSmallIntegerField(
default=1,
validators=(
MinValueValidator(PORT_POSITION_MIN),
MaxValueValidator(PORT_POSITION_MAX),
),
)
_netbox_private = True
class Meta:
abstract = True
constraints = (
models.UniqueConstraint(
fields=('front_port', 'front_port_position'),
name='%(app_label)s_%(class)s_unique_front_port_position'
),
models.UniqueConstraint(
fields=('rear_port', 'rear_port_position'),
name='%(app_label)s_%(class)s_unique_rear_port_position'
),
)
def clean(self):
super().clean()
# Validate rear port position
if self.rear_port_position > self.rear_port.positions:
raise ValidationError({
"rear_port_position": _(
"Invalid rear port position ({rear_port_position}): Rear port {name} has only {positions} "
"positions."
).format(
rear_port_position=self.rear_port_position,
name=self.rear_port.name,
positions=self.rear_port.positions
)
})
+81 -155
View File
@@ -1,9 +1,7 @@
import itertools import itertools
import logging
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
@@ -24,7 +22,7 @@ from utilities.fields import ColorField, GenericArrayForeignKey
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.serialization import deserialize_object, serialize_object from utilities.serialization import deserialize_object, serialize_object
from wireless.models import WirelessLink from wireless.models import WirelessLink
from .device_components import FrontPort, PathEndpoint, PortMapping, RearPort from .device_components import FrontPort, PathEndpoint, RearPort
__all__ = ( __all__ = (
'Cable', 'Cable',
@@ -32,8 +30,6 @@ __all__ = (
'CableTermination', 'CableTermination',
) )
logger = logging.getLogger(f'netbox.{__name__}')
trace_paths = Signal() trace_paths = Signal()
@@ -115,9 +111,8 @@ class Cable(PrimaryModel):
# A copy of the PK to be used by __str__ in case the object is deleted # A copy of the PK to be used by __str__ in case the object is deleted
self._pk = self.__dict__.get('id') 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_status = self.__dict__.get('status')
self._orig_profile = self.__dict__.get('profile')
self._terminations_modified = False self._terminations_modified = False
@@ -138,30 +133,10 @@ class Cable(PrimaryModel):
def profile_class(self): def profile_class(self):
from dcim import cable_profiles from dcim import cable_profiles
return { return {
CableProfileChoices.SINGLE_1C1P: cable_profiles.Single1C1PCableProfile, CableProfileChoices.STRAIGHT_SINGLE: cable_profiles.StraightSingleCableProfile,
CableProfileChoices.SINGLE_1C2P: cable_profiles.Single1C2PCableProfile, CableProfileChoices.STRAIGHT_MULTI: cable_profiles.StraightMultiCableProfile,
CableProfileChoices.SINGLE_1C4P: cable_profiles.Single1C4PCableProfile, CableProfileChoices.SHUFFLE_2X2_MPO8: cable_profiles.Shuffle2x2MPO8CableProfile,
CableProfileChoices.SINGLE_1C6P: cable_profiles.Single1C6PCableProfile, CableProfileChoices.SHUFFLE_4X4_MPO8: cable_profiles.Shuffle4x4MPO8CableProfile,
CableProfileChoices.SINGLE_1C8P: cable_profiles.Single1C8PCableProfile,
CableProfileChoices.SINGLE_1C12P: cable_profiles.Single1C12PCableProfile,
CableProfileChoices.SINGLE_1C16P: cable_profiles.Single1C16PCableProfile,
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,
}.get(self.profile) }.get(self.profile)
def _get_x_terminations(self, side): def _get_x_terminations(self, side):
@@ -291,10 +266,7 @@ class Cable(PrimaryModel):
# Update the private PK used in __str__() # Update the private PK used in __str__()
self._pk = self.pk self._pk = self.pk
if self._orig_profile != self.profile: if self._terminations_modified:
print(f'profile changed from {self._orig_profile} to {self.profile}')
self.update_terminations(force=True)
elif self._terminations_modified:
self.update_terminations() self.update_terminations()
super().save(*args, force_update=True, using=using, update_fields=update_fields) super().save(*args, force_update=True, using=using, update_fields=update_fields)
@@ -348,52 +320,29 @@ class Cable(PrimaryModel):
return a_terminations, b_terminations 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. 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() a_terminations, b_terminations = self.get_terminations()
# Delete any stale CableTerminations # Delete any stale CableTerminations
for termination, ct in a_terminations.items(): 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() ct.delete()
for termination, ct in b_terminations.items(): 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() ct.delete()
# Save any new CableTerminations # Save any new CableTerminations
profile = self.profile_class() if self.profile else None
for i, termination in enumerate(self.a_terminations, start=1): 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 position = i if self.profile and isinstance(termination, PathEndpoint) else None
if profile: CableTermination(cable=self, cable_end='A', position=position, termination=termination).save()
connector = i
positions = profile.get_position_list(profile.a_connectors[i])
CableTermination(
cable=self,
cable_end=CableEndChoices.SIDE_A,
connector=connector,
positions=positions,
termination=termination
).save()
for i, termination in enumerate(self.b_terminations, start=1): 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 position = i if self.profile and isinstance(termination, PathEndpoint) else None
if profile: CableTermination(cable=self, cable_end='B', position=position, termination=termination).save()
connector = i
positions = profile.get_position_list(profile.b_connectors[i])
CableTermination(
cable=self,
cable_end=CableEndChoices.SIDE_B,
connector=connector,
positions=positions,
termination=termination
).save()
class CableTermination(ChangeLoggedModel): class CableTermination(ChangeLoggedModel):
@@ -420,23 +369,13 @@ class CableTermination(ChangeLoggedModel):
ct_field='termination_type', ct_field='termination_type',
fk_field='termination_id' fk_field='termination_id'
) )
connector = models.PositiveSmallIntegerField( position = models.PositiveIntegerField(
blank=True, blank=True,
null=True, null=True,
validators=( validators=(
MinValueValidator(CABLE_CONNECTOR_MIN), MinValueValidator(CABLE_POSITION_MIN),
MaxValueValidator(CABLE_CONNECTOR_MAX) MaxValueValidator(CABLE_POSITION_MAX)
), )
)
positions = ArrayField(
base_field=models.PositiveSmallIntegerField(
validators=(
MinValueValidator(CABLE_POSITION_MIN),
MaxValueValidator(CABLE_POSITION_MAX)
)
),
blank=True,
null=True,
) )
# Cached associations to enable efficient filtering # Cached associations to enable efficient filtering
@@ -468,15 +407,15 @@ class CableTermination(ChangeLoggedModel):
objects = RestrictedQuerySet.as_manager() objects = RestrictedQuerySet.as_manager()
class Meta: class Meta:
ordering = ('cable', 'cable_end', 'connector', 'pk') ordering = ('cable', 'cable_end', 'position', 'pk')
constraints = ( constraints = (
models.UniqueConstraint( models.UniqueConstraint(
fields=('termination_type', 'termination_id'), fields=('termination_type', 'termination_id'),
name='%(app_label)s_%(class)s_unique_termination' name='%(app_label)s_%(class)s_unique_termination'
), ),
models.UniqueConstraint( models.UniqueConstraint(
fields=('cable', 'cable_end', 'connector'), fields=('cable', 'cable_end', 'position'),
name='%(app_label)s_%(class)s_unique_connector' name='%(app_label)s_%(class)s_unique_position'
), ),
) )
verbose_name = _('cable termination') verbose_name = _('cable termination')
@@ -539,7 +478,9 @@ class CableTermination(ChangeLoggedModel):
# Set the cable on the terminating object # Set the cable on the terminating object
termination = self.termination._meta.model.objects.get(pk=self.termination_id) termination = self.termination._meta.model.objects.get(pk=self.termination_id)
termination.snapshot() termination.snapshot()
termination.set_cable_termination(self) termination.cable = self.cable
termination.cable_end = self.cable_end
termination.cable_position = self.position
termination.save() termination.save()
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
@@ -547,7 +488,9 @@ class CableTermination(ChangeLoggedModel):
# Delete the cable association on the terminating object # Delete the cable association on the terminating object
termination = self.termination._meta.model.objects.get(pk=self.termination_id) termination = self.termination._meta.model.objects.get(pk=self.termination_id)
termination.snapshot() termination.snapshot()
termination.clear_cable_termination(self) termination.cable = None
termination.cable_end = None
termination.cable_position = None
termination.save() termination.save()
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
@@ -723,13 +666,7 @@ class CablePath(models.Model):
is_active = True is_active = True
is_split = False is_split = False
logger.debug(f'Tracing cable path from {terminations}...')
segment = 0
while terminations: while terminations:
segment += 1
logger.debug(f'[Path segment #{segment}] Position stack: {position_stack}')
logger.debug(f'[Path segment #{segment}] Local terminations: {terminations}')
# Terminations must all be of the same type # Terminations must all be of the same type
if not all(isinstance(t, type(terminations[0])) for t in terminations[1:]): if not all(isinstance(t, type(terminations[0])) for t in terminations[1:]):
@@ -755,15 +692,12 @@ class CablePath(models.Model):
path.append([ path.append([
object_to_path_node(t) for t in terminations object_to_path_node(t) for t in terminations
]) ])
# If not null, push cable position onto the stack # If not null, push cable_position onto the stack
if isinstance(terminations[0], PathEndpoint) and terminations[0].cable_positions: if terminations[0].cable_position is not None:
position_stack.append([terminations[0].cable_positions[0]]) position_stack.append([terminations[0].cable_position])
# Step 2: Determine the attached links (Cable or WirelessLink), if any # Step 2: Determine the attached links (Cable or WirelessLink), if any
links = list(dict.fromkeys( links = [termination.link for termination in terminations if termination.link is not None]
termination.link for termination in terminations if termination.link is not None
))
logger.debug(f'[Path segment #{segment}] Links: {links}')
if len(links) == 0: if len(links) == 0:
if len(path) == 1: if len(path) == 1:
# If this is the start of the path and no link exists, return None # If this is the start of the path and no link exists, return None
@@ -798,10 +732,8 @@ class CablePath(models.Model):
# Profile-based tracing # Profile-based tracing
if links[0].profile: if links[0].profile:
cable_profile = links[0].profile_class() cable_profile = links[0].profile_class()
position = position_stack.pop()[0] if position_stack else None peer_cable_terminations = cable_profile.get_peer_terminations(terminations, position_stack)
term, position = cable_profile.get_peer_termination(terminations[0], position) remote_terminations = [ct.termination for ct in peer_cable_terminations]
remote_terminations = [term]
position_stack.append([position])
# Legacy (positionless) behavior # Legacy (positionless) behavior
else: else:
@@ -828,13 +760,10 @@ class CablePath(models.Model):
link.interface_b if link.interface_a is terminations[0] else link.interface_a for link in links link.interface_b if link.interface_a is terminations[0] else link.interface_a for link in links
] ]
logger.debug(f'[Path segment #{segment}] Remote terminations: {remote_terminations}')
# Remote Terminations must all be of the same type, otherwise return a split path # Remote Terminations must all be of the same type, otherwise return a split path
if not all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]): if not all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
is_complete = False is_complete = False
is_split = True is_split = True
logger.debug('Remote termination types differ; aborting trace.')
break break
# Step 7: Record the far-end termination object(s) # Step 7: Record the far-end termination object(s)
@@ -848,53 +777,58 @@ class CablePath(models.Model):
if isinstance(remote_terminations[0], FrontPort): if isinstance(remote_terminations[0], FrontPort):
# Follow FrontPorts to their corresponding RearPorts # Follow FrontPorts to their corresponding RearPorts
if remote_terminations[0].positions > 1 and position_stack: rear_ports = RearPort.objects.filter(
positions = position_stack.pop() pk__in=[t.rear_port_id for t in remote_terminations]
q_filter = Q() )
for rt in remote_terminations: if len(rear_ports) > 1 or rear_ports[0].positions > 1:
q_filter |= Q(front_port=rt, front_port_position__in=positions) position_stack.append([fp.rear_port_position for fp in remote_terminations])
port_mappings = PortMapping.objects.filter(q_filter)
elif remote_terminations[0].positions > 1:
is_split = True
logger.debug(
'Encountered front port mapped to multiple rear ports but position stack is empty; aborting '
'trace.'
)
break
else:
port_mappings = PortMapping.objects.filter(front_port__in=remote_terminations)
if not port_mappings:
break
# Compile the list of RearPorts without duplication or altering their ordering terminations = rear_ports
terminations = list(dict.fromkeys(mapping.rear_port for mapping in port_mappings))
if any(t.positions > 1 for t in terminations):
position_stack.append([mapping.rear_port_position for mapping in port_mappings])
elif isinstance(remote_terminations[0], RearPort): elif isinstance(remote_terminations[0], RearPort):
# Follow RearPorts to their corresponding FrontPorts if len(remote_terminations) == 1 and remote_terminations[0].positions == 1:
if remote_terminations[0].positions > 1 and position_stack: front_ports = FrontPort.objects.filter(
rear_port_id__in=[rp.pk for rp in remote_terminations],
rear_port_position=1
)
# Obtain the individual front ports based on the termination and all positions
elif len(remote_terminations) > 1 and position_stack:
positions = position_stack.pop() positions = position_stack.pop()
# Ensure we have a number of positions equal to the amount of remote terminations
if len(remote_terminations) != len(positions):
raise UnsupportedCablePath(
_("All positions counts within the path on opposite ends of links must match")
)
# Get our front ports
q_filter = Q() q_filter = Q()
for rt in remote_terminations: for rt in remote_terminations:
q_filter |= Q(rear_port=rt, rear_port_position__in=positions) position = positions.pop()
port_mappings = PortMapping.objects.filter(q_filter) q_filter |= Q(rear_port_id=rt.pk, rear_port_position=position)
elif remote_terminations[0].positions > 1: if q_filter is Q():
is_split = True raise UnsupportedCablePath(_("Remote termination position filter is missing"))
logger.debug( front_ports = FrontPort.objects.filter(q_filter)
'Encountered rear port mapped to multiple front ports but position stack is empty; aborting ' # Obtain the individual front ports based on the termination and position
'trace.' elif position_stack:
front_ports = FrontPort.objects.filter(
rear_port_id=remote_terminations[0].pk,
rear_port_position__in=position_stack.pop()
) )
break # If all rear ports have a single position, we can just get the front ports
elif all([rp.positions == 1 for rp in remote_terminations]):
front_ports = FrontPort.objects.filter(rear_port_id__in=[rp.pk for rp in remote_terminations])
if len(front_ports) != len(remote_terminations):
# Some rear ports does not have a front port
is_split = True
break
else: else:
port_mappings = PortMapping.objects.filter(rear_port__in=remote_terminations) # No position indicated: path has split, so we stop at the RearPorts
if not port_mappings: is_split = True
break break
# Compile the list of FrontPorts without duplication or altering their ordering terminations = front_ports
terminations = list(dict.fromkeys(mapping.front_port for mapping in port_mappings))
if any(t.positions > 1 for t in terminations):
position_stack.append([mapping.front_port_position for mapping in port_mappings])
elif isinstance(remote_terminations[0], CircuitTermination): elif isinstance(remote_terminations[0], CircuitTermination):
# Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa) # Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
@@ -942,7 +876,6 @@ class CablePath(models.Model):
# Unsupported topology, mark as split and exit # Unsupported topology, mark as split and exit
is_complete = False is_complete = False
is_split = True is_split = True
logger.warning('Encountered an unsupported topology; aborting trace.')
break break
return cls( return cls(
@@ -1021,23 +954,16 @@ class CablePath(models.Model):
# RearPort splitting to multiple FrontPorts with no stack position # RearPort splitting to multiple FrontPorts with no stack position
if type(nodes[0]) is RearPort: if type(nodes[0]) is RearPort:
return [ return FrontPort.objects.filter(rear_port__in=nodes)
mapping.front_port for mapping in
PortMapping.objects.filter(rear_port__in=nodes).prefetch_related('front_port')
]
# Cable terminating to multiple FrontPorts mapped to different # Cable terminating to multiple FrontPorts mapped to different
# RearPorts connected to different cables # RearPorts connected to different cables
if type(nodes[0]) is FrontPort: elif type(nodes[0]) is FrontPort:
return [ return RearPort.objects.filter(pk__in=[fp.rear_port_id for fp in nodes])
mapping.rear_port for mapping in
PortMapping.objects.filter(front_port__in=nodes).prefetch_related('rear_port')
]
# Cable terminating to multiple CircuitTerminations # Cable terminating to multiple CircuitTerminations
if type(nodes[0]) is CircuitTermination: elif type(nodes[0]) is CircuitTermination:
return [ return [
ct.get_peer_termination() for ct in nodes ct.get_peer_termination() for ct in nodes
] ]
return []
def get_asymmetric_nodes(self): def get_asymmetric_nodes(self):
""" """
@@ -7,7 +7,6 @@ from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models.base import PortMappingBase
from dcim.models.mixins import InterfaceValidationMixin from dcim.models.mixins import InterfaceValidationMixin
from netbox.models import ChangeLoggedModel from netbox.models import ChangeLoggedModel
from utilities.fields import ColorField, NaturalOrderingField from utilities.fields import ColorField, NaturalOrderingField
@@ -29,7 +28,6 @@ __all__ = (
'InterfaceTemplate', 'InterfaceTemplate',
'InventoryItemTemplate', 'InventoryItemTemplate',
'ModuleBayTemplate', 'ModuleBayTemplate',
'PortTemplateMapping',
'PowerOutletTemplate', 'PowerOutletTemplate',
'PowerPortTemplate', 'PowerPortTemplate',
'RearPortTemplate', 'RearPortTemplate',
@@ -520,53 +518,6 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
} }
class PortTemplateMapping(PortMappingBase):
"""
Maps a FrontPortTemplate & position to a RearPortTemplate & position.
"""
device_type = models.ForeignKey(
to='dcim.DeviceType',
on_delete=models.CASCADE,
related_name='port_mappings',
blank=True,
null=True,
)
module_type = models.ForeignKey(
to='dcim.ModuleType',
on_delete=models.CASCADE,
related_name='port_mappings',
blank=True,
null=True,
)
front_port = models.ForeignKey(
to='dcim.FrontPortTemplate',
on_delete=models.CASCADE,
related_name='mappings',
)
rear_port = models.ForeignKey(
to='dcim.RearPortTemplate',
on_delete=models.CASCADE,
related_name='mappings',
)
def clean(self):
super().clean()
# Validate rear port assignment
if self.front_port.device_type_id != self.rear_port.device_type_id:
raise ValidationError({
"rear_port": _("Rear port ({rear_port}) must belong to the same device type").format(
rear_port=self.rear_port
)
})
def save(self, *args, **kwargs):
# Associate the mapping with the parent DeviceType/ModuleType
self.device_type = self.front_port.device_type
self.module_type = self.front_port.module_type
super().save(*args, **kwargs)
class FrontPortTemplate(ModularComponentTemplateModel): class FrontPortTemplate(ModularComponentTemplateModel):
""" """
Template for a pass-through port on the front of a new Device. Template for a pass-through port on the front of a new Device.
@@ -580,13 +531,18 @@ class FrontPortTemplate(ModularComponentTemplateModel):
verbose_name=_('color'), verbose_name=_('color'),
blank=True blank=True
) )
positions = models.PositiveSmallIntegerField( rear_port = models.ForeignKey(
verbose_name=_('positions'), to='dcim.RearPortTemplate',
on_delete=models.CASCADE,
related_name='frontport_templates'
)
rear_port_position = models.PositiveSmallIntegerField(
verbose_name=_('rear port position'),
default=1, default=1,
validators=[ validators=[
MinValueValidator(PORT_POSITION_MIN), MinValueValidator(REARPORT_POSITIONS_MIN),
MaxValueValidator(PORT_POSITION_MAX) MaxValueValidator(REARPORT_POSITIONS_MAX)
], ]
) )
component_model = FrontPort component_model = FrontPort
@@ -601,6 +557,10 @@ class FrontPortTemplate(ModularComponentTemplateModel):
fields=('module_type', 'name'), fields=('module_type', 'name'),
name='%(app_label)s_%(class)s_unique_module_type_name' name='%(app_label)s_%(class)s_unique_module_type_name'
), ),
models.UniqueConstraint(
fields=('rear_port', 'rear_port_position'),
name='%(app_label)s_%(class)s_unique_rear_port_position'
),
) )
verbose_name = _('front port template') verbose_name = _('front port template')
verbose_name_plural = _('front port templates') verbose_name_plural = _('front port templates')
@@ -608,23 +568,40 @@ class FrontPortTemplate(ModularComponentTemplateModel):
def clean(self): def clean(self):
super().clean() super().clean()
# Check that positions is greater than or equal to the number of associated RearPortTemplates try:
if not self._state.adding:
mapping_count = self.mappings.count() # Validate rear port assignment
if self.positions < mapping_count: if self.rear_port.device_type != self.device_type:
raise ValidationError({ raise ValidationError(
"positions": _( _("Rear port ({name}) must belong to the same device type").format(name=self.rear_port)
"The number of positions cannot be less than the number of mapped rear port templates ({count})" )
).format(count=mapping_count)
}) # Validate rear port position assignment
if self.rear_port_position > self.rear_port.positions:
raise ValidationError(
_("Invalid rear port position ({position}); rear port {name} has only {count} positions").format(
position=self.rear_port_position,
name=self.rear_port.name,
count=self.rear_port.positions
)
)
except RearPortTemplate.DoesNotExist:
pass
def instantiate(self, **kwargs): def instantiate(self, **kwargs):
if self.rear_port:
rear_port_name = self.rear_port.resolve_name(kwargs.get('module'))
rear_port = RearPort.objects.get(name=rear_port_name, **kwargs)
else:
rear_port = None
return self.component_model( return self.component_model(
name=self.resolve_name(kwargs.get('module')), name=self.resolve_name(kwargs.get('module')),
label=self.resolve_label(kwargs.get('module')), label=self.resolve_label(kwargs.get('module')),
type=self.type, type=self.type,
color=self.color, color=self.color,
positions=self.positions, rear_port=rear_port,
rear_port_position=self.rear_port_position,
**kwargs **kwargs
) )
instantiate.do_not_call_in_templates = True instantiate.do_not_call_in_templates = True
@@ -634,7 +611,8 @@ class FrontPortTemplate(ModularComponentTemplateModel):
'name': self.name, 'name': self.name,
'type': self.type, 'type': self.type,
'color': self.color, 'color': self.color,
'positions': self.positions, 'rear_port': self.rear_port.name,
'rear_port_position': self.rear_port_position,
'label': self.label, 'label': self.label,
'description': self.description, 'description': self.description,
} }
@@ -657,9 +635,9 @@ class RearPortTemplate(ModularComponentTemplateModel):
verbose_name=_('positions'), verbose_name=_('positions'),
default=1, default=1,
validators=[ validators=[
MinValueValidator(PORT_POSITION_MIN), MinValueValidator(REARPORT_POSITIONS_MIN),
MaxValueValidator(PORT_POSITION_MAX) MaxValueValidator(REARPORT_POSITIONS_MAX)
], ]
) )
component_model = RearPort component_model = RearPort
@@ -668,20 +646,6 @@ class RearPortTemplate(ModularComponentTemplateModel):
verbose_name = _('rear port template') verbose_name = _('rear port template')
verbose_name_plural = _('rear port templates') verbose_name_plural = _('rear port templates')
def clean(self):
super().clean()
# Check that positions is greater than or equal to the number of associated FrontPortTemplates
if not self._state.adding:
mapping_count = self.mappings.count()
if self.positions < mapping_count:
raise ValidationError({
"positions": _(
"The number of positions cannot be less than the number of mapped front port templates "
"({count})"
).format(count=mapping_count)
})
def instantiate(self, **kwargs): def instantiate(self, **kwargs):
return self.component_model( return self.component_model(
name=self.resolve_name(kwargs.get('module')), name=self.resolve_name(kwargs.get('module')),
@@ -723,8 +687,8 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
def instantiate(self, **kwargs): def instantiate(self, **kwargs):
return self.component_model( return self.component_model(
name=self.resolve_name(kwargs.get('module')), name=self.name,
label=self.resolve_label(kwargs.get('module')), label=self.label,
position=self.position, position=self.position,
**kwargs **kwargs
) )
+60 -113
View File
@@ -1,7 +1,6 @@
from functools import cached_property from functools import cached_property
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
@@ -12,7 +11,6 @@ from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.fields import WWNField from dcim.fields import WWNField
from dcim.models.base import PortMappingBase
from dcim.models.mixins import InterfaceValidationMixin from dcim.models.mixins import InterfaceValidationMixin
from netbox.choices import ColorChoices from netbox.choices import ColorChoices
from netbox.models import OrganizationalModel, NetBoxModel from netbox.models import OrganizationalModel, NetBoxModel
@@ -37,7 +35,6 @@ __all__ = (
'InventoryItemRole', 'InventoryItemRole',
'ModuleBay', 'ModuleBay',
'PathEndpoint', 'PathEndpoint',
'PortMapping',
'PowerOutlet', 'PowerOutlet',
'PowerPort', 'PowerPort',
'RearPort', 'RearPort',
@@ -178,24 +175,15 @@ class CabledObjectModel(models.Model):
blank=True, blank=True,
null=True null=True
) )
cable_connector = models.PositiveSmallIntegerField( cable_position = models.PositiveIntegerField(
verbose_name=_('cable position'),
blank=True, blank=True,
null=True, null=True,
validators=( validators=(
MinValueValidator(CABLE_CONNECTOR_MIN), MinValueValidator(CABLE_POSITION_MIN),
MaxValueValidator(CABLE_CONNECTOR_MAX) MaxValueValidator(CABLE_POSITION_MAX)
), ),
) )
cable_positions = ArrayField(
base_field=models.PositiveSmallIntegerField(
validators=(
MinValueValidator(CABLE_POSITION_MIN),
MaxValueValidator(CABLE_POSITION_MAX)
)
),
blank=True,
null=True,
)
mark_connected = models.BooleanField( mark_connected = models.BooleanField(
verbose_name=_('mark connected'), verbose_name=_('mark connected'),
default=False, default=False,
@@ -220,31 +208,22 @@ class CabledObjectModel(models.Model):
raise ValidationError({ raise ValidationError({
"cable_end": _("Must specify cable end (A or B) when attaching a cable.") "cable_end": _("Must specify cable end (A or B) when attaching a cable.")
}) })
if self.cable_connector and not self.cable_positions: if not self.cable_position:
raise ValidationError({ raise ValidationError({
"cable_positions": _("Must specify position(s) when specifying a cable connector.") "cable_position": _("Must specify cable termination position when attaching a cable.")
})
if self.cable_positions and not self.cable_connector:
raise ValidationError({
"cable_positions": _("Cable positions cannot be set without a cable connector.")
})
if self.mark_connected:
raise ValidationError({
"mark_connected": _("Cannot mark as connected with a cable attached.")
})
else:
if self.cable_end:
raise ValidationError({
"cable_end": _("Cable end must not be set without a cable.")
})
if self.cable_connector:
raise ValidationError({
"cable_connector": _("Cable connector must not be set without a cable.")
})
if self.cable_positions:
raise ValidationError({
"cable_positions": _("Cable termination positions must not be set without a cable.")
}) })
if self.cable_end and not self.cable:
raise ValidationError({
"cable_end": _("Cable end must not be set without a cable.")
})
if self.cable_position and not self.cable:
raise ValidationError({
"cable_position": _("Cable termination position must not be set without a cable.")
})
if self.mark_connected and self.cable:
raise ValidationError({
"mark_connected": _("Cannot mark as connected with a cable attached.")
})
@property @property
def link(self): def link(self):
@@ -279,22 +258,6 @@ class CabledObjectModel(models.Model):
return None return None
return CableEndChoices.SIDE_A if self.cable_end == CableEndChoices.SIDE_B else CableEndChoices.SIDE_B return CableEndChoices.SIDE_A if self.cable_end == CableEndChoices.SIDE_B else CableEndChoices.SIDE_B
def set_cable_termination(self, termination):
"""Save attributes from the given CableTermination on the terminating object."""
self.cable = termination.cable
self.cable_end = termination.cable_end
self.cable_connector = termination.connector
self.cable_positions = termination.positions
set_cable_termination.alters_data = True
def clear_cable_termination(self, termination):
"""Clear all cable termination attributes from the terminating object."""
self.cable = None
self.cable_end = None
self.cable_connector = None
self.cable_positions = None
clear_cable_termination.alters_data = True
class PathEndpoint(models.Model): class PathEndpoint(models.Model):
""" """
@@ -1106,43 +1069,6 @@ class Interface(
# Pass-through ports # Pass-through ports
# #
class PortMapping(PortMappingBase):
"""
Maps a FrontPort & position to a RearPort & position.
"""
device = models.ForeignKey(
to='dcim.Device',
on_delete=models.CASCADE,
related_name='port_mappings',
)
front_port = models.ForeignKey(
to='dcim.FrontPort',
on_delete=models.CASCADE,
related_name='mappings',
)
rear_port = models.ForeignKey(
to='dcim.RearPort',
on_delete=models.CASCADE,
related_name='mappings',
)
def clean(self):
super().clean()
# Both ports must belong to the same device
if self.front_port.device_id != self.rear_port.device_id:
raise ValidationError({
"rear_port": _("Rear port ({rear_port}) must belong to the same device").format(
rear_port=self.rear_port
)
})
def save(self, *args, **kwargs):
# Associate the mapping with the parent Device
self.device = self.front_port.device
super().save(*args, **kwargs)
class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin): class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
""" """
A pass-through port on the front of a Device. A pass-through port on the front of a Device.
@@ -1156,16 +1082,22 @@ class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
verbose_name=_('color'), verbose_name=_('color'),
blank=True blank=True
) )
positions = models.PositiveSmallIntegerField( rear_port = models.ForeignKey(
verbose_name=_('positions'), to='dcim.RearPort',
on_delete=models.CASCADE,
related_name='frontports'
)
rear_port_position = models.PositiveSmallIntegerField(
verbose_name=_('rear port position'),
default=1, default=1,
validators=[ validators=[
MinValueValidator(PORT_POSITION_MIN), MinValueValidator(REARPORT_POSITIONS_MIN),
MaxValueValidator(PORT_POSITION_MAX) MaxValueValidator(REARPORT_POSITIONS_MAX)
], ],
help_text=_('Mapped position on corresponding rear port')
) )
clone_fields = ('device', 'type', 'color', 'positions') clone_fields = ('device', 'type', 'color')
class Meta(ModularComponentModel.Meta): class Meta(ModularComponentModel.Meta):
constraints = ( constraints = (
@@ -1173,6 +1105,10 @@ class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
fields=('device', 'name'), fields=('device', 'name'),
name='%(app_label)s_%(class)s_unique_device_name' name='%(app_label)s_%(class)s_unique_device_name'
), ),
models.UniqueConstraint(
fields=('rear_port', 'rear_port_position'),
name='%(app_label)s_%(class)s_unique_rear_port_position'
),
) )
verbose_name = _('front port') verbose_name = _('front port')
verbose_name_plural = _('front ports') verbose_name_plural = _('front ports')
@@ -1180,14 +1116,27 @@ class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
def clean(self): def clean(self):
super().clean() super().clean()
# Check that positions is greater than or equal to the number of associated RearPorts if hasattr(self, 'rear_port'):
if not self._state.adding:
mapping_count = self.mappings.count() # Validate rear port assignment
if self.positions < mapping_count: if self.rear_port.device != self.device:
raise ValidationError({ raise ValidationError({
"positions": _( "rear_port": _(
"The number of positions cannot be less than the number of mapped rear ports ({count})" "Rear port ({rear_port}) must belong to the same device"
).format(count=mapping_count) ).format(rear_port=self.rear_port)
})
# Validate rear port position assignment
if self.rear_port_position > self.rear_port.positions:
raise ValidationError({
"rear_port_position": _(
"Invalid rear port position ({rear_port_position}): Rear port {name} has only {positions} "
"positions."
).format(
rear_port_position=self.rear_port_position,
name=self.rear_port.name,
positions=self.rear_port.positions
)
}) })
@@ -1208,11 +1157,11 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
verbose_name=_('positions'), verbose_name=_('positions'),
default=1, default=1,
validators=[ validators=[
MinValueValidator(PORT_POSITION_MIN), MinValueValidator(REARPORT_POSITIONS_MIN),
MaxValueValidator(PORT_POSITION_MAX) MaxValueValidator(REARPORT_POSITIONS_MAX)
], ],
help_text=_('Number of front ports which may be mapped')
) )
clone_fields = ('device', 'type', 'color', 'positions') clone_fields = ('device', 'type', 'color', 'positions')
class Meta(ModularComponentModel.Meta): class Meta(ModularComponentModel.Meta):
@@ -1224,13 +1173,13 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
# Check that positions count is greater than or equal to the number of associated FrontPorts # Check that positions count is greater than or equal to the number of associated FrontPorts
if not self._state.adding: if not self._state.adding:
mapping_count = self.mappings.count() frontport_count = self.frontports.count()
if self.positions < mapping_count: if self.positions < frontport_count:
raise ValidationError({ raise ValidationError({
"positions": _( "positions": _(
"The number of positions cannot be less than the number of mapped front ports " "The number of positions cannot be less than the number of mapped front ports "
"({count})" "({frontport_count})"
).format(count=mapping_count) ).format(frontport_count=frontport_count)
}) })
@@ -1292,8 +1241,6 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.module: if self.module:
self.parent = self.module.module_bay self.parent = self.module.module_bay
else:
self.parent = None
super().save(*args, **kwargs) super().save(*args, **kwargs)

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