mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-06 17:09:33 +01:00
Compare commits
206 Commits
v4.5-beta1
...
v4.5.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de1c5120dd | ||
|
|
87d2e02c85 | ||
|
|
cbbc4f74b8 | ||
|
|
be5bd74d4e | ||
|
|
cf12bb5bf5 | ||
|
|
c060eef1d8 | ||
|
|
96f0debe6e | ||
|
|
b26c7f34cd | ||
|
|
d6428c6aa4 | ||
|
|
e3eca98897 | ||
|
|
cdc735fe41 | ||
|
|
aa4a9da955 | ||
|
|
5c6fc2fb6f | ||
|
|
ad29cb2d66 | ||
|
|
bec5ecf6a9 | ||
|
|
c98f55dbd2 | ||
|
|
dfe20532a1 | ||
|
|
359179fd4a | ||
|
|
c44e8606f7 | ||
|
|
8e620ef325 | ||
|
|
1526e437f1 | ||
|
|
0b507eb207 | ||
|
|
5a36e79215 | ||
|
|
2a0f26623b | ||
|
|
43ae52089f | ||
|
|
1a603981b2 | ||
|
|
245495b2fe | ||
|
|
8d3eb69055 | ||
|
|
7e3b60f194 | ||
|
|
5338c842b8 | ||
|
|
9186b0edaa | ||
|
|
d883be9e56 | ||
|
|
6fc7fa6c64 | ||
|
|
3a33df0e43 | ||
|
|
433f46746e | ||
|
|
8f5f91fcfe | ||
|
|
1a2175127e | ||
|
|
e859807d1d | ||
|
|
a8c997ff29 | ||
|
|
4a28ab98f4 | ||
|
|
3636d55017 | ||
|
|
aa69e96818 | ||
|
|
1745d2ae93 | ||
|
|
e097a848dc | ||
|
|
595be6dcd4 | ||
|
|
a9e50238eb | ||
|
|
a9a300197a | ||
|
|
3dcca73ecc | ||
|
|
cedbeb7b19 | ||
|
|
a45b6b170d | ||
|
|
4b4c542dce | ||
|
|
077d9b1129 | ||
|
|
e81ccb9be6 | ||
|
|
bc83d04c8f | ||
|
|
42ecf3cac0 | ||
|
|
339ad455e4 | ||
|
|
af8e53d8fb | ||
|
|
f24376cfab | ||
|
|
47d4ae29c1 | ||
|
|
8fce672682 | ||
|
|
f776b97415 | ||
|
|
3cc1f30287 | ||
|
|
6d166aa10d | ||
|
|
040a2ae9a9 | ||
|
|
39f11f28fb | ||
|
|
62b9025a9e | ||
|
|
21091f22e6 | ||
|
|
3efa23cf8f | ||
|
|
0f62137957 | ||
|
|
7858ccb712 | ||
|
|
6b7b38ee0a | ||
|
|
c8f17e06a2 | ||
|
|
edace6aff4 | ||
|
|
586bc132b6 | ||
|
|
52a2b934a0 | ||
|
|
3d1f18d6dd | ||
|
|
3e2a26984f | ||
|
|
f5f0c19860 | ||
|
|
8da9b11ab8 | ||
|
|
ca67fa9999 | ||
|
|
eff768192e | ||
|
|
1e297d55ee | ||
|
|
fdb987ef91 | ||
|
|
b5a23db43c | ||
|
|
366b69aff7 | ||
|
|
c3e8c5e69c | ||
|
|
b55f36469d | ||
|
|
1c46215cd5 | ||
|
|
7fded2fd87 | ||
|
|
0ddc5805c4 | ||
|
|
c1bbc026e2 | ||
|
|
8cbfe94fba | ||
|
|
434334d927 | ||
|
|
fff99fd3ff | ||
|
|
6bd083b7ed | ||
|
|
f38faf2e01 | ||
|
|
f4892caa51 | ||
|
|
e60807adc5 | ||
|
|
e14934e5a5 | ||
|
|
ae03723e43 | ||
|
|
c0f79df91f | ||
|
|
edbfd0bae6 | ||
|
|
c3e111c769 | ||
|
|
c11f4b3716 | ||
|
|
a54ad24b47 | ||
|
|
3624b88c3f | ||
|
|
f54ed8bb7f | ||
|
|
5d0609e729 | ||
|
|
865b88e724 | ||
|
|
e73db97d46 | ||
|
|
6f2ba5c75c | ||
|
|
fa8a9ef9de | ||
|
|
6beb079b97 | ||
|
|
bad688b8aa | ||
|
|
c8aad24a1b | ||
|
|
42bd876604 | ||
|
|
f903442cb9 | ||
|
|
5a64cb712d | ||
|
|
4d90d559be | ||
|
|
19de058f94 | ||
|
|
d3e4c02807 | ||
|
|
dc00e19c3c | ||
|
|
6ed6da49d9 | ||
|
|
7154d4ae2e | ||
|
|
bc26529be8 | ||
|
|
da64c564ae | ||
|
|
6199b3e039 | ||
|
|
ebada4bf72 | ||
|
|
2a391253a5 | ||
|
|
914653d63e | ||
|
|
3813aad8b1 | ||
|
|
ea5371040e | ||
|
|
6c824cc48f | ||
|
|
c78b8401dc | ||
|
|
f510e40428 | ||
|
|
860db9590b | ||
|
|
7c63d001b1 | ||
|
|
93119f52c3 | ||
|
|
ee2aa35cba | ||
|
|
edf35e35be | ||
|
|
7896a48075 | ||
|
|
eb87c3f304 | ||
|
|
062a871521 | ||
|
|
3acbb0a08c | ||
|
|
f67cc47def | ||
|
|
f7219e0672 | ||
|
|
e5a975176d | ||
|
|
07d8157ccd | ||
|
|
83ee4fb593 | ||
|
|
db8271c904 | ||
|
|
712c743bcb | ||
|
|
2eb42d4907 | ||
|
|
5a24f99c9d | ||
|
|
9318c91405 | ||
|
|
5c6aaf2388 | ||
|
|
265f375595 | ||
|
|
a28269b73a | ||
|
|
d95fa8dbb2 | ||
|
|
2699149016 | ||
|
|
f371004809 | ||
|
|
44e731a40a | ||
|
|
a364ee832d | ||
|
|
875e3e7979 | ||
|
|
ad29402b87 | ||
|
|
598f8d034d | ||
|
|
ec13a79907 | ||
|
|
21f4036782 | ||
|
|
ce3738572c | ||
|
|
cbb979934e | ||
|
|
642d83a4c6 | ||
|
|
3140060f21 | ||
|
|
607a385a12 | ||
|
|
834da4e6cd | ||
|
|
a06c12c6b8 | ||
|
|
60fce84c96 | ||
|
|
8719fd4a54 | ||
|
|
59afa0b41d | ||
|
|
14b246cb8a | ||
|
|
f0507d00bf | ||
|
|
77b389f105 | ||
|
|
f56015e03d | ||
|
|
dc09ec3025 | ||
|
|
4e0265a001 | ||
|
|
113c8b7ae6 | ||
|
|
17d8f78ae3 | ||
|
|
97d0a16fd4 | ||
|
|
174b2d5f39 | ||
|
|
970f2bd4ed | ||
|
|
a4ee323cb6 | ||
|
|
17e5184a11 | ||
|
|
e1548bb290 | ||
|
|
27ffc3df6a | ||
|
|
7bf84eb400 | ||
|
|
e910d461ea | ||
|
|
269112a565 | ||
|
|
c6672538ac | ||
|
|
9ae53fc232 | ||
|
|
6efb258b9f | ||
|
|
da1e0f4b53 | ||
|
|
7f39f75d3d | ||
|
|
ebf8f7fa1b | ||
|
|
922b08c0ff | ||
|
|
84864fa5e1 | ||
|
|
767dfccd8f | ||
|
|
dc4bab7477 | ||
|
|
60aa952eb1 |
@@ -15,7 +15,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.4.7
|
||||
placeholder: v4.5.2
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
@@ -27,7 +27,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.4.7
|
||||
placeholder: v4.5.2
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
43
.github/ISSUE_TEMPLATE/03-performance.yaml
vendored
Normal file
43
.github/ISSUE_TEMPLATE/03-performance.yaml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
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.2
|
||||
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
.github/ISSUE_TEMPLATE/06-deprecation.yaml
vendored
25
.github/ISSUE_TEMPLATE/06-deprecation.yaml
vendored
@@ -1,25 +0,0 @@
|
||||
---
|
||||
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
.github/ISSUE_TEMPLATE/07-deprecation.yaml
vendored
Normal file
31
.github/ISSUE_TEMPLATE/07-deprecation.yaml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
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
.github/ISSUE_TEMPLATE/08-feature_removal.yaml
vendored
Normal file
20
.github/ISSUE_TEMPLATE/08-feature_removal.yaml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
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
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
NETBOX_CONFIGURATION: netbox.configuration_testing
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.12', '3.13']
|
||||
python-version: ['3.12', '3.13', '3.14']
|
||||
node-version: ['20.x']
|
||||
services:
|
||||
redis:
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -30,13 +30,13 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
config-file: .github/codeql/codeql-config.yml
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.11
|
||||
python-version: 3.12
|
||||
|
||||
- name: Install system dependencies
|
||||
run: sudo apt install -y gettext
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,7 +9,8 @@ yarn-error.log*
|
||||
/netbox/netbox/configuration.py
|
||||
/netbox/netbox/ldap_config.py
|
||||
/netbox/local/*
|
||||
/netbox/media
|
||||
/netbox/media/*
|
||||
!/netbox/media/.gitkeep
|
||||
/netbox/reports/*
|
||||
!/netbox/reports/__init__.py
|
||||
/netbox/scripts/*
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<a href="https://github.com/netbox-community/netbox/blob/main/LICENSE.txt"><img src="https://img.shields.io/badge/license-Apache_2.0-blue.svg" alt="License" /></a>
|
||||
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
|
||||
<a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
|
||||
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-15-blue" alt="Languages supported" /></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://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/actions/workflows/ci.yml/badge.svg" alt="CI status" /></a>
|
||||
<p>
|
||||
<strong><a href="https://netboxlabs.com/community/">NetBox Community</a></strong> |
|
||||
|
||||
@@ -85,7 +85,7 @@ drf-spectacular-sidecar
|
||||
feedparser
|
||||
|
||||
# WSGI HTTP server
|
||||
# https://docs.gunicorn.org/en/latest/news.html
|
||||
# https://gunicorn.org/news/
|
||||
gunicorn
|
||||
|
||||
# Platform-agnostic template rendering engine
|
||||
|
||||
15770
contrib/openapi.json
15770
contrib/openapi.json
File diff suppressed because it is too large
Load Diff
@@ -3,29 +3,41 @@
|
||||
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:
|
||||
|
||||
```
|
||||
./manage.py nbshell
|
||||
cd /opt/netbox
|
||||
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 pre-loaded. (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 preloaded. (If desired, the stock Django shell is also available by executing `./manage.py shell`.)
|
||||
|
||||
```
|
||||
$ ./manage.py nbshell
|
||||
(venv) $ python3 netbox/manage.py nbshell
|
||||
### NetBox interactive shell (localhost)
|
||||
### Python 3.7.10 | Django 3.2.5 | NetBox 3.0
|
||||
### lsmodels() will show available models. Use help(<model>) for more info.
|
||||
### Python v3.12.3 | Django v5.2.10 | NetBox Community v4.5.1
|
||||
### lsapps() & lsmodels() will show available models. Use help(<model>) for more info.
|
||||
```
|
||||
|
||||
The function `lsmodels()` will print a list of all available NetBox models:
|
||||
|
||||
```
|
||||
>>> lsmodels()
|
||||
DCIM:
|
||||
ConsolePort
|
||||
ConsolePortTemplate
|
||||
ConsoleServerPort
|
||||
ConsoleServerPortTemplate
|
||||
Device
|
||||
...
|
||||
DCIM:
|
||||
dcim.Cable
|
||||
dcim.CableTermination
|
||||
dcim.ConsolePort
|
||||
dcim.ConsolePortTemplate
|
||||
dcim.ConsoleServerPort
|
||||
dcim.ConsoleServerPortTemplate
|
||||
dcim.Device
|
||||
...
|
||||
```
|
||||
|
||||
To exit the NetBox shell, type `exit()` or press `Ctrl+D`.
|
||||
|
||||
```
|
||||
>>> exit()
|
||||
(venv) $
|
||||
```
|
||||
|
||||
!!! warning
|
||||
@@ -114,7 +126,7 @@ Reverse relationships can be traversed as well. For example, the following will
|
||||
>>> Device.objects.filter(interfaces__name="em0")
|
||||
```
|
||||
|
||||
Character fields can be filtered against partial matches using the `contains` or `icontains` field lookup (the later of which is case-insensitive).
|
||||
Character fields can be filtered against partial matches using the `contains` or `icontains` field lookup (the latter of which is case-insensitive).
|
||||
|
||||
```
|
||||
>>> Device.objects.filter(name__icontains="testdevice")
|
||||
|
||||
@@ -88,7 +88,7 @@ While permissions are typically assigned to specific groups and/or users, it is
|
||||
|
||||
### Viewing Objects
|
||||
|
||||
Object-based permissions work by filtering the database query generated by a user's request to restrict the set of objects returned. When a request is received, NetBox first determines whether the user is authenticated and has been granted 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 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.
|
||||
|
||||
If the permission _has_ been granted, NetBox will compile any specified constraints for the model and action. For example, suppose two permissions have been assigned to the user granting view access to the device model, with the following constraints:
|
||||
|
||||
@@ -102,9 +102,9 @@ If the permission _has_ been granted, NetBox will compile any specified constrai
|
||||
This grants the user access to view any device that is assigned to a site named NYC1 or NYC2, **or** which has a status of "offline" and has no tenant assigned. These constraints are equivalent to the following ORM query:
|
||||
|
||||
```no-highlight
|
||||
Site.objects.filter(
|
||||
Device.objects.filter(
|
||||
Q(site__name__in=['NYC1', 'NYC2']),
|
||||
Q(status='active', tenant__isnull=True)
|
||||
Q(status='offline', tenant__isnull=True)
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ This is a mapping of models to [custom validators](../customization/custom-valid
|
||||
|
||||
```python
|
||||
CUSTOM_VALIDATORS = {
|
||||
"dcim.site": [
|
||||
"dcim.Site": [
|
||||
{
|
||||
"name": {
|
||||
"min_length": 5,
|
||||
@@ -17,12 +17,15 @@ CUSTOM_VALIDATORS = {
|
||||
},
|
||||
"my_plugin.validators.Validator1"
|
||||
],
|
||||
"dcim.device": [
|
||||
"dcim.Device": [
|
||||
"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
|
||||
@@ -53,6 +56,9 @@ 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:
|
||||
|
||||
* `circuits.Circuit.status`
|
||||
@@ -98,7 +104,7 @@ This is a mapping of models to [custom validators](../customization/custom-valid
|
||||
|
||||
```python
|
||||
PROTECTION_RULES = {
|
||||
"dcim.site": [
|
||||
"dcim.Site": [
|
||||
{
|
||||
"status": {
|
||||
"eq": "decommissioning"
|
||||
@@ -108,3 +114,6 @@ PROTECTION_RULES = {
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
!!! info "Case-Insensitive Model Names"
|
||||
Model identifiers are case-insensitive. Both `dcim.site` and `dcim.Site` are valid and equivalent.
|
||||
|
||||
@@ -15,7 +15,7 @@ Some configuration parameters may alternatively be defined either in `configurat
|
||||
|
||||
## Dynamic Configuration Parameters
|
||||
|
||||
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:
|
||||
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:
|
||||
|
||||
* [`ALLOWED_URL_SCHEMES`](./security.md#allowed_url_schemes)
|
||||
* [`BANNER_BOTTOM`](./miscellaneous.md#banner_bottom)
|
||||
|
||||
@@ -12,7 +12,7 @@ Depending on its classification, each NetBox model may support various features
|
||||
|
||||
| Feature | Feature Mixin | Registry Key | Description |
|
||||
|------------------------------------------------------------|-------------------------|---------------------|-----------------------------------------------------------------------------------------|
|
||||
| [Bookmarks](../features/customization.md#bookmarks) | `BookmarksMixin` | `bookmarks` | These models can be bookmarked natively in the user interface |
|
||||
| [Bookmarks](../features/user-preferences.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 |
|
||||
| Cloning | `CloningMixin` | `cloning` | Provides the `clone()` method to prepare a copy |
|
||||
| [Contacts](../features/contacts.md) | `ContactsMixin` | `contacts` | Contacts can be associated with these models |
|
||||
|
||||
@@ -144,7 +144,7 @@ Then, compile these portable (`.po`) files for use in the application:
|
||||
|
||||
* Update the version number and published date in `netbox/release.yaml`. Add or remove the designation (e.g. `beta1`) if applicable.
|
||||
* Copy the version number from `release.yaml` to `pyproject.toml` in the project root.
|
||||
* Update the example version numbers in the feature request and bug report templates under `.github/ISSUE_TEMPLATES/`.
|
||||
* Update the example version numbers in the feature request, bug report, and performance templates under `.github/ISSUE_TEMPLATES/`.
|
||||
* Add a section for this release at the top of the changelog page for the minor version (e.g. `docs/release-notes/version-4.2.md`) listing all relevant changes made in this release.
|
||||
|
||||
!!! tip
|
||||
|
||||
@@ -10,9 +10,11 @@ Change records are exposed in the API via the read-only endpoint `/api/extras/ob
|
||||
|
||||
## User Messages
|
||||
|
||||
!!! info "This feature was introduced in NetBox v4.4."
|
||||
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.
|
||||
|
||||
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.
|
||||
When editing an object via the web UI, the "Changelog message" field appears at the bottom of the form. This field is optional. The changelog message field is available in object create forms, object edit forms, delete confirmation dialogs, and bulk operations.
|
||||
|
||||
For information on including changelog messages when making changes via the REST API, see [Changelog Messages](../integrations/rest-api.md#changelog-messages).
|
||||
|
||||
## Correlating Changes by Request
|
||||
|
||||
|
||||
@@ -51,14 +51,14 @@ You can verify that authentication works by executing the `psql` command and pas
|
||||
|
||||
```no-highlight
|
||||
$ psql --username netbox --password --host localhost netbox
|
||||
Password for user netbox:
|
||||
psql (12.5 (Ubuntu 12.5-0ubuntu0.20.04.1))
|
||||
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
|
||||
Password:
|
||||
psql (16.11 (Ubuntu 16.11-0ubuntu0.24.04.1))
|
||||
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
|
||||
Type "help" for help.
|
||||
|
||||
netbox=> \conninfo
|
||||
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, bits: 256, compression: off)
|
||||
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
|
||||
netbox=> \q
|
||||
```
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ sudo ln -s /opt/netbox-X.Y.Z/ /opt/netbox
|
||||
```
|
||||
|
||||
!!! note
|
||||
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.
|
||||
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.
|
||||
|
||||
### Option B: Clone the Git Repository
|
||||
|
||||
@@ -63,12 +63,12 @@ This command should generate output similar to the following:
|
||||
|
||||
```
|
||||
Cloning into '.'...
|
||||
remote: Enumerating objects: 996, done.
|
||||
remote: Counting objects: 100% (996/996), done.
|
||||
remote: Compressing objects: 100% (935/935), done.
|
||||
remote: Total 996 (delta 148), reused 386 (delta 34), pack-reused 0
|
||||
Receiving objects: 100% (996/996), 4.26 MiB | 9.81 MiB/s, done.
|
||||
Resolving deltas: 100% (148/148), done.
|
||||
remote: Enumerating objects: 148317, done.
|
||||
remote: Counting objects: 100% (183/183), done.
|
||||
remote: Compressing objects: 100% (115/115), done.
|
||||
remote: Total 148317 (delta 127), reused 68 (delta 68), pack-reused 148134 (from 3)
|
||||
Receiving objects: 100% (148317/148317), 165.12 MiB | 28.71 MiB/s, done.
|
||||
Resolving deltas: 100% (116428/116428), 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.
|
||||
@@ -102,7 +102,8 @@ 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:
|
||||
|
||||
* `ALLOWED_HOSTS`
|
||||
* `DATABASES` (or `DATABASE`)
|
||||
* `API_TOKEN_PEPPERS`
|
||||
* `DATABASES`
|
||||
* `REDIS`
|
||||
* `SECRET_KEY`
|
||||
|
||||
@@ -158,7 +159,7 @@ DATABASES = {
|
||||
|
||||
### REDIS
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -252,7 +253,7 @@ Once NetBox has been configured, we're ready to proceed with the actual installa
|
||||
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
|
||||
sudo PYTHON=/usr/bin/python3.12 /opt/netbox/upgrade.sh
|
||||
@@ -295,13 +296,12 @@ python3 manage.py runserver 0.0.0.0:8000 --insecure
|
||||
If successful, you should see output similar to the following:
|
||||
|
||||
```no-highlight
|
||||
Watching for file changes with StatReloader
|
||||
Performing system checks...
|
||||
|
||||
System check identified no issues (0 silenced).
|
||||
August 30, 2021 - 18:02:23
|
||||
Django version 3.2.6, using settings 'netbox.settings'
|
||||
Starting development server at http://127.0.0.1:8000/
|
||||
January 26, 2026 - 17:00:00
|
||||
Django version 5.2.10, using settings 'netbox.settings'
|
||||
Starting development server at http://0.0.0.0:8000/
|
||||
Quit the server with CONTROL-C.
|
||||
```
|
||||
|
||||
|
||||
@@ -43,16 +43,22 @@ You should see output similar to the following:
|
||||
|
||||
```no-highlight
|
||||
● netbox.service - NetBox WSGI Service
|
||||
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled)
|
||||
Active: active (running) since Mon 2021-08-30 04:02:36 UTC; 14h ago
|
||||
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; preset: enabled)
|
||||
Active: active (running) since Mon 2026-01-26 11:00:00 CST; 7s ago
|
||||
Docs: https://docs.netbox.dev/
|
||||
Main PID: 1140492 (gunicorn)
|
||||
Tasks: 19 (limit: 4683)
|
||||
Memory: 666.2M
|
||||
Main PID: 7283 (gunicorn)
|
||||
Tasks: 6 (limit: 4545)
|
||||
Memory: 556.1M (peak: 556.3M)
|
||||
CPU: 3.387s
|
||||
CGroup: /system.slice/netbox.service
|
||||
├─1140492 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /va>
|
||||
├─1140513 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /va>
|
||||
├─1140514 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /va>
|
||||
├─7283 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
|
||||
├─7285 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
|
||||
├─7286 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox>
|
||||
├─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.
|
||||
...
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
!!! info
|
||||
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.
|
||||
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.
|
||||
|
||||
## Obtain an SSL Certificate
|
||||
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
|
||||
</div>
|
||||
|
||||
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 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 following sections detail how to set up a new instance of NetBox:
|
||||
|
||||
1. [PostgreSQL database](1-postgresql.md)
|
||||
1. [Redis](2-redis.md)
|
||||
2. [Redis](2-redis.md)
|
||||
3. [NetBox components](3-netbox.md)
|
||||
4. [Gunicorn](4a-gunicorn.md) or [uWSGI](4b-uwsgi.md)
|
||||
5. [HTTP server](5-http-server.md)
|
||||
|
||||
@@ -65,7 +65,7 @@ Download and extract the latest version:
|
||||
|
||||
```no-highlight
|
||||
# Set $NEWVER to the NetBox version being installed
|
||||
NEWVER=3.5.0
|
||||
NEWVER=4.5.0
|
||||
wget https://github.com/netbox-community/netbox/archive/v$NEWVER.tar.gz
|
||||
sudo tar -xzf v$NEWVER.tar.gz -C /opt
|
||||
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
|
||||
# Set $OLDVER to the NetBox version currently installed
|
||||
OLDVER=3.4.9
|
||||
OLDVER=4.4.10
|
||||
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/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 && \
|
||||
sudo git fetch --tags && \
|
||||
sudo git checkout v4.2.7
|
||||
sudo git checkout v4.5.0
|
||||
```
|
||||
|
||||
## 4. Run the Upgrade Script
|
||||
@@ -128,7 +128,7 @@ sudo ./upgrade.sh
|
||||
```
|
||||
|
||||
!!! warning
|
||||
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:
|
||||
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:
|
||||
|
||||
```no-highlight
|
||||
sudo PYTHON=/usr/bin/python3.12 ./upgrade.sh
|
||||
|
||||
@@ -133,23 +133,67 @@ The field "class_type" is an easy way to distinguish what type of object it is w
|
||||
|
||||
## Pagination
|
||||
|
||||
Queries can be paginated by specifying pagination in the query and supplying an offset and optionaly a limit in the query. If no limit is given, a default of 100 is used. Queries are not paginated unless requested in the query. An example paginated query is shown below:
|
||||
The GraphQL API supports two types of pagination. Offset-based pagination operates using an offset relative to the first record in a set, specified by the `offset` parameter. For example, the response to a request specifying an offset of 100 will contain the 101st and later matching records. Offset-based pagination feels very natural, but its performance can suffer when dealing with large data sets due to the overhead involved in calculating the relative offset.
|
||||
|
||||
The alternative approach is cursor-based pagination, which operates using absolute (rather than relative) primary key values. (These are the numeric IDs assigned to each object in the database.) When using cursor-based pagination, the response will contain records with a primary key greater than or equal to the specified start value, up to the maximum number of results. This strategy requires keeping track of the last seen primary key from each response when paginating through data, but is extremely performant. The cursor is specified by passing the starting object ID via the `start` parameter.
|
||||
|
||||
To ensure consistent ordering, objects will always be ordered by their primary keys when cursor-based pagination is used.
|
||||
|
||||
!!! note "Cursor-based pagination was introduced in NetBox v4.5.2."
|
||||
|
||||
Both pagination strategies support passing an optional `limit` parameter. In both approaches, this specifies the maximum number of objects to include in the response. If no limit is specified, a default value of 100 is used.
|
||||
|
||||
### Offset Pagination
|
||||
|
||||
The first page will have an `offset` of zero, or the `offset` parameter will be omitted:
|
||||
|
||||
```
|
||||
query {
|
||||
device_list(pagination: { offset: 0, limit: 20 }) {
|
||||
device_list(pagination: {offset: 0, limit: 20}) {
|
||||
id
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The second page will have an offset equal to the size of the first page. If the number of records is less than the specified limit, there are no more records to process. For example, if a request specifies a `limit` of 20 but returns only 13 records, we can conclude that this is the final page of records.
|
||||
|
||||
```
|
||||
query {
|
||||
device_list(pagination: {offset: 20, limit: 20}) {
|
||||
id
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cursor Pagination
|
||||
|
||||
Set the `start` value to zero to fetch the first page. Note that if the `start` parameter is omitted, offset-based pagination will be used by default.
|
||||
|
||||
```
|
||||
query {
|
||||
device_list(pagination: {start: 0, limit: 20}) {
|
||||
id
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To determine the `start` value for the next page, add 1 to the primary key (`id`) of the last record in the previous page.
|
||||
|
||||
For example, if the ID of the last record in the previous response was 123, we would specify a `start` value of 124:
|
||||
|
||||
```
|
||||
query {
|
||||
device_list(pagination: {start: 124, limit: 20}) {
|
||||
id
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This will return up to 20 records with an ID greater than or equal to 124.
|
||||
|
||||
## Authentication
|
||||
|
||||
NetBox's GraphQL API uses the same API authentication tokens as its REST API. Authentication tokens are included with requests by attaching an `Authorization` HTTP header in the following form:
|
||||
|
||||
```
|
||||
Authorization: Token $TOKEN
|
||||
```
|
||||
NetBox's GraphQL API uses the same API authentication tokens as its REST API. See the [REST API authentication](./rest-api.md#authentication) documentation for further detail.
|
||||
|
||||
## Disabling the GraphQL API
|
||||
|
||||
|
||||
@@ -215,9 +215,51 @@ 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.
|
||||
|
||||
### Brief Format
|
||||
### Specifying Fields
|
||||
|
||||
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:
|
||||
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.
|
||||
|
||||
```
|
||||
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
|
||||
GET /api/ipam/prefixes/13980/
|
||||
@@ -270,10 +312,10 @@ GET /api/ipam/prefixes/13980/
|
||||
}
|
||||
```
|
||||
|
||||
The brief format is much more terse:
|
||||
The brief format includes only a few fields:
|
||||
|
||||
```no-highlight
|
||||
GET /api/ipam/prefixes/13980/?brief=1
|
||||
GET /api/ipam/prefixes/13980/?brief=true
|
||||
```
|
||||
|
||||
```json
|
||||
@@ -610,9 +652,7 @@ http://netbox/api/dcim/sites/ \
|
||||
|
||||
## Changelog Messages
|
||||
|
||||
!!! 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.
|
||||
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.
|
||||
|
||||
For example, the following API request will create a new site and record a message in the resulting changelog entry:
|
||||
|
||||
@@ -628,7 +668,7 @@ http://netbox/api/dcim/sites/ \
|
||||
}'
|
||||
```
|
||||
|
||||
This approach works when creating, modifying, or deleting objects, either individually or in bulk.
|
||||
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).
|
||||
|
||||
## Uploading Files
|
||||
|
||||
|
||||
@@ -32,6 +32,14 @@ class MyFilterSet(NetBoxModelFilterSet):
|
||||
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
|
||||
|
||||
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:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Form Classes
|
||||
|
||||
NetBox provides several base form classes for use by plugins.
|
||||
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).
|
||||
|
||||
| Form Class | Purpose |
|
||||
|----------------------------|--------------------------------------|
|
||||
@@ -19,7 +19,17 @@ 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) |
|
||||
|
||||
**Example**
|
||||
#### Subclasses
|
||||
|
||||
The corresponding model-specific subclasses of `NetBoxModelForm` are documented below.
|
||||
|
||||
| Model Class | Form Class |
|
||||
|-----------------------|---------------------------|
|
||||
| `PrimaryModel` | `PrimaryModelForm` |
|
||||
| `OrganizationalModel` | `OrganizationalModelForm` |
|
||||
| `NestedGroupModel` | `NestedGroupModelForm` |
|
||||
|
||||
#### Example
|
||||
|
||||
```python
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -49,9 +59,19 @@ class MyModelForm(NetBoxModelForm):
|
||||
|
||||
### `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 import various types of CSV data, listed below.
|
||||
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).
|
||||
|
||||
**Example**
|
||||
#### Subclasses
|
||||
|
||||
The corresponding model-specific subclasses of `NetBoxModelImportForm` are documented below.
|
||||
|
||||
| Model Class | Form Class |
|
||||
|-----------------------|---------------------------------|
|
||||
| `PrimaryModel` | `PrimaryModelImportForm` |
|
||||
| `OrganizationalModel` | `OrganizationalModelImportForm` |
|
||||
| `NestedGroupModel` | `NestedGroupModelImportForm` |
|
||||
|
||||
#### Example
|
||||
|
||||
```python
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -83,7 +103,17 @@ 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) |
|
||||
| `nullable_fields` | A tuple of fields which can be nullified (set to empty) using the bulk edit form (optional) |
|
||||
|
||||
**Example**
|
||||
#### Subclasses
|
||||
|
||||
The corresponding model-specific subclasses of `NetBoxModelBulkEditForm` are documented below.
|
||||
|
||||
| Model Class | Form Class |
|
||||
|-----------------------|-----------------------------------|
|
||||
| `PrimaryModel` | `PrimaryModelBulkEditForm` |
|
||||
| `OrganizationalModel` | `OrganizationalModelBulkEditForm` |
|
||||
| `NestedGroupModel` | `NestedGroupModelBulkEditForm` |
|
||||
|
||||
#### Example
|
||||
|
||||
```python
|
||||
from django import forms
|
||||
@@ -125,7 +155,17 @@ This form class is used to render a form expressly for filtering a list of objec
|
||||
| `model` | The model of object being edited |
|
||||
| `fieldsets` | A tuple of `FieldSet` instances which control how form fields are rendered (optional) |
|
||||
|
||||
**Example**
|
||||
#### Subclasses
|
||||
|
||||
The corresponding model-specific subclasses of `NetBoxModelFilterSetForm` are documented below.
|
||||
|
||||
| Model Class | Form Class |
|
||||
|-----------------------|------------------------------------|
|
||||
| `PrimaryModel` | `PrimaryModelFilterSetForm` |
|
||||
| `OrganizationalModel` | `OrganizationalModelFilterSetForm` |
|
||||
| `NestedGroupModel` | `NestedGroupModelFilterSetForm` |
|
||||
|
||||
#### Example
|
||||
|
||||
```python
|
||||
from dcim.models import Site
|
||||
|
||||
@@ -46,3 +46,19 @@ NetBox provides two object type classes for use by plugins.
|
||||
::: netbox.graphql.types.NetBoxObjectType
|
||||
options:
|
||||
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` |
|
||||
|
||||
@@ -74,7 +74,7 @@ The plugin source directory contains all the actual Python code and other resour
|
||||
|
||||
The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below:
|
||||
|
||||
```python
|
||||
```python title="__init__.py"
|
||||
from netbox.plugins import PluginConfig
|
||||
|
||||
class FooBarConfig(PluginConfig):
|
||||
@@ -151,7 +151,7 @@ Any additional apps must be installed within the same Python environment as NetB
|
||||
|
||||
An example `pyproject.toml` is below:
|
||||
|
||||
```
|
||||
```toml title="pyproject.toml"
|
||||
# See PEP 518 for the spec of this file
|
||||
# https://www.python.org/dev/peps/pep-0518/
|
||||
|
||||
@@ -179,11 +179,24 @@ classifiers=[
|
||||
]
|
||||
|
||||
requires-python = ">=3.12.0"
|
||||
|
||||
```
|
||||
|
||||
Many of these are self-explanatory, but for more information, see the [pyproject.toml documentation](https://packaging.python.org/en/latest/specifications/pyproject-toml/).
|
||||
|
||||
## Compatibility Matrix
|
||||
|
||||
Consider adding a file named `COMPATIBILITY.md` to your plugin project root (alongside `pyproject.toml`). This file should contain a table listing the minimum and maximum supported versions of NetBox (`min_version` and `max_version`) for each release. This serves as a handy reference for users who are upgrading from a previous version of your plugin. An example is shown below:
|
||||
|
||||
```markdown title="COMPATIBILITY.md"
|
||||
# Compatibility Matrix
|
||||
|
||||
| Release | Minimum NetBox Version | Maximum NetBox Version |
|
||||
|---------|------------------------|------------------------|
|
||||
| 0.2.0 | 4.4.0 | 4.5.x |
|
||||
| 0.1.1 | 4.3.0 | 4.4.x |
|
||||
| 0.1.0 | 4.3.0 | 4.4.x |
|
||||
```
|
||||
|
||||
## Create a Virtual Environment
|
||||
|
||||
It is strongly recommended to create a Python [virtual environment](https://docs.python.org/3/tutorial/venv.html) for the development of your plugin, as opposed to using system-wide packages. This will afford you complete control over the installed versions of all dependencies and avoid conflict with system packages. This environment can live wherever you'd like;however, it should be excluded from revision control. (A popular convention is to keep all virtual environments in the user's home directory, e.g. `~/.virtualenvs/`.)
|
||||
|
||||
@@ -67,6 +67,46 @@ 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
|
||||
|
||||
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.)
|
||||
|
||||
@@ -27,6 +27,14 @@ 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.)
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
@@ -36,6 +36,14 @@ class MyModelTable(NetBoxTable):
|
||||
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
|
||||
|
||||
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:
|
||||
|
||||
@@ -10,6 +10,14 @@ Minor releases are published in April, August, and December of each calendar yea
|
||||
|
||||
This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
|
||||
|
||||
#### [Version 4.5](./version-4.5.md) (January 2026)
|
||||
|
||||
* Lookup Modifiers in Filter Forms ([#7604](https://github.com/netbox-community/netbox/issues/7604))
|
||||
* Improved API Authentication Tokens ([#20210](https://github.com/netbox-community/netbox/issues/20210))
|
||||
* Object Ownership ([#20304](https://github.com/netbox-community/netbox/issues/20304))
|
||||
* Advanced Port Mappings ([#20564](https://github.com/netbox-community/netbox/issues/20564))
|
||||
* Cable Profiles ([#20788](https://github.com/netbox-community/netbox/issues/20788))
|
||||
|
||||
#### [Version 4.4](./version-4.4.md) (September 2025)
|
||||
|
||||
* Background Jobs for Bulk Operations ([#19589](https://github.com/netbox-community/netbox/issues/19589), [#19891](https://github.com/netbox-community/netbox/issues/19891))
|
||||
|
||||
@@ -1,5 +1,69 @@
|
||||
# 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)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -1,37 +1,151 @@
|
||||
## v4.5.0 (FUTURE)
|
||||
# NetBox v4.5
|
||||
|
||||
## v4.5.2 (2026-02-03)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#15801](https://github.com/netbox-community/netbox/issues/15801) - Add link peer and connection columns to the VLAN device interfaces table
|
||||
* [#19221](https://github.com/netbox-community/netbox/issues/19221) - Truncate long image attachment filenames in the UI
|
||||
* [#19869](https://github.com/netbox-community/netbox/issues/19869) - Display peer connections for LAG member interfaces
|
||||
* [#20052](https://github.com/netbox-community/netbox/issues/20052) - Increase logging level of error message when a custom script fails to load
|
||||
* [#20172](https://github.com/netbox-community/netbox/issues/20172) - Add `cabled` filter for interfaces in GraphQL API
|
||||
* [#21081](https://github.com/netbox-community/netbox/issues/21081) - Add owner group table columns & filters across all supported object list views
|
||||
* [#21088](https://github.com/netbox-community/netbox/issues/21088) - Add max depth and max length dropdowns for child prefix views
|
||||
* [#21110](https://github.com/netbox-community/netbox/issues/21110) - Support cursor-based pagination in GraphQL API
|
||||
* [#21201](https://github.com/netbox-community/netbox/issues/21201) - Pre-populate GenericForeignKey form fields when cloning
|
||||
* [#21209](https://github.com/netbox-community/netbox/issues/21209) - Ignore case sensitivity for configuration parameters which specify an app label and model name
|
||||
* [#21228](https://github.com/netbox-community/netbox/issues/21228) - Support image attachments for rack types
|
||||
* [#21244](https://github.com/netbox-community/netbox/issues/21244) - Enable omitting specific fields from REST API responses with `?omit=` parameter
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* [#21249](https://github.com/netbox-community/netbox/issues/21249) - Avoid extraneous user query when no event rules are present
|
||||
* [#21259](https://github.com/netbox-community/netbox/issues/21259) - Cache ObjectType lookups for the duration of a request
|
||||
* [#21260](https://github.com/netbox-community/netbox/issues/21260) - Defer object serialization for events pipeline processing
|
||||
* [#21263](https://github.com/netbox-community/netbox/issues/21263) - Prefetch related objects after creating/updating objects via REST API
|
||||
* [#21300](https://github.com/netbox-community/netbox/issues/21300) - Cache custom field lookups for the duration of a request
|
||||
* [#21302](https://github.com/netbox-community/netbox/issues/21302) - Avoid redundant uniqueness checks in ValidatedModelSerializer
|
||||
* [#21303](https://github.com/netbox-community/netbox/issues/21303) - Cache post-change snapshot on each instance after serialization
|
||||
* [#21327](https://github.com/netbox-community/netbox/issues/21327) - Always leverage `get_by_natural_key()` to resolve ContentTypes
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#20212](https://github.com/netbox-community/netbox/issues/20212) - Fix support for image attachment thumbnails when using S3 storage
|
||||
* [#20383](https://github.com/netbox-community/netbox/issues/20383) - When editing a device, clearing the assigned unit should also clear the rack face selection
|
||||
* [#20902](https://github.com/netbox-community/netbox/issues/20902) - Avoid `SyncError` exception when Git URL contains an embedded username
|
||||
* [#20977](https://github.com/netbox-community/netbox/issues/20977) - "Run again" button should respect script variable defaults
|
||||
* [#21115](https://github.com/netbox-community/netbox/issues/21115) - Include `attribute_data` in ModuleType YAML export
|
||||
* [#21129](https://github.com/netbox-community/netbox/issues/21129) - Store queue name on the Job model to ensure deletion of associated RQ task when a non-default queue is used
|
||||
* [#21168](https://github.com/netbox-community/netbox/issues/21168) - Fix Application Service cloning to preserve parent object
|
||||
* [#21173](https://github.com/netbox-community/netbox/issues/21173) - Ensure all plugin menu items are registered regardless of initialization order
|
||||
* [#21176](https://github.com/netbox-community/netbox/issues/21176) - Remove checkboxes from IP ranges in mixed-type tables
|
||||
* [#21202](https://github.com/netbox-community/netbox/issues/21202) - Fix scoped form cloning clearing the `scope` field when `scope_type` changes
|
||||
* [#21214](https://github.com/netbox-community/netbox/issues/21214) - Clean up AutoSyncRecord when detaching from DataSource
|
||||
* [#21242](https://github.com/netbox-community/netbox/issues/21242) - Navigation menu items for authentication should not require `staff_only` permission
|
||||
* [#21254](https://github.com/netbox-community/netbox/issues/21254) - Fix `AttributeError` exception when checking for latest release
|
||||
* [#21262](https://github.com/netbox-community/netbox/issues/21262) - Assigned scope should be replicated when cloning a prefix
|
||||
* [#21269](https://github.com/netbox-community/netbox/issues/21269) - Fix replication of front/rear port assignments from the module type when installing a module
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
* 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 }`.)
|
||||
* Python 3.10 and 3.11 are no longer supported. NetBox now requires Python 3.12, 3.13, or 3.14.
|
||||
* GraphQL API queries which filter by object IDs or enums must now specify a filter lookup similar to other fields. For example, `id: 123` becomes `id: {exact: 123 }`.
|
||||
* Rendering a device or virtual machine configuration is now restricted to users with the `render_config` permission for the applicable object type.
|
||||
* Retrieval of API token plaintexts is no longer supported. The `ALLOW_TOKEN_RETRIEVAL` config parameter has been removed.
|
||||
* The owner of an API token can no longer be changed once it has been created.
|
||||
* Config contexts now apply to all child platforms of a parent platform.
|
||||
* The `/api/extras/object-types/` REST API endpoint has been removed. (Use `/api/core/object-types/` instead.)
|
||||
* 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.
|
||||
* API tokens can no longer be reassigned from one user to another.
|
||||
* A config context assigned to a platform will now also apply to any children of that platform. (Although this is typically desired behavior, it may introduce unanticipated changes for existing deployments.)
|
||||
* The `/api/dcim/cable-terminations/` REST API endpoint is now read-only. Cable terminations must be set on cables directly via the `/api/dcim/cables/` endpoint.
|
||||
* The UI view dedicated to swapping A/Z circuit terminations has been removed.
|
||||
* The experimental HTMX navigation feature has been removed.
|
||||
* The obsolete field `is_staff` has been removed from the `User` model.
|
||||
* The obsolete boolean field `is_staff` has been removed from the `User` model.
|
||||
* Removal of deprecated behavior
|
||||
* The `/api/extras/object-types/` REST API endpoint has been removed. (Use `/api/core/object-types/` instead.)
|
||||
* Webhooks no longer specify a `model` in payload data. (Reference `object_type` instead, which includes the parent app label.)
|
||||
* The obsolete module `core.models.contenttypes` has been removed (replaced in v4.4 by `core.models.object_types`).
|
||||
* The `load_yaml()` and `load_json()` utility methods have been removed from the base class for custom scripts.
|
||||
|
||||
### New Features
|
||||
|
||||
#### Lookup Modifiers in Filter Forms ([#7604](https://github.com/netbox-community/netbox/issues/7604))
|
||||
|
||||
Most object list filters within the UI have been extended to include optional lookup modifiers to support more complex queries. For instance, filters for numeric values now include a dropdown where a user can select "less than," "greater than," or "not" in addition to the default equivalency match. The specific modifiers available depend on the type of each filter. Plugins can register their own filtersets using the `register_filterset()` decorator to enable this new functionality.
|
||||
|
||||
(Note that this feature does not introduce any new filters. Rather, it makes available in the UI filters which already exist.)
|
||||
|
||||
#### Improved API Authentication Tokens ([#20210](https://github.com/netbox-community/netbox/issues/20210))
|
||||
|
||||
This release introduces a new version of API token (v2) which implements several security improvements. HMAC hashing with a cryptographic pepper is used to authenticate these tokens, obviating the need to store plaintexts. The new tokens also employ a non-sensitive key which can be shared to identify tokens without divulging their plaintexts. We've also adopted the standard "bearer" HTTP header format, as shown below.
|
||||
|
||||
```
|
||||
# v1 token header
|
||||
Authorization: Token <TOKEN>
|
||||
|
||||
# v2 token header
|
||||
Authorization: Bearer nbt_<KEY>.<TOKEN>
|
||||
```
|
||||
|
||||
Note that v2 token keys are prefixed with the fixed string `nbt_`, which can be used to aid in secret detection.
|
||||
|
||||
Backward compatibility with legacy (v1) tokens is retained in this release. However, users are strongly encouraged to begin using only v2 tokens, as support for legacy tokens will be removed in NetBox v4.7.
|
||||
|
||||
#### Object Ownership ([#20304](https://github.com/netbox-community/netbox/issues/20304))
|
||||
|
||||
An optional `owner` foreign key field has been added to most models. This enables the assignment of objects to a new Owner model, which represents a set of users and/or groups. Through this relationship, we can now convey ownership of objects within NetBox natively, without needing to rely on the assignment of tags or custom fields.
|
||||
|
||||
(Note that ownership differs significantly in function from tenancy. Ownership determines the parties responsible for the maintenance of an object, whereas as tenancy conveys an operational dependency.)
|
||||
|
||||
#### Advanced Port Mappings ([#20564](https://github.com/netbox-community/netbox/issues/20564))
|
||||
|
||||
The previous many-to-one mapping of front to rear ports has been expanded to support bidirectional mappings. The `rear_port` and `rear_port_position` fields on the FrontPort model have been replaced with an intermediary PortMapping model, which supports any number of assignments between front port/position pair and a rear port/position pair. This change unlocks the ability to model complex inline devices that swap individual fiber pairs between cables.
|
||||
|
||||
#### Cable Profiles ([#20788](https://github.com/netbox-community/netbox/issues/20788))
|
||||
|
||||
Cables can now be assigned profiles which determine how they are treated for path tracing. A profile indicates the number of discrete parallel channels or lanes carried by the cable among its endpoints. For example, a 1-to-4 breakout cable has four lanes, shared at one end via a common termination and split out at the other end to four separate terminations. Profiles, when assigned, enable NetBox to more accurately trace a specific connection within a cable, rather than the cable as a whole.
|
||||
|
||||
The assignment of cable profiles is optional: Cable tracing will continue to operate as before for cables with no profile assigned.
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#16681](https://github.com/netbox-community/netbox/issues/16681) - Introduce a `render_config` permission, which is noq 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 now required to render a device or virtual machine configuration
|
||||
* [#18658](https://github.com/netbox-community/netbox/issues/18658) - Add a `start_on_boot` choice field for virtual machines
|
||||
* [#19095](https://github.com/netbox-community/netbox/issues/19095) - Add support for Python 3.13 and 3.14
|
||||
* [#19338](https://github.com/netbox-community/netbox/issues/19338) - Enable filter lookups for object IDs and enums in GraphQL API queries
|
||||
@@ -43,7 +157,9 @@
|
||||
* [#20834](https://github.com/netbox-community/netbox/issues/20834) - Add an `enabled` boolean field to API tokens
|
||||
* [#20917](https://github.com/netbox-community/netbox/issues/20917) - Include usage reference on API token views
|
||||
* [#20925](https://github.com/netbox-community/netbox/issues/20925) - Add optional `comments` field to all subclasses of `OrganizationalModel`
|
||||
* [#20929](https://github.com/netbox-community/netbox/issues/20929) - Require the `render_config` permission to view a rendered device/VM configuration in the UI
|
||||
* [#20936](https://github.com/netbox-community/netbox/issues/20936) - Introduce the `/api/authentication-check/` REST API endpoint for validating authentication tokens
|
||||
* [#20959](https://github.com/netbox-community/netbox/issues/20959) - Include a count of related module types for a manufacturer in the REST API
|
||||
|
||||
### Plugins
|
||||
|
||||
@@ -60,52 +176,60 @@
|
||||
* [#20095](https://github.com/netbox-community/netbox/issues/20095) - Remove the obsolete module `core.models.contenttypes`
|
||||
* [#20096](https://github.com/netbox-community/netbox/issues/20096) - Remove the `load_yaml()` and `load_json()` utility methods from the `BaseScript` class
|
||||
* [#20204](https://github.com/netbox-community/netbox/issues/20204) - Started migrating object views from custom HTML templates to declarative layouts
|
||||
* [#20295](https://github.com/netbox-community/netbox/issues/20295) - Cable terminations may be modified via the REST API only by modifying the cable itself
|
||||
* [#20617](https://github.com/netbox-community/netbox/issues/20617) - Introduce `BaseModel` as the global base class for models
|
||||
* [#20683](https://github.com/netbox-community/netbox/issues/20683) - Remove the UI view dedicated to swaping A/Z circuit terminations
|
||||
* [#20683](https://github.com/netbox-community/netbox/issues/20683) - Remove the UI view dedicated to swapping A/Z circuit terminations
|
||||
* [#20926](https://github.com/netbox-community/netbox/issues/20926) - Standardize naming of GraphQL filters
|
||||
|
||||
### REST API Changes
|
||||
|
||||
* Most objects now include an optional `owner` foreign key field.
|
||||
* The `/api/dcim/cable-terminations` endpoint is now read-only.
|
||||
* Introduced the `/api/authentication-check/` endpoint.
|
||||
* Introduced the `/api/authentication-check/` endpoint to test REST API credentials
|
||||
* `circuits.CircuitGroup`
|
||||
* Add optional `comments` field
|
||||
* Add optional `comments` field
|
||||
* `circuits.CircuitType`
|
||||
* Add optional `comments` field
|
||||
* Add optional `comments` field
|
||||
* `circuits.VirtualCircuitType`
|
||||
* Add optional `comments` field
|
||||
* Add optional `comments` field
|
||||
* `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`
|
||||
* Add optional `comments` field
|
||||
* Add optional `comments` field
|
||||
* `dcim.Manufacturer`
|
||||
* Add optional `comments` field
|
||||
* Add optional `comments` field
|
||||
* Add read-only `moduletype_count` integer field
|
||||
* `dcim.ModuleType`
|
||||
* Add read-only `module_count` integer field
|
||||
* Add read-only `module_count` integer field
|
||||
* `dcim.PowerOutletTemplate`
|
||||
* Add optional `color` field
|
||||
* Add optional `color` field
|
||||
* `dcim.RackRole`
|
||||
* Add optional `comments` field
|
||||
* Add optional `comments` field
|
||||
* `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`
|
||||
* Add optional `comments` field
|
||||
* Add optional `comments` field
|
||||
* `ipam.RIR`
|
||||
* Add optional `comments` field
|
||||
* Add optional `comments` field
|
||||
* `ipam.Role`
|
||||
* Add optional `comments` field
|
||||
* Add optional `comments` field
|
||||
* `ipam.VLANGroup`
|
||||
* Add optional `comments` field
|
||||
* Add optional `comments` field
|
||||
* `tenancy.ContactRole`
|
||||
* Add optional `comments` field
|
||||
* Add optional `comments` field
|
||||
* `users.Token`
|
||||
* Add `enabled` boolean field
|
||||
* Add `enabled` boolean field
|
||||
* `virtualization.ClusterGroup`
|
||||
* Add optional `comments` field
|
||||
* Add optional `comments` field
|
||||
* `virtualization.ClusterType`
|
||||
* Add optional `comments` field
|
||||
* Add optional `comments` field
|
||||
* `virtualization.VirtualMachine`
|
||||
* Add optional `start_on_boot` choice field
|
||||
* Add optional `start_on_boot` choice field
|
||||
* `vpn.TunnelGroup`
|
||||
* Add optional `comments` field
|
||||
* Add optional `comments` field
|
||||
|
||||
@@ -322,6 +322,7 @@ nav:
|
||||
- git Cheat Sheet: 'development/git-cheat-sheet.md'
|
||||
- Release Notes:
|
||||
- Summary: 'release-notes/index.md'
|
||||
- Version 4.5: 'release-notes/version-4.5.md'
|
||||
- Version 4.4: 'release-notes/version-4.4.md'
|
||||
- Version 4.3: 'release-notes/version-4.3.md'
|
||||
- Version 4.2: 'release-notes/version-4.2.md'
|
||||
|
||||
@@ -38,7 +38,7 @@ class CircuitTypeSerializer(OrganizationalModelSerializer):
|
||||
class Meta:
|
||||
model = CircuitType
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'owner', 'tags',
|
||||
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'owner', 'comments', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'circuit_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count')
|
||||
@@ -71,7 +71,7 @@ class CircuitGroupSerializer(OrganizationalModelSerializer):
|
||||
class Meta:
|
||||
model = CircuitGroup
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'tenant', 'owner', 'tags',
|
||||
'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'tenant', 'owner', 'comments', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'circuit_count'
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name')
|
||||
@@ -161,7 +161,7 @@ class VirtualCircuitTypeSerializer(OrganizationalModelSerializer):
|
||||
class Meta:
|
||||
model = VirtualCircuitType
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'owner', 'tags',
|
||||
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'owner', 'comments', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'virtual_circuit_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'virtual_circuit_count')
|
||||
|
||||
@@ -353,7 +353,7 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
|
||||
model = CircuitTermination
|
||||
fields = (
|
||||
'id', 'termination_id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description',
|
||||
'mark_connected', 'pp_info', 'cable_end', 'cable_position',
|
||||
'mark_connected', 'pp_info', 'cable_end', 'cable_connector',
|
||||
)
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
|
||||
@@ -99,7 +99,7 @@ class CircuitTypeBulkEditForm(OrganizationalModelBulkEditForm):
|
||||
fieldsets = (
|
||||
FieldSet('color', 'description'),
|
||||
)
|
||||
nullable_fields = ('color', 'description')
|
||||
nullable_fields = ('color', 'description', 'comments')
|
||||
|
||||
|
||||
class CircuitBulkEditForm(PrimaryModelBulkEditForm):
|
||||
@@ -241,7 +241,7 @@ class CircuitGroupBulkEditForm(OrganizationalModelBulkEditForm):
|
||||
|
||||
model = CircuitGroup
|
||||
nullable_fields = (
|
||||
'description', 'tenant',
|
||||
'description', 'tenant', 'comments',
|
||||
)
|
||||
|
||||
|
||||
@@ -274,7 +274,7 @@ class VirtualCircuitTypeBulkEditForm(OrganizationalModelBulkEditForm):
|
||||
fieldsets = (
|
||||
FieldSet('color', 'description'),
|
||||
)
|
||||
nullable_fields = ('color', 'description')
|
||||
nullable_fields = ('color', 'description', 'comments')
|
||||
|
||||
|
||||
class VirtualCircuitBulkEditForm(PrimaryModelBulkEditForm):
|
||||
|
||||
@@ -73,7 +73,7 @@ class CircuitTypeImportForm(OrganizationalModelImportForm):
|
||||
|
||||
class Meta:
|
||||
model = CircuitType
|
||||
fields = ('name', 'slug', 'color', 'description', 'owner', 'tags')
|
||||
fields = ('name', 'slug', 'color', 'description', 'owner', 'comments', 'tags')
|
||||
|
||||
|
||||
class CircuitImportForm(PrimaryModelImportForm):
|
||||
@@ -176,7 +176,7 @@ class CircuitGroupImportForm(OrganizationalModelImportForm):
|
||||
|
||||
class Meta:
|
||||
model = CircuitGroup
|
||||
fields = ('name', 'slug', 'description', 'tenant', 'owner', 'tags')
|
||||
fields = ('name', 'slug', 'description', 'tenant', 'owner', 'comments', 'tags')
|
||||
|
||||
|
||||
class CircuitGroupAssignmentImportForm(NetBoxModelImportForm):
|
||||
@@ -199,7 +199,7 @@ class VirtualCircuitTypeImportForm(OrganizationalModelImportForm):
|
||||
|
||||
class Meta:
|
||||
model = VirtualCircuitType
|
||||
fields = ('name', 'slug', 'color', 'description', 'owner', 'tags')
|
||||
fields = ('name', 'slug', 'color', 'description', 'owner', 'comments', 'tags')
|
||||
|
||||
|
||||
class VirtualCircuitImportForm(PrimaryModelImportForm):
|
||||
|
||||
@@ -34,9 +34,10 @@ __all__ = (
|
||||
class ProviderFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
|
||||
model = Provider
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
|
||||
FieldSet('asn_id', name=_('ASN')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
@@ -69,8 +70,9 @@ class ProviderFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
|
||||
class ProviderAccountFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
|
||||
model = ProviderAccount
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('provider_id', 'account', name=_('Attributes')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
provider_id = DynamicModelMultipleChoiceField(
|
||||
@@ -88,8 +90,9 @@ class ProviderAccountFilterForm(ContactModelFilterForm, PrimaryModelFilterSetFor
|
||||
class ProviderNetworkFilterForm(PrimaryModelFilterSetForm):
|
||||
model = ProviderNetwork
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('provider_id', 'service_id', name=_('Attributes')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
provider_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Provider.objects.all(),
|
||||
@@ -107,8 +110,9 @@ class ProviderNetworkFilterForm(PrimaryModelFilterSetForm):
|
||||
class CircuitTypeFilterForm(OrganizationalModelFilterSetForm):
|
||||
model = CircuitType
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('color', name=_('Attributes')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -121,7 +125,7 @@ class CircuitTypeFilterForm(OrganizationalModelFilterSetForm):
|
||||
class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
|
||||
model = Circuit
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
|
||||
FieldSet(
|
||||
'type_id', 'status', 'install_date', 'termination_date', 'commit_rate', 'distance', 'distance_unit',
|
||||
@@ -129,6 +133,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelF
|
||||
),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
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')
|
||||
@@ -274,8 +279,9 @@ class CircuitTerminationFilterForm(NetBoxModelFilterSetForm):
|
||||
class CircuitGroupFilterForm(TenancyFilterForm, OrganizationalModelFilterSetForm):
|
||||
model = CircuitGroup
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -312,8 +318,9 @@ class CircuitGroupAssignmentFilterForm(NetBoxModelFilterSetForm):
|
||||
class VirtualCircuitTypeFilterForm(OrganizationalModelFilterSetForm):
|
||||
model = VirtualCircuitType
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('color', name=_('Attributes')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -326,10 +333,11 @@ class VirtualCircuitTypeFilterForm(OrganizationalModelFilterSetForm):
|
||||
class VirtualCircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
|
||||
model = VirtualCircuit
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
|
||||
FieldSet('type_id', 'status', name=_('Attributes')),
|
||||
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')
|
||||
provider_id = DynamicModelMultipleChoiceField(
|
||||
|
||||
@@ -97,7 +97,7 @@ class CircuitTypeForm(OrganizationalModelForm):
|
||||
class Meta:
|
||||
model = CircuitType
|
||||
fields = [
|
||||
'name', 'slug', 'color', 'description', 'tags',
|
||||
'name', 'slug', 'color', 'description', 'comments', 'tags',
|
||||
]
|
||||
|
||||
|
||||
@@ -236,7 +236,7 @@ class CircuitGroupForm(TenancyForm, OrganizationalModelForm):
|
||||
class Meta:
|
||||
model = CircuitGroup
|
||||
fields = [
|
||||
'name', 'slug', 'description', 'tenant_group', 'tenant', 'owner', 'tags',
|
||||
'name', 'slug', 'description', 'tenant_group', 'tenant', 'owner', 'comments', 'tags',
|
||||
]
|
||||
|
||||
|
||||
@@ -307,7 +307,7 @@ class VirtualCircuitTypeForm(OrganizationalModelForm):
|
||||
class Meta:
|
||||
model = VirtualCircuitType
|
||||
fields = [
|
||||
'name', 'slug', 'color', 'description', 'owner', 'tags',
|
||||
'name', 'slug', 'color', 'description', 'owner', 'comments', 'tags',
|
||||
]
|
||||
|
||||
|
||||
|
||||
39
netbox/circuits/migrations/0054_cable_connector_positions.py
Normal file
39
netbox/circuits/migrations/0054_cable_connector_positions.py
Normal file
@@ -0,0 +1,39 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
17
netbox/circuits/migrations/0056_gfk_indexes.py
Normal file
17
netbox/circuits/migrations/0056_gfk_indexes.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('circuits', '0055_add_comments_to_organizationalmodel'),
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('dcim', '0224_add_comments_to_organizationalmodel'),
|
||||
('extras', '0134_owner'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name='circuittermination',
|
||||
index=models.Index(fields=['termination_type', 'termination_id'], name='circuits_ci_termina_505dda_idx'),
|
||||
),
|
||||
]
|
||||
@@ -335,6 +335,9 @@ class CircuitTermination(
|
||||
name='%(app_label)s_%(class)s_unique_circuit_term_side'
|
||||
),
|
||||
)
|
||||
indexes = (
|
||||
models.Index(fields=('termination_type', 'termination_id')),
|
||||
)
|
||||
verbose_name = _('circuit termination')
|
||||
verbose_name_plural = _('circuit terminations')
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ class CircuitGroupIndex(SearchIndex):
|
||||
('name', 100),
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('description',)
|
||||
|
||||
@@ -44,6 +45,7 @@ class CircuitTypeIndex(SearchIndex):
|
||||
('name', 100),
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('description',)
|
||||
|
||||
@@ -109,5 +111,6 @@ class VirtualCircuitTypeIndex(SearchIndex):
|
||||
('name', 100),
|
||||
('slug', 110),
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
display_attrs = ('description',)
|
||||
|
||||
@@ -40,8 +40,8 @@ class CircuitTypeTable(OrganizationalModelTable):
|
||||
class Meta(OrganizationalModelTable.Meta):
|
||||
model = CircuitType
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'circuit_count', 'color', 'description', 'slug', 'tags', 'created', 'last_updated',
|
||||
'actions',
|
||||
'pk', 'id', 'name', 'circuit_count', 'color', 'description', 'slug', 'comments', 'tags', 'created',
|
||||
'last_updated', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'circuit_count', 'color', 'description')
|
||||
|
||||
@@ -175,7 +175,7 @@ class CircuitGroupTable(OrganizationalModelTable):
|
||||
class Meta(OrganizationalModelTable.Meta):
|
||||
model = CircuitGroup
|
||||
fields = (
|
||||
'pk', 'name', 'description', 'circuit_group_assignment_count', 'tags',
|
||||
'pk', 'name', 'description', 'circuit_group_assignment_count', 'comments', 'tags',
|
||||
'created', 'last_updated', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'description', 'circuit_group_assignment_count')
|
||||
|
||||
@@ -433,7 +433,7 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = CircuitTermination.objects.all()
|
||||
filterset = CircuitTerminationFilterSet
|
||||
ignore_fields = ('cable',)
|
||||
ignore_fields = ('cable', 'cable_positions')
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
@@ -44,3 +44,4 @@ class DataFileSerializer(NetBoxModelSerializer):
|
||||
'id', 'url', 'display_url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'path')
|
||||
read_only_fields = ['path', 'last_updated', 'size', 'hash']
|
||||
|
||||
@@ -31,7 +31,8 @@ class JobSerializer(BaseModelSerializer):
|
||||
model = Job
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'object', 'name', 'status', 'created',
|
||||
'scheduled', 'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'log_entries',
|
||||
'scheduled', 'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'queue_name',
|
||||
'log_entries',
|
||||
]
|
||||
brief_fields = ('url', 'created', 'completed', 'user', 'status')
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.routers import APIRootView
|
||||
from rest_framework.viewsets import ReadOnlyModelViewSet
|
||||
from rq.job import Job as RQ_Job
|
||||
from rq.worker import Worker
|
||||
|
||||
@@ -64,7 +63,7 @@ class DataFileViewSet(NetBoxReadOnlyModelViewSet):
|
||||
filterset_class = filtersets.DataFileFilterSet
|
||||
|
||||
|
||||
class JobViewSet(ReadOnlyModelViewSet):
|
||||
class JobViewSet(NetBoxReadOnlyModelViewSet):
|
||||
"""
|
||||
Retrieve a list of job results
|
||||
"""
|
||||
@@ -73,19 +72,20 @@ class JobViewSet(ReadOnlyModelViewSet):
|
||||
filterset_class = filtersets.JobFilterSet
|
||||
|
||||
|
||||
class ObjectChangeViewSet(ReadOnlyModelViewSet):
|
||||
class ObjectChangeViewSet(NetBoxReadOnlyModelViewSet):
|
||||
"""
|
||||
Retrieve a list of recent changes.
|
||||
"""
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = ObjectChange.objects.all()
|
||||
serializer_class = serializers.ObjectChangeSerializer
|
||||
filterset_class = filtersets.ObjectChangeFilterSet
|
||||
|
||||
def get_queryset(self):
|
||||
return ObjectChange.objects.valid_models()
|
||||
return super().get_queryset().valid_models()
|
||||
|
||||
|
||||
class ObjectTypeViewSet(ReadOnlyModelViewSet):
|
||||
class ObjectTypeViewSet(NetBoxReadOnlyModelViewSet):
|
||||
"""
|
||||
Read-only list of ObjectTypes.
|
||||
"""
|
||||
@@ -94,6 +94,16 @@ class ObjectTypeViewSet(ReadOnlyModelViewSet):
|
||||
serializer_class = serializers.ObjectTypeSerializer
|
||||
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):
|
||||
"""
|
||||
|
||||
@@ -21,11 +21,24 @@ __all__ = (
|
||||
'GitBackend',
|
||||
'LocalBackend',
|
||||
'S3Backend',
|
||||
'url_has_embedded_credentials',
|
||||
)
|
||||
|
||||
logger = logging.getLogger('netbox.data_backends')
|
||||
|
||||
|
||||
def url_has_embedded_credentials(url):
|
||||
"""
|
||||
Check if a URL contains embedded credentials (username in the URL).
|
||||
|
||||
URLs like 'https://user@bitbucket.org/...' have embedded credentials.
|
||||
This is used to avoid passing explicit credentials to dulwich when the
|
||||
URL already contains them, which would cause authentication conflicts.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
return bool(parsed.username)
|
||||
|
||||
|
||||
@register_data_backend()
|
||||
class LocalBackend(DataBackend):
|
||||
name = 'local'
|
||||
@@ -102,7 +115,9 @@ class GitBackend(DataBackend):
|
||||
clone_args['pool_manager'] = ProxyPoolManager(self.socks_proxy)
|
||||
|
||||
if self.url_scheme in ('http', 'https'):
|
||||
if self.params.get('username'):
|
||||
# Only pass explicit credentials if URL doesn't already contain embedded username
|
||||
# to avoid credential conflicts (see #20902)
|
||||
if not url_has_embedded_credentials(self.url) and self.params.get('username'):
|
||||
clone_args.update(
|
||||
{
|
||||
"username": self.params.get('username'),
|
||||
|
||||
@@ -129,10 +129,14 @@ class JobFilterSet(BaseFilterSet):
|
||||
choices=JobStatusChoices,
|
||||
null_value=None
|
||||
)
|
||||
queue_name = django_filters.CharFilter()
|
||||
|
||||
class Meta:
|
||||
model = Job
|
||||
fields = ('id', 'object_type', 'object_type_id', 'object_id', 'name', 'interval', 'status', 'user', 'job_id')
|
||||
fields = (
|
||||
'id', 'object_type', 'object_type_id', 'object_id', 'name', 'interval', 'status', 'user', 'job_id',
|
||||
'queue_name',
|
||||
)
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
|
||||
@@ -26,8 +26,9 @@ __all__ = (
|
||||
class DataSourceFilterForm(PrimaryModelFilterSetForm):
|
||||
model = DataSource
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('type', 'status', 'enabled', 'sync_interval', name=_('Data Source')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
@@ -71,7 +72,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = Job
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('object_type_id', 'status', name=_('Attributes')),
|
||||
FieldSet('object_type_id', 'status', 'queue_name', name=_('Attributes')),
|
||||
FieldSet(
|
||||
'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before',
|
||||
'started__after', 'completed__before', 'completed__after', 'user', name=_('Creation')
|
||||
@@ -87,6 +88,10 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
|
||||
choices=JobStatusChoices,
|
||||
required=False
|
||||
)
|
||||
queue_name = forms.CharField(
|
||||
label=_('Queue'),
|
||||
required=False
|
||||
)
|
||||
created__after = forms.DateTimeField(
|
||||
label=_('Created after'),
|
||||
required=False,
|
||||
|
||||
18
netbox/core/migrations/0021_job_queue_name.py
Normal file
18
netbox/core/migrations/0021_job_queue_name.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -63,16 +63,20 @@ class ConfigRevision(models.Model):
|
||||
return reverse('core:config') # Default config view
|
||||
return reverse('core:configrevision', args=[self.pk])
|
||||
|
||||
def activate(self):
|
||||
def activate(self, update_db=True):
|
||||
"""
|
||||
Cache the configuration data.
|
||||
|
||||
Parameters:
|
||||
update_db: Mark the ConfigRevision as active in the database (default: True)
|
||||
"""
|
||||
cache.set('config', self.data, None)
|
||||
cache.set('config_version', self.pk, None)
|
||||
|
||||
# Set all instances of ConfigRevision to false and set this instance to true
|
||||
ConfigRevision.objects.all().update(active=False)
|
||||
ConfigRevision.objects.filter(pk=self.pk).update(active=True)
|
||||
if update_db:
|
||||
# Set all instances of ConfigRevision to false and set this instance to true
|
||||
ConfigRevision.objects.all().update(active=False)
|
||||
ConfigRevision.objects.filter(pk=self.pk).update(active=True)
|
||||
|
||||
activate.alters_data = True
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from django.core.validators import RegexValidator
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
|
||||
from netbox.models import PrimaryModel
|
||||
@@ -128,9 +128,24 @@ class DataSource(JobsMixin, PrimaryModel):
|
||||
# Ensure URL scheme matches selected type
|
||||
if self.backend_class.is_local and self.url_scheme not in ('file', ''):
|
||||
raise ValidationError({
|
||||
'source_url': "URLs for local sources must start with file:// (or specify no scheme)"
|
||||
'source_url': _("URLs for local sources must start with {scheme} (or specify no scheme)").format(
|
||||
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):
|
||||
objectchange = super().to_objectchange(action)
|
||||
|
||||
|
||||
@@ -112,6 +112,12 @@ class Job(models.Model):
|
||||
verbose_name=_('job ID'),
|
||||
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(
|
||||
verbose_name=_('log entries'),
|
||||
base_field=models.JSONField(
|
||||
@@ -179,11 +185,15 @@ class Job(models.Model):
|
||||
return f"{int(minutes)} minutes, {seconds:.2f} seconds"
|
||||
|
||||
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)
|
||||
|
||||
rq_queue_name = get_queue_for_model(self.object_type.model if self.object_type else None)
|
||||
# Cancel the RQ job using the stored queue name
|
||||
queue = django_rq.get_queue(rq_queue_name)
|
||||
job = queue.fetch_job(str(self.job_id))
|
||||
job = queue.fetch_job(rq_job_id)
|
||||
|
||||
if job:
|
||||
try:
|
||||
@@ -288,7 +298,8 @@ class Job(models.Model):
|
||||
scheduled=schedule_at,
|
||||
interval=interval,
|
||||
user=user,
|
||||
job_id=uuid.uuid4()
|
||||
job_id=uuid.uuid4(),
|
||||
queue_name=rq_queue_name
|
||||
)
|
||||
job.full_clean()
|
||||
job.save()
|
||||
|
||||
@@ -9,6 +9,7 @@ from django.db import connection, models
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from netbox.context import query_cache
|
||||
from netbox.plugins import PluginConfig
|
||||
from netbox.registry import registry
|
||||
from utilities.string import title
|
||||
@@ -35,6 +36,10 @@ class ObjectTypeQuerySet(models.QuerySet):
|
||||
|
||||
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):
|
||||
return ObjectTypeQuerySet(self.model, using=self._db)
|
||||
|
||||
@@ -66,13 +71,21 @@ class ObjectTypeManager(models.Manager):
|
||||
"""
|
||||
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
|
||||
# If the ObjectType table has not yet been provisioned (e.g. because we're in a pre-v4.4 migration),
|
||||
# fall back to ContentType.
|
||||
if 'core_objecttype' not in connection.introspection.table_names():
|
||||
ct = ContentType.objects.get_for_model(model, for_concrete_model=for_concrete_model)
|
||||
ct.features = get_model_features(ct.model_class())
|
||||
return ct
|
||||
if not ObjectTypeManager._table_exists:
|
||||
if 'core_objecttype' not in connection.introspection.table_names():
|
||||
ct = ContentType.objects.get_for_model(model, for_concrete_model=for_concrete_model)
|
||||
ct.features = get_model_features(ct.model_class())
|
||||
return ct
|
||||
ObjectTypeManager._table_exists = True
|
||||
|
||||
if not inspect.isclass(model):
|
||||
model = model.__class__
|
||||
@@ -90,6 +103,10 @@ class ObjectTypeManager(models.Manager):
|
||||
features=get_model_features(model),
|
||||
)[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
|
||||
|
||||
def get_for_models(self, *models, for_concrete_models=True):
|
||||
|
||||
@@ -3,7 +3,7 @@ from threading import local
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.db.models import CASCADE
|
||||
from django.db.models import CASCADE, RESTRICT
|
||||
from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel
|
||||
from django.db.models.signals import m2m_changed, post_migrate, post_save, pre_delete
|
||||
from django.dispatch import receiver, Signal
|
||||
@@ -18,6 +18,7 @@ from extras.events import enqueue_event
|
||||
from extras.models import Tag
|
||||
from extras.utils import run_validators
|
||||
from netbox.config import get_config
|
||||
from utilities.data import get_config_value_ci
|
||||
from netbox.context import current_request, events_queue
|
||||
from netbox.models.features import ChangeLoggingMixin, get_model_features, model_is_public
|
||||
from utilities.exceptions import AbortRequest
|
||||
@@ -168,7 +169,7 @@ def handle_deleted_object(sender, instance, **kwargs):
|
||||
# to queueing any events for the object being deleted, in case a validation error is
|
||||
# raised, causing the deletion to fail.
|
||||
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
|
||||
validators = get_config().PROTECTION_RULES.get(model_name, [])
|
||||
validators = get_config_value_ci(get_config().PROTECTION_RULES, model_name, default=[])
|
||||
try:
|
||||
run_validators(instance, validators)
|
||||
except ValidationError as e:
|
||||
@@ -221,7 +222,7 @@ def handle_deleted_object(sender, instance, **kwargs):
|
||||
obj.snapshot() # Ensure the change record includes the "before" state
|
||||
if type(relation) is ManyToManyRel:
|
||||
getattr(obj, related_field_name).remove(instance)
|
||||
elif type(relation) is ManyToOneRel and relation.null and relation.on_delete is not CASCADE:
|
||||
elif type(relation) is ManyToOneRel and relation.null and relation.on_delete not in (CASCADE, RESTRICT):
|
||||
setattr(obj, related_field_name, None)
|
||||
obj.save()
|
||||
|
||||
|
||||
@@ -42,6 +42,9 @@ class JobTable(NetBoxTable):
|
||||
completed = columns.DateTimeColumn(
|
||||
verbose_name=_('Completed'),
|
||||
)
|
||||
queue_name = tables.Column(
|
||||
verbose_name=_('Queue'),
|
||||
)
|
||||
log_entries = tables.Column(
|
||||
verbose_name=_('Log Entries'),
|
||||
)
|
||||
@@ -53,7 +56,7 @@ class JobTable(NetBoxTable):
|
||||
model = Job
|
||||
fields = (
|
||||
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started',
|
||||
'completed', 'user', 'error', 'job_id',
|
||||
'completed', 'user', 'queue_name', 'log_entries', 'error', 'job_id',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
|
||||
|
||||
116
netbox/core/tests/test_data_backends.py
Normal file
116
netbox/core/tests/test_data_backends.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from unittest import skipIf
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from core.data_backends import url_has_embedded_credentials
|
||||
|
||||
try:
|
||||
import dulwich # noqa: F401
|
||||
DULWICH_AVAILABLE = True
|
||||
except ImportError:
|
||||
DULWICH_AVAILABLE = False
|
||||
|
||||
|
||||
class URLEmbeddedCredentialsTests(TestCase):
|
||||
def test_url_with_embedded_username(self):
|
||||
self.assertTrue(url_has_embedded_credentials('https://myuser@bitbucket.org/workspace/repo.git'))
|
||||
|
||||
def test_url_without_embedded_username(self):
|
||||
self.assertFalse(url_has_embedded_credentials('https://bitbucket.org/workspace/repo.git'))
|
||||
|
||||
def test_url_with_username_and_password(self):
|
||||
self.assertTrue(url_has_embedded_credentials('https://user:pass@bitbucket.org/workspace/repo.git'))
|
||||
|
||||
def test_various_providers_with_embedded_username(self):
|
||||
urls = [
|
||||
'https://user@bitbucket.org/workspace/repo.git',
|
||||
'https://user@github.com/owner/repo.git',
|
||||
'https://deploy-key@gitlab.com/group/project.git',
|
||||
'http://user@internal-git.example.com/repo.git',
|
||||
]
|
||||
for url in urls:
|
||||
with self.subTest(url=url):
|
||||
self.assertTrue(url_has_embedded_credentials(url))
|
||||
|
||||
def test_various_providers_without_embedded_username(self):
|
||||
"""Various Git providers without embedded usernames."""
|
||||
urls = [
|
||||
'https://bitbucket.org/workspace/repo.git',
|
||||
'https://github.com/owner/repo.git',
|
||||
'https://gitlab.com/group/project.git',
|
||||
'http://internal-git.example.com/repo.git',
|
||||
]
|
||||
for url in urls:
|
||||
with self.subTest(url=url):
|
||||
self.assertFalse(url_has_embedded_credentials(url))
|
||||
|
||||
def test_ssh_url(self):
|
||||
# git@host:path format doesn't parse as having a username in the traditional sense
|
||||
self.assertFalse(url_has_embedded_credentials('git@github.com:owner/repo.git'))
|
||||
|
||||
def test_file_url(self):
|
||||
self.assertFalse(url_has_embedded_credentials('file:///path/to/repo'))
|
||||
|
||||
|
||||
@skipIf(not DULWICH_AVAILABLE, "dulwich is not installed")
|
||||
class GitBackendCredentialIntegrationTests(TestCase):
|
||||
"""
|
||||
Integration tests that verify GitBackend correctly applies credential logic.
|
||||
|
||||
These tests require dulwich to be installed and verify the full integration
|
||||
of the credential handling in GitBackend.fetch().
|
||||
"""
|
||||
|
||||
def _get_clone_kwargs(self, url, **params):
|
||||
from core.data_backends import GitBackend
|
||||
|
||||
backend = GitBackend(url=url, **params)
|
||||
|
||||
with patch('dulwich.porcelain.clone') as mock_clone, \
|
||||
patch('dulwich.porcelain.NoneStream'):
|
||||
try:
|
||||
with backend.fetch():
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if mock_clone.called:
|
||||
return mock_clone.call_args.kwargs
|
||||
return {}
|
||||
|
||||
def test_url_with_embedded_username_skips_explicit_credentials(self):
|
||||
kwargs = self._get_clone_kwargs(
|
||||
url='https://myuser@bitbucket.org/workspace/repo.git',
|
||||
username='myuser',
|
||||
password='my-api-key'
|
||||
)
|
||||
|
||||
self.assertEqual(kwargs.get('username'), None)
|
||||
self.assertEqual(kwargs.get('password'), None)
|
||||
|
||||
def test_url_without_embedded_username_passes_explicit_credentials(self):
|
||||
kwargs = self._get_clone_kwargs(
|
||||
url='https://bitbucket.org/workspace/repo.git',
|
||||
username='myuser',
|
||||
password='my-api-key'
|
||||
)
|
||||
|
||||
self.assertEqual(kwargs.get('username'), 'myuser')
|
||||
self.assertEqual(kwargs.get('password'), 'my-api-key')
|
||||
|
||||
def test_url_with_embedded_username_no_explicit_credentials(self):
|
||||
kwargs = self._get_clone_kwargs(
|
||||
url='https://myuser@bitbucket.org/workspace/repo.git'
|
||||
)
|
||||
|
||||
self.assertEqual(kwargs.get('username'), None)
|
||||
self.assertEqual(kwargs.get('password'), None)
|
||||
|
||||
def test_public_repo_no_credentials(self):
|
||||
kwargs = self._get_clone_kwargs(
|
||||
url='https://github.com/public/repo.git'
|
||||
)
|
||||
|
||||
self.assertEqual(kwargs.get('username'), None)
|
||||
self.assertEqual(kwargs.get('password'), None)
|
||||
@@ -1,8 +1,10 @@
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.test import TestCase
|
||||
|
||||
from core.models import DataSource, ObjectType
|
||||
from core.models import DataSource, Job, ObjectType
|
||||
from core.choices import ObjectChangeActionChoices
|
||||
from dcim.models import Site, Location, Device
|
||||
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
|
||||
@@ -200,3 +202,38 @@ class ObjectTypeTest(TestCase):
|
||||
bookmarks_ots = ObjectType.objects.with_feature('bookmarks')
|
||||
self.assertIn(ObjectType.objects.get_by_natural_key('dcim', 'site'), 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()
|
||||
|
||||
@@ -2,10 +2,12 @@ from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
from dcim.models import FrontPort, FrontPortTemplate, PortMapping, PortTemplateMapping, RearPort, RearPortTemplate
|
||||
from utilities.api import get_serializer_for_model
|
||||
|
||||
__all__ = (
|
||||
'ConnectedEndpointsSerializer',
|
||||
'PortSerializer',
|
||||
)
|
||||
|
||||
|
||||
@@ -35,3 +37,53 @@ class ConnectedEndpointsSerializer(serializers.ModelSerializer):
|
||||
@extend_schema_field(serializers.BooleanField)
|
||||
def get_connected_endpoints_reachable(self, obj):
|
||||
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
|
||||
|
||||
@@ -61,11 +61,12 @@ class CableTerminationSerializer(NetBoxModelSerializer):
|
||||
model = CableTermination
|
||||
fields = [
|
||||
'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id',
|
||||
'termination', 'position', 'created', 'last_updated',
|
||||
'termination', 'connector', 'positions', 'created', 'last_updated',
|
||||
]
|
||||
read_only_fields = fields
|
||||
brief_fields = (
|
||||
'id', 'url', 'display', 'cable', 'cable_end', 'position', 'termination_type', 'termination_id',
|
||||
'id', 'url', 'display', 'cable', 'cable_end', 'connector', 'positions', 'termination_type',
|
||||
'termination_id',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
from django.utils.translation import gettext as _
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework import serializers
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models import (
|
||||
ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort,
|
||||
RearPort, VirtualDeviceContext,
|
||||
ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PortMapping,
|
||||
PowerOutlet, PowerPort, RearPort, VirtualDeviceContext,
|
||||
)
|
||||
from ipam.api.serializers_.vlans import VLANSerializer, VLANTranslationPolicySerializer
|
||||
from ipam.api.serializers_.vrfs import VRFSerializer
|
||||
from ipam.models import VLAN
|
||||
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
|
||||
from netbox.api.gfk_fields import GFKSerializerField
|
||||
from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
|
||||
from netbox.api.serializers import NetBoxModelSerializer
|
||||
from users.api.serializers_.mixins import OwnerMixin
|
||||
from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer
|
||||
from wireless.api.serializers_.nested import NestedWirelessLinkSerializer
|
||||
from wireless.api.serializers_.wirelesslans import WirelessLANSerializer
|
||||
from wireless.choices import *
|
||||
from wireless.models import WirelessLAN
|
||||
from .base import ConnectedEndpointsSerializer
|
||||
from .base import ConnectedEndpointsSerializer, PortSerializer
|
||||
from .cables import CabledObjectSerializer
|
||||
from .devices import DeviceSerializer, MACAddressSerializer, ModuleSerializer, VirtualDeviceContextSerializer
|
||||
from .manufacturers import ManufacturerSerializer
|
||||
@@ -40,7 +41,12 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
|
||||
class ConsoleServerPortSerializer(
|
||||
OwnerMixin,
|
||||
NetBoxModelSerializer,
|
||||
CabledObjectSerializer,
|
||||
ConnectedEndpointsSerializer
|
||||
):
|
||||
device = DeviceSerializer(nested=True)
|
||||
module = ModuleSerializer(
|
||||
nested=True,
|
||||
@@ -64,13 +70,18 @@ class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer,
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
|
||||
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints',
|
||||
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
|
||||
'connected_endpoints_type', 'connected_endpoints_reachable', 'owner', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
|
||||
class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
|
||||
class ConsolePortSerializer(
|
||||
OwnerMixin,
|
||||
NetBoxModelSerializer,
|
||||
CabledObjectSerializer,
|
||||
ConnectedEndpointsSerializer
|
||||
):
|
||||
device = DeviceSerializer(nested=True)
|
||||
module = ModuleSerializer(
|
||||
nested=True,
|
||||
@@ -94,13 +105,18 @@ class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description',
|
||||
'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints',
|
||||
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
|
||||
'connected_endpoints_type', 'connected_endpoints_reachable', 'owner', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
|
||||
class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
|
||||
class PowerPortSerializer(
|
||||
OwnerMixin,
|
||||
NetBoxModelSerializer,
|
||||
CabledObjectSerializer,
|
||||
ConnectedEndpointsSerializer
|
||||
):
|
||||
device = DeviceSerializer(nested=True)
|
||||
module = ModuleSerializer(
|
||||
nested=True,
|
||||
@@ -120,13 +136,18 @@ class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'maximum_draw',
|
||||
'allocated_draw', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
|
||||
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', '_occupied',
|
||||
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'owner', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
|
||||
class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
|
||||
class PowerOutletSerializer(
|
||||
OwnerMixin,
|
||||
NetBoxModelSerializer,
|
||||
CabledObjectSerializer,
|
||||
ConnectedEndpointsSerializer
|
||||
):
|
||||
device = DeviceSerializer(nested=True)
|
||||
module = ModuleSerializer(
|
||||
nested=True,
|
||||
@@ -159,12 +180,17 @@ class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
|
||||
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'status', 'color',
|
||||
'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
|
||||
'link_peers_type', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable',
|
||||
'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
|
||||
'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
|
||||
class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
|
||||
class InterfaceSerializer(
|
||||
OwnerMixin,
|
||||
NetBoxModelSerializer,
|
||||
CabledObjectSerializer,
|
||||
ConnectedEndpointsSerializer
|
||||
):
|
||||
device = DeviceSerializer(nested=True)
|
||||
vdcs = SerializedPKRelatedField(
|
||||
queryset=VirtualDeviceContext.objects.all(),
|
||||
@@ -182,6 +208,7 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
|
||||
type = ChoiceField(choices=InterfaceTypeChoices)
|
||||
parent = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||
bridge = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||
bridge_interfaces = NestedInterfaceSerializer(many=True, read_only=True)
|
||||
lag = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||
mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True)
|
||||
duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True, allow_null=True)
|
||||
@@ -221,13 +248,13 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
|
||||
model = Interface
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled',
|
||||
'parent', 'bridge', 'lag', 'mtu', 'mac_address', 'primary_mac_address', 'mac_addresses', 'speed', 'duplex',
|
||||
'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'poe_mode', 'poe_type',
|
||||
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan',
|
||||
'vlan_translation_policy', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers',
|
||||
'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints',
|
||||
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
|
||||
'parent', 'bridge', 'bridge_interfaces', 'lag', 'mtu', 'mac_address', 'primary_mac_address',
|
||||
'mac_addresses', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
|
||||
'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',
|
||||
'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'mark_connected', 'cable', 'cable_end',
|
||||
'wireless_link', 'link_peers', 'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination',
|
||||
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'owner', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
@@ -294,7 +321,20 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
|
||||
return super().validate(data)
|
||||
|
||||
|
||||
class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
|
||||
class RearPortMappingSerializer(serializers.ModelSerializer):
|
||||
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)
|
||||
module = ModuleSerializer(
|
||||
nested=True,
|
||||
@@ -303,28 +343,36 @@ class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
|
||||
allow_null=True
|
||||
)
|
||||
type = ChoiceField(choices=PortTypeChoices)
|
||||
front_ports = RearPortMappingSerializer(
|
||||
source='mappings',
|
||||
many=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RearPort
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions',
|
||||
'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', '_occupied',
|
||||
'front_ports', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
|
||||
'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
|
||||
class FrontPortRearPortSerializer(WritableNestedSerializer):
|
||||
"""
|
||||
NestedRearPortSerializer but with parent device omitted (since front and rear ports must belong to same device)
|
||||
"""
|
||||
class FrontPortMappingSerializer(serializers.ModelSerializer):
|
||||
position = serializers.IntegerField(
|
||||
source='front_port_position'
|
||||
)
|
||||
rear_port = serializers.PrimaryKeyRelatedField(
|
||||
queryset=RearPort.objects.all(),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RearPort
|
||||
fields = ['id', 'url', 'display_url', 'display', 'name', 'label', 'description']
|
||||
model = PortMapping
|
||||
fields = ('position', 'rear_port', 'rear_port_position')
|
||||
|
||||
|
||||
class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
|
||||
class FrontPortSerializer(OwnerMixin, NetBoxModelSerializer, CabledObjectSerializer, PortSerializer):
|
||||
device = DeviceSerializer(nested=True)
|
||||
module = ModuleSerializer(
|
||||
nested=True,
|
||||
@@ -333,19 +381,23 @@ class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
|
||||
allow_null=True
|
||||
)
|
||||
type = ChoiceField(choices=PortTypeChoices)
|
||||
rear_port = FrontPortRearPortSerializer()
|
||||
rear_ports = FrontPortMappingSerializer(
|
||||
source='mappings',
|
||||
many=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = FrontPort
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port',
|
||||
'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
|
||||
'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
|
||||
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions',
|
||||
'rear_ports', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
|
||||
'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
|
||||
|
||||
|
||||
class ModuleBaySerializer(NetBoxModelSerializer):
|
||||
class ModuleBaySerializer(OwnerMixin, NetBoxModelSerializer):
|
||||
device = DeviceSerializer(nested=True)
|
||||
module = ModuleSerializer(
|
||||
nested=True,
|
||||
@@ -365,12 +417,12 @@ class ModuleBaySerializer(NetBoxModelSerializer):
|
||||
model = ModuleBay
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'installed_module', 'label', 'position',
|
||||
'description', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'description', 'owner', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'description')
|
||||
|
||||
|
||||
class DeviceBaySerializer(NetBoxModelSerializer):
|
||||
class DeviceBaySerializer(OwnerMixin, NetBoxModelSerializer):
|
||||
device = DeviceSerializer(nested=True)
|
||||
installed_device = DeviceSerializer(nested=True, required=False, allow_null=True)
|
||||
|
||||
@@ -378,12 +430,12 @@ class DeviceBaySerializer(NetBoxModelSerializer):
|
||||
model = DeviceBay
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'device', 'name', 'label', 'description', 'installed_device',
|
||||
'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'owner', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description')
|
||||
|
||||
|
||||
class InventoryItemSerializer(NetBoxModelSerializer):
|
||||
class InventoryItemSerializer(OwnerMixin, NetBoxModelSerializer):
|
||||
device = DeviceSerializer(nested=True)
|
||||
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
|
||||
role = InventoryItemRoleSerializer(nested=True, required=False, allow_null=True)
|
||||
@@ -402,6 +454,6 @@ class InventoryItemSerializer(NetBoxModelSerializer):
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'device', 'parent', 'name', 'label', 'status', 'role',
|
||||
'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', 'component_type',
|
||||
'component_id', 'component', 'tags', 'custom_fields', 'created', 'last_updated', '_depth',
|
||||
'component_id', 'component', 'owner', 'tags', 'custom_fields', 'created', 'last_updated', '_depth',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', '_depth')
|
||||
|
||||
@@ -111,7 +111,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
|
||||
'id', 'url', 'display_url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial',
|
||||
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
|
||||
'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
|
||||
'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'config_context',
|
||||
'vc_position', 'vc_priority', 'description', 'owner', 'comments', 'config_template', 'config_context',
|
||||
'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'console_port_count',
|
||||
'console_server_port_count', 'power_port_count', 'power_outlet_count', 'interface_count',
|
||||
'front_port_count', 'rear_port_count', 'device_bay_count', 'module_bay_count', 'inventory_item_count',
|
||||
|
||||
@@ -5,12 +5,14 @@ from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models import (
|
||||
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate,
|
||||
InventoryItemTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
|
||||
InventoryItemTemplate, ModuleBayTemplate, PortTemplateMapping, PowerOutletTemplate, PowerPortTemplate,
|
||||
RearPortTemplate,
|
||||
)
|
||||
from netbox.api.fields import ChoiceField, ContentTypeField
|
||||
from netbox.api.gfk_fields import GFKSerializerField
|
||||
from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer
|
||||
from wireless.choices import *
|
||||
from .base import PortSerializer
|
||||
from .devicetypes import DeviceTypeSerializer, ModuleTypeSerializer
|
||||
from .manufacturers import ManufacturerSerializer
|
||||
from .nested import NestedInterfaceTemplateSerializer
|
||||
@@ -205,7 +207,20 @@ class InterfaceTemplateSerializer(ComponentTemplateSerializer):
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
class RearPortTemplateSerializer(ComponentTemplateSerializer):
|
||||
class RearPortTemplateMappingSerializer(serializers.ModelSerializer):
|
||||
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(
|
||||
required=False,
|
||||
nested=True,
|
||||
@@ -219,17 +234,35 @@ class RearPortTemplateSerializer(ComponentTemplateSerializer):
|
||||
default=None
|
||||
)
|
||||
type = ChoiceField(choices=PortTypeChoices)
|
||||
front_ports = RearPortTemplateMappingSerializer(
|
||||
source='mappings',
|
||||
many=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RearPortTemplate
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color',
|
||||
'positions', 'description', 'created', 'last_updated',
|
||||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions',
|
||||
'front_ports', 'description', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
class FrontPortTemplateSerializer(ComponentTemplateSerializer):
|
||||
class FrontPortTemplateMappingSerializer(serializers.ModelSerializer):
|
||||
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(
|
||||
nested=True,
|
||||
required=False,
|
||||
@@ -243,13 +276,17 @@ class FrontPortTemplateSerializer(ComponentTemplateSerializer):
|
||||
default=None
|
||||
)
|
||||
type = ChoiceField(choices=PortTypeChoices)
|
||||
rear_port = RearPortTemplateSerializer(nested=True)
|
||||
rear_ports = FrontPortTemplateMappingSerializer(
|
||||
source='mappings',
|
||||
many=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = FrontPortTemplate
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color',
|
||||
'rear_port', 'rear_port_position', 'description', 'created', 'last_updated',
|
||||
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions',
|
||||
'rear_ports', 'description', 'created', 'last_updated',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
|
||||
|
||||
@@ -11,13 +11,15 @@ class ManufacturerSerializer(OrganizationalModelSerializer):
|
||||
|
||||
# Related object counts
|
||||
devicetype_count = RelatedObjectCountField('device_types')
|
||||
moduletype_count = RelatedObjectCountField('module_types')
|
||||
inventoryitem_count = RelatedObjectCountField('inventory_items')
|
||||
platform_count = RelatedObjectCountField('platforms')
|
||||
|
||||
class Meta:
|
||||
model = Manufacturer
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'owner', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', 'devicetype_count', 'inventoryitem_count', 'platform_count',
|
||||
'id', 'url', 'display_url', 'display', 'name', 'slug', 'description', 'owner', 'comments', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'devicetype_count', 'moduletype_count', 'inventoryitem_count',
|
||||
'platform_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'devicetype_count')
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description')
|
||||
|
||||
@@ -30,7 +30,7 @@ class RackRoleSerializer(OrganizationalModelSerializer):
|
||||
class Meta:
|
||||
model = RackRole
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'owner', 'tags',
|
||||
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'owner', 'comments', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'rack_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count')
|
||||
|
||||
@@ -38,7 +38,7 @@ class InventoryItemRoleSerializer(OrganizationalModelSerializer):
|
||||
class Meta:
|
||||
model = InventoryItemRole
|
||||
fields = [
|
||||
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'owner', 'tags',
|
||||
'id', 'url', 'display_url', 'display', 'name', 'slug', 'color', 'description', 'owner', 'comments', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'inventoryitem_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'inventoryitem_count')
|
||||
|
||||
@@ -1,108 +1,390 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.choices import CableEndChoices
|
||||
from dcim.models import CableTermination
|
||||
|
||||
|
||||
class BaseCableProfile:
|
||||
# Maximum number of terminations allowed per side
|
||||
a_max_connections = None
|
||||
b_max_connections = None
|
||||
"""Base class for representing a cable profile."""
|
||||
|
||||
# Mappings of connectors to the number of positions presented by each, at either end of the cable. For example, a
|
||||
# 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):
|
||||
# Enforce maximum connection limits
|
||||
if self.a_max_connections and len(cable.a_terminations) > self.a_max_connections:
|
||||
# Enforce maximum terminations limits
|
||||
a_terminations_count = len(cable.a_terminations)
|
||||
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({
|
||||
'a_terminations': _(
|
||||
'Maximum A side connections for profile {profile}: {max}'
|
||||
'A side of cable has {count} terminations but only {max} are permitted for profile {profile}'
|
||||
).format(
|
||||
count=a_terminations_count,
|
||||
profile=cable.get_profile_display(),
|
||||
max=self.a_max_connections,
|
||||
max=max_a_terminations,
|
||||
)
|
||||
})
|
||||
if self.b_max_connections and len(cable.b_terminations) > self.b_max_connections:
|
||||
if b_terminations_count > max_b_terminations:
|
||||
raise ValidationError({
|
||||
'b_terminations': _(
|
||||
'Maximum B side connections for profile {profile}: {max}'
|
||||
'B side of cable has {count} terminations but only {max} are permitted for profile {profile}'
|
||||
).format(
|
||||
count=b_terminations_count,
|
||||
profile=cable.get_profile_display(),
|
||||
max=self.b_max_connections,
|
||||
max=max_b_terminations,
|
||||
)
|
||||
})
|
||||
|
||||
def get_mapped_position(self, side, position):
|
||||
def get_mapped_position(self, side, connector, position):
|
||||
"""
|
||||
Return the mapped position for a given cable end and position.
|
||||
|
||||
By default, assume all positions are symmetrical.
|
||||
Return the mapped far-end connector & position for a given cable end the local connector & position.
|
||||
"""
|
||||
return position
|
||||
# By default, assume all positions are symmetrical.
|
||||
if self._mapping:
|
||||
return self._mapping.get((connector, position))
|
||||
return connector, position
|
||||
|
||||
def get_peer_terminations(self, terminations, position_stack):
|
||||
local_end = terminations[0].cable_end
|
||||
qs = CableTermination.objects.filter(
|
||||
cable=terminations[0].cable,
|
||||
cable_end=terminations[0].opposite_cable_end
|
||||
)
|
||||
def get_peer_termination(self, termination, position):
|
||||
"""
|
||||
Given a terminating object, return the peer terminating object (if any) on the opposite end of the cable.
|
||||
"""
|
||||
try:
|
||||
connector, position = self.get_mapped_position(
|
||||
termination.cable_end,
|
||||
termination.cable_connector,
|
||||
position
|
||||
)
|
||||
except TypeError:
|
||||
raise ValueError(
|
||||
f"Could not map connector {termination.cable_connector} position {position} on side "
|
||||
f"{termination.cable_end}"
|
||||
)
|
||||
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
|
||||
|
||||
# TODO: Optimize this to use a single query under any condition
|
||||
if position_stack:
|
||||
# Attempt to find a peer termination at the same position currently in the stack. Pop the stack only if
|
||||
# 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)
|
||||
@staticmethod
|
||||
def get_position_list(n):
|
||||
"""Return a list of integers from 1 to n, inclusive."""
|
||||
return list(range(1, n + 1))
|
||||
|
||||
|
||||
class StraightSingleCableProfile(BaseCableProfile):
|
||||
a_max_connections = 1
|
||||
b_max_connections = 1
|
||||
# Profile naming:
|
||||
# - Single: One connector per side, with one or more positions
|
||||
# - Trunk: Two or more connectors per side, with one or more positions per connector
|
||||
# - 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 StraightMultiCableProfile(BaseCableProfile):
|
||||
a_max_connections = None
|
||||
b_max_connections = None
|
||||
|
||||
|
||||
class Shuffle2x2MPO8CableProfile(BaseCableProfile):
|
||||
a_max_connections = 8
|
||||
b_max_connections = 8
|
||||
_mapping = {
|
||||
class Single1C1PCableProfile(BaseCableProfile):
|
||||
a_connectors = {
|
||||
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,
|
||||
3: 5,
|
||||
4: 6,
|
||||
5: 3,
|
||||
6: 4,
|
||||
7: 7,
|
||||
8: 8,
|
||||
}
|
||||
|
||||
def get_mapped_position(self, side, position):
|
||||
return self._mapping.get(position)
|
||||
b_connectors = a_connectors
|
||||
|
||||
|
||||
class Shuffle4x4MPO8CableProfile(BaseCableProfile):
|
||||
a_max_connections = 8
|
||||
b_max_connections = 8
|
||||
# A side to B side position mapping
|
||||
_a_mapping = {
|
||||
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: 3,
|
||||
3: 5,
|
||||
4: 7,
|
||||
5: 2,
|
||||
6: 4,
|
||||
7: 6,
|
||||
8: 8,
|
||||
2: 1,
|
||||
3: 1,
|
||||
4: 1,
|
||||
}
|
||||
# B side to A side position mapping (reverse of _a_mapping)
|
||||
_b_mapping = {v: k for k, v in _a_mapping.items()}
|
||||
b_connectors = a_connectors
|
||||
|
||||
def get_mapped_position(self, side, position):
|
||||
if side.lower() == 'b':
|
||||
return self._b_mapping.get(position)
|
||||
return self._a_mapping.get(position)
|
||||
|
||||
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,
|
||||
}
|
||||
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,
|
||||
7: 4,
|
||||
8: 4,
|
||||
}
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
class Breakout1C6Px6C1PCableProfile(BaseCableProfile):
|
||||
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):
|
||||
a_connectors = {
|
||||
1: 4,
|
||||
2: 4,
|
||||
}
|
||||
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 = {
|
||||
(1, 1): (1, 1),
|
||||
(1, 2): (2, 1),
|
||||
(1, 3): (5, 1),
|
||||
(1, 4): (6, 1),
|
||||
(2, 1): (3, 1),
|
||||
(2, 2): (4, 1),
|
||||
(2, 3): (7, 1),
|
||||
(2, 4): (8, 1),
|
||||
}
|
||||
_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),
|
||||
}
|
||||
|
||||
def get_mapped_position(self, side, connector, position):
|
||||
if side.upper() == CableEndChoices.SIDE_A:
|
||||
return self._a_mapping.get((connector, position))
|
||||
return self._b_mapping.get((connector, position))
|
||||
|
||||
@@ -1722,16 +1722,74 @@ class PortTypeChoices(ChoiceSet):
|
||||
#
|
||||
|
||||
class CableProfileChoices(ChoiceSet):
|
||||
STRAIGHT_SINGLE = 'straight-single'
|
||||
STRAIGHT_MULTI = 'straight-multi'
|
||||
SHUFFLE_2X2_MPO8 = 'shuffle-2x2-mpo8'
|
||||
SHUFFLE_4X4_MPO8 = 'shuffle-4x4-mpo8'
|
||||
# Singles
|
||||
SINGLE_1C1P = 'single-1c1p'
|
||||
SINGLE_1C2P = 'single-1c2p'
|
||||
SINGLE_1C4P = 'single-1c4p'
|
||||
SINGLE_1C6P = 'single-1c6p'
|
||||
SINGLE_1C8P = 'single-1c8p'
|
||||
SINGLE_1C12P = 'single-1c12p'
|
||||
SINGLE_1C16P = 'single-1c16p'
|
||||
# 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 = (
|
||||
(STRAIGHT_SINGLE, _('Straight (single position)')),
|
||||
(STRAIGHT_MULTI, _('Straight (multi-position)')),
|
||||
(SHUFFLE_2X2_MPO8, _('Shuffle (2x2 MPO8)')),
|
||||
(SHUFFLE_4X4_MPO8, _('Shuffle (4x4 MPO8)')),
|
||||
(
|
||||
_('Single'),
|
||||
(
|
||||
(SINGLE_1C1P, _('1C1P')),
|
||||
(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)')),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ RACK_STARTING_UNIT_DEFAULT = 1
|
||||
# Cables
|
||||
#
|
||||
|
||||
CABLE_CONNECTOR_MIN = 1
|
||||
CABLE_CONNECTOR_MAX = 256
|
||||
|
||||
CABLE_POSITION_MIN = 1
|
||||
CABLE_POSITION_MAX = 1024
|
||||
|
||||
@@ -32,8 +35,8 @@ CABLE_POSITION_MAX = 1024
|
||||
# RearPorts
|
||||
#
|
||||
|
||||
REARPORT_POSITIONS_MIN = 1
|
||||
REARPORT_POSITIONS_MAX = 1024
|
||||
PORT_POSITION_MIN = 1
|
||||
PORT_POSITION_MAX = 1024
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -904,12 +904,15 @@ class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
|
||||
null_value=None
|
||||
)
|
||||
rear_port_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=RearPort.objects.all()
|
||||
field_name='mappings__rear_port',
|
||||
queryset=RearPortTemplate.objects.all(),
|
||||
to_field_name='rear_port',
|
||||
label=_('Rear port (ID)'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = FrontPortTemplate
|
||||
fields = ('id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description')
|
||||
fields = ('id', 'name', 'label', 'type', 'color', 'positions', 'description')
|
||||
|
||||
|
||||
@register_filterset
|
||||
@@ -918,6 +921,12 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCom
|
||||
choices=PortTypeChoices,
|
||||
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:
|
||||
model = RearPortTemplate
|
||||
@@ -1664,6 +1673,17 @@ class DeviceComponentFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
|
||||
choices=DeviceStatusChoices,
|
||||
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):
|
||||
if not value.strip():
|
||||
@@ -1728,7 +1748,9 @@ class ConsolePortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSe
|
||||
|
||||
class Meta:
|
||||
model = ConsolePort
|
||||
fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end', 'cable_position')
|
||||
fields = (
|
||||
'id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end', 'cable_connector',
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
@@ -1740,7 +1762,9 @@ class ConsoleServerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFi
|
||||
|
||||
class Meta:
|
||||
model = ConsoleServerPort
|
||||
fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end', 'cable_position')
|
||||
fields = (
|
||||
'id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end', 'cable_connector',
|
||||
)
|
||||
|
||||
|
||||
@register_filterset
|
||||
@@ -1754,7 +1778,7 @@ class PowerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet,
|
||||
model = PowerPort
|
||||
fields = (
|
||||
'id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable_end',
|
||||
'cable_position',
|
||||
'cable_connector',
|
||||
)
|
||||
|
||||
|
||||
@@ -1781,7 +1805,7 @@ class PowerOutletFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSe
|
||||
model = PowerOutlet
|
||||
fields = (
|
||||
'id', 'name', 'status', 'label', 'feed_leg', 'description', 'color', 'mark_connected', 'cable_end',
|
||||
'cable_position',
|
||||
'cable_connector',
|
||||
)
|
||||
|
||||
|
||||
@@ -2091,7 +2115,7 @@ class InterfaceFilterSet(
|
||||
fields = (
|
||||
'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',
|
||||
'cable_id', 'cable_end', 'cable_position',
|
||||
'cable_id', 'cable_end', 'cable_connector',
|
||||
)
|
||||
|
||||
def filter_virtual_chassis_member_or_master(self, queryset, name, value):
|
||||
@@ -2137,14 +2161,17 @@ class FrontPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet)
|
||||
null_value=None
|
||||
)
|
||||
rear_port_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=RearPort.objects.all()
|
||||
field_name='mappings__rear_port',
|
||||
queryset=RearPort.objects.all(),
|
||||
to_field_name='rear_port',
|
||||
label=_('Rear port (ID)'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = FrontPort
|
||||
fields = (
|
||||
'id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description', 'mark_connected', 'cable_end',
|
||||
'cable_position',
|
||||
'id', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable_end',
|
||||
'cable_connector',
|
||||
)
|
||||
|
||||
|
||||
@@ -2154,12 +2181,18 @@ class RearPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet):
|
||||
choices=PortTypeChoices,
|
||||
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:
|
||||
model = RearPort
|
||||
fields = (
|
||||
'id', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable_end',
|
||||
'cable_position',
|
||||
'cable_connector',
|
||||
)
|
||||
|
||||
|
||||
@@ -2515,7 +2548,7 @@ class CableTerminationFilterSet(ChangeLoggedModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = CableTermination
|
||||
fields = ('id', 'cable', 'cable_end', 'position', 'termination_type', 'termination_id')
|
||||
fields = ('id', 'cable', 'cable_end', 'termination_type', 'termination_id')
|
||||
|
||||
|
||||
@register_filterset
|
||||
@@ -2634,7 +2667,7 @@ class PowerFeedFilterSet(PrimaryModelFilterSet, CabledObjectFilterSet, PathEndpo
|
||||
model = PowerFeed
|
||||
fields = (
|
||||
'id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization',
|
||||
'available_power', 'mark_connected', 'cable_end', 'cable_position', 'description',
|
||||
'available_power', 'mark_connected', 'cable_end', 'cable_connector', 'description',
|
||||
)
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
|
||||
@@ -208,7 +208,7 @@ class RackRoleBulkEditForm(OrganizationalModelBulkEditForm):
|
||||
fieldsets = (
|
||||
FieldSet('color', 'description'),
|
||||
)
|
||||
nullable_fields = ('color', 'description')
|
||||
nullable_fields = ('color', 'description', 'comments')
|
||||
|
||||
|
||||
class RackTypeBulkEditForm(PrimaryModelBulkEditForm):
|
||||
@@ -474,7 +474,7 @@ class ManufacturerBulkEditForm(OrganizationalModelBulkEditForm):
|
||||
fieldsets = (
|
||||
FieldSet('description'),
|
||||
)
|
||||
nullable_fields = ('description',)
|
||||
nullable_fields = ('description', 'comments')
|
||||
|
||||
|
||||
class DeviceTypeBulkEditForm(PrimaryModelBulkEditForm):
|
||||
@@ -1719,7 +1719,7 @@ class InventoryItemRoleBulkEditForm(OrganizationalModelBulkEditForm):
|
||||
fieldsets = (
|
||||
FieldSet('color', 'description'),
|
||||
)
|
||||
nullable_fields = ('color', 'description')
|
||||
nullable_fields = ('color', 'description', 'comments')
|
||||
|
||||
|
||||
class VirtualDeviceContextBulkEditForm(PrimaryModelBulkEditForm):
|
||||
|
||||
@@ -183,7 +183,7 @@ class RackRoleImportForm(OrganizationalModelImportForm):
|
||||
|
||||
class Meta:
|
||||
model = RackRole
|
||||
fields = ('name', 'slug', 'color', 'description', 'owner', 'tags')
|
||||
fields = ('name', 'slug', 'color', 'description', 'owner', 'comments', 'tags')
|
||||
|
||||
|
||||
class RackTypeImportForm(PrimaryModelImportForm):
|
||||
@@ -400,7 +400,7 @@ class ManufacturerImportForm(OrganizationalModelImportForm):
|
||||
|
||||
class Meta:
|
||||
model = Manufacturer
|
||||
fields = ('name', 'slug', 'description', 'owner', 'tags')
|
||||
fields = ('name', 'slug', 'description', 'owner', 'comments', 'tags')
|
||||
|
||||
|
||||
class DeviceTypeImportForm(PrimaryModelImportForm):
|
||||
@@ -476,14 +476,30 @@ class ModuleTypeImportForm(PrimaryModelImportForm):
|
||||
required=False,
|
||||
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:
|
||||
model = ModuleType
|
||||
fields = [
|
||||
'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'profile',
|
||||
'owner', 'comments', 'tags'
|
||||
'attribute_data', '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):
|
||||
parent = CSVModelChoiceField(
|
||||
@@ -1075,12 +1091,6 @@ class FrontPortImportForm(OwnerCSVMixin, NetBoxModelImportForm):
|
||||
queryset=Device.objects.all(),
|
||||
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(
|
||||
label=_('Type'),
|
||||
choices=PortTypeChoices,
|
||||
@@ -1090,32 +1100,9 @@ class FrontPortImportForm(OwnerCSVMixin, NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = FrontPort
|
||||
fields = (
|
||||
'device', 'name', 'label', 'type', 'color', 'mark_connected', 'rear_port', 'rear_port_position',
|
||||
'description', 'owner', 'tags'
|
||||
'device', 'name', 'label', 'type', 'color', 'mark_connected', 'positions', '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):
|
||||
device = CSVModelChoiceField(
|
||||
@@ -1311,7 +1298,7 @@ class InventoryItemRoleImportForm(OrganizationalModelImportForm):
|
||||
|
||||
class Meta:
|
||||
model = InventoryItemRole
|
||||
fields = ('name', 'slug', 'color', 'description')
|
||||
fields = ('name', 'slug', 'color', 'description', 'owner', 'comments')
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -12,10 +12,12 @@ from netbox.forms import (
|
||||
NestedGroupModelFilterSetForm, NetBoxModelFilterSetForm, OrganizationalModelFilterSetForm,
|
||||
PrimaryModelFilterSetForm,
|
||||
)
|
||||
from netbox.forms.mixins import OwnerFilterMixin
|
||||
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
||||
from users.models import Owner, User
|
||||
from tenancy.models import Tenant
|
||||
from users.models import User
|
||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
|
||||
from utilities.forms.fields import ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
|
||||
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
|
||||
from utilities.forms.rendering import FieldSet
|
||||
from utilities.forms.widgets import NumberWithOptions
|
||||
from virtualization.models import Cluster, ClusterGroup, VirtualMachine
|
||||
@@ -26,44 +28,54 @@ __all__ = (
|
||||
'CableFilterForm',
|
||||
'ConsoleConnectionFilterForm',
|
||||
'ConsolePortFilterForm',
|
||||
'ConsolePortTemplateFilterForm',
|
||||
'ConsoleServerPortFilterForm',
|
||||
'ConsoleServerPortTemplateFilterForm',
|
||||
'DeviceBayFilterForm',
|
||||
'DeviceBayTemplateFilterForm',
|
||||
'DeviceFilterForm',
|
||||
'DeviceRoleFilterForm',
|
||||
'DeviceTypeFilterForm',
|
||||
'FrontPortFilterForm',
|
||||
'FrontPortTemplateFilterForm',
|
||||
'InterfaceConnectionFilterForm',
|
||||
'InterfaceFilterForm',
|
||||
'InterfaceTemplateFilterForm',
|
||||
'InventoryItemFilterForm',
|
||||
'InventoryItemTemplateFilterForm',
|
||||
'InventoryItemRoleFilterForm',
|
||||
'LocationFilterForm',
|
||||
'MACAddressFilterForm',
|
||||
'ManufacturerFilterForm',
|
||||
'ModuleFilterForm',
|
||||
'ModuleBayFilterForm',
|
||||
'ModuleBayTemplateFilterForm',
|
||||
'ModuleTypeFilterForm',
|
||||
'ModuleTypeProfileFilterForm',
|
||||
'PlatformFilterForm',
|
||||
'PowerConnectionFilterForm',
|
||||
'PowerFeedFilterForm',
|
||||
'PowerOutletFilterForm',
|
||||
'PowerOutletTemplateFilterForm',
|
||||
'PowerPanelFilterForm',
|
||||
'PowerPortFilterForm',
|
||||
'PowerPortTemplateFilterForm',
|
||||
'RackFilterForm',
|
||||
'RackElevationFilterForm',
|
||||
'RackReservationFilterForm',
|
||||
'RackRoleFilterForm',
|
||||
'RackTypeFilterForm',
|
||||
'RearPortFilterForm',
|
||||
'RearPortTemplateFilterForm',
|
||||
'RegionFilterForm',
|
||||
'SiteFilterForm',
|
||||
'SiteGroupFilterForm',
|
||||
'VirtualChassisFilterForm',
|
||||
'VirtualDeviceContextFilterForm'
|
||||
'VirtualDeviceContextFilterForm',
|
||||
)
|
||||
|
||||
|
||||
class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
|
||||
class DeviceComponentFilterForm(OwnerFilterMixin, NetBoxModelFilterSetForm):
|
||||
name = forms.CharField(
|
||||
label=_('Name'),
|
||||
required=False
|
||||
@@ -123,6 +135,11 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
|
||||
required=False,
|
||||
label=_('Device role')
|
||||
)
|
||||
tenant_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False,
|
||||
label=_('Tenant')
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
@@ -131,7 +148,8 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
|
||||
'location_id': '$location_id',
|
||||
'virtual_chassis_id': '$virtual_chassis_id',
|
||||
'device_type_id': '$device_type_id',
|
||||
'role_id': '$role_id'
|
||||
'role_id': '$role_id',
|
||||
'tenant_id': '$tenant_id'
|
||||
},
|
||||
label=_('Device')
|
||||
)
|
||||
@@ -140,18 +158,14 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
|
||||
required=False,
|
||||
label=_('Device Status'),
|
||||
)
|
||||
owner_id = DynamicModelChoiceField(
|
||||
queryset=Owner.objects.all(),
|
||||
required=False,
|
||||
label=_('Owner'),
|
||||
)
|
||||
|
||||
|
||||
class RegionFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm):
|
||||
model = Region
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('parent_id', name=_('Region')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
|
||||
)
|
||||
parent_id = DynamicModelMultipleChoiceField(
|
||||
@@ -165,8 +179,9 @@ class RegionFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm):
|
||||
class SiteGroupFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm):
|
||||
model = SiteGroup
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('parent_id', name=_('Site Group')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
|
||||
)
|
||||
parent_id = DynamicModelMultipleChoiceField(
|
||||
@@ -180,9 +195,10 @@ class SiteGroupFilterForm(ContactModelFilterForm, NestedGroupModelFilterSetForm)
|
||||
class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
|
||||
model = Site
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('status', 'region_id', 'group_id', 'asn_id', name=_('Attributes')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'region_id', 'group_id')
|
||||
@@ -212,9 +228,10 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilt
|
||||
class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NestedGroupModelFilterSetForm):
|
||||
model = Location
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'parent_id', 'status', 'facility', name=_('Attributes')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
@@ -260,7 +277,8 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NestedGroupM
|
||||
class RackRoleFilterForm(OrganizationalModelFilterSetForm):
|
||||
model = RackRole
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -311,10 +329,11 @@ class RackBaseFilterForm(PrimaryModelFilterSetForm):
|
||||
class RackTypeFilterForm(RackBaseFilterForm):
|
||||
model = RackType
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('manufacturer_id', 'form_factor', 'width', 'u_height', 'rack_count', name=_('Rack Type')),
|
||||
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
|
||||
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'manufacturer_id')
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
@@ -333,13 +352,14 @@ class RackTypeFilterForm(RackBaseFilterForm):
|
||||
class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterForm):
|
||||
model = Rack
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
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('form_factor', 'width', 'u_height', 'airflow', name=_('Hardware')),
|
||||
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
|
||||
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')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id')
|
||||
@@ -416,9 +436,10 @@ class RackElevationFilterForm(RackFilterForm):
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'id', name=_('Location')),
|
||||
FieldSet('status', 'role_id', name=_('Function')),
|
||||
FieldSet('type', 'width', 'serial', 'asset_tag', name=_('Hardware')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
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')),
|
||||
)
|
||||
id = DynamicModelMultipleChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
@@ -434,10 +455,11 @@ class RackElevationFilterForm(RackFilterForm):
|
||||
class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
model = RackReservation
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('status', 'user_id', name=_('Reservation')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Rack')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -492,7 +514,8 @@ class RackReservationFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
class ManufacturerFilterForm(ContactModelFilterForm, OrganizationalModelFilterSetForm):
|
||||
model = Manufacturer
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts'))
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
@@ -501,7 +524,7 @@ class ManufacturerFilterForm(ContactModelFilterForm, OrganizationalModelFilterSe
|
||||
class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
|
||||
model = DeviceType
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet(
|
||||
'manufacturer_id', 'default_platform_id', 'part_number', 'device_count',
|
||||
'subdevice_role', 'airflow', name=_('Hardware')
|
||||
@@ -512,6 +535,7 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
|
||||
'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', name=_('Components')
|
||||
),
|
||||
FieldSet('weight', 'weight_unit', name=_('Weight')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'manufacturer_id')
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
@@ -635,7 +659,8 @@ class DeviceTypeFilterForm(PrimaryModelFilterSetForm):
|
||||
class ModuleTypeProfileFilterForm(PrimaryModelFilterSetForm):
|
||||
model = ModuleTypeProfile
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q')
|
||||
tag = TagFilterField(model)
|
||||
@@ -644,7 +669,7 @@ class ModuleTypeProfileFilterForm(PrimaryModelFilterSetForm):
|
||||
class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
|
||||
model = ModuleType
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet(
|
||||
'profile_id', 'manufacturer_id', 'part_number', 'module_count',
|
||||
'airflow', name=_('Hardware')
|
||||
@@ -654,6 +679,7 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
|
||||
'pass_through_ports', name=_('Components')
|
||||
),
|
||||
FieldSet('weight', 'weight_unit', name=_('Weight')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'manufacturer_id')
|
||||
profile_id = DynamicModelMultipleChoiceField(
|
||||
@@ -737,8 +763,9 @@ class ModuleTypeFilterForm(PrimaryModelFilterSetForm):
|
||||
class DeviceRoleFilterForm(NestedGroupModelFilterSetForm):
|
||||
model = DeviceRole
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('parent_id', 'config_template_id', name=_('Device Role'))
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('parent_id', 'config_template_id', name=_('Device Role')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
config_template_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ConfigTemplate.objects.all(),
|
||||
@@ -756,8 +783,9 @@ class DeviceRoleFilterForm(NestedGroupModelFilterSetForm):
|
||||
class PlatformFilterForm(NestedGroupModelFilterSetForm):
|
||||
model = Platform
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('manufacturer_id', 'parent_id', 'config_template_id', name=_('Platform'))
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
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')
|
||||
parent_id = DynamicModelMultipleChoiceField(
|
||||
@@ -786,11 +814,12 @@ class DeviceFilterForm(
|
||||
):
|
||||
model = Device
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
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('manufacturer_id', 'device_type_id', 'platform_id', name=_('Hardware')),
|
||||
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(
|
||||
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
|
||||
@@ -979,9 +1008,10 @@ class DeviceFilterForm(
|
||||
class VirtualDeviceContextFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
model = VirtualDeviceContext
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('device', 'status', 'has_primary_ip', name=_('Attributes')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
device = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
@@ -1006,9 +1036,10 @@ class VirtualDeviceContextFilterForm(TenancyFilterForm, PrimaryModelFilterSetFor
|
||||
class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
model = Module
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
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('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
@@ -1089,9 +1120,10 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, PrimaryM
|
||||
class VirtualChassisFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
model = VirtualChassis
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -1118,10 +1150,11 @@ class VirtualChassisFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
model = Cable
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('site_id', 'location_id', 'rack_id', 'device_id', name=_('Location')),
|
||||
FieldSet('type', 'status', 'profile', 'color', 'length', 'length_unit', 'unterminated', name=_('Attributes')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -1207,8 +1240,9 @@ class CableFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
class PowerPanelFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
|
||||
model = PowerPanel
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
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')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'site_id', 'location_id')
|
||||
@@ -1246,10 +1280,11 @@ class PowerPanelFilterForm(ContactModelFilterForm, PrimaryModelFilterSetForm):
|
||||
class PowerFeedFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
model = PowerFeed
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id', name=_('Location')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', name=_('Attributes')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@@ -1326,6 +1361,23 @@ class PowerFeedFilterForm(TenancyFilterForm, PrimaryModelFilterSetForm):
|
||||
# Device components
|
||||
#
|
||||
|
||||
class DeviceComponentTemplateFilterForm(NetBoxModelFilterSetForm):
|
||||
device_type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DeviceType.objects.all(),
|
||||
required=False,
|
||||
label=_('Device type'),
|
||||
)
|
||||
|
||||
|
||||
class ModularDeviceComponentTemplateFilterForm(DeviceComponentTemplateFilterForm):
|
||||
module_type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=ModuleType.objects.all(),
|
||||
required=False,
|
||||
query_params={'manufacturer_id': '$manufacturer_id'},
|
||||
label=_('Module Type'),
|
||||
)
|
||||
|
||||
|
||||
class CabledFilterForm(forms.Form):
|
||||
cabled = forms.NullBooleanField(
|
||||
label=_('Cabled'),
|
||||
@@ -1356,13 +1408,15 @@ class PathEndpointFilterForm(CabledFilterForm):
|
||||
class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
model = ConsolePort
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
@@ -1377,17 +1431,32 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ConsolePortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = ConsolePortTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', name=_('Attributes')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=ConsolePortTypeChoices,
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
model = ConsoleServerPort
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
@@ -1402,16 +1471,32 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = ConsoleServerPortTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', name=_('Attributes')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=ConsolePortTypeChoices,
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
model = PowerPort
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
@@ -1421,17 +1506,32 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class PowerPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = PowerPortTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', name=_('Attributes')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=PowerPortTypeChoices,
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
model = PowerOutlet
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', 'color', 'status', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
@@ -1450,10 +1550,24 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
)
|
||||
|
||||
|
||||
class PowerOutletTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = PowerOutletTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', name=_('Attributes')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=PowerOutletTypeChoices,
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
model = Interface
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only', name=_('Attributes')),
|
||||
FieldSet('vrf_id', 'l2vpn_id', 'mac_address', 'wwn', name=_('Addressing')),
|
||||
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
|
||||
@@ -1461,10 +1575,12 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
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(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', 'vdc_id',
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
'vdc_id',
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'device_id')
|
||||
vdc_id = DynamicModelMultipleChoiceField(
|
||||
@@ -1576,15 +1692,62 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class InterfaceTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = InterfaceTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', 'enabled', 'mgmt_only', name=_('Attributes')),
|
||||
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
|
||||
FieldSet('rf_role', name=_('Wireless')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=InterfaceTypeChoices,
|
||||
required=False
|
||||
)
|
||||
enabled = forms.NullBooleanField(
|
||||
label=_('Enabled'),
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
mgmt_only = forms.NullBooleanField(
|
||||
label=_('Management only'),
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
poe_mode = forms.MultipleChoiceField(
|
||||
choices=InterfacePoEModeChoices,
|
||||
required=False,
|
||||
label=_('PoE mode')
|
||||
)
|
||||
poe_type = forms.MultipleChoiceField(
|
||||
choices=InterfacePoETypeChoices,
|
||||
required=False,
|
||||
label=_('PoE type')
|
||||
)
|
||||
rf_role = forms.MultipleChoiceField(
|
||||
choices=WirelessRoleChoices,
|
||||
required=False,
|
||||
label=_('Wireless role')
|
||||
)
|
||||
|
||||
|
||||
class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('cabled', 'occupied', name=_('Cable')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
model = FrontPort
|
||||
type = forms.MultipleChoiceField(
|
||||
@@ -1599,17 +1762,36 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class FrontPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = FrontPortTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=PortTypeChoices,
|
||||
required=False
|
||||
)
|
||||
color = ColorField(
|
||||
label=_('Color'),
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
model = RearPort
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('cabled', 'occupied', name=_('Cable')),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
@@ -1623,16 +1805,35 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class RearPortTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = RearPortTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
label=_('Type'),
|
||||
choices=PortTypeChoices,
|
||||
required=False
|
||||
)
|
||||
color = ColorField(
|
||||
label=_('Color'),
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class ModuleBayFilterForm(DeviceComponentFilterForm):
|
||||
model = ModuleBay
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'position', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
position = forms.CharField(
|
||||
@@ -1641,33 +1842,57 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
|
||||
)
|
||||
|
||||
|
||||
class ModuleBayTemplateFilterForm(ModularDeviceComponentTemplateFilterForm):
|
||||
model = ModuleBayTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'position', name=_('Attributes')),
|
||||
FieldSet('device_type_id', 'module_type_id', name=_('Device')),
|
||||
)
|
||||
position = forms.CharField(
|
||||
label=_('Position'),
|
||||
required=False,
|
||||
)
|
||||
|
||||
|
||||
class DeviceBayFilterForm(DeviceComponentFilterForm):
|
||||
model = DeviceBay
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', name=_('Attributes')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class DeviceBayTemplateFilterForm(DeviceComponentTemplateFilterForm):
|
||||
model = DeviceBayTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', name=_('Attributes')),
|
||||
FieldSet('device_type_id', name=_('Device')),
|
||||
)
|
||||
|
||||
|
||||
class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||
model = InventoryItem
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet(
|
||||
'name', 'label', 'status', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered',
|
||||
name=_('Attributes')
|
||||
),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
|
||||
FieldSet(
|
||||
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
'tenant_id', 'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
|
||||
name=_('Device')
|
||||
),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
queryset=InventoryItemRole.objects.all(),
|
||||
@@ -1702,6 +1927,25 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class InventoryItemTemplateFilterForm(DeviceComponentTemplateFilterForm):
|
||||
model = InventoryItemTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('name', 'label', 'role_id', 'manufacturer_id', name=_('Attributes')),
|
||||
FieldSet('device_type_id', name=_('Device')),
|
||||
)
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
queryset=InventoryItemRole.objects.all(),
|
||||
required=False,
|
||||
label=_('Role')
|
||||
)
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
label=_('Manufacturer')
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Device component roles
|
||||
#
|
||||
@@ -1709,7 +1953,8 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||
class InventoryItemRoleFilterForm(OrganizationalModelFilterSetForm):
|
||||
model = InventoryItemRole
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('owner_group_id', 'owner_id', name=_('Ownership')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -1721,9 +1966,10 @@ class InventoryItemRoleFilterForm(OrganizationalModelFilterSetForm):
|
||||
class MACAddressFilterForm(PrimaryModelFilterSetForm):
|
||||
model = MACAddress
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('mac_address', name=_('Attributes')),
|
||||
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')
|
||||
mac_address = forms.CharField(
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
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 dcim.constants import LOCATION_SCOPE_TYPES
|
||||
from dcim.models import Site
|
||||
from dcim.models import PortMapping, PortTemplateMapping, Site
|
||||
from utilities.forms import get_field_value
|
||||
from utilities.forms.fields import (
|
||||
ContentTypeChoiceField, CSVContentTypeField, DynamicModelChoiceField,
|
||||
@@ -13,6 +15,7 @@ from utilities.templatetags.builtins.filters import bettertitle
|
||||
from utilities.forms.widgets import HTMXSelect
|
||||
|
||||
__all__ = (
|
||||
'FrontPortFormMixin',
|
||||
'ScopedBulkEditForm',
|
||||
'ScopedForm',
|
||||
'ScopedImportForm',
|
||||
@@ -72,7 +75,7 @@ class ScopedForm(forms.Form):
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
if self.instance and scope_type_id != self.instance.scope_type_id:
|
||||
if self.instance and self.instance.pk and scope_type_id != self.instance.scope_type_id:
|
||||
self.initial['scope'] = None
|
||||
|
||||
else:
|
||||
@@ -128,3 +131,91 @@ class ScopedImportForm(forms.Form):
|
||||
"Please select a {scope_type}."
|
||||
).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
|
||||
|
||||
@@ -6,6 +6,7 @@ from timezone_field import TimeZoneFormField
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.forms.mixins import FrontPortFormMixin
|
||||
from dcim.models import *
|
||||
from extras.models import ConfigTemplate
|
||||
from ipam.choices import VLANQinQRoleChoices
|
||||
@@ -19,7 +20,9 @@ from utilities.forms.fields import (
|
||||
DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SlugField,
|
||||
)
|
||||
from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
|
||||
from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK
|
||||
from utilities.forms.widgets import (
|
||||
APISelect, ClearableFileInput, ClearableSelect, HTMXSelect, NumberWithOptions, SelectWithPK,
|
||||
)
|
||||
from utilities.jsonschema import JSONSchemaProperty
|
||||
from virtualization.models import Cluster, VMInterface
|
||||
from wireless.models import WirelessLAN, WirelessLANGroup
|
||||
@@ -201,7 +204,7 @@ class RackRoleForm(OrganizationalModelForm):
|
||||
class Meta:
|
||||
model = RackRole
|
||||
fields = [
|
||||
'name', 'slug', 'color', 'description', 'owner', 'tags',
|
||||
'name', 'slug', 'color', 'description', 'owner', 'comments', 'tags',
|
||||
]
|
||||
|
||||
|
||||
@@ -344,7 +347,7 @@ class ManufacturerForm(OrganizationalModelForm):
|
||||
class Meta:
|
||||
model = Manufacturer
|
||||
fields = [
|
||||
'name', 'slug', 'description', 'owner', 'tags',
|
||||
'name', 'slug', 'description', 'owner', 'comments', 'tags',
|
||||
]
|
||||
|
||||
|
||||
@@ -591,6 +594,14 @@ class DeviceForm(TenancyForm, PrimaryModelForm):
|
||||
},
|
||||
)
|
||||
)
|
||||
face = forms.ChoiceField(
|
||||
label=_('Face'),
|
||||
choices=add_blank_choice(DeviceFaceChoices),
|
||||
required=False,
|
||||
widget=ClearableSelect(
|
||||
requires_fields=['rack']
|
||||
)
|
||||
)
|
||||
device_type = DynamicModelChoiceField(
|
||||
label=_('Device type'),
|
||||
queryset=DeviceType.objects.all(),
|
||||
@@ -1112,34 +1123,45 @@ class InterfaceTemplateForm(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',
|
||||
}
|
||||
)
|
||||
|
||||
class FrontPortTemplateForm(FrontPortFormMixin, ModularComponentTemplateForm):
|
||||
fieldsets = (
|
||||
FieldSet(
|
||||
TabbedGroups(
|
||||
FieldSet('device_type', name=_('Device Type')),
|
||||
FieldSet('module_type', name=_('Module Type')),
|
||||
),
|
||||
'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
|
||||
'name', 'label', 'type', 'positions', 'rear_ports', 'description',
|
||||
),
|
||||
)
|
||||
|
||||
port_mapping_model = PortTemplateMapping
|
||||
rear_port_model = RearPortTemplate
|
||||
|
||||
class Meta:
|
||||
model = FrontPortTemplate
|
||||
fields = [
|
||||
'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position',
|
||||
'description',
|
||||
'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', '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):
|
||||
fieldsets = (
|
||||
@@ -1578,28 +1600,41 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
|
||||
}
|
||||
|
||||
|
||||
class FrontPortForm(ModularDeviceComponentForm):
|
||||
rear_port = DynamicModelChoiceField(
|
||||
queryset=RearPort.objects.all(),
|
||||
query_params={
|
||||
'device_id': '$device',
|
||||
}
|
||||
)
|
||||
|
||||
class FrontPortForm(FrontPortFormMixin, ModularDeviceComponentForm):
|
||||
fieldsets = (
|
||||
FieldSet(
|
||||
'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected',
|
||||
'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'rear_ports', 'mark_connected',
|
||||
'description', 'tags',
|
||||
),
|
||||
)
|
||||
|
||||
port_mapping_model = PortMapping
|
||||
rear_port_model = RearPort
|
||||
|
||||
class Meta:
|
||||
model = FrontPort
|
||||
fields = [
|
||||
'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected',
|
||||
'description', 'owner', 'tags',
|
||||
'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', '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):
|
||||
fieldsets = (
|
||||
@@ -1815,7 +1850,7 @@ class InventoryItemRoleForm(OrganizationalModelForm):
|
||||
class Meta:
|
||||
model = InventoryItemRole
|
||||
fields = [
|
||||
'name', 'slug', 'color', 'description', 'owner', 'tags',
|
||||
'name', 'slug', 'color', 'description', 'owner', 'comments', 'tags',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.models import *
|
||||
from netbox.forms import NetBoxModelForm
|
||||
from netbox.forms.mixins import OwnerMixin
|
||||
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
|
||||
from utilities.forms.rendering import FieldSet, TabbedGroups
|
||||
from utilities.forms.widgets import APISelect
|
||||
@@ -109,85 +110,30 @@ class InterfaceTemplateCreateForm(ComponentCreateForm, model_forms.InterfaceTemp
|
||||
|
||||
|
||||
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 to omit rear_port_position
|
||||
# Override fieldsets from FrontPortTemplateForm
|
||||
fieldsets = (
|
||||
FieldSet(
|
||||
TabbedGroups(
|
||||
FieldSet('device_type', name=_('Device Type')),
|
||||
FieldSet('module_type', name=_('Module Type')),
|
||||
),
|
||||
'name', 'label', 'type', 'color', 'rear_port', 'description',
|
||||
'name', 'label', 'type', 'color', 'positions', 'rear_ports', 'description',
|
||||
),
|
||||
)
|
||||
|
||||
class Meta(model_forms.FrontPortTemplateForm.Meta):
|
||||
exclude = ('name', 'label', 'rear_port', 'rear_port_position')
|
||||
|
||||
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
|
||||
)
|
||||
})
|
||||
class Meta:
|
||||
model = FrontPortTemplate
|
||||
fields = (
|
||||
'device_type', 'module_type', 'type', 'color', 'positions', 'description',
|
||||
)
|
||||
|
||||
def get_iterative_data(self, iteration):
|
||||
|
||||
# Assign rear port and position from selected set
|
||||
rear_port, position = self.cleaned_data['rear_port'][iteration].split(':')
|
||||
positions = self.cleaned_data['positions']
|
||||
offset = positions * iteration
|
||||
|
||||
return {
|
||||
'rear_port': int(rear_port),
|
||||
'rear_port_position': int(position),
|
||||
'rear_ports': self.cleaned_data['rear_ports'][offset:offset + positions]
|
||||
}
|
||||
|
||||
|
||||
@@ -269,74 +215,26 @@ 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
|
||||
fieldsets = (
|
||||
FieldSet(
|
||||
'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'mark_connected', 'description', 'tags',
|
||||
'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'rear_ports', 'mark_connected',
|
||||
'description', 'tags',
|
||||
),
|
||||
)
|
||||
|
||||
class Meta(model_forms.FrontPortForm.Meta):
|
||||
exclude = ('name', 'label', 'rear_port', 'rear_port_position')
|
||||
|
||||
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()
|
||||
class Meta:
|
||||
model = FrontPort
|
||||
fields = [
|
||||
'device', 'module', 'type', 'color', 'positions', 'mark_connected', 'description', 'owner', 'tags',
|
||||
]
|
||||
|
||||
# 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):
|
||||
|
||||
# Assign rear port and position from selected set
|
||||
rear_port, position = self.cleaned_data['rear_port'][iteration].split(':')
|
||||
|
||||
positions = self.cleaned_data['positions']
|
||||
offset = positions * iteration
|
||||
return {
|
||||
'rear_port': int(rear_port),
|
||||
'rear_port_position': int(position),
|
||||
'rear_ports': self.cleaned_data['rear_ports'][offset:offset + positions]
|
||||
}
|
||||
|
||||
|
||||
@@ -374,7 +272,7 @@ class InventoryItemCreateForm(ComponentCreateForm, model_forms.InventoryItemForm
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
class VirtualChassisCreateForm(NetBoxModelForm):
|
||||
class VirtualChassisCreateForm(OwnerMixin, NetBoxModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
label=_('Region'),
|
||||
queryset=Region.objects.all(),
|
||||
|
||||
@@ -13,6 +13,7 @@ __all__ = (
|
||||
'InterfaceTemplateImportForm',
|
||||
'InventoryItemTemplateImportForm',
|
||||
'ModuleBayTemplateImportForm',
|
||||
'PortTemplateMappingImportForm',
|
||||
'PowerOutletTemplateImportForm',
|
||||
'PowerPortTemplateImportForm',
|
||||
'RearPortTemplateImportForm',
|
||||
@@ -113,31 +114,11 @@ class FrontPortTemplateImportForm(forms.ModelForm):
|
||||
label=_('Type'),
|
||||
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:
|
||||
model = FrontPortTemplate
|
||||
fields = [
|
||||
'device_type', 'module_type', 'name', 'type', 'color', 'rear_port', 'rear_port_position', 'label',
|
||||
'description',
|
||||
'device_type', 'module_type', 'name', 'type', 'color', 'positions', 'label', 'description',
|
||||
]
|
||||
|
||||
|
||||
@@ -154,6 +135,25 @@ 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 Meta:
|
||||
|
||||
@@ -13,6 +13,7 @@ if TYPE_CHECKING:
|
||||
from netbox.graphql.filter_lookups import IntegerLookup
|
||||
from extras.graphql.filters import ConfigTemplateFilter
|
||||
from ipam.graphql.filters import VLANFilter, VLANTranslationPolicyFilter
|
||||
from dcim.graphql.filters import LocationFilter, RegionFilter, SiteFilter, SiteGroupFilter
|
||||
from .filters import *
|
||||
|
||||
__all__ = (
|
||||
@@ -35,9 +36,32 @@ class ScopedFilterMixin:
|
||||
)
|
||||
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
|
||||
class ComponentModelFilterMixin:
|
||||
_site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='site')
|
||||
)
|
||||
_location: Annotated['LocationFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='location')
|
||||
)
|
||||
_rack: Annotated['RackFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='rack')
|
||||
)
|
||||
device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
|
||||
device_id: ID | None = strawberry_django.filter_field()
|
||||
name: FilterLookup[str] | None = strawberry_django.filter_field()
|
||||
|
||||
@@ -16,7 +16,8 @@ from dcim.graphql.filter_mixins import (
|
||||
from extras.graphql.filter_mixins import ConfigContextFilterMixin
|
||||
from netbox.graphql.filter_mixins import ImageAttachmentFilterMixin, WeightFilterMixin
|
||||
from netbox.graphql.filters import (
|
||||
ChangeLoggedModelFilter, NestedGroupModelFilter, OrganizationalModelFilter, PrimaryModelFilter, NetBoxModelFilter,
|
||||
BaseModelFilter, ChangeLoggedModelFilter, NestedGroupModelFilter, OrganizationalModelFilter, PrimaryModelFilter,
|
||||
NetBoxModelFilter,
|
||||
)
|
||||
from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
|
||||
from virtualization.models import VMInterface
|
||||
@@ -70,6 +71,8 @@ __all__ = (
|
||||
'ModuleTypeFilter',
|
||||
'ModuleTypeProfileFilter',
|
||||
'PlatformFilter',
|
||||
'PortMappingFilter',
|
||||
'PortTemplateMappingFilter',
|
||||
'PowerFeedFilter',
|
||||
'PowerOutletFilter',
|
||||
'PowerOutletTemplateFilter',
|
||||
@@ -404,13 +407,6 @@ class FrontPortFilter(ModularComponentFilterMixin, CabledObjectModelFilterMixin,
|
||||
color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
|
||||
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)
|
||||
@@ -421,13 +417,37 @@ class FrontPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM
|
||||
color: BaseFilterLookup[Annotated['ColorEnum', strawberry.lazy('netbox.graphql.enums')]] | None = (
|
||||
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 = (
|
||||
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()
|
||||
)
|
||||
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.MACAddress, lookups=True)
|
||||
@@ -530,6 +550,10 @@ class InterfaceFilter(
|
||||
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
|
||||
def connected(self, queryset, value: bool, prefix: str):
|
||||
if value is True:
|
||||
@@ -869,7 +893,7 @@ class PowerPortTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.RackType, lookups=True)
|
||||
class RackTypeFilter(RackFilterMixin, WeightFilterMixin, PrimaryModelFilter):
|
||||
class RackTypeFilter(ImageAttachmentFilterMixin, RackFilterMixin, WeightFilterMixin, PrimaryModelFilter):
|
||||
form_factor: BaseFilterLookup[Annotated['RackFormFactorEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
|
||||
@@ -385,7 +385,8 @@ class DeviceTypeType(PrimaryObjectType):
|
||||
)
|
||||
class FrontPortType(ModularComponentType, CabledObjectMixin):
|
||||
color: str
|
||||
rear_port: Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')]
|
||||
|
||||
mappings: List[Annotated["PortMappingType", strawberry.lazy('dcim.graphql.types')]]
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
@@ -396,7 +397,8 @@ class FrontPortType(ModularComponentType, CabledObjectMixin):
|
||||
)
|
||||
class FrontPortTemplateType(ModularComponentTemplateType):
|
||||
color: str
|
||||
rear_port: Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')]
|
||||
|
||||
mappings: List[Annotated["PortMappingTemplateType", strawberry.lazy('dcim.graphql.types')]]
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
@@ -636,6 +638,28 @@ class PlatformType(NestedGroupObjectType):
|
||||
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(
|
||||
models.PowerFeed,
|
||||
exclude=['_path'],
|
||||
@@ -710,7 +734,7 @@ class PowerPortTemplateType(ModularComponentTemplateType):
|
||||
filters=RackTypeFilter,
|
||||
pagination=True
|
||||
)
|
||||
class RackTypeType(PrimaryObjectType):
|
||||
class RackTypeType(ImageAttachmentsMixin, PrimaryObjectType):
|
||||
rack_count: BigInt
|
||||
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
|
||||
|
||||
@@ -768,7 +792,7 @@ class RackRoleType(OrganizationalObjectType):
|
||||
class RearPortType(ModularComponentType, CabledObjectMixin):
|
||||
color: str
|
||||
|
||||
frontports: List[Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')]]
|
||||
mappings: List[Annotated["PortMappingType", strawberry.lazy('dcim.graphql.types')]]
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
@@ -780,7 +804,7 @@ class RearPortType(ModularComponentType, CabledObjectMixin):
|
||||
class RearPortTemplateType(ModularComponentTemplateType):
|
||||
color: str
|
||||
|
||||
frontport_templates: List[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]]
|
||||
mappings: List[Annotated["PortMappingTemplateType", strawberry.lazy('dcim.graphql.types')]]
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import decimal
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
@@ -17,8 +19,8 @@ class Migration(migrations.Migration):
|
||||
max_digits=8,
|
||||
null=True,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(-90.0),
|
||||
django.core.validators.MaxValueValidator(90.0),
|
||||
django.core.validators.MinValueValidator(decimal.Decimal('-90.0')),
|
||||
django.core.validators.MaxValueValidator(decimal.Decimal('90.0'))
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -31,8 +33,8 @@ class Migration(migrations.Migration):
|
||||
max_digits=9,
|
||||
null=True,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(-180.0),
|
||||
django.core.validators.MaxValueValidator(180.0),
|
||||
django.core.validators.MinValueValidator(decimal.Decimal('-180.0')),
|
||||
django.core.validators.MaxValueValidator(decimal.Decimal('180.0'))
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -45,8 +47,8 @@ class Migration(migrations.Migration):
|
||||
max_digits=8,
|
||||
null=True,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(-90.0),
|
||||
django.core.validators.MaxValueValidator(90.0),
|
||||
django.core.validators.MinValueValidator(decimal.Decimal('-90.0')),
|
||||
django.core.validators.MaxValueValidator(decimal.Decimal('90.0'))
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -59,8 +61,8 @@ class Migration(migrations.Migration):
|
||||
max_digits=9,
|
||||
null=True,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(-180.0),
|
||||
django.core.validators.MaxValueValidator(180.0),
|
||||
django.core.validators.MinValueValidator(decimal.Decimal('-180.0')),
|
||||
django.core.validators.MaxValueValidator(decimal.Decimal('180.0'))
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import django.contrib.postgres.fields
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
@@ -16,25 +17,40 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cabletermination',
|
||||
name='position',
|
||||
field=models.PositiveIntegerField(
|
||||
name='connector',
|
||||
field=models.PositiveSmallIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(1024),
|
||||
],
|
||||
django.core.validators.MaxValueValidator(256)
|
||||
]
|
||||
),
|
||||
),
|
||||
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(
|
||||
name='cabletermination',
|
||||
options={'ordering': ('cable', 'cable_end', 'position', 'pk')},
|
||||
options={'ordering': ('cable', 'cable_end', 'connector', 'pk')}, # connector may be null
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='cabletermination',
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=('cable', 'cable_end', 'position'),
|
||||
name='dcim_cabletermination_unique_position'
|
||||
fields=('cable', 'cable_end', 'connector'),
|
||||
name='dcim_cabletermination_unique_connector'
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
228
netbox/dcim/migrations/0221_cable_connector_positions.py
Normal file
228
netbox/dcim/migrations/0221_cable_connector_positions.py
Normal file
@@ -0,0 +1,228 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,107 +0,0 @@
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
219
netbox/dcim/migrations/0222_port_mappings.py
Normal file
219
netbox/dcim/migrations/0222_port_mappings.py
Normal file
@@ -0,0 +1,219 @@
|
||||
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
|
||||
),
|
||||
]
|
||||
65
netbox/dcim/migrations/0223_frontport_positions.py
Normal file
65
netbox/dcim/migrations/0223_frontport_positions.py
Normal file
@@ -0,0 +1,65 @@
|
||||
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)
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user