mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-05 00:19:32 +01:00
Compare commits
280 Commits
v3.6-beta1
...
v3.6.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28080e9b14 | ||
|
|
04fd45581d | ||
|
|
0a8eb7fcbe | ||
|
|
ac3fc25dfd | ||
|
|
82591ad8a1 | ||
|
|
6dddb6c9d2 | ||
|
|
290aae592d | ||
|
|
ff021a8e4e | ||
|
|
3a3d43911c | ||
|
|
c43c63a817 | ||
|
|
792b353f64 | ||
|
|
01ba4ce129 | ||
|
|
fc7d6e1387 | ||
|
|
080da68b6a | ||
|
|
7d413ea3c2 | ||
|
|
40763b58bd | ||
|
|
d52a6d3b10 | ||
|
|
6ac25eeb65 | ||
|
|
41eae1bc19 | ||
|
|
351aaf8397 | ||
|
|
5c27d29b08 | ||
|
|
e1bedb8350 | ||
|
|
dd5e20aa1a | ||
|
|
217a9edb4c | ||
|
|
ad95760ead | ||
|
|
57bf2a2f00 | ||
|
|
e5c38e0829 | ||
|
|
6b89da2233 | ||
|
|
092f2b06ab | ||
|
|
6900097e2d | ||
|
|
5000564430 | ||
|
|
95519b42a0 | ||
|
|
dfef89ab88 | ||
|
|
0603dd1be4 | ||
|
|
1203d761f4 | ||
|
|
d2c727c0a2 | ||
|
|
ac4b46b502 | ||
|
|
6e8ee9db89 | ||
|
|
94858ac13f | ||
|
|
b0f2de5bd7 | ||
|
|
60e98324c3 | ||
|
|
66b9cdf141 | ||
|
|
22e474ff96 | ||
|
|
b3fb393490 | ||
|
|
5b2f29480a | ||
|
|
809b049590 | ||
|
|
2a0a7d45aa | ||
|
|
7efbfabc0b | ||
|
|
d195f9c6ea | ||
|
|
de298224f1 | ||
|
|
3fd8e48fac | ||
|
|
ab9de43447 | ||
|
|
51ef4fb920 | ||
|
|
7983c2590e | ||
|
|
d77d45e795 | ||
|
|
a24864bc6d | ||
|
|
c671ac2f28 | ||
|
|
18a813aa39 | ||
|
|
14447befb9 | ||
|
|
06ed7ac8a5 | ||
|
|
72f01b3e89 | ||
|
|
2522056bd1 | ||
|
|
01c894e625 | ||
|
|
4286c1cde2 | ||
|
|
383285fb94 | ||
|
|
e23b246d46 | ||
|
|
a543bd469a | ||
|
|
d03859b27b | ||
|
|
bbb133019d | ||
|
|
285187542d | ||
|
|
4d13f4d252 | ||
|
|
e4a9cad756 | ||
|
|
b93b331d86 | ||
|
|
a46255ddda | ||
|
|
6093debb71 | ||
|
|
6dc560596d | ||
|
|
5cb1a6b790 | ||
|
|
ef460a38ed | ||
|
|
786f0cc7f3 | ||
|
|
ccc9e89e1a | ||
|
|
9e35cefaf2 | ||
|
|
1a00765b72 | ||
|
|
4dd229e73a | ||
|
|
db40119faa | ||
|
|
f65744faee | ||
|
|
1ad6d94dc3 | ||
|
|
b759d694ee | ||
|
|
3cb41bbe3a | ||
|
|
099aff5ebe | ||
|
|
f9ceaad284 | ||
|
|
e67624f042 | ||
|
|
27297c7556 | ||
|
|
685ac5f571 | ||
|
|
0ce2b1b779 | ||
|
|
04796a6ac6 | ||
|
|
a8a4bd7c21 | ||
|
|
a0e5e69283 | ||
|
|
df46198b91 | ||
|
|
b670a1e22c | ||
|
|
9b325f4b86 | ||
|
|
952be24365 | ||
|
|
b57a47475d | ||
|
|
4f05cf55a5 | ||
|
|
5dcf8502af | ||
|
|
7a21541ed6 | ||
|
|
ae4ea3443e | ||
|
|
f5dd7d853a | ||
|
|
a1e42dad10 | ||
|
|
6e4b4a553b | ||
|
|
7a410dfd00 | ||
|
|
6fb980349f | ||
|
|
8e251ac33c | ||
|
|
35bcc2ce9d | ||
|
|
69215c411b | ||
|
|
a08b5793f6 | ||
|
|
252bf03525 | ||
|
|
b9b9bb134f | ||
|
|
68966db23d | ||
|
|
9aa7444bf9 | ||
|
|
b0541be107 | ||
|
|
3d1f668235 | ||
|
|
940c947d3f | ||
|
|
c7dd4206c8 | ||
|
|
79bf12a8fe | ||
|
|
2dfbd72f10 | ||
|
|
487827c776 | ||
|
|
6939bf8aed | ||
|
|
e4cb0c3cc2 | ||
|
|
cf2f39a0a8 | ||
|
|
b7cfb2f7d9 | ||
|
|
39cb9c32d6 | ||
|
|
75b71890a4 | ||
|
|
2ffa6d0188 | ||
|
|
026386db50 | ||
|
|
b5125e512f | ||
|
|
a8a36c0a8f | ||
|
|
99ab054ea0 | ||
|
|
90ab4b3c86 | ||
|
|
bb6b4d01c1 | ||
|
|
2d1457b94b | ||
|
|
9d851924c8 | ||
|
|
9be5918c83 | ||
|
|
6db6616892 | ||
|
|
004daca862 | ||
|
|
559f65f6b2 | ||
|
|
c38884fa11 | ||
|
|
7848beedce | ||
|
|
296166da95 | ||
|
|
679cc8fdda | ||
|
|
0cdc26e013 | ||
|
|
2503568875 | ||
|
|
78966e12a9 | ||
|
|
f962fb3b53 | ||
|
|
2544e2bf18 | ||
|
|
06f2c6f867 | ||
|
|
272d2c54d4 | ||
|
|
cb93abb0f4 | ||
|
|
316d991b33 | ||
|
|
46f734eba2 | ||
|
|
671a56100a | ||
|
|
dfcfbe240d | ||
|
|
b040fdcf2c | ||
|
|
8525f994c0 | ||
|
|
eb9a804914 | ||
|
|
210d7bb573 | ||
|
|
dc85476b9e | ||
|
|
1854a6b76b | ||
|
|
aebf3288d1 | ||
|
|
065a40dfb3 | ||
|
|
83536fbb23 | ||
|
|
420090dc6c | ||
|
|
4ab0eb570c | ||
|
|
2a4e3dd09f | ||
|
|
0dbfbf6941 | ||
|
|
d515530277 | ||
|
|
4343e0566b | ||
|
|
8555269f7e | ||
|
|
f42a2ac10c | ||
|
|
4ea3a29c0e | ||
|
|
29877c9abe | ||
|
|
480f83c42d | ||
|
|
faf89350ac | ||
|
|
d9c3ce935f | ||
|
|
8d8f57e8b8 | ||
|
|
0a3be0b7ea | ||
|
|
00ebdfe0df | ||
|
|
d79fa131bb | ||
|
|
be2b24a155 | ||
|
|
03b341dbfd | ||
|
|
ca5e69897d | ||
|
|
3090dd4934 | ||
|
|
1f1d1ee502 | ||
|
|
1c2cf11f47 | ||
|
|
08961e751d | ||
|
|
88bf82be05 | ||
|
|
506884bc4d | ||
|
|
646fa341ab | ||
|
|
d73f7b1943 | ||
|
|
a75e8416a4 | ||
|
|
f743f2cfb8 | ||
|
|
7d7e8127f5 | ||
|
|
3c0a3ca703 | ||
|
|
45062697c5 | ||
|
|
66e4e31209 | ||
|
|
c86cfe3cbf | ||
|
|
28e112743f | ||
|
|
229007082b | ||
|
|
4004966b16 | ||
|
|
fe95cb434a | ||
|
|
5709bc3b2b | ||
|
|
af06510921 | ||
|
|
b4acbb5e16 | ||
|
|
b96e437e2b | ||
|
|
16e2283d19 | ||
|
|
c46536f469 | ||
|
|
0457520f51 | ||
|
|
44f8a777df | ||
|
|
9450ce4c3a | ||
|
|
1c9a8ec6bd | ||
|
|
8f5005efd5 | ||
|
|
e61795d5c6 | ||
|
|
892c10b1f0 | ||
|
|
752e26c7de | ||
|
|
ea107b6b86 | ||
|
|
b9b9c065cc | ||
|
|
b583770765 | ||
|
|
37d6f6abca | ||
|
|
be3f48c677 | ||
|
|
5de9d3f15f | ||
|
|
8593715149 | ||
|
|
40afe6cf36 | ||
|
|
9fd07b594c | ||
|
|
dc7411e4c5 | ||
|
|
315c4bb1ac | ||
|
|
1ff1b4dc89 | ||
|
|
a332adf962 | ||
|
|
856cc0f885 | ||
|
|
89d8f7aa70 | ||
|
|
4d2ef0a8b5 | ||
|
|
23b3f72dee | ||
|
|
ff59845821 | ||
|
|
914588f55d | ||
|
|
72e1e8fab1 | ||
|
|
8b01c30c51 | ||
|
|
dcdb4d27ec | ||
|
|
9b1406a1a7 | ||
|
|
545769ad88 | ||
|
|
16bcb1dbb0 | ||
|
|
5dce5563ab | ||
|
|
4e8a3e0a6f | ||
|
|
646d52d498 | ||
|
|
cd5012bd59 | ||
|
|
4bb0388118 | ||
|
|
f255fe507d | ||
|
|
f5a1f83f9f | ||
|
|
36072f17a9 | ||
|
|
f9648d8544 | ||
|
|
2236b86c35 | ||
|
|
0dd319d0c8 | ||
|
|
53615944c5 | ||
|
|
88562d7dcf | ||
|
|
01bb09db67 | ||
|
|
f1c182bb65 | ||
|
|
43ce453938 | ||
|
|
2afce6c94b | ||
|
|
14e23c3d00 | ||
|
|
7f22c6bf12 | ||
|
|
93a862cded | ||
|
|
9cc295827b | ||
|
|
14988fc91c | ||
|
|
31f41855f4 | ||
|
|
caedc8dbe3 | ||
|
|
24ffaf09d4 | ||
|
|
d9f3637e25 | ||
|
|
a807cca29e | ||
|
|
57860f26b7 | ||
|
|
ab916a1819 | ||
|
|
a68831d3a1 | ||
|
|
04a2543e68 | ||
|
|
a4c9cbc6dd |
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.5.7
|
||||
placeholder: v3.6.6
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.5.7
|
||||
placeholder: v3.6.6
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
37
.github/ISSUE_TEMPLATE/translation.yaml
vendored
Normal file
37
.github/ISSUE_TEMPLATE/translation.yaml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: 🌍 Translation
|
||||
description: Request support for a new language in the user interface
|
||||
labels: ["type: translation"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: >
|
||||
**NOTE:** This template is used only for proposing the addition of *new* languages. Please do
|
||||
not use it to request changes to existing translations.
|
||||
- type: input
|
||||
attributes:
|
||||
label: Language
|
||||
description: What is the name of the language in English?
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: ISO 639-1 code
|
||||
description: >
|
||||
What is the two-letter [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes)
|
||||
assigned to the language?
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Volunteer
|
||||
description: Are you a fluent speaker of this language **and** willing to contribute a translation map?
|
||||
options:
|
||||
- "Yes"
|
||||
- "No"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Comments
|
||||
description: Any other notes you would like to share
|
||||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -31,15 +31,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
run: npm install -g yarn
|
||||
|
||||
- name: Setup Node.js with Yarn Caching
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: yarn
|
||||
|
||||
2
.github/workflows/lock.yml
vendored
2
.github/workflows/lock.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
lock:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v3
|
||||
- uses: dessant/lock-threads@v4
|
||||
with:
|
||||
issue-inactive-days: 90
|
||||
pr-inactive-days: 30
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v6
|
||||
- uses: actions/stale@v8
|
||||
with:
|
||||
close-issue-message: >
|
||||
This issue has been automatically closed due to lack of activity. In an
|
||||
|
||||
@@ -14,12 +14,25 @@
|
||||
</div>
|
||||
<h3></h3>
|
||||
|
||||
Some general tips for engaging here on GitHub:
|
||||
## :information_source: Welcome to the Stadium!
|
||||
|
||||
In her book [Working in Public](https://www.amazon.com/Working-Public-Making-Maintenance-Software/dp/0578675862), Nadia Eghbal defines four production models for open source projects, categorized by contributor and user growth: federations, clubs, toys, and stadiums. The NetBox project fits her definition of a stadium very well:
|
||||
|
||||
> Stadiums are projects with low contributor growth and high user growth. While they may receive casual contributions, their regular contributor base does not grow proportionately to their users. As a result, they tend to be powered by one or a few developers.
|
||||
|
||||
The bulk of NetBox's development is carried out by a handful of core maintainers, with occasional contributions from collaborators in the community. We find the stadium analogy very useful in conveying the roles and obligations of both contributors and users.
|
||||
|
||||
If you're a contributor, actively working on the center stage, you have an obligation to produce quality content that will benefit the project as a whole. Conversely, if you're in the audience consuming the work being produced, you have the option of making requests and suggestions, but must also recognize that contributors are under no obligation to act on them.
|
||||
|
||||
NetBox users are welcome to participate in either role, on stage or in the crowd. We ask only that you acknowledge the role you've chosen and respect the roles of others.
|
||||
|
||||
### General Tips for Working on GitHub
|
||||
|
||||
* Register for a free [GitHub account](https://github.com/signup) if you haven't already.
|
||||
* You can use [GitHub Markdown](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax) for formatting text and adding images.
|
||||
* To help mitigate notification spam, please avoid "bumping" issues with no activity. (To vote an issue up or down, use a :thumbsup: or :thumbsdown: reaction.)
|
||||
* Please avoid pinging members with `@` unless they've previously expressed interest or involvement with that particular issue.
|
||||
* Familiarize yourself with [this list of discussion anti-patterns](https://github.com/bradfitz/issue-tracker-behaviors) and make every effort to avoid them.
|
||||
|
||||
## :bug: Reporting Bugs
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
|
||||
<p>The premiere source of truth powering network automation</p>
|
||||
<p>The premier source of truth powering network automation</p>
|
||||
<img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" />
|
||||
<p></p>
|
||||
</div>
|
||||
|
||||
@@ -23,8 +23,9 @@ django-filter
|
||||
django-graphiql-debug-toolbar
|
||||
|
||||
# Modified Preorder Tree Traversal (recursive nesting of objects)
|
||||
# Pinned to 0.14.0; 0.15.0 requires Python 3.9+
|
||||
# https://github.com/django-mptt/django-mptt/blob/main/CHANGELOG.rst
|
||||
django-mptt
|
||||
django-mptt==0.14.0
|
||||
|
||||
# Context managers for PostgreSQL advisory locks
|
||||
# https://github.com/Xof/django-pglocks/blob/master/CHANGES.txt
|
||||
@@ -52,7 +53,8 @@ django-tables2
|
||||
|
||||
# User-defined tags for objects
|
||||
# https://github.com/jazzband/django-taggit/blob/master/CHANGELOG.rst
|
||||
django-taggit
|
||||
# TODO: Upgrade to v5.0 for NetBox v3.7 beta
|
||||
django-taggit<5.0
|
||||
|
||||
# A Django field for representing time zones
|
||||
# https://github.com/mfogel/django-timezone-field/
|
||||
@@ -120,6 +122,10 @@ psycopg[binary,pool]
|
||||
# https://github.com/yaml/pyyaml/blob/master/CHANGES
|
||||
PyYAML
|
||||
|
||||
# Requests
|
||||
# https://github.com/psf/requests/blob/main/HISTORY.md
|
||||
requests
|
||||
|
||||
# Sentry SDK
|
||||
# https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md
|
||||
sentry-sdk
|
||||
|
||||
564
contrib/generated_schema.json
Normal file
564
contrib/generated_schema.json
Normal file
@@ -0,0 +1,564 @@
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"definitions": {
|
||||
"airflow": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"front-to-rear",
|
||||
"rear-to-front",
|
||||
"left-to-right",
|
||||
"right-to-left",
|
||||
"side-to-rear",
|
||||
"passive",
|
||||
"mixed"
|
||||
]
|
||||
},
|
||||
"weight-unit": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"kg",
|
||||
"g",
|
||||
"lb",
|
||||
"oz"
|
||||
]
|
||||
},
|
||||
"subdevice-role": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"parent",
|
||||
"child"
|
||||
]
|
||||
},
|
||||
"console-port": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"de-9",
|
||||
"db-25",
|
||||
"rj-11",
|
||||
"rj-12",
|
||||
"rj-45",
|
||||
"mini-din-8",
|
||||
"usb-a",
|
||||
"usb-b",
|
||||
"usb-c",
|
||||
"usb-mini-a",
|
||||
"usb-mini-b",
|
||||
"usb-micro-a",
|
||||
"usb-micro-b",
|
||||
"usb-micro-ab",
|
||||
"other"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"console-server-port": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"de-9",
|
||||
"db-25",
|
||||
"rj-11",
|
||||
"rj-12",
|
||||
"rj-45",
|
||||
"mini-din-8",
|
||||
"usb-a",
|
||||
"usb-b",
|
||||
"usb-c",
|
||||
"usb-mini-a",
|
||||
"usb-mini-b",
|
||||
"usb-micro-a",
|
||||
"usb-micro-b",
|
||||
"usb-micro-ab",
|
||||
"other"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"power-port": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"iec-60320-c6",
|
||||
"iec-60320-c8",
|
||||
"iec-60320-c14",
|
||||
"iec-60320-c16",
|
||||
"iec-60320-c20",
|
||||
"iec-60320-c22",
|
||||
"iec-60309-p-n-e-4h",
|
||||
"iec-60309-p-n-e-6h",
|
||||
"iec-60309-p-n-e-9h",
|
||||
"iec-60309-2p-e-4h",
|
||||
"iec-60309-2p-e-6h",
|
||||
"iec-60309-2p-e-9h",
|
||||
"iec-60309-3p-e-4h",
|
||||
"iec-60309-3p-e-6h",
|
||||
"iec-60309-3p-e-9h",
|
||||
"iec-60309-3p-n-e-4h",
|
||||
"iec-60309-3p-n-e-6h",
|
||||
"iec-60309-3p-n-e-9h",
|
||||
"iec-60906-1",
|
||||
"nbr-14136-10a",
|
||||
"nbr-14136-20a",
|
||||
"nema-1-15p",
|
||||
"nema-5-15p",
|
||||
"nema-5-20p",
|
||||
"nema-5-30p",
|
||||
"nema-5-50p",
|
||||
"nema-6-15p",
|
||||
"nema-6-20p",
|
||||
"nema-6-30p",
|
||||
"nema-6-50p",
|
||||
"nema-10-30p",
|
||||
"nema-10-50p",
|
||||
"nema-14-20p",
|
||||
"nema-14-30p",
|
||||
"nema-14-50p",
|
||||
"nema-14-60p",
|
||||
"nema-15-15p",
|
||||
"nema-15-20p",
|
||||
"nema-15-30p",
|
||||
"nema-15-50p",
|
||||
"nema-15-60p",
|
||||
"nema-l1-15p",
|
||||
"nema-l5-15p",
|
||||
"nema-l5-20p",
|
||||
"nema-l5-30p",
|
||||
"nema-l5-50p",
|
||||
"nema-l6-15p",
|
||||
"nema-l6-20p",
|
||||
"nema-l6-30p",
|
||||
"nema-l6-50p",
|
||||
"nema-l10-30p",
|
||||
"nema-l14-20p",
|
||||
"nema-l14-30p",
|
||||
"nema-l14-50p",
|
||||
"nema-l14-60p",
|
||||
"nema-l15-20p",
|
||||
"nema-l15-30p",
|
||||
"nema-l15-50p",
|
||||
"nema-l15-60p",
|
||||
"nema-l21-20p",
|
||||
"nema-l21-30p",
|
||||
"nema-l22-30p",
|
||||
"cs6361c",
|
||||
"cs6365c",
|
||||
"cs8165c",
|
||||
"cs8265c",
|
||||
"cs8365c",
|
||||
"cs8465c",
|
||||
"ita-c",
|
||||
"ita-e",
|
||||
"ita-f",
|
||||
"ita-ef",
|
||||
"ita-g",
|
||||
"ita-h",
|
||||
"ita-i",
|
||||
"ita-j",
|
||||
"ita-k",
|
||||
"ita-l",
|
||||
"ita-m",
|
||||
"ita-n",
|
||||
"ita-o",
|
||||
"usb-a",
|
||||
"usb-b",
|
||||
"usb-c",
|
||||
"usb-mini-a",
|
||||
"usb-mini-b",
|
||||
"usb-micro-a",
|
||||
"usb-micro-b",
|
||||
"usb-micro-ab",
|
||||
"usb-3-b",
|
||||
"usb-3-micro-b",
|
||||
"dc-terminal",
|
||||
"saf-d-grid",
|
||||
"neutrik-powercon-20",
|
||||
"neutrik-powercon-32",
|
||||
"neutrik-powercon-true1",
|
||||
"neutrik-powercon-true1-top",
|
||||
"ubiquiti-smartpower",
|
||||
"hardwired",
|
||||
"other"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"power-outlet": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"iec-60320-c5",
|
||||
"iec-60320-c7",
|
||||
"iec-60320-c13",
|
||||
"iec-60320-c15",
|
||||
"iec-60320-c19",
|
||||
"iec-60320-c21",
|
||||
"iec-60309-p-n-e-4h",
|
||||
"iec-60309-p-n-e-6h",
|
||||
"iec-60309-p-n-e-9h",
|
||||
"iec-60309-2p-e-4h",
|
||||
"iec-60309-2p-e-6h",
|
||||
"iec-60309-2p-e-9h",
|
||||
"iec-60309-3p-e-4h",
|
||||
"iec-60309-3p-e-6h",
|
||||
"iec-60309-3p-e-9h",
|
||||
"iec-60309-3p-n-e-4h",
|
||||
"iec-60309-3p-n-e-6h",
|
||||
"iec-60309-3p-n-e-9h",
|
||||
"iec-60906-1",
|
||||
"nbr-14136-10a",
|
||||
"nbr-14136-20a",
|
||||
"nema-1-15r",
|
||||
"nema-5-15r",
|
||||
"nema-5-20r",
|
||||
"nema-5-30r",
|
||||
"nema-5-50r",
|
||||
"nema-6-15r",
|
||||
"nema-6-20r",
|
||||
"nema-6-30r",
|
||||
"nema-6-50r",
|
||||
"nema-10-30r",
|
||||
"nema-10-50r",
|
||||
"nema-14-20r",
|
||||
"nema-14-30r",
|
||||
"nema-14-50r",
|
||||
"nema-14-60r",
|
||||
"nema-15-15r",
|
||||
"nema-15-20r",
|
||||
"nema-15-30r",
|
||||
"nema-15-50r",
|
||||
"nema-15-60r",
|
||||
"nema-l1-15r",
|
||||
"nema-l5-15r",
|
||||
"nema-l5-20r",
|
||||
"nema-l5-30r",
|
||||
"nema-l5-50r",
|
||||
"nema-l6-15r",
|
||||
"nema-l6-20r",
|
||||
"nema-l6-30r",
|
||||
"nema-l6-50r",
|
||||
"nema-l10-30r",
|
||||
"nema-l14-20r",
|
||||
"nema-l14-30r",
|
||||
"nema-l14-50r",
|
||||
"nema-l14-60r",
|
||||
"nema-l15-20r",
|
||||
"nema-l15-30r",
|
||||
"nema-l15-50r",
|
||||
"nema-l15-60r",
|
||||
"nema-l21-20r",
|
||||
"nema-l21-30r",
|
||||
"nema-l22-30r",
|
||||
"CS6360C",
|
||||
"CS6364C",
|
||||
"CS8164C",
|
||||
"CS8264C",
|
||||
"CS8364C",
|
||||
"CS8464C",
|
||||
"ita-e",
|
||||
"ita-f",
|
||||
"ita-g",
|
||||
"ita-h",
|
||||
"ita-i",
|
||||
"ita-j",
|
||||
"ita-k",
|
||||
"ita-l",
|
||||
"ita-m",
|
||||
"ita-n",
|
||||
"ita-o",
|
||||
"ita-multistandard",
|
||||
"usb-a",
|
||||
"usb-micro-b",
|
||||
"usb-c",
|
||||
"dc-terminal",
|
||||
"hdot-cx",
|
||||
"saf-d-grid",
|
||||
"neutrik-powercon-20a",
|
||||
"neutrik-powercon-32a",
|
||||
"neutrik-powercon-true1",
|
||||
"neutrik-powercon-true1-top",
|
||||
"ubiquiti-smartpower",
|
||||
"hardwired",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"feed-leg": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"A",
|
||||
"B",
|
||||
"C"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"interface": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"virtual",
|
||||
"bridge",
|
||||
"lag",
|
||||
"100base-fx",
|
||||
"100base-lfx",
|
||||
"100base-tx",
|
||||
"100base-t1",
|
||||
"1000base-t",
|
||||
"2.5gbase-t",
|
||||
"5gbase-t",
|
||||
"10gbase-t",
|
||||
"10gbase-cx4",
|
||||
"1000base-x-gbic",
|
||||
"1000base-x-sfp",
|
||||
"10gbase-x-sfpp",
|
||||
"10gbase-x-xfp",
|
||||
"10gbase-x-xenpak",
|
||||
"10gbase-x-x2",
|
||||
"25gbase-x-sfp28",
|
||||
"50gbase-x-sfp56",
|
||||
"40gbase-x-qsfpp",
|
||||
"50gbase-x-sfp28",
|
||||
"100gbase-x-cfp",
|
||||
"100gbase-x-cfp2",
|
||||
"200gbase-x-cfp2",
|
||||
"400gbase-x-cfp2",
|
||||
"100gbase-x-cfp4",
|
||||
"100gbase-x-cxp",
|
||||
"100gbase-x-cpak",
|
||||
"100gbase-x-dsfp",
|
||||
"100gbase-x-sfpdd",
|
||||
"100gbase-x-qsfp28",
|
||||
"100gbase-x-qsfpdd",
|
||||
"200gbase-x-qsfp56",
|
||||
"200gbase-x-qsfpdd",
|
||||
"400gbase-x-qsfp112",
|
||||
"400gbase-x-qsfpdd",
|
||||
"400gbase-x-osfp",
|
||||
"400gbase-x-osfp-rhs",
|
||||
"400gbase-x-cdfp",
|
||||
"400gbase-x-cfp8",
|
||||
"800gbase-x-qsfpdd",
|
||||
"800gbase-x-osfp",
|
||||
"1000base-kx",
|
||||
"10gbase-kr",
|
||||
"10gbase-kx4",
|
||||
"25gbase-kr",
|
||||
"40gbase-kr4",
|
||||
"50gbase-kr",
|
||||
"100gbase-kp4",
|
||||
"100gbase-kr2",
|
||||
"100gbase-kr4",
|
||||
"ieee802.11a",
|
||||
"ieee802.11g",
|
||||
"ieee802.11n",
|
||||
"ieee802.11ac",
|
||||
"ieee802.11ad",
|
||||
"ieee802.11ax",
|
||||
"ieee802.11ay",
|
||||
"ieee802.15.1",
|
||||
"other-wireless",
|
||||
"gsm",
|
||||
"cdma",
|
||||
"lte",
|
||||
"sonet-oc3",
|
||||
"sonet-oc12",
|
||||
"sonet-oc48",
|
||||
"sonet-oc192",
|
||||
"sonet-oc768",
|
||||
"sonet-oc1920",
|
||||
"sonet-oc3840",
|
||||
"1gfc-sfp",
|
||||
"2gfc-sfp",
|
||||
"4gfc-sfp",
|
||||
"8gfc-sfpp",
|
||||
"16gfc-sfpp",
|
||||
"32gfc-sfp28",
|
||||
"64gfc-qsfpp",
|
||||
"128gfc-qsfp28",
|
||||
"infiniband-sdr",
|
||||
"infiniband-ddr",
|
||||
"infiniband-qdr",
|
||||
"infiniband-fdr10",
|
||||
"infiniband-fdr",
|
||||
"infiniband-edr",
|
||||
"infiniband-hdr",
|
||||
"infiniband-ndr",
|
||||
"infiniband-xdr",
|
||||
"t1",
|
||||
"e1",
|
||||
"t3",
|
||||
"e3",
|
||||
"xdsl",
|
||||
"docsis",
|
||||
"gpon",
|
||||
"xg-pon",
|
||||
"xgs-pon",
|
||||
"ng-pon2",
|
||||
"epon",
|
||||
"10g-epon",
|
||||
"cisco-stackwise",
|
||||
"cisco-stackwise-plus",
|
||||
"cisco-flexstack",
|
||||
"cisco-flexstack-plus",
|
||||
"cisco-stackwise-80",
|
||||
"cisco-stackwise-160",
|
||||
"cisco-stackwise-320",
|
||||
"cisco-stackwise-480",
|
||||
"cisco-stackwise-1t",
|
||||
"juniper-vcp",
|
||||
"extreme-summitstack",
|
||||
"extreme-summitstack-128",
|
||||
"extreme-summitstack-256",
|
||||
"extreme-summitstack-512",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"poe_mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pd",
|
||||
"pse"
|
||||
]
|
||||
},
|
||||
"poe_type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"type1-ieee802.3af",
|
||||
"type2-ieee802.3at",
|
||||
"type3-ieee802.3bt",
|
||||
"type4-ieee802.3bt",
|
||||
"passive-24v-2pair",
|
||||
"passive-24v-4pair",
|
||||
"passive-48v-2pair",
|
||||
"passive-48v-4pair"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"front-port": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"8p8c",
|
||||
"8p6c",
|
||||
"8p4c",
|
||||
"8p2c",
|
||||
"6p6c",
|
||||
"6p4c",
|
||||
"6p2c",
|
||||
"4p4c",
|
||||
"4p2c",
|
||||
"gg45",
|
||||
"tera-4p",
|
||||
"tera-2p",
|
||||
"tera-1p",
|
||||
"110-punch",
|
||||
"bnc",
|
||||
"f",
|
||||
"n",
|
||||
"mrj21",
|
||||
"fc",
|
||||
"lc",
|
||||
"lc-pc",
|
||||
"lc-upc",
|
||||
"lc-apc",
|
||||
"lsh",
|
||||
"lsh-pc",
|
||||
"lsh-upc",
|
||||
"lsh-apc",
|
||||
"lx5",
|
||||
"lx5-pc",
|
||||
"lx5-upc",
|
||||
"lx5-apc",
|
||||
"mpo",
|
||||
"mtrj",
|
||||
"sc",
|
||||
"sc-pc",
|
||||
"sc-upc",
|
||||
"sc-apc",
|
||||
"st",
|
||||
"cs",
|
||||
"sn",
|
||||
"sma-905",
|
||||
"sma-906",
|
||||
"urm-p2",
|
||||
"urm-p4",
|
||||
"urm-p8",
|
||||
"splice",
|
||||
"other"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"rear-port": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"8p8c",
|
||||
"8p6c",
|
||||
"8p4c",
|
||||
"8p2c",
|
||||
"6p6c",
|
||||
"6p4c",
|
||||
"6p2c",
|
||||
"4p4c",
|
||||
"4p2c",
|
||||
"gg45",
|
||||
"tera-4p",
|
||||
"tera-2p",
|
||||
"tera-1p",
|
||||
"110-punch",
|
||||
"bnc",
|
||||
"f",
|
||||
"n",
|
||||
"mrj21",
|
||||
"fc",
|
||||
"lc",
|
||||
"lc-pc",
|
||||
"lc-upc",
|
||||
"lc-apc",
|
||||
"lsh",
|
||||
"lsh-pc",
|
||||
"lsh-upc",
|
||||
"lsh-apc",
|
||||
"lx5",
|
||||
"lx5-pc",
|
||||
"lx5-upc",
|
||||
"lx5-apc",
|
||||
"mpo",
|
||||
"mtrj",
|
||||
"sc",
|
||||
"sc-pc",
|
||||
"sc-upc",
|
||||
"sc-apc",
|
||||
"st",
|
||||
"cs",
|
||||
"sn",
|
||||
"sma-905",
|
||||
"sma-906",
|
||||
"urm-p2",
|
||||
"urm-p4",
|
||||
"urm-p8",
|
||||
"splice",
|
||||
"other"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ pg_dump --username netbox --password --host localhost -s netbox > netbox_schema.
|
||||
By default, NetBox stores uploaded files (such as image attachments) in its media directory. To fully replicate an instance of NetBox, you'll need to copy both the database and the media files.
|
||||
|
||||
!!! note
|
||||
These operations are not necessary if your installation is utilizing a [remote storage backend](../../configuration/optional-settings/#storage_backend).
|
||||
These operations are not necessary if your installation is utilizing a [remote storage backend](../configuration/system.md#storage_backend).
|
||||
|
||||
### Archive the Media Directory
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ DEFAULT_DASHBOARD = [
|
||||
{
|
||||
'widget': 'extras.ObjectCountsWidget',
|
||||
'width': 4,
|
||||
'height': 2,
|
||||
'height': 3,
|
||||
'title': 'Organization',
|
||||
'config': {
|
||||
'models': [
|
||||
@@ -32,6 +32,8 @@ DEFAULT_DASHBOARD = [
|
||||
},
|
||||
{
|
||||
'widget': 'extras.ObjectCountsWidget',
|
||||
'width': 4,
|
||||
'height': 3,
|
||||
'title': 'IPAM',
|
||||
'color': 'blue',
|
||||
'config': {
|
||||
|
||||
@@ -80,6 +80,14 @@ changes in the database indefinitely.
|
||||
|
||||
---
|
||||
|
||||
## DATA_UPLOAD_MAX_MEMORY_SIZE
|
||||
|
||||
Default: `2621440` (2.5 MB)
|
||||
|
||||
The maximum size (in bytes) of an incoming HTTP request (i.e. `GET` or `POST` data). Requests which exceed this size will raise a `RequestDataTooBig` exception.
|
||||
|
||||
---
|
||||
|
||||
## ENFORCE_GLOBAL_UNIQUE
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
@@ -90,9 +98,9 @@ By default, NetBox will permit users to create duplicate prefixes and IP address
|
||||
|
||||
---
|
||||
|
||||
## `FILE_UPLOAD_MAX_MEMORY_SIZE`
|
||||
## FILE_UPLOAD_MAX_MEMORY_SIZE
|
||||
|
||||
Default: `2621440` (2.5 MB).
|
||||
Default: `2621440` (2.5 MB)
|
||||
|
||||
The maximum amount (in bytes) of uploaded data that will be held in memory before being written to the filesystem. Changing this setting can be useful for example to be able to upload files bigger than 2.5MB to custom scripts for processing.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Default: Empty
|
||||
|
||||
A list of installed [NetBox plugins](../../plugins/) to enable. Plugins will not take effect unless they are listed here.
|
||||
A list of installed [NetBox plugins](../plugins/index.md) to enable. Plugins will not take effect unless they are listed here.
|
||||
|
||||
!!! warning
|
||||
Plugins extend NetBox by allowing external code to run with the same access and privileges as NetBox itself. Only install plugins from trusted sources. The NetBox maintainers make absolutely no guarantees about the integrity or security of your installation with plugins enabled.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Default: True
|
||||
|
||||
If disabled, the values of API tokens will not be displayed after each token's initial creation. A user **must** record the value of a token immediately upon its creation, or it will be lost. Note that this affects _all_ users, regardless of assigned permissions.
|
||||
If disabled, the values of API tokens will not be displayed after each token's initial creation. A user **must** record the value of a token prior to its creation, or it will be lost. Note that this affects _all_ users, regardless of assigned permissions.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -288,7 +288,7 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
|
||||
## Running Custom Scripts
|
||||
|
||||
!!! note
|
||||
To run a custom script, a user must be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below.
|
||||
To run a custom script, a user must be assigned via permissions for `Extras > Script`, `Extras > ScriptModule`, and `Core > ManagedFile` objects. They must also be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ The following methods are available to log results within a report:
|
||||
|
||||
The recording of one or more failure messages will automatically flag a report as failed. It is advised to log a success for each object that is evaluated so that the results will reflect how many objects are being reported on. (The inclusion of a log message is optional for successes.) Messages recorded with `log()` will appear in a report's results but are not associated with a particular object or status. Log messages also support using markdown syntax and will be rendered on the report result page.
|
||||
|
||||
To perform additional tasks, such as sending an email or calling a webhook, before or after a report is run, extend the `pre_run()` and/or `post_run()` methods, respectively. The status of a completed report is available as `self.failed` and the results object is `self.result`.
|
||||
To perform additional tasks, such as sending an email or calling a webhook, before or after a report is run, extend the `pre_run()` and/or `post_run()` methods, respectively.
|
||||
|
||||
By default, reports within a module are ordered alphabetically in the reports list page. To return reports in a specific order, you can define the `report_order` variable at the end of your module. The `report_order` variable is a tuple which contains each Report class in the desired order. Any reports that are omitted from this list will be listed last.
|
||||
|
||||
@@ -132,7 +132,7 @@ Once you have created a report, it will appear in the reports list. Initially, r
|
||||
## Running Reports
|
||||
|
||||
!!! note
|
||||
To run a report, a user must be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.
|
||||
To run a report, a user must be assigned via permissions for `Extras > Report`, `Extras > ReportModule`, and `Core > ManagedFile` objects. They must also be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.
|
||||
|
||||

|
||||
|
||||
|
||||
123
docs/development/internationalization.md
Normal file
123
docs/development/internationalization.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Internationalization
|
||||
|
||||
Beginning with NetBox v4.0, NetBox will leverage [Django's automatic translation](https://docs.djangoproject.com/en/stable/topics/i18n/translation/) to support languages other than English. This page details the areas of the project which require special attention to ensure functioning translation support. Briefly, these include:
|
||||
|
||||
* The `verbose_name` and `verbose_name_plural` Meta attributes for each model
|
||||
* The `verbose_name` and (if defined) `help_text` for each model field
|
||||
* The `label` for each form field
|
||||
* Headers for `fieldsets` on each form class
|
||||
* The `verbose_name` for each table column
|
||||
* All human-readable strings within templates must be wrapped with `{% trans %}` or `{% blocktrans %}`
|
||||
|
||||
The rest of this document elaborates on each of the items above.
|
||||
|
||||
## General Guidance
|
||||
|
||||
* Wrap human-readable strings with Django's `gettext()` or `gettext_lazy()` utility functions to enable automatic translation. Generally, `gettext_lazy()` is preferred (and sometimes required) to defer translation until the string is displayed.
|
||||
|
||||
* By convention, the preferred translation function is typically imported as an underscore (`_`) to minimize boilerplate code. Thus, you will often see translation as e.g. `_("Some text")`. It is still an option to import and use alternative translation functions (e.g. `pgettext()` and `ngettext()`) normally as needed.
|
||||
|
||||
* Avoid passing markup and other non-natural language where possible. Everything wrapped by a translation function gets exported to a messages file for translation by a human.
|
||||
|
||||
* Where the intended meaning of the translated string may not be obvious, use `pgettext()` or `pgettext_lazy()` to include assisting context for the translator. For example:
|
||||
|
||||
```python
|
||||
# Context, string
|
||||
pgettext("month name", "May")
|
||||
```
|
||||
|
||||
* **Format strings do not support translation.** Avoid "f" strings for messages that must support translation. Instead, use `format()` to accomplish variable replacement:
|
||||
|
||||
```python
|
||||
# Translation will not work
|
||||
f"There are {count} objects"
|
||||
|
||||
# Do this instead
|
||||
"There are {count} objects".format(count=count)
|
||||
```
|
||||
|
||||
## Models
|
||||
|
||||
1. Import `gettext_lazy` as `_`.
|
||||
2. Ensure both `verbose_name` and `verbose_name_plural` are defined under the model's `Meta` class and wrapped with the `gettext_lazy()` shortcut.
|
||||
3. Ensure each model field specifies a `verbose_name` wrapped with `gettext_lazy()`.
|
||||
4. Ensure any `help_text` attributes on model fields are also wrapped with `gettext_lazy()`.
|
||||
|
||||
```python
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
class Circuit(PrimaryModel):
|
||||
commit_rate = models.PositiveIntegerField(
|
||||
...
|
||||
verbose_name=_('commit rate (Kbps)'),
|
||||
help_text=_("Committed rate")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('circuit')
|
||||
verbose_name_plural = _('circuits')
|
||||
```
|
||||
|
||||
## Forms
|
||||
|
||||
1. Import `gettext_lazy` as `_`.
|
||||
2. All form fields must specify a `label` wrapped with `gettext_lazy()`.
|
||||
3. All headers under a form's `fieldsets` property must be wrapped with `gettext_lazy()`.
|
||||
|
||||
```python
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
class CircuitBulkEditForm(NetBoxModelBulkEditForm):
|
||||
description = forms.CharField(
|
||||
label=_('Description'),
|
||||
...
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
(_('Circuit'), ('provider', 'type', 'status', 'description')),
|
||||
)
|
||||
```
|
||||
|
||||
## Tables
|
||||
|
||||
1. Import `gettext_lazy` as `_`.
|
||||
2. All table columns must specify a `verbose_name` wrapped with `gettext_lazy()`.
|
||||
|
||||
```python
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
provider = tables.Column(
|
||||
verbose_name=_('Provider'),
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
## Templates
|
||||
|
||||
1. Ensure translation support is enabled by including `{% load i18n %}` at the top of the template.
|
||||
2. Use the [`{% trans %}`](https://docs.djangoproject.com/en/stable/topics/i18n/translation/#translate-template-tag) tag (short for "translate") to wrap short strings.
|
||||
3. Longer strings may be enclosed between [`{% blocktrans %}`](https://docs.djangoproject.com/en/stable/topics/i18n/translation/#blocktranslate-template-tag) and `{% endblocktrans %}` tags to improve readability and to enable variable replacement. (Remember to include the `trimmed` argument to trim whitespace between the tags.)
|
||||
4. Avoid passing HTML within translated strings where possible, as this can complicate the work needed of human translators to develop message maps.
|
||||
|
||||
```
|
||||
{% load i18n %}
|
||||
|
||||
{# A short string #}
|
||||
<h5 class="card-header">{% trans "Circuit List" %}</h5>
|
||||
|
||||
{# A longer string with a context variable #}
|
||||
{% blocktrans trimmed with count=object.circuits.count %}
|
||||
There are {count} circuits. Would you like to continue?
|
||||
{% endblocktrans %}
|
||||
```
|
||||
|
||||
!!! warning
|
||||
The `{% blocktrans %}` tag supports only **limited variable replacement**, comparable to the `format()` method on Python strings. It does not permit access to object attributes or the use of other template tags or filters inside it. Ensure that any necessary context is passed as simple variables.
|
||||
|
||||
!!! info
|
||||
The `{% trans %}` and `{% blocktrans %}` support the inclusion of contextual hints for translators using the `context` argument:
|
||||
|
||||
```nohighlight
|
||||
{% trans "May" context "month name" %}
|
||||
```
|
||||
@@ -43,10 +43,22 @@ Follow these instructions to perform a new installation of NetBox in a temporary
|
||||
|
||||
Submit a pull request to merge the `feature` branch into the `develop` branch in preparation for its release. Once it has been merged, continue with the section for patch releases below.
|
||||
|
||||
### Rebuild Demo Data (After Release)
|
||||
|
||||
After the release of a new minor version, generate a new demo data snapshot compatible with the new release. See the [`netbox-demo-data`](https://github.com/netbox-community/netbox-demo-data) repository for instructions.
|
||||
|
||||
---
|
||||
|
||||
## Patch Releases
|
||||
|
||||
### Notify netbox-docker Project of Any Relevant Changes
|
||||
|
||||
Notify the [`netbox-docker`](https://github.com/netbox-community/netbox-docker) maintainers (in **#netbox-docker**) of any changes that may be relevant to their build process, including:
|
||||
|
||||
* Significant changes to `upgrade.sh`
|
||||
* Increases in minimum versions for service dependencies (PostgreSQL, Redis, etc.)
|
||||
* Any changes to the reference installation
|
||||
|
||||
### Update Requirements
|
||||
|
||||
Before each release, update each of NetBox's Python dependencies to its most recent stable version. These are defined in `requirements.txt`, which is updated from `base_requirements.txt` using `pip`. To do this:
|
||||
@@ -58,6 +70,16 @@ Before each release, update each of NetBox's Python dependencies to its most rec
|
||||
|
||||
In cases where upgrading a dependency to its most recent release is breaking, it should be constrained to its current minor version in `base_requirements.txt` with an explanatory comment and revisited for the next major NetBox release (see the [Address Constrained Dependencies](#address-constrained-dependencies) section above).
|
||||
|
||||
### Rebuild the Device Type Definition Schema
|
||||
|
||||
Run the following command to update the device type definition validation schema:
|
||||
|
||||
```nohighlight
|
||||
./manage.py buildschema --write
|
||||
```
|
||||
|
||||
This will automatically update the schema file at `contrib/generated_schema.json`.
|
||||
|
||||
### Update Version and Changelog
|
||||
|
||||
* Update the `VERSION` constant in `settings.py` to the new release version.
|
||||
|
||||
@@ -37,6 +37,14 @@ Configuration templates are written in the [Jinja2 templating language](https://
|
||||
|
||||
When rendered for a specific NetBox device, the template's `device` variable will be populated with the device instance, and `ntp_servers` will be pulled from the device's available context data. The resulting output will be a valid configuration segment that can be applied directly to a compatible network device.
|
||||
|
||||
### Context Data
|
||||
|
||||
The objet for which the configuration is being rendered is made available as template context as `device` or `virtualmachine` for devices and virtual machines, respectively. Additionally, NetBox model classes can be accessed by the app or plugin in which they reside. For example:
|
||||
|
||||
```
|
||||
There are {{ dcim.Site.objects.count() }} sites.
|
||||
```
|
||||
|
||||
## Rendering Templates
|
||||
|
||||
### Device Configurations
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{style="height: 100px; margin-bottom: 3em"}
|
||||
|
||||
# The Premiere Network Source of Truth
|
||||
# The Premier Network Source of Truth
|
||||
|
||||
NetBox is the leading solution for modeling and documenting modern networks. By combining the traditional disciplines of IP address management (IPAM) and datacenter infrastructure management (DCIM) with powerful APIs and extensions, NetBox provides the ideal "source of truth" to power network automation. Read on to discover why thousands of organizations worldwide put NetBox at the heart of their infrastructure.
|
||||
|
||||
|
||||
@@ -148,6 +148,126 @@ AUTH_LDAP_CACHE_TIMEOUT = 3600
|
||||
!!! warning
|
||||
Authentication will fail if the groups (the distinguished names) do not exist in the LDAP directory.
|
||||
|
||||
## Authenticating with Active Directory
|
||||
|
||||
Integrating Active Directory for authentication can be a bit challenging as it may require handling different login formats. This solution will allow users to log in either using their full User Principal Name (UPN) or their username alone, by filtering the DN according to either the `sAMAccountName` or the `userPrincipalName`. The following configuration options will allow your users to enter their usernames in the format `username` or `username@domain.tld`.
|
||||
|
||||
Just as before, the configuration options are defined in the file ldap_config.py. First, modify the `AUTH_LDAP_USER_SEARCH` option to match the following:
|
||||
|
||||
```python
|
||||
AUTH_LDAP_USER_SEARCH = LDAPSearch(
|
||||
"ou=Users,dc=example,dc=com",
|
||||
ldap.SCOPE_SUBTREE,
|
||||
"(|(userPrincipalName=%(user)s)(sAMAccountName=%(user)s))"
|
||||
)
|
||||
```
|
||||
|
||||
In addition, `AUTH_LDAP_USER_DN_TEMPLATE` should be set to `None` as described in the previous sections. Next, modify `AUTH_LDAP_USER_ATTR_MAP` to match the following:
|
||||
|
||||
```python
|
||||
AUTH_LDAP_USER_ATTR_MAP = {
|
||||
"username": "sAMAccountName",
|
||||
"email": "mail",
|
||||
"first_name": "givenName",
|
||||
"last_name": "sn",
|
||||
}
|
||||
```
|
||||
|
||||
Finally, we need to add one more configuration option, `AUTH_LDAP_USER_QUERY_FIELD`. The following should be added to your LDAP configuration file:
|
||||
|
||||
```python
|
||||
AUTH_LDAP_USER_QUERY_FIELD = "username"
|
||||
```
|
||||
|
||||
With these configuration options, your users will be able to log in either with or without the UPN suffix.
|
||||
|
||||
### Example Configuration
|
||||
|
||||
!!! info
|
||||
This configuration is intended to serve as a template, but may need to be modified in accordance with your environment.
|
||||
|
||||
```python
|
||||
import ldap
|
||||
from django_auth_ldap.config import LDAPSearch, NestedGroupOfNamesType
|
||||
|
||||
# Server URI
|
||||
AUTH_LDAP_SERVER_URI = "ldaps://ad.example.com:3269"
|
||||
|
||||
# The following may be needed if you are binding to Active Directory.
|
||||
AUTH_LDAP_CONNECTION_OPTIONS = {
|
||||
ldap.OPT_REFERRALS: 0
|
||||
}
|
||||
|
||||
# Set the DN and password for the NetBox service account.
|
||||
AUTH_LDAP_BIND_DN = "CN=NETBOXSA,OU=Service Accounts,DC=example,DC=com"
|
||||
AUTH_LDAP_BIND_PASSWORD = "demo"
|
||||
|
||||
# Include this setting if you want to ignore certificate errors. This might be needed to accept a self-signed cert.
|
||||
# Note that this is a NetBox-specific setting which sets:
|
||||
# ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
|
||||
LDAP_IGNORE_CERT_ERRORS = False
|
||||
|
||||
# Include this setting if you want to validate the LDAP server certificates against a CA certificate directory on your server
|
||||
# Note that this is a NetBox-specific setting which sets:
|
||||
# ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, LDAP_CA_CERT_DIR)
|
||||
LDAP_CA_CERT_DIR = '/etc/ssl/certs'
|
||||
|
||||
# Include this setting if you want to validate the LDAP server certificates against your own CA.
|
||||
# Note that this is a NetBox-specific setting which sets:
|
||||
# ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, LDAP_CA_CERT_FILE)
|
||||
LDAP_CA_CERT_FILE = '/path/to/example-CA.crt'
|
||||
|
||||
# This search matches users with the sAMAccountName equal to the provided username. This is required if the user's
|
||||
# username is not in their DN (Active Directory).
|
||||
AUTH_LDAP_USER_SEARCH = LDAPSearch(
|
||||
"ou=Users,dc=example,dc=com",
|
||||
ldap.SCOPE_SUBTREE,
|
||||
"(|(userPrincipalName=%(user)s)(sAMAccountName=%(user)s))"
|
||||
)
|
||||
|
||||
# If a user's DN is producible from their username, we don't need to search.
|
||||
AUTH_LDAP_USER_DN_TEMPLATE = None
|
||||
|
||||
# You can map user attributes to Django attributes as so.
|
||||
AUTH_LDAP_USER_ATTR_MAP = {
|
||||
"username": "sAMAccountName",
|
||||
"email": "mail",
|
||||
"first_name": "givenName",
|
||||
"last_name": "sn",
|
||||
}
|
||||
|
||||
AUTH_LDAP_USER_QUERY_FIELD = "username"
|
||||
|
||||
# This search ought to return all groups to which the user belongs. django_auth_ldap uses this to determine group
|
||||
# hierarchy.
|
||||
AUTH_LDAP_GROUP_SEARCH = LDAPSearch(
|
||||
"dc=example,dc=com",
|
||||
ldap.SCOPE_SUBTREE,
|
||||
"(objectClass=group)"
|
||||
)
|
||||
AUTH_LDAP_GROUP_TYPE = NestedGroupOfNamesType()
|
||||
|
||||
# Define a group required to login.
|
||||
AUTH_LDAP_REQUIRE_GROUP = "CN=NETBOX_USERS,DC=example,DC=com"
|
||||
|
||||
# Mirror LDAP group assignments.
|
||||
AUTH_LDAP_MIRROR_GROUPS = True
|
||||
|
||||
# Define special user types using groups. Exercise great caution when assigning superuser status.
|
||||
AUTH_LDAP_USER_FLAGS_BY_GROUP = {
|
||||
"is_active": "cn=active,ou=groups,dc=example,dc=com",
|
||||
"is_staff": "cn=staff,ou=groups,dc=example,dc=com",
|
||||
"is_superuser": "cn=superuser,ou=groups,dc=example,dc=com"
|
||||
}
|
||||
|
||||
# For more granular permissions, we can map LDAP groups to Django groups.
|
||||
AUTH_LDAP_FIND_GROUP_PERMS = True
|
||||
|
||||
# Cache groups for one hour to reduce LDAP traffic
|
||||
AUTH_LDAP_CACHE_TIMEOUT = 3600
|
||||
AUTH_LDAP_ALWAYS_UPDATE_USER = True
|
||||
```
|
||||
|
||||
## Troubleshooting LDAP
|
||||
|
||||
`systemctl restart netbox` restarts the NetBox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/messages`.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# Installation
|
||||
|
||||
!!! info "NetBox Cloud"
|
||||
The instructions below are for installing NetBox as a standalone, self-hosted application. For a Cloud-delivered solution, check out [NetBox Cloud](https://netboxlabs.com/netbox-cloud/) by NetBox Labs.
|
||||
|
||||
The installation instructions provided here have been tested to work on Ubuntu 22.04 and CentOS 8.3. 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.
|
||||
|
||||
<iframe width="560" height="315" src="https://www.youtube.com/embed/_y5JRiW_PLM" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
|
||||
|
||||
@@ -59,7 +59,7 @@ Copy `local_requirements.txt`, `configuration.py`, and `ldap_config.py` (if pres
|
||||
|
||||
```no-highlight
|
||||
# Set $OLDVER to the NetBox version currently installed
|
||||
NEWVER=3.4.9
|
||||
OLDVER=3.4.9
|
||||
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/
|
||||
|
||||
@@ -570,27 +570,26 @@ The NetBox REST API primarily employs token-based authentication. For convenienc
|
||||
|
||||
A token is a unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile.
|
||||
|
||||
!!! note
|
||||
All users can create and manage REST API tokens under the user control panel in the UI. The ability to view, add, change, or delete tokens via the REST API itself is controlled by the relevant model permissions, assigned to users and/or groups in the admin UI. These permissions should be used with great care to avoid accidentally permitting a user to create tokens for other user accounts.
|
||||
By default, all users can create and manage their own REST API tokens under the user control panel in the UI or via the REST API. This ability can be disabled by overriding the [`DEFAULT_PERMISSIONS`](../configuration/security.md#default_permissions) configuration parameter.
|
||||
|
||||
Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation.
|
||||
|
||||
By default, a token can be used to perform all actions via the API that a user would be permitted to do via the web UI. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only.
|
||||
|
||||
Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox.
|
||||
|
||||
!!! warning "Restricting Token Retrieval"
|
||||
!!! info "Restricting Token Retrieval"
|
||||
The ability to retrieve the key value of a previously-created API token can be restricted by disabling the [`ALLOW_TOKEN_RETRIEVAL`](../configuration/security.md#allow_token_retrieval) configuration parameter.
|
||||
|
||||
### Restricting Write Operations
|
||||
|
||||
By default, a token can be used to perform all actions via the API that a user would be permitted to do via the web UI. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only.
|
||||
|
||||
#### Client IP Restriction
|
||||
|
||||
Each API token can optionally be restricted by client IP address. If one or more allowed IP prefixes/addresses is defined for a token, authentication will fail for any client connecting from an IP address outside the defined range(s). This enables restricting the use a token to a specific client. (By default, any client IP address is permitted.)
|
||||
|
||||
#### Creating Tokens for Other Users
|
||||
|
||||
It is possible to provision authentication tokens for other users via the REST API. To do, so the requesting user must have the `users.grant_token` permission assigned. While all users have inherent permission to create their own tokens, this permission is required to enable the creation of tokens for other users.
|
||||
|
||||

|
||||
It is possible to provision authentication tokens for other users via the REST API. To do, so the requesting user must have the `users.grant_token` permission assigned. While all users have inherent permission by default to create their own tokens, this permission is required to enable the creation of tokens for other users.
|
||||
|
||||
!!! warning "Exercise Caution"
|
||||
The ability to create tokens on behalf of other users enables the requestor to access the created token. This ability is intended e.g. for the provisioning of tokens by automated services, and should be used with extreme caution to avoid a security compromise.
|
||||
@@ -627,7 +626,7 @@ When a token is used to authenticate a request, its `last_updated` time updated
|
||||
|
||||
### Initial Token Provisioning
|
||||
|
||||
Ideally, each user should provision his or her own REST API token(s) via the web UI. However, you may encounter where a token must be created by a user via the REST API itself. NetBox provides a special endpoint to provision tokens using a valid username and password combination.
|
||||
Ideally, each user should provision his or her own API token(s) via the web UI. However, you may encounter a scenario where a token must be created by a user via the REST API itself. NetBox provides a special endpoint to provision tokens using a valid username and password combination. (Note that the user must have permission to create API tokens regardless of the interface used.)
|
||||
|
||||
To provision a token via the REST API, make a `POST` request to the `/api/users/tokens/provision/` endpoint:
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 22 KiB |
@@ -23,17 +23,3 @@ If designated, this platform will be available for use only to devices assigned
|
||||
### Configuration Template
|
||||
|
||||
The default [configuration template](../extras/configtemplate.md) for devices assigned to this platform.
|
||||
|
||||
### NAPALM Driver
|
||||
|
||||
!!! warning "Deprecated Field"
|
||||
NAPALM integration was removed from NetBox core in v3.5 and is now available as a [plugin](https://github.com/netbox-community/netbox-napalm). This field will be removed in NetBox v3.6.
|
||||
|
||||
The [NAPALM driver](https://napalm.readthedocs.io/en/latest/support/index.html) associated with this platform.
|
||||
|
||||
### NAPALM Arguments
|
||||
|
||||
!!! warning "Deprecated Field"
|
||||
NAPALM integration was removed from NetBox core in v3.5 and is now available as a [plugin](https://github.com/netbox-community/netbox-napalm). This field will be removed in NetBox v3.6.
|
||||
|
||||
Any additional arguments to send when invoking the NAPALM driver assigned to this platform.
|
||||
|
||||
@@ -26,7 +26,9 @@ Every model includes by default a numeric primary key. This value is generated a
|
||||
|
||||
Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable features unique to NetBox, including:
|
||||
|
||||
* Bookmarks
|
||||
* Change logging
|
||||
* Cloning
|
||||
* Custom fields
|
||||
* Custom links
|
||||
* Custom validation
|
||||
@@ -105,6 +107,8 @@ For more information about database migrations, see the [Django documentation](h
|
||||
!!! warning
|
||||
Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `features` module, they are not yet supported for use by plugins.
|
||||
|
||||
::: netbox.models.features.BookmarksMixin
|
||||
|
||||
::: netbox.models.features.ChangeLoggingMixin
|
||||
|
||||
::: netbox.models.features.CloningMixin
|
||||
|
||||
@@ -64,12 +64,15 @@ item1 = PluginMenuItem(
|
||||
|
||||
A `PluginMenuItem` has the following attributes:
|
||||
|
||||
| Attribute | Required | Description |
|
||||
|---------------|----------|------------------------------------------------------|
|
||||
| `link` | Yes | Name of the URL path to which this menu item links |
|
||||
| `link_text` | Yes | The text presented to the user |
|
||||
| `permissions` | - | A list of permissions required to display this link |
|
||||
| `buttons` | - | An iterable of PluginMenuButton instances to include |
|
||||
| Attribute | Required | Description |
|
||||
|---------------|----------|----------------------------------------------------------------------------------------------------------|
|
||||
| `link` | Yes | Name of the URL path to which this menu item links |
|
||||
| `link_text` | Yes | The text presented to the user |
|
||||
| `permissions` | - | A list of permissions required to display this link |
|
||||
| `staff_only` | - | Display only for users who have `is_staff` set to true (any specified permissions will also be required) |
|
||||
| `buttons` | - | An iterable of PluginMenuButton instances to include |
|
||||
|
||||
!!! info "The `staff_only` attribute was introduced in NetBox v3.6.1."
|
||||
|
||||
## Menu Buttons
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ Multiple conditions can be combined into nested sets using AND or OR logic. This
|
||||
]
|
||||
},
|
||||
{
|
||||
"attr": "tags",
|
||||
"attr": "tags.slug",
|
||||
"value": "exempt",
|
||||
"op": "contains"
|
||||
}
|
||||
|
||||
@@ -61,13 +61,14 @@ These lookup expressions can be applied by adding a suffix to the desired field'
|
||||
|
||||
Numeric based fields (ASN, VLAN ID, etc) support these lookup expressions:
|
||||
|
||||
| Filter | Description |
|
||||
|--------|-------------|
|
||||
| `n` | Not equal to |
|
||||
| `lt` | Less than |
|
||||
| `lte` | Less than or equal to |
|
||||
| `gt` | Greater than |
|
||||
| `gte` | Greater than or equal to |
|
||||
| Filter | Description |
|
||||
|---------|--------------------------|
|
||||
| `n` | Not equal to |
|
||||
| `lt` | Less than |
|
||||
| `lte` | Less than or equal to |
|
||||
| `gt` | Greater than |
|
||||
| `gte` | Greater than or equal to |
|
||||
| `empty` | Is empty/null (boolean) |
|
||||
|
||||
Here is an example of a numeric field lookup expression that will return all VLANs with a VLAN ID greater than 900:
|
||||
|
||||
@@ -79,18 +80,18 @@ GET /api/ipam/vlans/?vid__gt=900
|
||||
|
||||
String based (char) fields (Name, Address, etc) support these lookup expressions:
|
||||
|
||||
| Filter | Description |
|
||||
|--------|-------------|
|
||||
| `n` | Not equal to |
|
||||
| `ic` | Contains (case-insensitive) |
|
||||
| `nic` | Does not contain (case-insensitive) |
|
||||
| `isw` | Starts with (case-insensitive) |
|
||||
| `nisw` | Does not start with (case-insensitive) |
|
||||
| `iew` | Ends with (case-insensitive) |
|
||||
| `niew` | Does not end with (case-insensitive) |
|
||||
| `ie` | Exact match (case-insensitive) |
|
||||
| `nie` | Inverse exact match (case-insensitive) |
|
||||
| `empty` | Is empty (boolean) |
|
||||
| Filter | Description |
|
||||
|---------|----------------------------------------|
|
||||
| `n` | Not equal to |
|
||||
| `ic` | Contains (case-insensitive) |
|
||||
| `nic` | Does not contain (case-insensitive) |
|
||||
| `isw` | Starts with (case-insensitive) |
|
||||
| `nisw` | Does not start with (case-insensitive) |
|
||||
| `iew` | Ends with (case-insensitive) |
|
||||
| `niew` | Does not end with (case-insensitive) |
|
||||
| `ie` | Exact match (case-insensitive) |
|
||||
| `nie` | Inverse exact match (case-insensitive) |
|
||||
| `empty` | Is empty/null (boolean) |
|
||||
|
||||
Here is an example of a lookup expression on a string field that will return all devices with `switch` in the name:
|
||||
|
||||
|
||||
@@ -10,6 +10,15 @@ 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 3.6](./version-3.6.md) (August 2023)
|
||||
|
||||
* Relocated Admin UI Views ([#12589](https://github.com/netbox-community/netbox/issues/12589), [#12590](https://github.com/netbox-community/netbox/issues/12590), [#12591](https://github.com/netbox-community/netbox/issues/12591), [#13044](https://github.com/netbox-community/netbox/issues/13044))
|
||||
* Configurable Default Permissions ([#13038](https://github.com/netbox-community/netbox/issues/13038))
|
||||
* User Bookmarks ([#8248](https://github.com/netbox-community/netbox/issues/8248))
|
||||
* Custom Field Choice Sets ([#12988](https://github.com/netbox-community/netbox/issues/12988))
|
||||
* Pre-Defined Location Choices for Custom Fields ([#12194](https://github.com/netbox-community/netbox/issues/12194))
|
||||
* Restrict Tag Usage by Object Type ([#11541](https://github.com/netbox-community/netbox/issues/11541))
|
||||
|
||||
#### [Version 3.5](./version-3.5.md) (April 2023)
|
||||
|
||||
* Customizable Dashboard ([#9416](https://github.com/netbox-community/netbox/issues/9416))
|
||||
|
||||
@@ -1,6 +1,59 @@
|
||||
# NetBox v3.5
|
||||
|
||||
## v3.5.8 (FUTURE)
|
||||
## v3.5.9 (2023-08-28)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#12489](https://github.com/netbox-community/netbox/issues/12489) - Dynamically render location and device lists under site and location views
|
||||
* [#12825](https://github.com/netbox-community/netbox/issues/12825) - Display assigned values count per obejct type under custom field view
|
||||
* [#13313](https://github.com/netbox-community/netbox/issues/13313) - Enable filtering IP ranges by containing prefix
|
||||
* [#13415](https://github.com/netbox-community/netbox/issues/13415) - Include request object in custom link renderer on tables
|
||||
* [#13536](https://github.com/netbox-community/netbox/issues/13536) - Move child VLANs list to a separate tab under VLAN group view
|
||||
* [#13542](https://github.com/netbox-community/netbox/issues/13542) - Pass additional HTTP headers through to custom script context
|
||||
* [#13585](https://github.com/netbox-community/netbox/issues/13585) - Introduce `empty` lookup for numeric value filters
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11272](https://github.com/netbox-community/netbox/issues/11272) - Fix localization support for device position field
|
||||
* [#13358](https://github.com/netbox-community/netbox/issues/13358) - Git backend should send HTTP auth headers only if credentials have been defined
|
||||
* [#13477](https://github.com/netbox-community/netbox/issues/13477) - Fix filtering of modified objects after bulk import/update
|
||||
* [#13478](https://github.com/netbox-community/netbox/issues/13478) - Fix filtering of export templates by content type under web UI
|
||||
* [#13500](https://github.com/netbox-community/netbox/issues/13500) - Fix form validation for bulk update of L2VPN terminations via bulk import form
|
||||
* [#13503](https://github.com/netbox-community/netbox/issues/13503) - Fix utilization graph proportions when localization is enabled
|
||||
* [#13507](https://github.com/netbox-community/netbox/issues/13507) - Avoid raising exception for invalid content type during global search
|
||||
* [#13516](https://github.com/netbox-community/netbox/issues/13516) - Plugin utility functions should be importable from `extras.plugins`
|
||||
* [#13530](https://github.com/netbox-community/netbox/issues/13530) - Ensure script log messages can be serialized as JSON data
|
||||
* [#13543](https://github.com/netbox-community/netbox/issues/13543) - Config context tab under device/VM view should not require `extras.view_configcontext` permission
|
||||
* [#13544](https://github.com/netbox-community/netbox/issues/13544) - Ensure `reindex` command clears all cached values when not in lazy mode
|
||||
* [#13556](https://github.com/netbox-community/netbox/issues/13556) - Correct REST API representation of VDC status choice
|
||||
* [#13569](https://github.com/netbox-community/netbox/issues/13569) - Fix selection widgets for related interfaces when bulk editing interfaces under device view
|
||||
|
||||
---
|
||||
|
||||
## v3.5.8 (2023-08-15)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#10030](https://github.com/netbox-community/netbox/issues/10030) - Ship a validation schema for the device type library with each release
|
||||
* [#11675](https://github.com/netbox-community/netbox/issues/11675) - Add support for specifying import/export route targets during VRF bulk import
|
||||
* [#11922](https://github.com/netbox-community/netbox/issues/11922) - Automatically populate any VDC assignments from the parent when adding a child interface via the UI
|
||||
* [#12889](https://github.com/netbox-community/netbox/issues/12889) - Add 400GE CFP2 interface type
|
||||
* [#13033](https://github.com/netbox-community/netbox/issues/13033) - Add human-friendly speed column to interfaces table
|
||||
* [#13151](https://github.com/netbox-community/netbox/issues/13151) - Add "assigned" filter for IP addresses
|
||||
* [#13368](https://github.com/netbox-community/netbox/issues/13368) - List installed plugins on the server error report page
|
||||
* [#13442](https://github.com/netbox-community/netbox/issues/13442) - Add 200 and 400 Gbps speeds to dropdown choices on interface form
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11578](https://github.com/netbox-community/netbox/issues/11578) - Fix schema definition for available IP & VLAN REST API endpoints
|
||||
* [#12639](https://github.com/netbox-community/netbox/issues/12639) - Raise validation error for invalid alphanumeric ranges when creating objects
|
||||
* [#12665](https://github.com/netbox-community/netbox/issues/12665) - Avoid escaping semicolons when rendering custom links
|
||||
* [#12750](https://github.com/netbox-community/netbox/issues/12750) - Automatically delete an AutoSyncRecord when its object is deleted
|
||||
* [#13343](https://github.com/netbox-community/netbox/issues/13343) - Fix filtering of circuits under provider network view
|
||||
* [#13369](https://github.com/netbox-community/netbox/issues/13369) - Fix job termination status for failed reports
|
||||
* [#13414](https://github.com/netbox-community/netbox/issues/13414) - Fix support for "hide-if-unset" custom fields on bulk import forms
|
||||
* [#13446](https://github.com/netbox-community/netbox/issues/13446) - Don't disable bulk edit/delete buttons after deselecting "select all" checkbox
|
||||
* [#13451](https://github.com/netbox-community/netbox/issues/13451) - Disable table ordering for custom link columns
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,17 +1,186 @@
|
||||
# NetBox v3.6
|
||||
|
||||
## v3.6-beta1 (2023-08-02)
|
||||
## v3.6.6 (2023-11-29)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#13735](https://github.com/netbox-community/netbox/issues/13735) - Show complete region hierarchy in UI for all relevant objects
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#14056](https://github.com/netbox-community/netbox/issues/14056) - Record a pre-change snapshot when bulk editing objects via CSV
|
||||
* [#14187](https://github.com/netbox-community/netbox/issues/14187) - Raise a validation error when attempting to create a duplicate script or report
|
||||
* [#14199](https://github.com/netbox-community/netbox/issues/14199) - Fix jobs list for reports with a custom name
|
||||
* [#14239](https://github.com/netbox-community/netbox/issues/14239) - Fix CustomFieldChoiceSet search filter
|
||||
* [#14242](https://github.com/netbox-community/netbox/issues/14242) - Enable export templates for contact assignments
|
||||
* [#14299](https://github.com/netbox-community/netbox/issues/14299) - Webhook timestamps should be in proper ISO 8601 format
|
||||
* [#14325](https://github.com/netbox-community/netbox/issues/14325) - Fix numeric ordering of service ports
|
||||
* [#14339](https://github.com/netbox-community/netbox/issues/14339) - Correctly hash local user password when set via REST API
|
||||
* [#14343](https://github.com/netbox-community/netbox/issues/14343) - Fix ordering ASN table by ASDOT column
|
||||
* [#14346](https://github.com/netbox-community/netbox/issues/14346) - Fix running reports via REST API
|
||||
* [#14349](https://github.com/netbox-community/netbox/issues/14349) - Fix custom validation support for remote data sources
|
||||
* [#14363](https://github.com/netbox-community/netbox/issues/14363) - Fix bulk editing of interfaces assigned to VM with no cluster
|
||||
|
||||
---
|
||||
|
||||
## v3.6.5 (2023-11-09)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#12741](https://github.com/netbox-community/netbox/issues/12741) - Add selector widget to platform field on device & virtual machine forms
|
||||
* [#13022](https://github.com/netbox-community/netbox/issues/13022) - Introduce support for assigning IP addresses when bulk importing services
|
||||
* [#13587](https://github.com/netbox-community/netbox/issues/13587) - Annotate units of measurement on power port table columns
|
||||
* [#13669](https://github.com/netbox-community/netbox/issues/13669) - Add bulk import button to contact assignments list view
|
||||
* [#13723](https://github.com/netbox-community/netbox/issues/13723) - Add inventory items column to interfaces table
|
||||
* [#13743](https://github.com/netbox-community/netbox/issues/13743) - Add site column to power feeds table
|
||||
* [#13936](https://github.com/netbox-community/netbox/issues/13936) - Add primary IPv4 and IPv6 filters for virtual machines and VDCs
|
||||
* [#13951](https://github.com/netbox-community/netbox/issues/13951) - Add device & virtual machine fields to service filter form
|
||||
* [#14085](https://github.com/netbox-community/netbox/issues/14085) - Strip trailing port number from value returned by `get_client_ip()`
|
||||
* [#14101](https://github.com/netbox-community/netbox/issues/14101) - Add greater/less than mask length filters for IP addresses
|
||||
* [#14112](https://github.com/netbox-community/netbox/issues/14112) - Add tab listing child items under inventory item view
|
||||
* [#14113](https://github.com/netbox-community/netbox/issues/14113) - Add optional parent column to inventory items table
|
||||
* [#14220](https://github.com/netbox-community/netbox/issues/14220) - Order available columns alphabetically in table configuration form
|
||||
* [#14221](https://github.com/netbox-community/netbox/issues/14221) - Add contact group column on contact assignments table
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#14033](https://github.com/netbox-community/netbox/issues/14033) - Avoid exception when attempting to connect both ends of a cable to the same object
|
||||
* [#14117](https://github.com/netbox-community/netbox/issues/14117) - Check that enough rear port positions have been selected to accommodate the number of front ports being created
|
||||
* [#14166](https://github.com/netbox-community/netbox/issues/14166) - Permit user login when maintenance mode is enabled
|
||||
* [#14182](https://github.com/netbox-community/netbox/issues/14182) - Ensure the active configuration is restored upon clearing cache
|
||||
* [#14195](https://github.com/netbox-community/netbox/issues/14195) - Correct permissions evaluation for ASN range child ASNs view
|
||||
* [#14223](https://github.com/netbox-community/netbox/issues/14223) - Disable ordering of jobs by assigned object
|
||||
|
||||
---
|
||||
|
||||
## v3.6.4 (2023-10-17)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#12831](https://github.com/netbox-community/netbox/issues/12831) - Include circuit description in cable trace SVG image
|
||||
* [#12872](https://github.com/netbox-community/netbox/issues/12872) - Introduce the `DATA_UPLOAD_MAX_MEMORY_SIZE` configuration parameter
|
||||
* [#13950](https://github.com/netbox-community/netbox/issues/13950) - Display custom choice field labels rather than values in UI
|
||||
* [#13957](https://github.com/netbox-community/netbox/issues/13957) - Add DNS name filter on IP addresses list
|
||||
* [#13962](https://github.com/netbox-community/netbox/issues/13962) - Add a copy-to-clipboard button for API tokens
|
||||
* [#13972](https://github.com/netbox-community/netbox/issues/13972) - Introduce a filter to find unterminated cables
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11987](https://github.com/netbox-community/netbox/issues/11987) - Fix validation of bulk cable updates via bulk import form
|
||||
* [#12328](https://github.com/netbox-community/netbox/issues/12328) - Ensure generic foreign key relationships are populated in REST API serializations of objects
|
||||
* [#12336](https://github.com/netbox-community/netbox/issues/12336) - Employ PostgreSQL advisory locks to avoid duplicate MPTT tree IDs when bulk creating objects
|
||||
* [#13064](https://github.com/netbox-community/netbox/issues/13064) - Fix resetting of checkbox fields triggered by HTMX form re-rendering
|
||||
* [#13440](https://github.com/netbox-community/netbox/issues/13440) - Fix support for assigning a tenant when creating "next available" VLANs via the REST API
|
||||
* [#13746](https://github.com/netbox-community/netbox/issues/13746) - Fix support for setting custom field values when creating "next available" IP addresses via the REST API
|
||||
* [#13872](https://github.com/netbox-community/netbox/issues/13872) - Add CSV delimiter field to file upload tab under bulk object upload views
|
||||
* [#13876](https://github.com/netbox-community/netbox/issues/13876) - Fix support for assigning an interface when creating "next available" IP addresses via the REST API
|
||||
* [#13910](https://github.com/netbox-community/netbox/issues/13910) - Correct "add device" button link under platform view
|
||||
* [#13944](https://github.com/netbox-community/netbox/issues/13944) - Correct serialization of several report attributes in the REST API
|
||||
* [#13966](https://github.com/netbox-community/netbox/issues/13966) - Restore "last login" column on users table
|
||||
* [#14013](https://github.com/netbox-community/netbox/issues/14013) - Fix device role filter choices under inventory items list filters
|
||||
* [#14023](https://github.com/netbox-community/netbox/issues/14023) - Fix exception when bulk disconnecting interfaces connected to the same cable
|
||||
* [#14025](https://github.com/netbox-community/netbox/issues/14025) - Fix exception when viewing a script that begins with the same name as another
|
||||
* [#14026](https://github.com/netbox-community/netbox/issues/14026) - Optimize the automatic creation of available IP addresses for large prefixes
|
||||
* [#14042](https://github.com/netbox-community/netbox/issues/14042) - Fix duplicated child object count decrements when removing objects in bulk
|
||||
|
||||
---
|
||||
|
||||
## v3.6.3 (2023-09-26)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#12732](https://github.com/netbox-community/netbox/issues/12732) - Add toggle to hide disconnected interfaces under device view
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11079](https://github.com/netbox-community/netbox/issues/11079) - Enable tracing cable paths across multiple cables in parallel
|
||||
* [#11901](https://github.com/netbox-community/netbox/issues/11901) - Fix `IndexError` exception when manipulating terminations for existing cables via REST API
|
||||
* [#13506](https://github.com/netbox-community/netbox/issues/13506) - Enable creating a config template which references a data file via the REST API
|
||||
* [#13666](https://github.com/netbox-community/netbox/issues/13666) - Cleanly handle reports without any test methods defined
|
||||
* [#13839](https://github.com/netbox-community/netbox/issues/13839) - Restore original text color for HTML code elements
|
||||
* [#13843](https://github.com/netbox-community/netbox/issues/13843) - Fix assignment of VLAN group scope during bulk edit
|
||||
* [#13845](https://github.com/netbox-community/netbox/issues/13845) - Fix `AttributeError` exception when attaching front/rear images to a device type
|
||||
* [#13849](https://github.com/netbox-community/netbox/issues/13849) - Fix `KeyError` exception when deleting an object which references a configured choice value that has been removed
|
||||
* [#13859](https://github.com/netbox-community/netbox/issues/13859) - Fix invalid response when searching for custom choice field values returns no matches
|
||||
* [#13864](https://github.com/netbox-community/netbox/issues/13864) - Correct default background color for dashboard widget headers
|
||||
* [#13871](https://github.com/netbox-community/netbox/issues/13871) - Fix rack filtering for empty location during device bulk import
|
||||
* [#13891](https://github.com/netbox-community/netbox/issues/13891) - Allow designating an IP address as primary for device/VM while assigning it to an interface
|
||||
|
||||
---
|
||||
|
||||
## v3.6.2 (2023-09-20)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#13245](https://github.com/netbox-community/netbox/issues/13245) - Add interface types for QSFP112 and OSFP-RHS
|
||||
* [#13563](https://github.com/netbox-community/netbox/issues/13563) - Add support for other delimiting characters when using CSV import
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11209](https://github.com/netbox-community/netbox/issues/11209) - Hide available IP/VLAN listing when sorting under a parent prefix or VLAN range
|
||||
* [#11617](https://github.com/netbox-community/netbox/issues/11617) - Raise validation error on the presence of an unknown CSV header during bulk import
|
||||
* [#12219](https://github.com/netbox-community/netbox/issues/12219) - Fix dashboard widget heading contrast under dark mode
|
||||
* [#12685](https://github.com/netbox-community/netbox/issues/12685) - Render Markdown in custom field help text on object edit forms
|
||||
* [#13653](https://github.com/netbox-community/netbox/issues/13653) - Tweak color of error text to improve legibility
|
||||
* [#13701](https://github.com/netbox-community/netbox/issues/13701) - Correct display of power feed legs under device view
|
||||
* [#13706](https://github.com/netbox-community/netbox/issues/13706) - Restore extra filters dropdown on device interfaces list
|
||||
* [#13721](https://github.com/netbox-community/netbox/issues/13721) - Filter VLAN choices by selected site (if any) when creating a prefix
|
||||
* [#13727](https://github.com/netbox-community/netbox/issues/13727) - Fix exception when viewing rendered config for VM without a role assigned
|
||||
* [#13745](https://github.com/netbox-community/netbox/issues/13745) - Optimize counter field migrations for large databases
|
||||
* [#13756](https://github.com/netbox-community/netbox/issues/13756) - Fix exception when sorting module bay list by installed module status
|
||||
* [#13757](https://github.com/netbox-community/netbox/issues/13757) - Fix RecursionError exception when assigning config context to a device type
|
||||
* [#13767](https://github.com/netbox-community/netbox/issues/13767) - Fix support for comments when creating a new service via web UI
|
||||
* [#13782](https://github.com/netbox-community/netbox/issues/13782) - Fix tag exclusion support for contact assignments
|
||||
* [#13791](https://github.com/netbox-community/netbox/issues/13791) - Preserve whitespace in values when performing bulk rename of objects via web UI
|
||||
* [#13809](https://github.com/netbox-community/netbox/issues/13809) - Avoid TypeError exception when editing active configuration with statically defined `CUSTOM_VALIDATORS`
|
||||
* [#13813](https://github.com/netbox-community/netbox/issues/13813) - Fix member count for newly created virtual chassis
|
||||
* [#13818](https://github.com/netbox-community/netbox/issues/13818) - Restore missing tags field on L2VPN termination edit form
|
||||
|
||||
---
|
||||
|
||||
## v3.6.1 (2023-09-06)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#12870](https://github.com/netbox-community/netbox/issues/12870) - Support setting token expiration time using the provisioning API endpoint
|
||||
* [#13444](https://github.com/netbox-community/netbox/issues/13444) - Add bulk rename functionality to the global device component lists
|
||||
* [#13638](https://github.com/netbox-community/netbox/issues/13638) - Add optional `staff_only` attribute to MenuItem
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#12553](https://github.com/netbox-community/netbox/issues/12552) - Ensure `family` attribute is always returned when creating aggregates and prefixes via REST API
|
||||
* [#13619](https://github.com/netbox-community/netbox/issues/13619) - Fix exception when viewing IP address assigned to a virtual machine
|
||||
* [#13596](https://github.com/netbox-community/netbox/issues/13596) - Always display "render config" tab for devices and virtual machines
|
||||
* [#13620](https://github.com/netbox-community/netbox/issues/13620) - Show admin menu items only for staff users
|
||||
* [#13622](https://github.com/netbox-community/netbox/issues/13622) - Fix exception when viewing current config and no revisions have been created
|
||||
* [#13626](https://github.com/netbox-community/netbox/issues/13626) - Correct filtering of recent activity list under user view
|
||||
* [#13628](https://github.com/netbox-community/netbox/issues/13628) - Remove stale references to obsolete NAPALM integration
|
||||
* [#13630](https://github.com/netbox-community/netbox/issues/13630) - Fix display of active status under user view
|
||||
* [#13632](https://github.com/netbox-community/netbox/issues/13632) - Avoid raising exception when checking if FHRP group IP address is primary
|
||||
* [#13642](https://github.com/netbox-community/netbox/issues/13642) - Suppress warning about unreflected model changes when applying migrations
|
||||
* [#13657](https://github.com/netbox-community/netbox/issues/13657) - Fix decoding of data file content
|
||||
* [#13674](https://github.com/netbox-community/netbox/issues/13674) - Fix retrieving individual report via REST API
|
||||
* [#13682](https://github.com/netbox-community/netbox/issues/13682) - Fix error message returned when validation of custom field default value fails
|
||||
* [#13684](https://github.com/netbox-community/netbox/issues/13684) - Enable modifying the configuration when maintenance mode is enabled
|
||||
|
||||
---
|
||||
|
||||
## v3.6.0 (2023-08-30)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
* PostgreSQL 11 is no longer supported (dropped in Django 4.2). NetBox v3.6 requires PostgreSQL 12 or later.
|
||||
* The `boto3` and `dulwich` packages are no longer installed automatically. If needed for S3/git remote data backend support, add them to `local_requirements.txt` to ensure their installation.
|
||||
* The `device_role` field on the Device model has been renamed to `role`. The `device_role` field has been temporarily retained on the REST API serializer for devices for backward compatibility, but is read-only.
|
||||
* The `choices` array field has been removed from the CustomField model. Any defined choices are automatically migrated to CustomFieldChoiceSets, accessible via the new `choice_set` field on the CustomField model.
|
||||
* The `napalm_driver` and `napalm_args` fields (which were deprecated in v3.5) have been removed from the Platform model.
|
||||
* The `device` and `device_id` filter for interfaces will no longer include interfaces from virtual chassis peers. Two new filters, `virtual_chassis_member` and `virtual_chassis_member_id`, have been introduced to match all interfaces belonging to the specified device's virtual chassis (if any).
|
||||
* Reports and scripts are now returned within a `results` list when fetched via the REST API, consistent with other models.
|
||||
* Superusers can no longer retrieve API token keys via the web UI if [`ALLOW_TOKEN_RETRIEVAL`](https://docs.netbox.dev/en/stable/configuration/security/#allow_token_retrieval) is disabled. (The admin view has been removed per [#13044](https://github.com/netbox-community/netbox/issues/13044).)
|
||||
|
||||
### New Features
|
||||
|
||||
#### Relocated Admin Views ([#12589](https://github.com/netbox-community/netbox/issues/12589), [#12590](https://github.com/netbox-community/netbox/issues/12590), [#12591](https://github.com/netbox-community/netbox/issues/12591), [#13044](https://github.com/netbox-community/netbox/issues/13044))
|
||||
#### Relocated Admin UI Views ([#12589](https://github.com/netbox-community/netbox/issues/12589), [#12590](https://github.com/netbox-community/netbox/issues/12590), [#12591](https://github.com/netbox-community/netbox/issues/12591), [#13044](https://github.com/netbox-community/netbox/issues/13044))
|
||||
|
||||
Management views for the following object types, previously available only under the backend admin interface, have been relocated to the primary user interface:
|
||||
|
||||
@@ -55,15 +224,25 @@ Tags may now be restricted to use with designated object types. Tags that have n
|
||||
* [#8137](https://github.com/netbox-community/netbox/issues/8137) - Add a field for designating the out-of-band (OOB) IP address for devices
|
||||
* [#10197](https://github.com/netbox-community/netbox/issues/10197) - Cache the number of member devices on each virtual chassis
|
||||
* [#11305](https://github.com/netbox-community/netbox/issues/11305) - Add GPS coordinate fields to the device model
|
||||
* [#11478](https://github.com/netbox-community/netbox/issues/11478) - Introduce `virtual_chassis_member` filter for interfaces & restore default behavior for `device` filter
|
||||
* [#11519](https://github.com/netbox-community/netbox/issues/11519) - Add a SQL index for IP address host values to optimize queries
|
||||
* [#11732](https://github.com/netbox-community/netbox/issues/11732) - Prevent inadvertent overwriting of object attributes by competing users
|
||||
* [#11936](https://github.com/netbox-community/netbox/issues/11936) - Introduce support for tags and custom fields on webhooks
|
||||
* [#12175](https://github.com/netbox-community/netbox/issues/12175) - Permit racks to start numbering at values greater than one
|
||||
* [#12210](https://github.com/netbox-community/netbox/issues/12210) - Add tenancy assignment for power feeds
|
||||
* [#12461](https://github.com/netbox-community/netbox/issues/12461) - Add config template rendering for virtual machines
|
||||
* [#12814](https://github.com/netbox-community/netbox/issues/12814) - Expose NetBox models within ConfigTemplate rendering context
|
||||
* [#12882](https://github.com/netbox-community/netbox/issues/12882) - Add tag support for contact assignments
|
||||
* [#13037](https://github.com/netbox-community/netbox/issues/13037) - Return reports & scripts within a `results` list when fetched via the REST API
|
||||
* [#13170](https://github.com/netbox-community/netbox/issues/13170) - Add `rf_role` to InterfaceTemplate
|
||||
* [#13269](https://github.com/netbox-community/netbox/issues/13269) - Cache the number of assigned component templates for device types
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#13513](https://github.com/netbox-community/netbox/issues/13513) - Prevent exception when rendering bookmarks widget for anonymous user
|
||||
* [#13599](https://github.com/netbox-community/netbox/issues/13599) - Fix errant counter increments when editing device/VM components
|
||||
* [#13605](https://github.com/netbox-community/netbox/issues/13605) - Optimize cached counter migrations to avoid excessive memory consumption
|
||||
|
||||
### Other Changes
|
||||
|
||||
* Work has begun on introducing translation and localization support in NetBox. This work is being performed in preparation for release 4.0.
|
||||
@@ -72,8 +251,9 @@ Tags may now be restricted to use with designated object types. Tags that have n
|
||||
* [#11766](https://github.com/netbox-community/netbox/issues/11766) - Remove obsolete custom `ChoiceField` and `MultipleChoiceField` classes
|
||||
* [#12180](https://github.com/netbox-community/netbox/issues/12180) - All API endpoints for available objects (e.g. IP addresses) now inherit from a common parent view
|
||||
* [#12237](https://github.com/netbox-community/netbox/issues/12237) - Upgrade Django to v4.2
|
||||
* [#12794](https://github.com/netbox-community/netbox/issues/12794) - Avoid direct imports of Django's stock user model
|
||||
* [#12320](https://github.com/netbox-community/netbox/issues/12320) - Remove obsolete fields `napalm_driver` and `napalm_args` from Platform
|
||||
* [#12794](https://github.com/netbox-community/netbox/issues/12794) - Avoid direct imports of Django's stock user model
|
||||
* [#12906](https://github.com/netbox-community/netbox/issues/12906) - The `boto3` (AWS) and `dulwich` (git) packages for remote data sources are now optional requirements
|
||||
* [#12964](https://github.com/netbox-community/netbox/issues/12964) - Drop support for PostgreSQL 11
|
||||
* [#13309](https://github.com/netbox-community/netbox/issues/13309) - User account-specific resources have been moved to a new `account` app for better organization
|
||||
|
||||
@@ -112,6 +292,10 @@ Tags may now be restricted to use with designated object types. Tags that have n
|
||||
* extras.CustomField
|
||||
* Removed the `choices` array field
|
||||
* Added the `choice_set` foreign key field (to ChoiceSet)
|
||||
* extras.Report
|
||||
* Reports are now returned within a `results` list
|
||||
* extras.Script
|
||||
* Scripts are now returned within a `results` list
|
||||
* extras.Tag
|
||||
* Added the `object_types` field for optional restriction to specific object types
|
||||
* extras.Webhook
|
||||
|
||||
@@ -211,6 +211,7 @@ nav:
|
||||
- ConfigContext: 'models/extras/configcontext.md'
|
||||
- ConfigTemplate: 'models/extras/configtemplate.md'
|
||||
- CustomField: 'models/extras/customfield.md'
|
||||
- CustomFieldChoiceSet: 'models/extras/customfieldchoiceset.md'
|
||||
- CustomLink: 'models/extras/customlink.md'
|
||||
- ExportTemplate: 'models/extras/exporttemplate.md'
|
||||
- ImageAttachment: 'models/extras/imageattachment.md'
|
||||
@@ -270,6 +271,7 @@ nav:
|
||||
- Application Registry: 'development/application-registry.md'
|
||||
- User Preferences: 'development/user-preferences.md'
|
||||
- Web UI: 'development/web-ui.md'
|
||||
- Internationalization: 'development/internationalization.md'
|
||||
- Release Checklist: 'development/release-checklist.md'
|
||||
- git Cheat Sheet: 'development/git-cheat-sheet.md'
|
||||
- Release Notes:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
@@ -6,9 +5,8 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from circuits.choices import *
|
||||
from dcim.models import CabledObjectModel
|
||||
from netbox.models import (
|
||||
ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, PrimaryModel, TagsMixin,
|
||||
)
|
||||
from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
|
||||
from netbox.models.features import ContactsMixin, CustomFieldsMixin, CustomLinksMixin, ImageAttachmentsMixin, TagsMixin
|
||||
|
||||
__all__ = (
|
||||
'Circuit',
|
||||
@@ -25,8 +23,13 @@ class CircuitType(OrganizationalModel):
|
||||
def get_absolute_url(self):
|
||||
return reverse('circuits:circuittype', args=[self.pk])
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('circuit type')
|
||||
verbose_name_plural = _('circuit types')
|
||||
|
||||
class Circuit(PrimaryModel):
|
||||
|
||||
class Circuit(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
|
||||
"""
|
||||
A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple
|
||||
circuits. Each circuit is also assigned a CircuitType and a Site, and may optionally be assigned to a particular
|
||||
@@ -84,14 +87,6 @@ class Circuit(PrimaryModel):
|
||||
help_text=_("Committed rate")
|
||||
)
|
||||
|
||||
# Generic relations
|
||||
contacts = GenericRelation(
|
||||
to='tenancy.ContactAssignment'
|
||||
)
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
# Cache associated CircuitTerminations
|
||||
termination_a = models.ForeignKey(
|
||||
to='circuits.CircuitTermination',
|
||||
@@ -131,6 +126,8 @@ class Circuit(PrimaryModel):
|
||||
name='%(app_label)s_%(class)s_unique_provideraccount_cid'
|
||||
),
|
||||
)
|
||||
verbose_name = _('circuit')
|
||||
verbose_name_plural = _('circuits')
|
||||
|
||||
def __str__(self):
|
||||
return self.cid
|
||||
@@ -217,6 +214,8 @@ class CircuitTermination(
|
||||
name='%(app_label)s_%(class)s_unique_circuit_term_side'
|
||||
),
|
||||
)
|
||||
verbose_name = _('circuit termination')
|
||||
verbose_name_plural = _('circuit terminations')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.circuit}: Termination {self.term_side}'
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.models import PrimaryModel
|
||||
from netbox.models.features import ContactsMixin
|
||||
|
||||
__all__ = (
|
||||
'ProviderNetwork',
|
||||
@@ -13,7 +13,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class Provider(PrimaryModel):
|
||||
class Provider(ContactsMixin, PrimaryModel):
|
||||
"""
|
||||
Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
|
||||
stores information pertinent to the user's relationship with the Provider.
|
||||
@@ -35,15 +35,12 @@ class Provider(PrimaryModel):
|
||||
blank=True
|
||||
)
|
||||
|
||||
# Generic relations
|
||||
contacts = GenericRelation(
|
||||
to='tenancy.ContactAssignment'
|
||||
)
|
||||
|
||||
clone_fields = ()
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
verbose_name = _('provider')
|
||||
verbose_name_plural = _('providers')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -52,7 +49,7 @@ class Provider(PrimaryModel):
|
||||
return reverse('circuits:provider', args=[self.pk])
|
||||
|
||||
|
||||
class ProviderAccount(PrimaryModel):
|
||||
class ProviderAccount(ContactsMixin, PrimaryModel):
|
||||
"""
|
||||
This is a discrete account within a provider. Each Circuit belongs to a Provider Account.
|
||||
"""
|
||||
@@ -71,11 +68,6 @@ class ProviderAccount(PrimaryModel):
|
||||
blank=True
|
||||
)
|
||||
|
||||
# Generic relations
|
||||
contacts = GenericRelation(
|
||||
to='tenancy.ContactAssignment'
|
||||
)
|
||||
|
||||
clone_fields = ('provider', )
|
||||
|
||||
class Meta:
|
||||
@@ -91,6 +83,8 @@ class ProviderAccount(PrimaryModel):
|
||||
condition=~Q(name="")
|
||||
),
|
||||
)
|
||||
verbose_name = _('provider account')
|
||||
verbose_name_plural = _('provider accounts')
|
||||
|
||||
def __str__(self):
|
||||
if self.name:
|
||||
@@ -129,6 +123,8 @@ class ProviderNetwork(PrimaryModel):
|
||||
name='%(app_label)s_%(class)s_unique_provider_name'
|
||||
),
|
||||
)
|
||||
verbose_name = _('provider network')
|
||||
verbose_name_plural = _('provider networks')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@@ -163,7 +163,7 @@ class ProviderNetworkView(generic.ObjectView):
|
||||
related_models = (
|
||||
(
|
||||
Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance),
|
||||
'providernetwork_id',
|
||||
'provider_network_id',
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
from django.apps import AppConfig
|
||||
from django.db import models
|
||||
from django.db.migrations.operations import AlterModelOptions
|
||||
|
||||
from utilities.migration import custom_deconstruct
|
||||
|
||||
# Ignore verbose_name & verbose_name_plural Meta options when calculating model migrations
|
||||
AlterModelOptions.ALTER_OPTION_KEYS.remove('verbose_name')
|
||||
AlterModelOptions.ALTER_OPTION_KEYS.remove('verbose_name_plural')
|
||||
|
||||
# Use our custom destructor to ignore certain attributes when calculating field migrations
|
||||
models.Field.deconstruct = custom_deconstruct
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
|
||||
@@ -14,8 +14,8 @@ class DataSourceTypeChoices(ChoiceSet):
|
||||
|
||||
CHOICES = (
|
||||
(LOCAL, _('Local'), 'gray'),
|
||||
(GIT, _('Git'), 'blue'),
|
||||
(AMAZON_S3, _('Amazon S3'), 'blue'),
|
||||
(GIT, 'Git', 'blue'),
|
||||
(AMAZON_S3, 'Amazon S3', 'blue'),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -81,13 +81,13 @@ class GitBackend(DataBackend):
|
||||
required=False,
|
||||
label=_('Username'),
|
||||
widget=forms.TextInput(attrs={'class': 'form-control'}),
|
||||
help_text=_("Only used for cloning with HTTP / HTTPS"),
|
||||
help_text=_("Only used for cloning with HTTP(S)"),
|
||||
),
|
||||
'password': forms.CharField(
|
||||
required=False,
|
||||
label=_('Password'),
|
||||
widget=forms.TextInput(attrs={'class': 'form-control'}),
|
||||
help_text=_("Only used for cloning with HTTP / HTTPS"),
|
||||
help_text=_("Only used for cloning with HTTP(S)"),
|
||||
),
|
||||
'branch': forms.CharField(
|
||||
required=False,
|
||||
@@ -125,12 +125,13 @@ class GitBackend(DataBackend):
|
||||
}
|
||||
|
||||
if self.url_scheme in ('http', 'https'):
|
||||
clone_args.update(
|
||||
{
|
||||
"username": self.params.get('username'),
|
||||
"password": self.params.get('password'),
|
||||
}
|
||||
)
|
||||
if self.params.get('username'):
|
||||
clone_args.update(
|
||||
{
|
||||
"username": self.params.get('username'),
|
||||
"password": self.params.get('password'),
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug(f"Cloning git repo: {self.url}")
|
||||
try:
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
from django.core.cache import cache
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from extras.models import ConfigRevision
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Command to clear the entire cache."""
|
||||
help = 'Clears the cache.'
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
# Fetch the current config revision from the cache
|
||||
config_version = cache.get('config_version')
|
||||
# Clear the cache
|
||||
cache.clear()
|
||||
self.stdout.write('Cache has been cleared.', ending="\n")
|
||||
if config_version:
|
||||
# Activate the current config revision
|
||||
ConfigRevision.objects.get(id=config_version).activate()
|
||||
self.stdout.write(f'Config revision ({config_version}) has been restored.', ending="\n")
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
# noinspection PyUnresolvedReferences
|
||||
from django.conf import settings
|
||||
from django.core.management.base import CommandError
|
||||
from django.core.management.commands.makemigrations import Command as _Command
|
||||
from django.db import models
|
||||
|
||||
from utilities.migration import custom_deconstruct
|
||||
|
||||
models.Field.deconstruct = custom_deconstruct
|
||||
|
||||
|
||||
class Command(_Command):
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# noinspection PyUnresolvedReferences
|
||||
from django.core.management.commands.migrate import Command
|
||||
from django.db import models
|
||||
|
||||
from utilities.migration import custom_deconstruct
|
||||
|
||||
models.Field.deconstruct = custom_deconstruct
|
||||
@@ -83,6 +83,8 @@ class DataSource(JobsMixin, PrimaryModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('data source')
|
||||
verbose_name_plural = _('data sources')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name}'
|
||||
@@ -120,6 +122,7 @@ class DataSource(JobsMixin, PrimaryModel):
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Ensure URL scheme matches selected type
|
||||
if self.type == DataSourceTypeChoices.LOCAL and self.url_scheme not in ('file', ''):
|
||||
@@ -300,6 +303,8 @@ class DataFile(models.Model):
|
||||
indexes = [
|
||||
models.Index(fields=('source', 'path'), name='core_datafile_source_path'),
|
||||
]
|
||||
verbose_name = _('data file')
|
||||
verbose_name_plural = _('data files')
|
||||
|
||||
def __str__(self):
|
||||
return self.path
|
||||
@@ -312,7 +317,7 @@ class DataFile(models.Model):
|
||||
if not self.data:
|
||||
return None
|
||||
try:
|
||||
return bytes(self.data, 'utf-8')
|
||||
return self.data.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
@@ -383,3 +388,5 @@ class AutoSyncRecord(models.Model):
|
||||
indexes = (
|
||||
models.Index(fields=('object_type', 'object_id')),
|
||||
)
|
||||
verbose_name = _('auto sync record')
|
||||
verbose_name_plural = _('auto sync records')
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -56,6 +57,8 @@ class ManagedFile(SyncedDataMixin, models.Model):
|
||||
indexes = [
|
||||
models.Index(fields=('file_root', 'file_path'), name='core_managedfile_root_path'),
|
||||
]
|
||||
verbose_name = _('managed file')
|
||||
verbose_name_plural = _('managed files')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -82,6 +85,14 @@ class ManagedFile(SyncedDataMixin, models.Model):
|
||||
self.file_path = os.path.basename(self.data_path)
|
||||
self.data_file.write_to_disk(self.full_path, overwrite=True)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Ensure that the file root and path make a unique pair
|
||||
if self._meta.model.objects.filter(file_root=self.file_root, file_path=self.file_path).exclude(pk=self.pk).exists():
|
||||
raise ValidationError(
|
||||
f"A {self._meta.verbose_name.lower()} with this file path already exists ({self.file_root}/{self.file_path}).")
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
# Delete file from disk
|
||||
try:
|
||||
|
||||
@@ -101,6 +101,8 @@ class Job(models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created']
|
||||
verbose_name = _('job')
|
||||
verbose_name_plural = _('jobs')
|
||||
|
||||
def __str__(self):
|
||||
return str(self.job_id)
|
||||
@@ -227,7 +229,7 @@ class Job(models.Model):
|
||||
model_name=self.object_type.model,
|
||||
event=event,
|
||||
data=self.data,
|
||||
timestamp=str(timezone.now()),
|
||||
timestamp=timezone.now().isoformat(),
|
||||
username=self.user.username,
|
||||
retry=get_rq_retry()
|
||||
)
|
||||
|
||||
@@ -19,7 +19,8 @@ class JobTable(NetBoxTable):
|
||||
)
|
||||
object = tables.Column(
|
||||
verbose_name=_('Object'),
|
||||
linkify=True
|
||||
linkify=True,
|
||||
orderable=False
|
||||
)
|
||||
status = columns.ChoiceFieldColumn(
|
||||
verbose_name=_('Status'),
|
||||
|
||||
@@ -25,4 +25,7 @@ urlpatterns = (
|
||||
path('jobs/<int:pk>/', views.JobView.as_view(), name='job'),
|
||||
path('jobs/<int:pk>/delete/', views.JobDeleteView.as_view(), name='job_delete'),
|
||||
|
||||
# Configuration
|
||||
path('config/', views.ConfigView.as_view(), name='config'),
|
||||
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from django.contrib import messages
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
|
||||
from extras.models import ConfigRevision
|
||||
from netbox.config import get_config
|
||||
from netbox.views import generic
|
||||
from netbox.views.generic.base import BaseObjectView
|
||||
from utilities.utils import count_related
|
||||
@@ -141,3 +143,19 @@ class JobBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Job.objects.all()
|
||||
filterset = filtersets.JobFilterSet
|
||||
table = tables.JobTable
|
||||
|
||||
|
||||
#
|
||||
# Config Revisions
|
||||
#
|
||||
|
||||
class ConfigView(generic.ObjectView):
|
||||
queryset = ConfigRevision.objects.all()
|
||||
|
||||
def get_object(self, **kwargs):
|
||||
if config := self.queryset.first():
|
||||
return config
|
||||
# Instantiate a dummy default config if none has been created yet
|
||||
return ConfigRevision(
|
||||
data=get_config().defaults
|
||||
)
|
||||
|
||||
@@ -738,12 +738,12 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
|
||||
class Meta(DeviceSerializer.Meta):
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'device_type', 'role', 'device_role', 'tenant', 'platform', 'serial',
|
||||
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow',
|
||||
'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position',
|
||||
'vc_priority', 'description', 'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context',
|
||||
'config_template', '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',
|
||||
'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',
|
||||
'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',
|
||||
]
|
||||
|
||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||
@@ -758,6 +758,7 @@ class VirtualDeviceContextSerializer(NetBoxModelSerializer):
|
||||
primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True)
|
||||
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
|
||||
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
|
||||
status = ChoiceField(choices=VirtualDeviceContextStatusChoices)
|
||||
|
||||
# Related object counts
|
||||
interface_count = serializers.IntegerField(read_only=True)
|
||||
@@ -786,10 +787,6 @@ class ModuleSerializer(NetBoxModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class DeviceNAPALMSerializer(serializers.Serializer):
|
||||
method = serializers.JSONField()
|
||||
|
||||
|
||||
#
|
||||
# Device components
|
||||
#
|
||||
|
||||
@@ -20,7 +20,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
from netbox.api.metadata import ContentTypeMetadata
|
||||
from netbox.api.pagination import StripCountAnnotationsPaginator
|
||||
from netbox.api.renderers import TextRenderer
|
||||
from netbox.api.viewsets import NetBoxModelViewSet
|
||||
from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
|
||||
from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
|
||||
from netbox.constants import NESTED_SERIALIZER_PREFIX
|
||||
from utilities.api import get_serializer_for_model
|
||||
@@ -98,7 +98,7 @@ class PassThroughPortMixin(object):
|
||||
# Regions
|
||||
#
|
||||
|
||||
class RegionViewSet(NetBoxModelViewSet):
|
||||
class RegionViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||
queryset = Region.objects.add_related_count(
|
||||
Region.objects.all(),
|
||||
Site,
|
||||
@@ -114,7 +114,7 @@ class RegionViewSet(NetBoxModelViewSet):
|
||||
# Site groups
|
||||
#
|
||||
|
||||
class SiteGroupViewSet(NetBoxModelViewSet):
|
||||
class SiteGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||
queryset = SiteGroup.objects.add_related_count(
|
||||
SiteGroup.objects.all(),
|
||||
Site,
|
||||
@@ -149,7 +149,7 @@ class SiteViewSet(NetBoxModelViewSet):
|
||||
# Locations
|
||||
#
|
||||
|
||||
class LocationViewSet(NetBoxModelViewSet):
|
||||
class LocationViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||
queryset = Location.objects.add_related_count(
|
||||
Location.objects.add_related_count(
|
||||
Location.objects.all(),
|
||||
@@ -350,7 +350,7 @@ class DeviceBayTemplateViewSet(NetBoxModelViewSet):
|
||||
filterset_class = filtersets.DeviceBayTemplateFilterSet
|
||||
|
||||
|
||||
class InventoryItemTemplateViewSet(NetBoxModelViewSet):
|
||||
class InventoryItemTemplateViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||
queryset = InventoryItemTemplate.objects.prefetch_related('device_type__manufacturer', 'role')
|
||||
serializer_class = serializers.InventoryItemTemplateSerializer
|
||||
filterset_class = filtersets.InventoryItemTemplateFilterSet
|
||||
@@ -538,7 +538,7 @@ class DeviceBayViewSet(NetBoxModelViewSet):
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
class InventoryItemViewSet(NetBoxModelViewSet):
|
||||
class InventoryItemViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||
queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'tags')
|
||||
serializer_class = serializers.InventoryItemSerializer
|
||||
filterset_class = filtersets.InventoryItemFilterSet
|
||||
|
||||
@@ -836,8 +836,11 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_200GE_CFP2 = '200gbase-x-cfp2'
|
||||
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
|
||||
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
|
||||
TYPE_400GE_CFP2 = '400gbase-x-cfp2'
|
||||
TYPE_400GE_QSFP112 = '400gbase-x-qsfp112'
|
||||
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
|
||||
TYPE_400GE_OSFP = '400gbase-x-osfp'
|
||||
TYPE_400GE_OSFP_RHS = '400gbase-x-osfp-rhs'
|
||||
TYPE_400GE_CDFP = '400gbase-x-cdfp'
|
||||
TYPE_400GE_CFP8 = '400gbase-x-cfp8'
|
||||
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
|
||||
@@ -978,6 +981,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_100GE_CFP, 'CFP (100GE)'),
|
||||
(TYPE_100GE_CFP2, 'CFP2 (100GE)'),
|
||||
(TYPE_200GE_CFP2, 'CFP2 (200GE)'),
|
||||
(TYPE_400GE_CFP2, 'CFP2 (400GE)'),
|
||||
(TYPE_100GE_CFP4, 'CFP4 (100GE)'),
|
||||
(TYPE_100GE_CXP, 'CXP (100GE)'),
|
||||
(TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),
|
||||
@@ -987,8 +991,10 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'),
|
||||
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
|
||||
(TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
|
||||
(TYPE_400GE_QSFP112, 'QSFP112 (400GE)'),
|
||||
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
|
||||
(TYPE_400GE_OSFP, 'OSFP (400GE)'),
|
||||
(TYPE_400GE_OSFP_RHS, 'OSFP-RHS (400GE)'),
|
||||
(TYPE_400GE_CDFP, 'CDFP (400GE)'),
|
||||
(TYPE_400GE_CFP8, 'CPF8 (400GE)'),
|
||||
(TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
|
||||
@@ -1141,6 +1147,8 @@ class InterfaceSpeedChoices(ChoiceSet):
|
||||
(25000000, '25 Gbps'),
|
||||
(40000000, '40 Gbps'),
|
||||
(100000000, '100 Gbps'),
|
||||
(200000000, '200 Gbps'),
|
||||
(400000000, '400 Gbps'),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from django.utils.translation import gettext as _
|
||||
|
||||
from extras.filtersets import LocalConfigContextFilterSet
|
||||
from extras.models import ConfigTemplate
|
||||
from ipam.filtersets import PrimaryIPFilterSet
|
||||
from ipam.models import ASN, L2VPN, IPAddress, VRF
|
||||
from netbox.filtersets import (
|
||||
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
|
||||
@@ -817,7 +818,13 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
|
||||
|
||||
class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
|
||||
class DeviceFilterSet(
|
||||
NetBoxModelFilterSet,
|
||||
TenancyFilterSet,
|
||||
ContactModelFilterSet,
|
||||
LocalConfigContextFilterSet,
|
||||
PrimaryIPFilterSet,
|
||||
):
|
||||
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device_type__manufacturer',
|
||||
queryset=Manufacturer.objects.all(),
|
||||
@@ -993,16 +1000,6 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
|
||||
method='_device_bays',
|
||||
label=_('Has device bays'),
|
||||
)
|
||||
primary_ip4_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='primary_ip4',
|
||||
queryset=IPAddress.objects.all(),
|
||||
label=_('Primary IPv4 (ID)'),
|
||||
)
|
||||
primary_ip6_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='primary_ip6',
|
||||
queryset=IPAddress.objects.all(),
|
||||
label=_('Primary IPv6 (ID)'),
|
||||
)
|
||||
oob_ip_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='oob_ip',
|
||||
queryset=IPAddress.objects.all(),
|
||||
@@ -1069,7 +1066,7 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
|
||||
return queryset.exclude(devicebays__isnull=value)
|
||||
|
||||
|
||||
class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet, PrimaryIPFilterSet):
|
||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device',
|
||||
queryset=Device.objects.all(),
|
||||
@@ -1462,17 +1459,15 @@ class InterfaceFilterSet(
|
||||
PathEndpointFilterSet,
|
||||
CommonInterfaceFilterSet
|
||||
):
|
||||
# Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis
|
||||
# members
|
||||
device = MultiValueCharFilter(
|
||||
method='filter_device',
|
||||
virtual_chassis_member = MultiValueCharFilter(
|
||||
method='filter_virtual_chassis_member',
|
||||
field_name='name',
|
||||
label=_('Device'),
|
||||
label=_('Virtual Chassis Interfaces for Device')
|
||||
)
|
||||
device_id = MultiValueNumberFilter(
|
||||
method='filter_device_id',
|
||||
virtual_chassis_member_id = MultiValueNumberFilter(
|
||||
method='filter_virtual_chassis_member',
|
||||
field_name='pk',
|
||||
label=_('Device (ID)'),
|
||||
label=_('Virtual Chassis Interfaces for Device (ID)')
|
||||
)
|
||||
kind = django_filters.CharFilter(
|
||||
method='filter_kind',
|
||||
@@ -1540,23 +1535,11 @@ class InterfaceFilterSet(
|
||||
'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'cable_end',
|
||||
]
|
||||
|
||||
def filter_device(self, queryset, name, value):
|
||||
def filter_virtual_chassis_member(self, queryset, name, value):
|
||||
try:
|
||||
devices = Device.objects.filter(**{'{}__in'.format(name): value})
|
||||
vc_interface_ids = []
|
||||
for device in devices:
|
||||
vc_interface_ids.extend(device.vc_interfaces().values_list('id', flat=True))
|
||||
return queryset.filter(pk__in=vc_interface_ids)
|
||||
except Device.DoesNotExist:
|
||||
return queryset.none()
|
||||
|
||||
def filter_device_id(self, queryset, name, id_list):
|
||||
# Include interfaces belonging to peer virtual chassis members
|
||||
vc_interface_ids = []
|
||||
try:
|
||||
devices = Device.objects.filter(pk__in=id_list)
|
||||
for device in devices:
|
||||
vc_interface_ids += device.vc_interfaces(if_master=False).values_list('id', flat=True)
|
||||
for device in Device.objects.filter(**{f'{name}__in': value}):
|
||||
vc_interface_ids.extend(device.vc_interfaces(if_master=False).values_list('id', flat=True))
|
||||
return queryset.filter(pk__in=vc_interface_ids)
|
||||
except Device.DoesNotExist:
|
||||
return queryset.none()
|
||||
@@ -1759,6 +1742,10 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
method='filter_by_cable_end_b',
|
||||
field_name='terminations__termination_id'
|
||||
)
|
||||
unterminated = django_filters.BooleanFilter(
|
||||
method='_unterminated',
|
||||
label=_('Unterminated'),
|
||||
)
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=CableTypeChoices
|
||||
)
|
||||
@@ -1826,6 +1813,19 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
# Filter by termination id and cable_end type
|
||||
return self.filter_by_cable_end(queryset, name, value, CableEndChoices.SIDE_B)
|
||||
|
||||
def _unterminated(self, queryset, name, value):
|
||||
if value:
|
||||
terminated_ids = (
|
||||
queryset.filter(terminations__cable_end=CableEndChoices.SIDE_A)
|
||||
.filter(terminations__cable_end=CableEndChoices.SIDE_B)
|
||||
.values("id")
|
||||
)
|
||||
return queryset.exclude(id__in=terminated_ids)
|
||||
else:
|
||||
return queryset.filter(terminations__cable_end=CableEndChoices.SIDE_A).filter(
|
||||
terminations__cable_end=CableEndChoices.SIDE_B
|
||||
)
|
||||
|
||||
|
||||
class CableTerminationFilterSet(BaseFilterSet):
|
||||
termination_type = ContentTypeFilter()
|
||||
|
||||
@@ -118,7 +118,9 @@ class SiteImportForm(NetBoxModelImportForm):
|
||||
)
|
||||
help_texts = {
|
||||
'time_zone': mark_safe(
|
||||
_('Time zone (<a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones">available options</a>)')
|
||||
'{} (<a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones">{}</a>)'.format(
|
||||
_('Time zone'), _('available options')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -165,7 +167,7 @@ class RackRoleImportForm(NetBoxModelImportForm):
|
||||
model = RackRole
|
||||
fields = ('name', 'slug', 'color', 'description', 'tags')
|
||||
help_texts = {
|
||||
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
|
||||
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
|
||||
}
|
||||
|
||||
|
||||
@@ -375,7 +377,7 @@ class DeviceRoleImportForm(NetBoxModelImportForm):
|
||||
model = DeviceRole
|
||||
fields = ('name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags')
|
||||
help_texts = {
|
||||
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
|
||||
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
|
||||
}
|
||||
|
||||
|
||||
@@ -547,9 +549,9 @@ class DeviceImportForm(BaseDeviceImportForm):
|
||||
params = {
|
||||
f"site__{self.fields['site'].to_field_name}": data.get('site'),
|
||||
}
|
||||
if 'location' in data:
|
||||
if location := data.get('location'):
|
||||
params.update({
|
||||
f"location__{self.fields['location'].to_field_name}": data.get('location'),
|
||||
f"location__{self.fields['location'].to_field_name}": location,
|
||||
})
|
||||
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
|
||||
|
||||
@@ -790,7 +792,9 @@ class InterfaceImportForm(NetBoxModelImportForm):
|
||||
queryset=VirtualDeviceContext.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_('VDC names separated by commas, encased with double quotes (e.g. "vdc1, vdc2, vdc3")')
|
||||
help_text=mark_safe(
|
||||
_('VDC names separated by commas, encased with double quotes. Example:') + ' <code>vdc1,vdc2,vdc3</code>'
|
||||
)
|
||||
)
|
||||
type = CSVChoiceField(
|
||||
label=_('Type'),
|
||||
@@ -1085,7 +1089,7 @@ class InventoryItemRoleImportForm(NetBoxModelImportForm):
|
||||
model = InventoryItemRole
|
||||
fields = ('name', 'slug', 'color', 'description')
|
||||
help_texts = {
|
||||
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
|
||||
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
|
||||
}
|
||||
|
||||
|
||||
@@ -1096,38 +1100,38 @@ class InventoryItemRoleImportForm(NetBoxModelImportForm):
|
||||
class CableImportForm(NetBoxModelImportForm):
|
||||
# Termination A
|
||||
side_a_device = CSVModelChoiceField(
|
||||
label=_('Side a device'),
|
||||
label=_('Side A device'),
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
help_text=_('Side A device')
|
||||
help_text=_('Device name')
|
||||
)
|
||||
side_a_type = CSVContentTypeField(
|
||||
label=_('Side a type'),
|
||||
label=_('Side A type'),
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=CABLE_TERMINATION_MODELS,
|
||||
help_text=_('Side A type')
|
||||
help_text=_('Termination type')
|
||||
)
|
||||
side_a_name = forms.CharField(
|
||||
label=_('Side a name'),
|
||||
help_text=_('Side A component name')
|
||||
label=_('Side A name'),
|
||||
help_text=_('Termination name')
|
||||
)
|
||||
|
||||
# Termination B
|
||||
side_b_device = CSVModelChoiceField(
|
||||
label=_('Side b device'),
|
||||
label=_('Side B device'),
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
help_text=_('Side B device')
|
||||
help_text=_('Device name')
|
||||
)
|
||||
side_b_type = CSVContentTypeField(
|
||||
label=_('Side b type'),
|
||||
label=_('Side B type'),
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=CABLE_TERMINATION_MODELS,
|
||||
help_text=_('Side B type')
|
||||
help_text=_('Termination type')
|
||||
)
|
||||
side_b_name = forms.CharField(
|
||||
label=_('Side b name'),
|
||||
help_text=_('Side B component name')
|
||||
label=_('Side B name'),
|
||||
help_text=_('Termination name')
|
||||
)
|
||||
|
||||
# Cable attributes
|
||||
@@ -1164,7 +1168,7 @@ class CableImportForm(NetBoxModelImportForm):
|
||||
'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags',
|
||||
]
|
||||
help_texts = {
|
||||
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
|
||||
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
|
||||
}
|
||||
|
||||
def _clean_side(self, side):
|
||||
@@ -1188,7 +1192,7 @@ class CableImportForm(NetBoxModelImportForm):
|
||||
termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name)
|
||||
else:
|
||||
termination_object = model.objects.get(device=device, name=name)
|
||||
if termination_object.cable is not None:
|
||||
if termination_object.cable is not None and termination_object.cable != self.instance:
|
||||
raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
|
||||
except ObjectDoesNotExist:
|
||||
raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
|
||||
|
||||
@@ -109,7 +109,7 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
|
||||
required=False,
|
||||
label=_('Device type')
|
||||
)
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
device_role_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DeviceRole.objects.all(),
|
||||
required=False,
|
||||
label=_('Device role')
|
||||
@@ -910,7 +910,7 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Location'), ('site_id', 'location_id', 'rack_id', 'device_id')),
|
||||
(_('Attributes'), ('type', 'status', 'color', 'length', 'length_unit')),
|
||||
(_('Attributes'), ('type', 'status', 'color', 'length', 'length_unit', 'unterminated')),
|
||||
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
@@ -979,6 +979,13 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
choices=add_blank_choice(CableLengthUnitChoices),
|
||||
required=False
|
||||
)
|
||||
unterminated = forms.NullBooleanField(
|
||||
label=_('Unterminated'),
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
@@ -1136,7 +1143,7 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'type', 'speed')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Connection'), ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@@ -1158,7 +1165,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'type', 'speed')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Connection'), ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@@ -1180,7 +1187,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'type')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Connection'), ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@@ -1197,7 +1204,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'type')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Connection'), ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@@ -1217,7 +1224,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
(_('PoE'), ('poe_mode', 'poe_type')),
|
||||
(_('Wireless'), ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
|
||||
(_('Connection'), ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
vdc_id = DynamicModelMultipleChoiceField(
|
||||
@@ -1324,7 +1331,7 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'type', 'color')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Cable'), ('cabled', 'occupied')),
|
||||
)
|
||||
model = FrontPort
|
||||
@@ -1346,7 +1353,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'type', 'color')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Cable'), ('cabled', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@@ -1367,7 +1374,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'position')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
position = forms.CharField(
|
||||
@@ -1382,7 +1389,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -1393,7 +1400,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
)
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
queryset=InventoryItemRole.objects.all(),
|
||||
|
||||
@@ -421,12 +421,13 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||
label=_('Position'),
|
||||
required=False,
|
||||
help_text=_("The lowest-numbered unit occupied by the device"),
|
||||
localize=True,
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/racks/{{rack}}/elevation/',
|
||||
attrs={
|
||||
'disabled-indicator': 'device',
|
||||
'data-dynamic-params': '[{"fieldName":"face","queryParam":"face"}]'
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
device_type = DynamicModelChoiceField(
|
||||
@@ -441,7 +442,8 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||
platform = DynamicModelChoiceField(
|
||||
label=_('Platform'),
|
||||
queryset=Platform.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
selector=True
|
||||
)
|
||||
cluster = DynamicModelChoiceField(
|
||||
label=_('Cluster'),
|
||||
@@ -1098,6 +1100,9 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
|
||||
queryset=VirtualDeviceContext.objects.all(),
|
||||
required=False,
|
||||
label=_('Virtual device contexts'),
|
||||
initial_params={
|
||||
'interfaces': '$parent',
|
||||
},
|
||||
query_params={
|
||||
'device_id': '$device',
|
||||
}
|
||||
@@ -1107,7 +1112,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
|
||||
required=False,
|
||||
label=_('Parent interface'),
|
||||
query_params={
|
||||
'device_id': '$device',
|
||||
'virtual_chassis_member_id': '$device',
|
||||
}
|
||||
)
|
||||
bridge = DynamicModelChoiceField(
|
||||
@@ -1115,7 +1120,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
|
||||
required=False,
|
||||
label=_('Bridged interface'),
|
||||
query_params={
|
||||
'device_id': '$device',
|
||||
'virtual_chassis_member_id': '$device',
|
||||
}
|
||||
)
|
||||
lag = DynamicModelChoiceField(
|
||||
@@ -1123,7 +1128,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
|
||||
required=False,
|
||||
label=_('LAG interface'),
|
||||
query_params={
|
||||
'device_id': '$device',
|
||||
'virtual_chassis_member_id': '$device',
|
||||
'type': 'lag',
|
||||
}
|
||||
)
|
||||
|
||||
@@ -55,7 +55,10 @@ class ComponentCreateForm(forms.Form):
|
||||
super().clean()
|
||||
|
||||
# Validate that all replication fields generate an equal number of values
|
||||
pattern_count = len(self.cleaned_data[self.replication_fields[0]])
|
||||
if not (patterns := self.cleaned_data.get(self.replication_fields[0])):
|
||||
return
|
||||
|
||||
pattern_count = len(patterns)
|
||||
for field_name in self.replication_fields:
|
||||
value_count = len(self.cleaned_data[field_name])
|
||||
if self.cleaned_data[field_name] and value_count != pattern_count:
|
||||
@@ -148,6 +151,23 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
|
||||
)
|
||||
self.fields['rear_port'].choices = choices
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Check that the number of FrontPortTemplates to be created matches the selected number of RearPortTemplate
|
||||
# positions
|
||||
frontport_count = len(self.cleaned_data['name'])
|
||||
rearport_count = len(self.cleaned_data['rear_port'])
|
||||
if frontport_count != rearport_count:
|
||||
raise forms.ValidationError({
|
||||
'rear_port': _(
|
||||
"The number of front port templates to be created ({frontport_count}) must match the selected "
|
||||
"number of rear port positions ({rearport_count})."
|
||||
).format(
|
||||
frontport_count=frontport_count,
|
||||
rearport_count=rearport_count
|
||||
)
|
||||
})
|
||||
|
||||
def get_iterative_data(self, iteration):
|
||||
|
||||
# Assign rear port and position from selected set
|
||||
@@ -288,6 +308,22 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
|
||||
)
|
||||
self.fields['rear_port'].choices = choices
|
||||
|
||||
def clean(self):
|
||||
|
||||
# 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
|
||||
|
||||
62
netbox/dcim/management/commands/buildschema.py
Normal file
62
netbox/dcim/management/commands/buildschema.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from jinja2 import FileSystemLoader, Environment
|
||||
|
||||
from dcim.choices import *
|
||||
|
||||
TEMPLATE_FILENAME = 'devicetype_schema.jinja2'
|
||||
OUTPUT_FILENAME = 'contrib/generated_schema.json'
|
||||
|
||||
CHOICES_MAP = {
|
||||
'airflow_choices': DeviceAirflowChoices,
|
||||
'weight_unit_choices': WeightUnitChoices,
|
||||
'subdevice_role_choices': SubdeviceRoleChoices,
|
||||
'console_port_type_choices': ConsolePortTypeChoices,
|
||||
'console_server_port_type_choices': ConsolePortTypeChoices,
|
||||
'power_port_type_choices': PowerPortTypeChoices,
|
||||
'power_outlet_type_choices': PowerOutletTypeChoices,
|
||||
'power_outlet_feedleg_choices': PowerOutletFeedLegChoices,
|
||||
'interface_type_choices': InterfaceTypeChoices,
|
||||
'interface_poe_mode_choices': InterfacePoEModeChoices,
|
||||
'interface_poe_type_choices': InterfacePoETypeChoices,
|
||||
'front_port_type_choices': PortTypeChoices,
|
||||
'rear_port_type_choices': PortTypeChoices,
|
||||
}
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Generate JSON schema for validating NetBox device type definitions"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--write',
|
||||
action='store_true',
|
||||
help="Write the generated schema to file"
|
||||
)
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
# Initialize template
|
||||
template_loader = FileSystemLoader(searchpath=f'{settings.TEMPLATES_DIR}/extras/schema/')
|
||||
template_env = Environment(loader=template_loader)
|
||||
template = template_env.get_template(TEMPLATE_FILENAME)
|
||||
|
||||
# Render template
|
||||
context = {
|
||||
key: json.dumps(choices.values())
|
||||
for key, choices in CHOICES_MAP.items()
|
||||
}
|
||||
rendered = template.render(**context)
|
||||
|
||||
if kwargs['write']:
|
||||
# $root/contrib/generated_schema.json
|
||||
filename = os.path.join(os.path.split(settings.BASE_DIR)[0], OUTPUT_FILENAME)
|
||||
with open(filename, mode='w', encoding='UTF-8') as f:
|
||||
f.write(json.dumps(json.loads(rendered), indent=4))
|
||||
f.write('\n')
|
||||
f.close()
|
||||
self.stdout.write(self.style.SUCCESS(f"Schema written to {filename}."))
|
||||
else:
|
||||
self.stdout.write(rendered)
|
||||
@@ -13,7 +13,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='config_template',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='extras.configtemplate'),
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='extras.configtemplate'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='devicerole',
|
||||
|
||||
@@ -2,47 +2,22 @@ from django.db import migrations
|
||||
from django.db.models import Count
|
||||
|
||||
import utilities.fields
|
||||
from utilities.counters import update_counts
|
||||
|
||||
|
||||
def recalculate_device_counts(apps, schema_editor):
|
||||
Device = apps.get_model("dcim", "Device")
|
||||
devices = list(Device.objects.all().annotate(
|
||||
_console_port_count=Count('consoleports', distinct=True),
|
||||
_console_server_port_count=Count('consoleserverports', distinct=True),
|
||||
_power_port_count=Count('powerports', distinct=True),
|
||||
_power_outlet_count=Count('poweroutlets', distinct=True),
|
||||
_interface_count=Count('interfaces', distinct=True),
|
||||
_front_port_count=Count('frontports', distinct=True),
|
||||
_rear_port_count=Count('rearports', distinct=True),
|
||||
_device_bay_count=Count('devicebays', distinct=True),
|
||||
_module_bay_count=Count('modulebays', distinct=True),
|
||||
_inventory_item_count=Count('inventoryitems', distinct=True),
|
||||
))
|
||||
|
||||
for device in devices:
|
||||
device.console_port_count = device._console_port_count
|
||||
device.console_server_port_count = device._console_server_port_count
|
||||
device.power_port_count = device._power_port_count
|
||||
device.power_outlet_count = device._power_outlet_count
|
||||
device.interface_count = device._interface_count
|
||||
device.front_port_count = device._front_port_count
|
||||
device.rear_port_count = device._rear_port_count
|
||||
device.device_bay_count = device._device_bay_count
|
||||
device.module_bay_count = device._module_bay_count
|
||||
device.inventory_item_count = device._inventory_item_count
|
||||
|
||||
Device.objects.bulk_update(devices, [
|
||||
'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',
|
||||
])
|
||||
update_counts(Device, 'console_port_count', 'consoleports')
|
||||
update_counts(Device, 'console_server_port_count', 'consoleserverports')
|
||||
update_counts(Device, 'power_port_count', 'powerports')
|
||||
update_counts(Device, 'power_outlet_count', 'poweroutlets')
|
||||
update_counts(Device, 'interface_count', 'interfaces')
|
||||
update_counts(Device, 'front_port_count', 'frontports')
|
||||
update_counts(Device, 'rear_port_count', 'rearports')
|
||||
update_counts(Device, 'device_bay_count', 'devicebays')
|
||||
update_counts(Device, 'module_bay_count', 'modulebays')
|
||||
update_counts(Device, 'inventory_item_count', 'inventoryitems')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -2,47 +2,22 @@ from django.db import migrations
|
||||
from django.db.models import Count
|
||||
|
||||
import utilities.fields
|
||||
from utilities.counters import update_counts
|
||||
|
||||
|
||||
def recalculate_devicetype_template_counts(apps, schema_editor):
|
||||
DeviceType = apps.get_model("dcim", "DeviceType")
|
||||
device_types = list(DeviceType.objects.all().annotate(
|
||||
_console_port_template_count=Count('consoleporttemplates', distinct=True),
|
||||
_console_server_port_template_count=Count('consoleserverporttemplates', distinct=True),
|
||||
_power_port_template_count=Count('powerporttemplates', distinct=True),
|
||||
_power_outlet_template_count=Count('poweroutlettemplates', distinct=True),
|
||||
_interface_template_count=Count('interfacetemplates', distinct=True),
|
||||
_front_port_template_count=Count('frontporttemplates', distinct=True),
|
||||
_rear_port_template_count=Count('rearporttemplates', distinct=True),
|
||||
_device_bay_template_count=Count('devicebaytemplates', distinct=True),
|
||||
_module_bay_template_count=Count('modulebaytemplates', distinct=True),
|
||||
_inventory_item_template_count=Count('inventoryitemtemplates', distinct=True),
|
||||
))
|
||||
|
||||
for devicetype in device_types:
|
||||
devicetype.console_port_template_count = devicetype._console_port_template_count
|
||||
devicetype.console_server_port_template_count = devicetype._console_server_port_template_count
|
||||
devicetype.power_port_template_count = devicetype._power_port_template_count
|
||||
devicetype.power_outlet_template_count = devicetype._power_outlet_template_count
|
||||
devicetype.interface_template_count = devicetype._interface_template_count
|
||||
devicetype.front_port_template_count = devicetype._front_port_template_count
|
||||
devicetype.rear_port_template_count = devicetype._rear_port_template_count
|
||||
devicetype.device_bay_template_count = devicetype._device_bay_template_count
|
||||
devicetype.module_bay_template_count = devicetype._module_bay_template_count
|
||||
devicetype.inventory_item_template_count = devicetype._inventory_item_template_count
|
||||
|
||||
DeviceType.objects.bulk_update(device_types, [
|
||||
'console_port_template_count',
|
||||
'console_server_port_template_count',
|
||||
'power_port_template_count',
|
||||
'power_outlet_template_count',
|
||||
'interface_template_count',
|
||||
'front_port_template_count',
|
||||
'rear_port_template_count',
|
||||
'device_bay_template_count',
|
||||
'module_bay_template_count',
|
||||
'inventory_item_template_count',
|
||||
])
|
||||
update_counts(DeviceType, 'console_port_template_count', 'consoleporttemplates')
|
||||
update_counts(DeviceType, 'console_server_port_template_count', 'consoleserverporttemplates')
|
||||
update_counts(DeviceType, 'power_port_template_count', 'powerporttemplates')
|
||||
update_counts(DeviceType, 'power_outlet_template_count', 'poweroutlettemplates')
|
||||
update_counts(DeviceType, 'interface_template_count', 'interfacetemplates')
|
||||
update_counts(DeviceType, 'front_port_template_count', 'frontporttemplates')
|
||||
update_counts(DeviceType, 'rear_port_template_count', 'rearporttemplates')
|
||||
update_counts(DeviceType, 'device_bay_template_count', 'devicebaytemplates')
|
||||
update_counts(DeviceType, 'module_bay_template_count', 'modulebaytemplates')
|
||||
update_counts(DeviceType, 'inventory_item_template_count', 'inventoryitemtemplates')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -2,17 +2,13 @@ from django.db import migrations
|
||||
from django.db.models import Count
|
||||
|
||||
import utilities.fields
|
||||
from utilities.counters import update_counts
|
||||
|
||||
|
||||
def populate_virtualchassis_members(apps, schema_editor):
|
||||
VirtualChassis = apps.get_model('dcim', 'VirtualChassis')
|
||||
|
||||
vcs = list(VirtualChassis.objects.annotate(_member_count=Count('members', distinct=True)))
|
||||
|
||||
for vc in vcs:
|
||||
vc.member_count = vc._member_count
|
||||
|
||||
VirtualChassis.objects.bulk_update(vcs, ['member_count'])
|
||||
update_counts(VirtualChassis, 'member_count', 'members')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -20,7 +20,7 @@ from utilities.fields import ColorField
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.utils import to_meters
|
||||
from wireless.models import WirelessLink
|
||||
from .device_components import FrontPort, RearPort
|
||||
from .device_components import FrontPort, RearPort, PathEndpoint
|
||||
|
||||
__all__ = (
|
||||
'Cable',
|
||||
@@ -91,15 +91,17 @@ class Cable(PrimaryModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
verbose_name = _('cable')
|
||||
verbose_name_plural = _('cables')
|
||||
|
||||
def __init__(self, *args, a_terminations=None, b_terminations=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# A copy of the PK to be used by __str__ in case the object is deleted
|
||||
self._pk = self.pk
|
||||
self._pk = self.__dict__.get('id')
|
||||
|
||||
# Cache the original status so we can check later if it's been changed
|
||||
self._orig_status = self.status
|
||||
self._orig_status = self.__dict__.get('status')
|
||||
|
||||
self._terminations_modified = False
|
||||
|
||||
@@ -178,6 +180,17 @@ class Cable(PrimaryModel):
|
||||
if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
|
||||
raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}")
|
||||
|
||||
if a_type == b_type:
|
||||
# can't directly use self.a_terminations here as possible they
|
||||
# don't have pk yet
|
||||
a_pks = set(obj.pk for obj in self.a_terminations if obj.pk)
|
||||
b_pks = set(obj.pk for obj in self.b_terminations if obj.pk)
|
||||
|
||||
if (a_pks & b_pks):
|
||||
raise ValidationError(
|
||||
_("A and B terminations cannot connect to the same object.")
|
||||
)
|
||||
|
||||
# Run clean() on any new CableTerminations
|
||||
for termination in self.a_terminations:
|
||||
CableTermination(cable=self, cable_end='A', termination=termination).clean()
|
||||
@@ -292,6 +305,8 @@ class CableTermination(ChangeLoggedModel):
|
||||
name='%(app_label)s_%(class)s_unique_termination'
|
||||
),
|
||||
)
|
||||
verbose_name = _('cable termination')
|
||||
verbose_name_plural = _('cable terminations')
|
||||
|
||||
def __str__(self):
|
||||
return f'Cable {self.cable} to {self.termination}'
|
||||
@@ -427,6 +442,10 @@ class CablePath(models.Model):
|
||||
)
|
||||
_nodes = PathField()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('cable path')
|
||||
verbose_name_plural = _('cable paths')
|
||||
|
||||
def __str__(self):
|
||||
return f"Path #{self.pk}: {len(self.path)} hops"
|
||||
|
||||
@@ -510,9 +529,16 @@ class CablePath(models.Model):
|
||||
# Terminations must all be of the same type
|
||||
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
|
||||
|
||||
# All mid-span terminations must all be attached to the same device
|
||||
if not isinstance(terminations[0], PathEndpoint):
|
||||
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
|
||||
assert all(t.parent_object == terminations[0].parent_object for t in terminations[1:])
|
||||
|
||||
# Check for a split path (e.g. rear port fanning out to multiple front ports with
|
||||
# different cables attached)
|
||||
if len(set(t.link for t in terminations)) > 1:
|
||||
if len(set(t.link for t in terminations)) > 1 and (
|
||||
position_stack and len(terminations) != len(position_stack[-1])
|
||||
):
|
||||
is_split = True
|
||||
break
|
||||
|
||||
@@ -521,46 +547,68 @@ class CablePath(models.Model):
|
||||
object_to_path_node(t) for t in terminations
|
||||
])
|
||||
|
||||
# Step 2: Determine the attached link (Cable or WirelessLink), if any
|
||||
link = terminations[0].link
|
||||
if link is None and len(path) == 1:
|
||||
# If this is the start of the path and no link exists, return None
|
||||
return None
|
||||
elif link is None:
|
||||
# Step 2: Determine the attached links (Cable or WirelessLink), if any
|
||||
links = [termination.link for termination in terminations if termination.link is not None]
|
||||
if len(links) == 0:
|
||||
if len(path) == 1:
|
||||
# If this is the start of the path and no link exists, return None
|
||||
return None
|
||||
# Otherwise, halt the trace if no link exists
|
||||
break
|
||||
assert type(link) in (Cable, WirelessLink)
|
||||
assert all(type(link) in (Cable, WirelessLink) for link in links)
|
||||
assert all(isinstance(link, type(links[0])) for link in links)
|
||||
|
||||
# Step 3: Record the link and update path status if not "connected"
|
||||
path.append([object_to_path_node(link)])
|
||||
if hasattr(link, 'status') and link.status != LinkStatusChoices.STATUS_CONNECTED:
|
||||
# Step 3: Record asymmetric paths as split
|
||||
not_connected_terminations = [termination.link for termination in terminations if termination.link is None]
|
||||
if len(not_connected_terminations) > 0:
|
||||
is_complete = False
|
||||
is_split = True
|
||||
|
||||
# Step 4: Record the links, keeping cables in order to allow for SVG rendering
|
||||
cables = []
|
||||
for link in links:
|
||||
if object_to_path_node(link) not in cables:
|
||||
cables.append(object_to_path_node(link))
|
||||
path.append(cables)
|
||||
|
||||
# Step 5: Update the path status if a link is not connected
|
||||
links_status = [link.status for link in links if link.status != LinkStatusChoices.STATUS_CONNECTED]
|
||||
if any([status != LinkStatusChoices.STATUS_CONNECTED for status in links_status]):
|
||||
is_active = False
|
||||
|
||||
# Step 4: Determine the far-end terminations
|
||||
if isinstance(link, Cable):
|
||||
# Step 6: Determine the far-end terminations
|
||||
if isinstance(links[0], Cable):
|
||||
termination_type = ContentType.objects.get_for_model(terminations[0])
|
||||
local_cable_terminations = CableTermination.objects.filter(
|
||||
termination_type=termination_type,
|
||||
termination_id__in=[t.pk for t in terminations]
|
||||
)
|
||||
# Terminations must all belong to same end of Cable
|
||||
local_cable_end = local_cable_terminations[0].cable_end
|
||||
assert all(ct.cable_end == local_cable_end for ct in local_cable_terminations[1:])
|
||||
remote_cable_terminations = CableTermination.objects.filter(
|
||||
cable=link,
|
||||
cable_end='A' if local_cable_end == 'B' else 'B'
|
||||
)
|
||||
|
||||
q_filter = Q()
|
||||
for lct in local_cable_terminations:
|
||||
cable_end = 'A' if lct.cable_end == 'B' else 'B'
|
||||
q_filter |= Q(cable=lct.cable, cable_end=cable_end)
|
||||
|
||||
remote_cable_terminations = CableTermination.objects.filter(q_filter)
|
||||
remote_terminations = [ct.termination for ct in remote_cable_terminations]
|
||||
else:
|
||||
# WirelessLink
|
||||
remote_terminations = [link.interface_b] if link.interface_a is terminations[0] else [link.interface_a]
|
||||
remote_terminations = [
|
||||
link.interface_b if link.interface_a is terminations[0] else link.interface_a for link in links
|
||||
]
|
||||
|
||||
# Step 5: Record the far-end termination object(s)
|
||||
# Remote Terminations must all be of the same type, otherwise return a split path
|
||||
if not all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
|
||||
is_complete = False
|
||||
is_split = True
|
||||
break
|
||||
|
||||
# Step 7: Record the far-end termination object(s)
|
||||
path.append([
|
||||
object_to_path_node(t) for t in remote_terminations if t is not None
|
||||
])
|
||||
|
||||
# Step 6: Determine the "next hop" terminations, if applicable
|
||||
# Step 8: Determine the "next hop" terminations, if applicable
|
||||
if not remote_terminations:
|
||||
break
|
||||
|
||||
@@ -569,20 +617,32 @@ class CablePath(models.Model):
|
||||
rear_ports = RearPort.objects.filter(
|
||||
pk__in=[t.rear_port_id for t in remote_terminations]
|
||||
)
|
||||
if len(rear_ports) > 1:
|
||||
assert all(rp.positions == 1 for rp in rear_ports)
|
||||
elif rear_ports[0].positions > 1:
|
||||
if len(rear_ports) > 1 or rear_ports[0].positions > 1:
|
||||
position_stack.append([fp.rear_port_position for fp in remote_terminations])
|
||||
|
||||
terminations = rear_ports
|
||||
|
||||
elif isinstance(remote_terminations[0], RearPort):
|
||||
|
||||
if len(remote_terminations) > 1 or remote_terminations[0].positions == 1:
|
||||
if len(remote_terminations) == 1 and remote_terminations[0].positions == 1:
|
||||
front_ports = FrontPort.objects.filter(
|
||||
rear_port_id__in=[rp.pk for rp in remote_terminations],
|
||||
rear_port_position=1
|
||||
)
|
||||
# Obtain the individual front ports based on the termination and all positions
|
||||
elif len(remote_terminations) > 1 and position_stack:
|
||||
positions = position_stack.pop()
|
||||
|
||||
# Ensure we have a number of positions equal to the amount of remote terminations
|
||||
assert len(remote_terminations) == len(positions)
|
||||
|
||||
# Get our front ports
|
||||
q_filter = Q()
|
||||
for rt in remote_terminations:
|
||||
position = positions.pop()
|
||||
q_filter |= Q(rear_port_id=rt.pk, rear_port_position=position)
|
||||
assert q_filter is not Q()
|
||||
front_ports = FrontPort.objects.filter(q_filter)
|
||||
# Obtain the individual front ports based on the termination and position
|
||||
elif position_stack:
|
||||
front_ports = FrontPort.objects.filter(
|
||||
rear_port_id=remote_terminations[0].pk,
|
||||
@@ -624,9 +684,16 @@ class CablePath(models.Model):
|
||||
|
||||
terminations = [circuit_termination]
|
||||
|
||||
# Anything else marks the end of the path
|
||||
else:
|
||||
is_complete = True
|
||||
# Check for non-symmetric path
|
||||
if all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
|
||||
is_complete = True
|
||||
elif len(remote_terminations) == 0:
|
||||
is_complete = False
|
||||
else:
|
||||
# Unsupported topology, mark as split and exit
|
||||
is_complete = False
|
||||
is_split = True
|
||||
break
|
||||
|
||||
return cls(
|
||||
@@ -732,3 +799,15 @@ class CablePath(models.Model):
|
||||
return [
|
||||
ct.get_peer_termination() for ct in nodes
|
||||
]
|
||||
|
||||
def get_asymmetric_nodes(self):
|
||||
"""
|
||||
Return all available next segments in a split cable path.
|
||||
"""
|
||||
from circuits.models import CircuitTermination
|
||||
asymmetric_nodes = []
|
||||
for nodes in self.path_objects:
|
||||
if type(nodes[0]) in [RearPort, FrontPort, CircuitTermination]:
|
||||
asymmetric_nodes.extend([node for node in nodes if node.link is None])
|
||||
|
||||
return asymmetric_nodes
|
||||
|
||||
@@ -89,7 +89,7 @@ class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Cache the original DeviceType ID for reference under clean()
|
||||
self._original_device_type = self.device_type_id
|
||||
self._original_device_type = self.__dict__.get('device_type_id')
|
||||
|
||||
def to_objectchange(self, action):
|
||||
objectchange = super().to_objectchange(action)
|
||||
@@ -183,6 +183,10 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
|
||||
|
||||
component_model = ConsolePort
|
||||
|
||||
class Meta(ModularComponentTemplateModel.Meta):
|
||||
verbose_name = _('console port template')
|
||||
verbose_name_plural = _('console port templates')
|
||||
|
||||
def instantiate(self, **kwargs):
|
||||
return self.component_model(
|
||||
name=self.resolve_name(kwargs.get('module')),
|
||||
@@ -213,6 +217,10 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
|
||||
|
||||
component_model = ConsoleServerPort
|
||||
|
||||
class Meta(ModularComponentTemplateModel.Meta):
|
||||
verbose_name = _('console server port template')
|
||||
verbose_name_plural = _('console server port templates')
|
||||
|
||||
def instantiate(self, **kwargs):
|
||||
return self.component_model(
|
||||
name=self.resolve_name(kwargs.get('module')),
|
||||
@@ -258,6 +266,10 @@ class PowerPortTemplate(ModularComponentTemplateModel):
|
||||
|
||||
component_model = PowerPort
|
||||
|
||||
class Meta(ModularComponentTemplateModel.Meta):
|
||||
verbose_name = _('power port template')
|
||||
verbose_name_plural = _('power port templates')
|
||||
|
||||
def instantiate(self, **kwargs):
|
||||
return self.component_model(
|
||||
name=self.resolve_name(kwargs.get('module')),
|
||||
@@ -316,6 +328,10 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
|
||||
|
||||
component_model = PowerOutlet
|
||||
|
||||
class Meta(ModularComponentTemplateModel.Meta):
|
||||
verbose_name = _('power outlet template')
|
||||
verbose_name_plural = _('power outlet templates')
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
@@ -410,6 +426,10 @@ class InterfaceTemplate(ModularComponentTemplateModel):
|
||||
|
||||
component_model = Interface
|
||||
|
||||
class Meta(ModularComponentTemplateModel.Meta):
|
||||
verbose_name = _('interface template')
|
||||
verbose_name_plural = _('interface templates')
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
@@ -503,6 +523,8 @@ class FrontPortTemplate(ModularComponentTemplateModel):
|
||||
name='%(app_label)s_%(class)s_unique_rear_port_position'
|
||||
),
|
||||
)
|
||||
verbose_name = _('front port template')
|
||||
verbose_name_plural = _('front port templates')
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
@@ -579,6 +601,10 @@ class RearPortTemplate(ModularComponentTemplateModel):
|
||||
|
||||
component_model = RearPort
|
||||
|
||||
class Meta(ModularComponentTemplateModel.Meta):
|
||||
verbose_name = _('rear port template')
|
||||
verbose_name_plural = _('rear port templates')
|
||||
|
||||
def instantiate(self, **kwargs):
|
||||
return self.component_model(
|
||||
name=self.resolve_name(kwargs.get('module')),
|
||||
@@ -614,6 +640,10 @@ class ModuleBayTemplate(ComponentTemplateModel):
|
||||
|
||||
component_model = ModuleBay
|
||||
|
||||
class Meta(ComponentTemplateModel.Meta):
|
||||
verbose_name = _('module bay template')
|
||||
verbose_name_plural = _('module bay templates')
|
||||
|
||||
def instantiate(self, device):
|
||||
return self.component_model(
|
||||
device=device,
|
||||
@@ -638,6 +668,10 @@ class DeviceBayTemplate(ComponentTemplateModel):
|
||||
"""
|
||||
component_model = DeviceBay
|
||||
|
||||
class Meta(ComponentTemplateModel.Meta):
|
||||
verbose_name = _('device bay template')
|
||||
verbose_name_plural = _('device bay templates')
|
||||
|
||||
def instantiate(self, device):
|
||||
return self.component_model(
|
||||
device=device,
|
||||
@@ -720,6 +754,8 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
|
||||
name='%(app_label)s_%(class)s_unique_device_type_parent_name'
|
||||
),
|
||||
)
|
||||
verbose_name = _('inventory item template')
|
||||
verbose_name_plural = _('inventory item templates')
|
||||
|
||||
def instantiate(self, **kwargs):
|
||||
parent = InventoryItem.objects.get(name=self.parent.name, **kwargs) if self.parent else None
|
||||
|
||||
@@ -86,7 +86,7 @@ class ComponentModel(NetBoxModel):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Cache the original Device ID for reference under clean()
|
||||
self._original_device = self.device_id
|
||||
self._original_device = self.__dict__.get('device_id')
|
||||
|
||||
def __str__(self):
|
||||
if self.label:
|
||||
@@ -298,6 +298,10 @@ class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki
|
||||
|
||||
clone_fields = ('device', 'module', 'type', 'speed')
|
||||
|
||||
class Meta(ModularComponentModel.Meta):
|
||||
verbose_name = _('console port')
|
||||
verbose_name_plural = _('console ports')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:consoleport', kwargs={'pk': self.pk})
|
||||
|
||||
@@ -323,6 +327,10 @@ class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint,
|
||||
|
||||
clone_fields = ('device', 'module', 'type', 'speed')
|
||||
|
||||
class Meta(ModularComponentModel.Meta):
|
||||
verbose_name = _('console server port')
|
||||
verbose_name_plural = _('console server ports')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:consoleserverport', kwargs={'pk': self.pk})
|
||||
|
||||
@@ -359,6 +367,10 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracking
|
||||
|
||||
clone_fields = ('device', 'module', 'maximum_draw', 'allocated_draw')
|
||||
|
||||
class Meta(ModularComponentModel.Meta):
|
||||
verbose_name = _('power port')
|
||||
verbose_name_plural = _('power ports')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:powerport', kwargs={'pk': self.pk})
|
||||
|
||||
@@ -473,6 +485,10 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki
|
||||
|
||||
clone_fields = ('device', 'module', 'type', 'power_port', 'feed_leg')
|
||||
|
||||
class Meta(ModularComponentModel.Meta):
|
||||
verbose_name = _('power outlet')
|
||||
verbose_name_plural = _('power outlets')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:poweroutlet', kwargs={'pk': self.pk})
|
||||
|
||||
@@ -718,6 +734,8 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
||||
|
||||
class Meta(ModularComponentModel.Meta):
|
||||
ordering = ('device', CollateAsChar('_name'))
|
||||
verbose_name = _('interface')
|
||||
verbose_name_plural = _('interfaces')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:interface', kwargs={'pk': self.pk})
|
||||
@@ -781,9 +799,9 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
||||
if self.bridge and self.bridge.device != self.device:
|
||||
if self.device.virtual_chassis is None:
|
||||
raise ValidationError({
|
||||
'bridge': _("""
|
||||
The selected bridge interface ({bridge}) belongs to a different device
|
||||
({device}).""").format(bridge=self.bridge, device=self.bridge.device)
|
||||
'bridge': _(
|
||||
"The selected bridge interface ({bridge}) belongs to a different device ({device})."
|
||||
).format(bridge=self.bridge, device=self.bridge.device)
|
||||
})
|
||||
elif self.bridge.device.virtual_chassis != self.device.virtual_chassis:
|
||||
raise ValidationError({
|
||||
@@ -871,10 +889,10 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
||||
# Validate untagged VLAN
|
||||
if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:
|
||||
raise ValidationError({
|
||||
'untagged_vlan': _("""
|
||||
The untagged VLAN ({untagged_vlan}) must belong to the same site as the
|
||||
interface's parent device, or it must be global.
|
||||
""").format(untagged_vlan=self.untagged_vlan)
|
||||
'untagged_vlan': _(
|
||||
"The untagged VLAN ({untagged_vlan}) must belong to the same site as the interface's parent "
|
||||
"device, or it must be global."
|
||||
).format(untagged_vlan=self.untagged_vlan)
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
@@ -977,6 +995,8 @@ class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
|
||||
name='%(app_label)s_%(class)s_unique_rear_port_position'
|
||||
),
|
||||
)
|
||||
verbose_name = _('front port')
|
||||
verbose_name_plural = _('front ports')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:frontport', kwargs={'pk': self.pk})
|
||||
@@ -1032,6 +1052,10 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
|
||||
)
|
||||
clone_fields = ('device', 'type', 'color', 'positions')
|
||||
|
||||
class Meta(ModularComponentModel.Meta):
|
||||
verbose_name = _('rear port')
|
||||
verbose_name_plural = _('rear ports')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:rearport', kwargs={'pk': self.pk})
|
||||
|
||||
@@ -1043,9 +1067,10 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
|
||||
frontport_count = self.frontports.count()
|
||||
if self.positions < frontport_count:
|
||||
raise ValidationError({
|
||||
"positions": _("""
|
||||
The number of positions cannot be less than the number of mapped front ports
|
||||
({frontport_count})""").format(frontport_count=frontport_count)
|
||||
"positions": _(
|
||||
"The number of positions cannot be less than the number of mapped front ports "
|
||||
"({frontport_count})"
|
||||
).format(frontport_count=frontport_count)
|
||||
})
|
||||
|
||||
|
||||
@@ -1066,6 +1091,10 @@ class ModuleBay(ComponentModel, TrackingModelMixin):
|
||||
|
||||
clone_fields = ('device',)
|
||||
|
||||
class Meta(ComponentModel.Meta):
|
||||
verbose_name = _('module bay')
|
||||
verbose_name_plural = _('module bays')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:modulebay', kwargs={'pk': self.pk})
|
||||
|
||||
@@ -1084,6 +1113,10 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
|
||||
|
||||
clone_fields = ('device',)
|
||||
|
||||
class Meta(ComponentModel.Meta):
|
||||
verbose_name = _('device bay')
|
||||
verbose_name_plural = _('device bays')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:devicebay', kwargs={'pk': self.pk})
|
||||
|
||||
@@ -1125,6 +1158,11 @@ class InventoryItemRole(OrganizationalModel):
|
||||
default=ColorChoices.COLOR_GREY
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('inventory item role')
|
||||
verbose_name_plural = _('inventory item roles')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:inventoryitemrole', args=[self.pk])
|
||||
|
||||
@@ -1209,6 +1247,8 @@ class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
|
||||
name='%(app_label)s_%(class)s_unique_device_parent_name'
|
||||
),
|
||||
)
|
||||
verbose_name = _('inventory item')
|
||||
verbose_name_plural = _('inventory items')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:inventoryitem', kwargs={'pk': self.pk})
|
||||
|
||||
@@ -3,8 +3,8 @@ import yaml
|
||||
|
||||
from functools import cached_property
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import F, ProtectedError
|
||||
@@ -20,11 +20,12 @@ from extras.models import ConfigContextModel
|
||||
from extras.querysets import ConfigContextModelQuerySet
|
||||
from netbox.config import ConfigItem
|
||||
from netbox.models import OrganizationalModel, PrimaryModel
|
||||
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField, CounterCacheField, NaturalOrderingField
|
||||
from utilities.tracking import TrackingModelMixin
|
||||
from .device_components import *
|
||||
from .mixins import WeightMixin
|
||||
from .mixins import RenderConfigMixin, WeightMixin
|
||||
|
||||
|
||||
__all__ = (
|
||||
@@ -44,20 +45,20 @@ __all__ = (
|
||||
# Device Types
|
||||
#
|
||||
|
||||
class Manufacturer(OrganizationalModel):
|
||||
class Manufacturer(ContactsMixin, OrganizationalModel):
|
||||
"""
|
||||
A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
|
||||
"""
|
||||
# Generic relations
|
||||
contacts = GenericRelation(
|
||||
to='tenancy.ContactAssignment'
|
||||
)
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('manufacturer')
|
||||
verbose_name_plural = _('manufacturers')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:manufacturer', args=[self.pk])
|
||||
|
||||
|
||||
class DeviceType(PrimaryModel, WeightMixin):
|
||||
class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
||||
"""
|
||||
A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
|
||||
well as high-level functional role(s).
|
||||
@@ -175,10 +176,6 @@ class DeviceType(PrimaryModel, WeightMixin):
|
||||
to_field='device_type'
|
||||
)
|
||||
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
clone_fields = (
|
||||
'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
|
||||
'weight_unit',
|
||||
@@ -199,6 +196,8 @@ class DeviceType(PrimaryModel, WeightMixin):
|
||||
name='%(app_label)s_%(class)s_unique_manufacturer_slug'
|
||||
),
|
||||
)
|
||||
verbose_name = _('device type')
|
||||
verbose_name_plural = _('device types')
|
||||
|
||||
def __str__(self):
|
||||
return self.model
|
||||
@@ -207,11 +206,11 @@ class DeviceType(PrimaryModel, WeightMixin):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Save a copy of u_height for validation in clean()
|
||||
self._original_u_height = self.u_height
|
||||
self._original_u_height = self.__dict__.get('u_height')
|
||||
|
||||
# Save references to the original front/rear images
|
||||
self._original_front_image = self.front_image
|
||||
self._original_rear_image = self.rear_image
|
||||
self._original_front_image = self.__dict__.get('front_image')
|
||||
self._original_rear_image = self.__dict__.get('rear_image')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:devicetype', args=[self.pk])
|
||||
@@ -334,10 +333,10 @@ class DeviceType(PrimaryModel, WeightMixin):
|
||||
ret = super().save(*args, **kwargs)
|
||||
|
||||
# Delete any previously uploaded image files that are no longer in use
|
||||
if self.front_image != self._original_front_image:
|
||||
self._original_front_image.delete(save=False)
|
||||
if self.rear_image != self._original_rear_image:
|
||||
self._original_rear_image.delete(save=False)
|
||||
if self._original_front_image and self.front_image != self._original_front_image:
|
||||
default_storage.delete(self._original_front_image)
|
||||
if self._original_rear_image and self.rear_image != self._original_rear_image:
|
||||
default_storage.delete(self._original_rear_image)
|
||||
|
||||
return ret
|
||||
|
||||
@@ -359,7 +358,7 @@ class DeviceType(PrimaryModel, WeightMixin):
|
||||
return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD
|
||||
|
||||
|
||||
class ModuleType(PrimaryModel, WeightMixin):
|
||||
class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
||||
"""
|
||||
A ModuleType represents a hardware element that can be installed within a device and which houses additional
|
||||
components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a
|
||||
@@ -382,11 +381,6 @@ class ModuleType(PrimaryModel, WeightMixin):
|
||||
help_text=_('Discrete part number (optional)')
|
||||
)
|
||||
|
||||
# Generic relations
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
clone_fields = ('manufacturer', 'weight', 'weight_unit',)
|
||||
prerequisite_models = (
|
||||
'dcim.Manufacturer',
|
||||
@@ -400,6 +394,8 @@ class ModuleType(PrimaryModel, WeightMixin):
|
||||
name='%(app_label)s_%(class)s_unique_manufacturer_model'
|
||||
),
|
||||
)
|
||||
verbose_name = _('module type')
|
||||
verbose_name_plural = _('module types')
|
||||
|
||||
def __str__(self):
|
||||
return self.model
|
||||
@@ -477,6 +473,11 @@ class DeviceRole(OrganizationalModel):
|
||||
null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('device role')
|
||||
verbose_name_plural = _('device roles')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:devicerole', args=[self.pk])
|
||||
|
||||
@@ -502,6 +503,11 @@ class Platform(OrganizationalModel):
|
||||
null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('platform')
|
||||
verbose_name_plural = _('platforms')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:platform', args=[self.pk])
|
||||
|
||||
@@ -520,7 +526,14 @@ def update_interface_bridges(device, interface_templates, module=None):
|
||||
interface.save()
|
||||
|
||||
|
||||
class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
|
||||
class Device(
|
||||
ContactsMixin,
|
||||
ImageAttachmentsMixin,
|
||||
RenderConfigMixin,
|
||||
ConfigContextModel,
|
||||
TrackingModelMixin,
|
||||
PrimaryModel
|
||||
):
|
||||
"""
|
||||
A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
|
||||
DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique.
|
||||
@@ -681,13 +694,6 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
|
||||
validators=[MaxValueValidator(255)],
|
||||
help_text=_('Virtual chassis master election priority')
|
||||
)
|
||||
config_template = models.ForeignKey(
|
||||
to='extras.ConfigTemplate',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='devices',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
latitude = models.DecimalField(
|
||||
verbose_name=_('latitude'),
|
||||
max_digits=8,
|
||||
@@ -747,14 +753,6 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
|
||||
to_field='device'
|
||||
)
|
||||
|
||||
# Generic relations
|
||||
contacts = GenericRelation(
|
||||
to='tenancy.ContactAssignment'
|
||||
)
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
objects = ConfigContextModelQuerySet.as_manager()
|
||||
|
||||
clone_fields = (
|
||||
@@ -789,6 +787,8 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
|
||||
name='%(app_label)s_%(class)s_unique_virtual_chassis_vc_position'
|
||||
),
|
||||
)
|
||||
verbose_name = _('device')
|
||||
verbose_name_plural = _('devices')
|
||||
|
||||
def __str__(self):
|
||||
if self.name and self.asset_tag:
|
||||
@@ -1071,17 +1071,6 @@ class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
|
||||
def interfaces_count(self):
|
||||
return self.vc_interfaces().count()
|
||||
|
||||
def get_config_template(self):
|
||||
"""
|
||||
Return the appropriate ConfigTemplate (if any) for this Device.
|
||||
"""
|
||||
if self.config_template:
|
||||
return self.config_template
|
||||
if self.role.config_template:
|
||||
return self.role.config_template
|
||||
if self.platform and self.platform.config_template:
|
||||
return self.platform.config_template
|
||||
|
||||
def get_vc_master(self):
|
||||
"""
|
||||
If this Device is a VirtualChassis member, return the VC master. Otherwise, return None.
|
||||
@@ -1182,6 +1171,8 @@ class Module(PrimaryModel, ConfigContextModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ('module_bay',)
|
||||
verbose_name = _('module')
|
||||
verbose_name_plural = _('modules')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.module_bay.name}: {self.module_type} ({self.pk})'
|
||||
@@ -1314,7 +1305,8 @@ class VirtualChassis(PrimaryModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
verbose_name_plural = 'virtual chassis'
|
||||
verbose_name = _('virtual chassis')
|
||||
verbose_name_plural = _('virtual chassis')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -1415,6 +1407,8 @@ class VirtualDeviceContext(PrimaryModel):
|
||||
name='%(app_label)s_%(class)s_device_name'
|
||||
),
|
||||
)
|
||||
verbose_name = _('virtual device context')
|
||||
verbose_name_plural = _('virtual device contexts')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@@ -4,6 +4,11 @@ from django.utils.translation import gettext_lazy as _
|
||||
from dcim.choices import *
|
||||
from utilities.utils import to_grams
|
||||
|
||||
__all__ = (
|
||||
'RenderConfigMixin',
|
||||
'WeightMixin',
|
||||
)
|
||||
|
||||
|
||||
class WeightMixin(models.Model):
|
||||
weight = models.DecimalField(
|
||||
@@ -44,3 +49,27 @@ class WeightMixin(models.Model):
|
||||
# Validate weight and weight_unit
|
||||
if self.weight and not self.weight_unit:
|
||||
raise ValidationError(_("Must specify a unit when setting a weight"))
|
||||
|
||||
|
||||
class RenderConfigMixin(models.Model):
|
||||
config_template = models.ForeignKey(
|
||||
to='extras.ConfigTemplate',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='%(class)ss',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def get_config_template(self):
|
||||
"""
|
||||
Return the appropriate ConfigTemplate (if any) for this Device.
|
||||
"""
|
||||
if self.config_template:
|
||||
return self.config_template
|
||||
if self.role and self.role.config_template:
|
||||
return self.role.config_template
|
||||
if self.platform and self.platform.config_template:
|
||||
return self.platform.config_template
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
@@ -8,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from dcim.choices import *
|
||||
from netbox.config import ConfigItem
|
||||
from netbox.models import PrimaryModel
|
||||
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
|
||||
from utilities.validators import ExclusionValidator
|
||||
from .device_components import CabledObjectModel, PathEndpoint
|
||||
|
||||
@@ -21,7 +21,7 @@ __all__ = (
|
||||
# Power
|
||||
#
|
||||
|
||||
class PowerPanel(PrimaryModel):
|
||||
class PowerPanel(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
|
||||
"""
|
||||
A distribution point for electrical power; e.g. a data center RPP.
|
||||
"""
|
||||
@@ -40,14 +40,6 @@ class PowerPanel(PrimaryModel):
|
||||
max_length=100
|
||||
)
|
||||
|
||||
# Generic relations
|
||||
contacts = GenericRelation(
|
||||
to='tenancy.ContactAssignment'
|
||||
)
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
prerequisite_models = (
|
||||
'dcim.Site',
|
||||
)
|
||||
@@ -60,6 +52,8 @@ class PowerPanel(PrimaryModel):
|
||||
name='%(app_label)s_%(class)s_unique_site_name'
|
||||
),
|
||||
)
|
||||
verbose_name = _('power panel')
|
||||
verbose_name_plural = _('power panels')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -166,6 +160,8 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
|
||||
name='%(app_label)s_%(class)s_unique_power_panel_name'
|
||||
),
|
||||
)
|
||||
verbose_name = _('power feed')
|
||||
verbose_name_plural = _('power feeds')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -178,8 +174,13 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
|
||||
|
||||
# Rack must belong to same Site as PowerPanel
|
||||
if self.rack and self.rack.site != self.power_panel.site:
|
||||
raise ValidationError(_("Rack {} ({}) and power panel {} ({}) are in different sites").format(
|
||||
self.rack, self.rack.site, self.power_panel, self.power_panel.site
|
||||
raise ValidationError(_(
|
||||
"Rack {rack} ({site}) and power panel {powerpanel} ({powerpanel_site}) are in different sites"
|
||||
).format(
|
||||
rack=self.rack,
|
||||
rack_site=self.rack.site,
|
||||
powerpanel=self.power_panel,
|
||||
powerpanel_site=self.power_panel.site
|
||||
))
|
||||
|
||||
# AC voltage cannot be negative
|
||||
|
||||
@@ -15,6 +15,7 @@ from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.svg import RackElevationSVG
|
||||
from netbox.models import OrganizationalModel, PrimaryModel
|
||||
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
from utilities.utils import array_to_string, drange, to_grams
|
||||
@@ -43,11 +44,16 @@ class RackRole(OrganizationalModel):
|
||||
default=ColorChoices.COLOR_GREY
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('rack role')
|
||||
verbose_name_plural = _('rack roles')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:rackrole', args=[self.pk])
|
||||
|
||||
|
||||
class Rack(PrimaryModel, WeightMixin):
|
||||
class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
||||
"""
|
||||
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
|
||||
Each Rack is assigned to a Site and (optionally) a Location.
|
||||
@@ -188,12 +194,6 @@ class Rack(PrimaryModel, WeightMixin):
|
||||
object_id_field='scope_id',
|
||||
related_query_name='rack'
|
||||
)
|
||||
contacts = GenericRelation(
|
||||
to='tenancy.ContactAssignment'
|
||||
)
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
clone_fields = (
|
||||
'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
|
||||
@@ -216,6 +216,8 @@ class Rack(PrimaryModel, WeightMixin):
|
||||
name='%(app_label)s_%(class)s_unique_location_facility_id'
|
||||
),
|
||||
)
|
||||
verbose_name = _('rack')
|
||||
verbose_name_plural = _('racks')
|
||||
|
||||
def __str__(self):
|
||||
if self.facility_id:
|
||||
@@ -538,6 +540,8 @@ class RackReservation(PrimaryModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ['created', 'pk']
|
||||
verbose_name = _('rack reservation')
|
||||
verbose_name_plural = _('rack reservations')
|
||||
|
||||
def __str__(self):
|
||||
return "Reservation for rack {}".format(self.rack)
|
||||
|
||||
@@ -8,6 +8,7 @@ from timezone_field import TimeZoneField
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from netbox.models import NestedGroupModel, PrimaryModel
|
||||
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
|
||||
from utilities.fields import NaturalOrderingField
|
||||
|
||||
__all__ = (
|
||||
@@ -22,22 +23,18 @@ __all__ = (
|
||||
# Regions
|
||||
#
|
||||
|
||||
class Region(NestedGroupModel):
|
||||
class Region(ContactsMixin, NestedGroupModel):
|
||||
"""
|
||||
A region represents a geographic collection of sites. For example, you might create regions representing countries,
|
||||
states, and/or cities. Regions are recursively nested into a hierarchy: all sites belonging to a child region are
|
||||
also considered to be members of its parent and ancestor region(s).
|
||||
"""
|
||||
# Generic relations
|
||||
vlan_groups = GenericRelation(
|
||||
to='ipam.VLANGroup',
|
||||
content_type_field='scope_type',
|
||||
object_id_field='scope_id',
|
||||
related_query_name='region'
|
||||
)
|
||||
contacts = GenericRelation(
|
||||
to='tenancy.ContactAssignment'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
constraints = (
|
||||
@@ -62,6 +59,8 @@ class Region(NestedGroupModel):
|
||||
violation_error_message=_("A top-level region with this slug already exists.")
|
||||
),
|
||||
)
|
||||
verbose_name = _('region')
|
||||
verbose_name_plural = _('regions')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:region', args=[self.pk])
|
||||
@@ -77,22 +76,18 @@ class Region(NestedGroupModel):
|
||||
# Site groups
|
||||
#
|
||||
|
||||
class SiteGroup(NestedGroupModel):
|
||||
class SiteGroup(ContactsMixin, NestedGroupModel):
|
||||
"""
|
||||
A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and
|
||||
within corporate sites you might distinguish between offices and data centers. Like regions, site groups can be
|
||||
nested recursively to form a hierarchy.
|
||||
"""
|
||||
# Generic relations
|
||||
vlan_groups = GenericRelation(
|
||||
to='ipam.VLANGroup',
|
||||
content_type_field='scope_type',
|
||||
object_id_field='scope_id',
|
||||
related_query_name='site_group'
|
||||
)
|
||||
contacts = GenericRelation(
|
||||
to='tenancy.ContactAssignment'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
constraints = (
|
||||
@@ -117,6 +112,8 @@ class SiteGroup(NestedGroupModel):
|
||||
violation_error_message=_("A top-level site group with this slug already exists.")
|
||||
),
|
||||
)
|
||||
verbose_name = _('site group')
|
||||
verbose_name_plural = _('site groups')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:sitegroup', args=[self.pk])
|
||||
@@ -132,7 +129,7 @@ class SiteGroup(NestedGroupModel):
|
||||
# Sites
|
||||
#
|
||||
|
||||
class Site(PrimaryModel):
|
||||
class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
|
||||
"""
|
||||
A Site represents a geographic location within a network; typically a building or campus. The optional facility
|
||||
field can be used to include an external designation, such as a data center name (e.g. Equinix SV6).
|
||||
@@ -230,12 +227,6 @@ class Site(PrimaryModel):
|
||||
object_id_field='scope_id',
|
||||
related_query_name='site'
|
||||
)
|
||||
contacts = GenericRelation(
|
||||
to='tenancy.ContactAssignment'
|
||||
)
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
clone_fields = (
|
||||
'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'physical_address', 'shipping_address',
|
||||
@@ -244,6 +235,8 @@ class Site(PrimaryModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ('_name',)
|
||||
verbose_name = _('site')
|
||||
verbose_name_plural = _('sites')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -259,7 +252,7 @@ class Site(PrimaryModel):
|
||||
# Locations
|
||||
#
|
||||
|
||||
class Location(NestedGroupModel):
|
||||
class Location(ContactsMixin, ImageAttachmentsMixin, NestedGroupModel):
|
||||
"""
|
||||
A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a
|
||||
site, or a room within a building, for example.
|
||||
@@ -290,12 +283,6 @@ class Location(NestedGroupModel):
|
||||
object_id_field='scope_id',
|
||||
related_query_name='location'
|
||||
)
|
||||
contacts = GenericRelation(
|
||||
to='tenancy.ContactAssignment'
|
||||
)
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
clone_fields = ('site', 'parent', 'status', 'tenant', 'description')
|
||||
prerequisite_models = (
|
||||
@@ -326,6 +313,8 @@ class Location(NestedGroupModel):
|
||||
violation_error_message=_("A location with this slug already exists within the specified site.")
|
||||
),
|
||||
)
|
||||
verbose_name = _('location')
|
||||
verbose_name_plural = _('locations')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:location', args=[self.pk])
|
||||
|
||||
@@ -32,11 +32,18 @@ class Node(Hyperlink):
|
||||
color: Box fill color (RRGGBB format)
|
||||
labels: An iterable of text strings. Each label will render on a new line within the box.
|
||||
radius: Box corner radius, for rounded corners (default: 10)
|
||||
object: A copy of the object to allow reference when drawing cables to determine which cables are connected to
|
||||
which terminations.
|
||||
"""
|
||||
|
||||
def __init__(self, position, width, url, color, labels, radius=10, **extra):
|
||||
object = None
|
||||
|
||||
def __init__(self, position, width, url, color, labels, radius=10, object=object, **extra):
|
||||
super(Node, self).__init__(href=url, target='_parent', **extra)
|
||||
|
||||
# Save object for reference by cable systems
|
||||
self.object = object
|
||||
|
||||
x, y = position
|
||||
|
||||
# Add the box
|
||||
@@ -77,7 +84,7 @@ class Connector(Group):
|
||||
labels: Iterable of text labels
|
||||
"""
|
||||
|
||||
def __init__(self, start, url, color, labels=[], **extra):
|
||||
def __init__(self, start, url, color, labels=[], description=[], **extra):
|
||||
super().__init__(class_='connector', **extra)
|
||||
|
||||
self.start = start
|
||||
@@ -104,6 +111,8 @@ class Connector(Group):
|
||||
text_coords = (start[0] + PADDING * 2, cursor - LINE_HEIGHT / 2)
|
||||
text = Text(label, insert=text_coords, class_='bold' if not i else [])
|
||||
link.add(text)
|
||||
if len(description) > 0:
|
||||
link.set_desc("\n".join(description))
|
||||
|
||||
self.add(link)
|
||||
|
||||
@@ -151,6 +160,8 @@ class CableTraceSVG:
|
||||
elif instance._meta.model_name == 'circuit':
|
||||
labels[0] = f'Circuit {instance}'
|
||||
labels.append(instance.provider)
|
||||
if instance.description:
|
||||
labels.append(instance.description)
|
||||
elif instance._meta.model_name == 'circuittermination':
|
||||
if instance.xconnect_id:
|
||||
labels.append(f'{instance.xconnect_id}')
|
||||
@@ -206,7 +217,8 @@ class CableTraceSVG:
|
||||
url=f'{self.base_url}{term.get_absolute_url()}',
|
||||
color=self._get_color(term),
|
||||
labels=self._get_labels(term),
|
||||
radius=5
|
||||
radius=5,
|
||||
object=term
|
||||
)
|
||||
nodes_height = max(nodes_height, node.box['height'])
|
||||
nodes.append(node)
|
||||
@@ -238,22 +250,65 @@ class CableTraceSVG:
|
||||
Polyline(points=points, style=f'stroke: #{connector.color}'),
|
||||
))
|
||||
|
||||
def draw_cable(self, cable):
|
||||
labels = [
|
||||
f'Cable {cable}',
|
||||
cable.get_status_display()
|
||||
]
|
||||
if cable.type:
|
||||
labels.append(cable.get_type_display())
|
||||
if cable.length and cable.length_unit:
|
||||
labels.append(f'{cable.length} {cable.get_length_unit_display()}')
|
||||
def draw_cable(self, cable, terminations, cable_count=0):
|
||||
"""
|
||||
Draw a single cable. Terminations and cable count are passed for determining position and padding
|
||||
|
||||
:param cable: The cable to draw
|
||||
:param terminations: List of terminations to build positioning data off of
|
||||
:param cable_count: Count of all cables on this layer for determining whether to collapse description into a
|
||||
tooltip.
|
||||
"""
|
||||
|
||||
# If the cable count is higher than 2, collapse the description into a tooltip
|
||||
if cable_count > 2:
|
||||
# Use the cable __str__ function to denote the cable
|
||||
labels = [f'{cable}']
|
||||
|
||||
# Include the label and the status description in the tooltip
|
||||
description = [
|
||||
f'Cable {cable}',
|
||||
cable.get_status_display()
|
||||
]
|
||||
|
||||
if cable.type:
|
||||
# Include the cable type in the tooltip
|
||||
description.append(cable.get_type_display())
|
||||
if cable.length and cable.length_unit:
|
||||
# Include the cable length in the tooltip
|
||||
description.append(f'{cable.length} {cable.get_length_unit_display()}')
|
||||
else:
|
||||
labels = [
|
||||
f'Cable {cable}',
|
||||
cable.get_status_display()
|
||||
]
|
||||
description = []
|
||||
if cable.type:
|
||||
labels.append(cable.get_type_display())
|
||||
if cable.length and cable.length_unit:
|
||||
# Include the cable length in the tooltip
|
||||
labels.append(f'{cable.length} {cable.get_length_unit_display()}')
|
||||
|
||||
# If there is only one termination, center on that termination
|
||||
# Otherwise average the center across the terminations
|
||||
if len(terminations) == 1:
|
||||
center = terminations[0].bottom_center[0]
|
||||
else:
|
||||
# Get a list of termination centers
|
||||
termination_centers = [term.bottom_center[0] for term in terminations]
|
||||
# Average the centers
|
||||
center = sum(termination_centers) / len(termination_centers)
|
||||
|
||||
# Create the connector
|
||||
connector = Connector(
|
||||
start=(self.center + OFFSET, self.cursor),
|
||||
start=(center, self.cursor),
|
||||
color=cable.color or '000000',
|
||||
url=f'{self.base_url}{cable.get_absolute_url()}',
|
||||
labels=labels
|
||||
labels=labels,
|
||||
description=description
|
||||
)
|
||||
|
||||
# Set the cursor position
|
||||
self.cursor += connector.height
|
||||
|
||||
return connector
|
||||
@@ -334,34 +389,52 @@ class CableTraceSVG:
|
||||
|
||||
# Connector (a Cable or WirelessLink)
|
||||
if links:
|
||||
link = links[0] # Remove Cable from list
|
||||
link_cables = {}
|
||||
fanin = False
|
||||
fanout = False
|
||||
|
||||
# Cable
|
||||
if type(link) is Cable:
|
||||
# Determine if we have fanins or fanouts
|
||||
if len(near_ends) > len(set(links)):
|
||||
self.cursor += FANOUT_HEIGHT
|
||||
fanin = True
|
||||
if len(far_ends) > len(set(links)):
|
||||
fanout = True
|
||||
cursor = self.cursor
|
||||
for link in links:
|
||||
# Cable
|
||||
if type(link) is Cable and not link_cables.get(link.pk):
|
||||
# Reset cursor
|
||||
self.cursor = cursor
|
||||
# Generate a list of terminations connected to this cable
|
||||
near_end_link_terminations = [term for term in terminations if term.object.cable == link]
|
||||
# Draw the cable
|
||||
cable = self.draw_cable(link, near_end_link_terminations, cable_count=len(links))
|
||||
# Add cable to the list of cables
|
||||
link_cables.update({link.pk: cable})
|
||||
# Add cable to drawing
|
||||
self.connectors.append(cable)
|
||||
|
||||
# Account for fan-ins height
|
||||
if len(near_ends) > 1:
|
||||
self.cursor += FANOUT_HEIGHT
|
||||
# Draw fan-ins
|
||||
if len(near_ends) > 1 and fanin:
|
||||
for term in terminations:
|
||||
if term.object.cable == link:
|
||||
self.draw_fanin(term, cable)
|
||||
|
||||
cable = self.draw_cable(link)
|
||||
self.connectors.append(cable)
|
||||
|
||||
# Draw fan-ins
|
||||
if len(near_ends) > 1:
|
||||
for term in terminations:
|
||||
self.draw_fanin(term, cable)
|
||||
|
||||
# WirelessLink
|
||||
elif type(link) is WirelessLink:
|
||||
wirelesslink = self.draw_wirelesslink(link)
|
||||
self.connectors.append(wirelesslink)
|
||||
# WirelessLink
|
||||
elif type(link) is WirelessLink:
|
||||
wirelesslink = self.draw_wirelesslink(link)
|
||||
self.connectors.append(wirelesslink)
|
||||
|
||||
# Far end termination(s)
|
||||
if len(far_ends) > 1:
|
||||
self.cursor += FANOUT_HEIGHT
|
||||
terminations = self.draw_terminations(far_ends)
|
||||
for term in terminations:
|
||||
self.draw_fanout(term, cable)
|
||||
if fanout:
|
||||
self.cursor += FANOUT_HEIGHT
|
||||
terminations = self.draw_terminations(far_ends)
|
||||
for term in terminations:
|
||||
if hasattr(term.object, 'cable') and link_cables.get(term.object.cable.pk):
|
||||
self.draw_fanout(term, link_cables.get(term.object.cable.pk))
|
||||
else:
|
||||
self.draw_terminations(far_ends)
|
||||
elif far_ends:
|
||||
self.draw_terminations(far_ends)
|
||||
else:
|
||||
|
||||
@@ -64,9 +64,19 @@ def get_interface_state_attribute(record):
|
||||
Get interface enabled state as string to attach to <tr/> DOM element.
|
||||
"""
|
||||
if record.enabled:
|
||||
return "enabled"
|
||||
return 'enabled'
|
||||
else:
|
||||
return "disabled"
|
||||
return 'disabled'
|
||||
|
||||
|
||||
def get_interface_connected_attribute(record):
|
||||
"""
|
||||
Get interface disconnected state as string to attach to <tr/> DOM element.
|
||||
"""
|
||||
if record.mark_connected or record.cable:
|
||||
return 'connected'
|
||||
else:
|
||||
return 'disconnected'
|
||||
|
||||
|
||||
#
|
||||
@@ -456,6 +466,12 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable):
|
||||
'args': [Accessor('device_id')],
|
||||
}
|
||||
)
|
||||
maximum_draw = tables.Column(
|
||||
verbose_name=_('Maximum draw (W)')
|
||||
)
|
||||
allocated_draw = tables.Column(
|
||||
verbose_name=_('Allocated draw (W)')
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:powerport_list'
|
||||
)
|
||||
@@ -591,7 +607,12 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
||||
}
|
||||
)
|
||||
mgmt_only = columns.BooleanColumn(
|
||||
verbose_name=_('Management Only'),
|
||||
verbose_name=_('Management Only')
|
||||
)
|
||||
speed_formatted = columns.TemplateColumn(
|
||||
template_code='{% load helpers %}{{ value|humanize_speed }}',
|
||||
accessor=Accessor('speed'),
|
||||
verbose_name=_('Speed')
|
||||
)
|
||||
wireless_link = tables.Column(
|
||||
verbose_name=_('Wireless link'),
|
||||
@@ -610,6 +631,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
||||
verbose_name=_('VRF'),
|
||||
linkify=True
|
||||
)
|
||||
inventory_items = tables.ManyToManyColumn(
|
||||
linkify_item=True,
|
||||
verbose_name=_('Inventory Items'),
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:interface_list'
|
||||
)
|
||||
@@ -618,10 +643,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
||||
model = models.Interface
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
|
||||
'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel',
|
||||
'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel',
|
||||
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
|
||||
'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn',
|
||||
'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
|
||||
'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'inventory_items', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
|
||||
|
||||
@@ -669,6 +694,7 @@ class DeviceInterfaceTable(InterfaceTable):
|
||||
'data-name': lambda record: record.name,
|
||||
'data-enabled': get_interface_state_attribute,
|
||||
'data-type': lambda record: record.type,
|
||||
'data-connected': get_interface_connected_attribute
|
||||
}
|
||||
|
||||
|
||||
@@ -866,8 +892,9 @@ class ModuleBayTable(DeviceComponentTable):
|
||||
url_name='dcim:modulebay_list'
|
||||
)
|
||||
module_status = columns.TemplateColumn(
|
||||
verbose_name=_('Module Status'),
|
||||
template_code=MODULEBAY_STATUS
|
||||
accessor=tables.A('installed_module__status'),
|
||||
template_code=MODULEBAY_STATUS,
|
||||
verbose_name=_('Module Status')
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
@@ -916,6 +943,10 @@ class InventoryItemTable(DeviceComponentTable):
|
||||
discovered = columns.BooleanColumn(
|
||||
verbose_name=_('Discovered'),
|
||||
)
|
||||
parent = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name=_('Parent'),
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:inventoryitem_list'
|
||||
)
|
||||
@@ -924,7 +955,7 @@ class InventoryItemTable(DeviceComponentTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = models.InventoryItem
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial',
|
||||
'pk', 'id', 'name', 'device', 'parent', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial',
|
||||
'asset_tag', 'description', 'discovered', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
|
||||
@@ -87,6 +87,11 @@ class PowerFeedTable(TenancyColumnsMixin, CableTerminationTable):
|
||||
linkify=True,
|
||||
verbose_name=_('Tenant')
|
||||
)
|
||||
site = tables.Column(
|
||||
accessor='rack__site',
|
||||
linkify=True,
|
||||
verbose_name=_('Site'),
|
||||
)
|
||||
comments = columns.MarkdownColumn(
|
||||
verbose_name=_('Comments'),
|
||||
)
|
||||
@@ -97,9 +102,9 @@ class PowerFeedTable(TenancyColumnsMixin, CableTerminationTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = PowerFeed
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
|
||||
'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'available_power', 'tenant',
|
||||
'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'power_panel', 'site', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage',
|
||||
'phase', 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'available_power',
|
||||
'tenant', 'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',
|
||||
|
||||
@@ -15,6 +15,7 @@ class CablePathTestCase(TestCase):
|
||||
1XX: Test direct connections between different endpoint types
|
||||
2XX: Test different cable topologies
|
||||
3XX: Test responses to changes in existing objects
|
||||
4XX: Test to exclude specific cable topologies
|
||||
"""
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -33,12 +34,11 @@ class CablePathTestCase(TestCase):
|
||||
circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type')
|
||||
cls.circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 1')
|
||||
|
||||
def assertPathExists(self, nodes, **kwargs):
|
||||
def _get_cablepath(self, nodes, **kwargs):
|
||||
"""
|
||||
Assert that a CablePath from origin to destination with a specific intermediate path exists.
|
||||
Return a given cable path
|
||||
|
||||
:param nodes: Iterable of steps, with each step being either a single node or a list of nodes
|
||||
:param is_active: Boolean indicating whether the end-to-end path is complete and active (optional)
|
||||
|
||||
:return: The matching CablePath (if any)
|
||||
"""
|
||||
@@ -48,12 +48,29 @@ class CablePathTestCase(TestCase):
|
||||
path.append([object_to_path_node(node) for node in step])
|
||||
else:
|
||||
path.append([object_to_path_node(step)])
|
||||
return CablePath.objects.filter(path=path, **kwargs).first()
|
||||
|
||||
cablepath = CablePath.objects.filter(path=path, **kwargs).first()
|
||||
def assertPathExists(self, nodes, **kwargs):
|
||||
"""
|
||||
Assert that a CablePath from origin to destination with a specific intermediate path exists. Returns the
|
||||
first matching CablePath, if found.
|
||||
|
||||
:param nodes: Iterable of steps, with each step being either a single node or a list of nodes
|
||||
"""
|
||||
cablepath = self._get_cablepath(nodes, **kwargs)
|
||||
self.assertIsNotNone(cablepath, msg='CablePath not found')
|
||||
|
||||
return cablepath
|
||||
|
||||
def assertPathDoesNotExist(self, nodes, **kwargs):
|
||||
"""
|
||||
Assert that a specific CablePath does *not* exist.
|
||||
|
||||
:param nodes: Iterable of steps, with each step being either a single node or a list of nodes
|
||||
"""
|
||||
cablepath = self._get_cablepath(nodes, **kwargs)
|
||||
self.assertIsNone(cablepath, msg='Unexpected CablePath found')
|
||||
|
||||
def assertPathIsSet(self, origin, cablepath, msg=None):
|
||||
"""
|
||||
Assert that a specific CablePath instance is set as the path on the origin.
|
||||
@@ -1695,6 +1712,291 @@ class CablePathTestCase(TestCase):
|
||||
self.assertPathIsSet(interface3, path3)
|
||||
self.assertPathIsSet(interface4, path4)
|
||||
|
||||
def test_219_interface_to_interface_duplex_via_multiple_rearports(self):
|
||||
"""
|
||||
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]
|
||||
[FP3] [RP3] --C4-- [RP4] [FP4]
|
||||
"""
|
||||
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
|
||||
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
|
||||
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
|
||||
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
|
||||
rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1)
|
||||
rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1)
|
||||
frontport1 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
|
||||
)
|
||||
frontport2 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
|
||||
)
|
||||
frontport3 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1
|
||||
)
|
||||
frontport4 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1
|
||||
)
|
||||
|
||||
cable2 = Cable(
|
||||
a_terminations=[rearport1],
|
||||
b_terminations=[rearport2]
|
||||
)
|
||||
cable2.save()
|
||||
cable4 = Cable(
|
||||
a_terminations=[rearport3],
|
||||
b_terminations=[rearport4]
|
||||
)
|
||||
cable4.save()
|
||||
self.assertEqual(CablePath.objects.count(), 0)
|
||||
|
||||
# Create cable1
|
||||
cable1 = Cable(
|
||||
a_terminations=[interface1],
|
||||
b_terminations=[frontport1, frontport3]
|
||||
)
|
||||
cable1.save()
|
||||
self.assertPathExists(
|
||||
(interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), (rearport2, rearport4), (frontport2, frontport4)),
|
||||
is_complete=False
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 1)
|
||||
|
||||
# Create cable 3
|
||||
cable3 = Cable(
|
||||
a_terminations=[frontport2, frontport4],
|
||||
b_terminations=[interface2]
|
||||
)
|
||||
cable3.save()
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4),
|
||||
(rearport2, rearport4), (frontport2, frontport4), cable3, interface2
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4),
|
||||
(rearport1, rearport3), (frontport1, frontport3), cable1, interface1
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 2)
|
||||
|
||||
def test_220_interface_to_interface_duplex_via_multiple_front_and_rear_ports(self):
|
||||
"""
|
||||
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]
|
||||
[IF2] --C5-- [FP3] [RP3] --C4-- [RP4] [FP4]
|
||||
"""
|
||||
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
|
||||
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
|
||||
interface3 = Interface.objects.create(device=self.device, name='Interface 3')
|
||||
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
|
||||
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
|
||||
rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1)
|
||||
rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1)
|
||||
frontport1 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
|
||||
)
|
||||
frontport2 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
|
||||
)
|
||||
frontport3 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1
|
||||
)
|
||||
frontport4 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1
|
||||
)
|
||||
|
||||
cable2 = Cable(
|
||||
a_terminations=[rearport1],
|
||||
b_terminations=[rearport2]
|
||||
)
|
||||
cable2.save()
|
||||
cable4 = Cable(
|
||||
a_terminations=[rearport3],
|
||||
b_terminations=[rearport4]
|
||||
)
|
||||
cable4.save()
|
||||
self.assertEqual(CablePath.objects.count(), 0)
|
||||
|
||||
# Create cable1
|
||||
cable1 = Cable(
|
||||
a_terminations=[interface1],
|
||||
b_terminations=[frontport1]
|
||||
)
|
||||
cable1.save()
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2
|
||||
),
|
||||
is_complete=False
|
||||
)
|
||||
# Create cable1
|
||||
cable5 = Cable(
|
||||
a_terminations=[interface3],
|
||||
b_terminations=[frontport3]
|
||||
)
|
||||
cable5.save()
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4
|
||||
),
|
||||
is_complete=False
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 2)
|
||||
|
||||
# Create cable 3
|
||||
cable3 = Cable(
|
||||
a_terminations=[frontport2, frontport4],
|
||||
b_terminations=[interface2]
|
||||
)
|
||||
cable3.save()
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4),
|
||||
(rearport1, rearport3), (frontport1, frontport3), (cable1, cable5), (interface1, interface3)
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2, cable3, interface2
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable3, interface2
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 3)
|
||||
|
||||
def test_221_non_symmetric_paths(self):
|
||||
"""
|
||||
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- -------------------------------------- [IF2]
|
||||
[IF2] --C5-- [FP3] [RP3] --C4-- [RP4] [FP4] --C6-- [FP5] [RP5] --C7-- [RP6] [FP6] --C3---/
|
||||
"""
|
||||
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
|
||||
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
|
||||
interface3 = Interface.objects.create(device=self.device, name='Interface 3')
|
||||
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
|
||||
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
|
||||
rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1)
|
||||
rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1)
|
||||
rearport5 = RearPort.objects.create(device=self.device, name='Rear Port 5', positions=1)
|
||||
rearport6 = RearPort.objects.create(device=self.device, name='Rear Port 6', positions=1)
|
||||
frontport1 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
|
||||
)
|
||||
frontport2 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
|
||||
)
|
||||
frontport3 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1
|
||||
)
|
||||
frontport4 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1
|
||||
)
|
||||
frontport5 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 5', rear_port=rearport5, rear_port_position=1
|
||||
)
|
||||
frontport6 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 6', rear_port=rearport6, rear_port_position=1
|
||||
)
|
||||
|
||||
cable2 = Cable(
|
||||
a_terminations=[rearport1],
|
||||
b_terminations=[rearport2],
|
||||
label='C2'
|
||||
)
|
||||
cable2.save()
|
||||
cable4 = Cable(
|
||||
a_terminations=[rearport3],
|
||||
b_terminations=[rearport4],
|
||||
label='C4'
|
||||
)
|
||||
cable4.save()
|
||||
cable6 = Cable(
|
||||
a_terminations=[frontport4],
|
||||
b_terminations=[frontport5],
|
||||
label='C6'
|
||||
)
|
||||
cable6.save()
|
||||
cable7 = Cable(
|
||||
a_terminations=[rearport5],
|
||||
b_terminations=[rearport6],
|
||||
label='C7'
|
||||
)
|
||||
cable7.save()
|
||||
self.assertEqual(CablePath.objects.count(), 0)
|
||||
|
||||
# Create cable1
|
||||
cable1 = Cable(
|
||||
a_terminations=[interface1],
|
||||
b_terminations=[frontport1],
|
||||
label='C1'
|
||||
)
|
||||
cable1.save()
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2
|
||||
),
|
||||
is_complete=False
|
||||
)
|
||||
# Create cable1
|
||||
cable5 = Cable(
|
||||
a_terminations=[interface3],
|
||||
b_terminations=[frontport3],
|
||||
label='C5'
|
||||
)
|
||||
cable5.save()
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable6, frontport5, rearport5,
|
||||
cable7, rearport6, frontport6
|
||||
),
|
||||
is_complete=False
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 2)
|
||||
|
||||
# Create cable 3
|
||||
cable3 = Cable(
|
||||
a_terminations=[frontport2, frontport6],
|
||||
b_terminations=[interface2],
|
||||
label='C3'
|
||||
)
|
||||
cable3.save()
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface2, cable3, (frontport2, frontport6), (rearport2, rearport6), (cable2, cable7),
|
||||
(rearport1, rearport5), (frontport1, frontport5), (cable1, cable6)
|
||||
),
|
||||
is_complete=False,
|
||||
is_split=True
|
||||
)
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2, cable3, interface2
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable6, frontport5, rearport5,
|
||||
cable7, rearport6, frontport6, cable3, interface2
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 3)
|
||||
|
||||
def test_301_create_path_via_existing_cable(self):
|
||||
"""
|
||||
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]
|
||||
@@ -1845,3 +2147,93 @@ class CablePathTestCase(TestCase):
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
def test_401_exclude_midspan_devices(self):
|
||||
"""
|
||||
[IF1] --C1-- [FP1][Test Device][RP1] --C2-- [RP2][Test Device][FP2] --C3-- [IF2]
|
||||
[FP3][Test mid-span Device][RP3] --C4-- [RP4][Test mid-span Device][FP4] /
|
||||
"""
|
||||
device = Device.objects.create(
|
||||
site=self.site,
|
||||
device_type=self.device.device_type,
|
||||
device_role=self.device.device_role,
|
||||
name='Test mid-span Device'
|
||||
)
|
||||
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
|
||||
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
|
||||
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
|
||||
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
|
||||
rearport3 = RearPort.objects.create(device=device, name='Rear Port 3', positions=1)
|
||||
rearport4 = RearPort.objects.create(device=device, name='Rear Port 4', positions=1)
|
||||
frontport1 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
|
||||
)
|
||||
frontport2 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
|
||||
)
|
||||
frontport3 = FrontPort.objects.create(
|
||||
device=device, name='Front Port 3', rear_port=rearport3, rear_port_position=1
|
||||
)
|
||||
frontport4 = FrontPort.objects.create(
|
||||
device=device, name='Front Port 4', rear_port=rearport4, rear_port_position=1
|
||||
)
|
||||
|
||||
cable2 = Cable(
|
||||
a_terminations=[rearport1],
|
||||
b_terminations=[rearport2],
|
||||
label='C2'
|
||||
)
|
||||
cable2.save()
|
||||
cable4 = Cable(
|
||||
a_terminations=[rearport3],
|
||||
b_terminations=[rearport4],
|
||||
label='C4'
|
||||
)
|
||||
cable4.save()
|
||||
self.assertEqual(CablePath.objects.count(), 0)
|
||||
|
||||
# Create cable1
|
||||
cable1 = Cable(
|
||||
a_terminations=[interface1],
|
||||
b_terminations=[frontport1, frontport3],
|
||||
label='C1'
|
||||
)
|
||||
with self.assertRaises(AssertionError):
|
||||
cable1.save()
|
||||
|
||||
self.assertPathDoesNotExist(
|
||||
(
|
||||
interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4),
|
||||
(rearport2, rearport4), (frontport2, frontport4)
|
||||
),
|
||||
is_complete=False
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 0)
|
||||
|
||||
# Create cable 3
|
||||
cable3 = Cable(
|
||||
a_terminations=[frontport2, frontport4],
|
||||
b_terminations=[interface2],
|
||||
label='C3'
|
||||
)
|
||||
|
||||
with self.assertRaises(AssertionError):
|
||||
cable3.save()
|
||||
|
||||
self.assertPathDoesNotExist(
|
||||
(
|
||||
interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4),
|
||||
(rearport1, rearport3), (frontport1, frontport2), cable1, interface1
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertPathDoesNotExist(
|
||||
(
|
||||
interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4),
|
||||
(rearport2, rearport4), (frontport2, frontport4), cable3, interface2
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 0)
|
||||
|
||||
@@ -2822,11 +2822,56 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
)
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
# VirtualChassis assignment for filtering
|
||||
virtual_chassis = VirtualChassis(name='Virtual Chassis')
|
||||
virtual_chassis.save()
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_types[0], role=roles[0], site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_types[1], role=roles[1], site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_types[2], role=roles[2], site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_types[2], role=roles[2], site=sites[3]), # For cable connections
|
||||
Device(
|
||||
name='Device 1A',
|
||||
device_type=device_types[0],
|
||||
role=roles[0],
|
||||
site=sites[0],
|
||||
location=locations[0],
|
||||
rack=racks[0],
|
||||
virtual_chassis=virtual_chassis,
|
||||
vc_position=1,
|
||||
vc_priority=1
|
||||
),
|
||||
Device(
|
||||
name='Device 1B',
|
||||
device_type=device_types[2],
|
||||
role=roles[2],
|
||||
site=sites[2],
|
||||
location=locations[2],
|
||||
rack=racks[2],
|
||||
virtual_chassis=virtual_chassis,
|
||||
vc_position=2,
|
||||
vc_priority=1
|
||||
),
|
||||
Device(
|
||||
name='Device 2',
|
||||
device_type=device_types[1],
|
||||
role=roles[1],
|
||||
site=sites[1],
|
||||
location=locations[1],
|
||||
rack=racks[1]
|
||||
),
|
||||
Device(
|
||||
name='Device 3',
|
||||
device_type=device_types[2],
|
||||
role=roles[2],
|
||||
site=sites[2],
|
||||
location=locations[2],
|
||||
rack=racks[2]
|
||||
),
|
||||
# For cable connections
|
||||
Device(
|
||||
name=None,
|
||||
device_type=device_types[2],
|
||||
role=roles[2],
|
||||
site=sites[3]
|
||||
),
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
@@ -2834,6 +2879,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
ModuleBay(device=devices[0], name='Module Bay 1'),
|
||||
ModuleBay(device=devices[1], name='Module Bay 2'),
|
||||
ModuleBay(device=devices[2], name='Module Bay 3'),
|
||||
ModuleBay(device=devices[3], name='Module Bay 4'),
|
||||
)
|
||||
ModuleBay.objects.bulk_create(module_bays)
|
||||
|
||||
@@ -2841,6 +2887,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
|
||||
Module(device=devices[1], module_bay=module_bays[1], module_type=module_type),
|
||||
Module(device=devices[2], module_bay=module_bays[2], module_type=module_type),
|
||||
Module(device=devices[3], module_bay=module_bays[3], module_type=module_type),
|
||||
)
|
||||
Module.objects.bulk_create(modules)
|
||||
|
||||
@@ -2853,16 +2900,11 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
|
||||
# Virtual Device Context Creation
|
||||
vdcs = (
|
||||
VirtualDeviceContext(device=devices[3], name='VDC 1', identifier=1, status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE),
|
||||
VirtualDeviceContext(device=devices[3], name='VDC 2', identifier=2, status=VirtualDeviceContextStatusChoices.STATUS_PLANNED),
|
||||
VirtualDeviceContext(device=devices[4], name='VDC 1', identifier=1, status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE),
|
||||
VirtualDeviceContext(device=devices[4], name='VDC 2', identifier=2, status=VirtualDeviceContextStatusChoices.STATUS_PLANNED),
|
||||
)
|
||||
VirtualDeviceContext.objects.bulk_create(vdcs)
|
||||
|
||||
# VirtualChassis assignment for filtering
|
||||
virtual_chassis = VirtualChassis.objects.create(master=devices[0])
|
||||
Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis, vc_position=1, vc_priority=1)
|
||||
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2)
|
||||
|
||||
interfaces = (
|
||||
Interface(
|
||||
device=devices[0],
|
||||
@@ -2885,6 +2927,13 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
Interface(
|
||||
device=devices[1],
|
||||
module=modules[1],
|
||||
name='VC Chassis Interface',
|
||||
type=InterfaceTypeChoices.TYPE_1GE_SFP,
|
||||
enabled=True
|
||||
),
|
||||
Interface(
|
||||
device=devices[2],
|
||||
module=modules[2],
|
||||
name='Interface 2',
|
||||
label='B',
|
||||
type=InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||
@@ -2901,8 +2950,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
poe_type=InterfacePoETypeChoices.TYPE_1_8023AF
|
||||
),
|
||||
Interface(
|
||||
device=devices[2],
|
||||
module=modules[2],
|
||||
device=devices[3],
|
||||
module=modules[3],
|
||||
name='Interface 3',
|
||||
label='C',
|
||||
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
|
||||
@@ -2919,7 +2968,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
poe_type=InterfacePoETypeChoices.TYPE_2_8023AT
|
||||
),
|
||||
Interface(
|
||||
device=devices[3],
|
||||
device=devices[4],
|
||||
name='Interface 4',
|
||||
label='D',
|
||||
type=InterfaceTypeChoices.TYPE_OTHER,
|
||||
@@ -2932,7 +2981,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
poe_type=InterfacePoETypeChoices.TYPE_2_8023AT
|
||||
),
|
||||
Interface(
|
||||
device=devices[3],
|
||||
device=devices[4],
|
||||
name='Interface 5',
|
||||
label='E',
|
||||
type=InterfaceTypeChoices.TYPE_OTHER,
|
||||
@@ -2941,7 +2990,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
tx_power=40
|
||||
),
|
||||
Interface(
|
||||
device=devices[3],
|
||||
device=devices[4],
|
||||
name='Interface 6',
|
||||
label='F',
|
||||
type=InterfaceTypeChoices.TYPE_OTHER,
|
||||
@@ -2950,7 +2999,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
tx_power=40
|
||||
),
|
||||
Interface(
|
||||
device=devices[3],
|
||||
device=devices[4],
|
||||
name='Interface 7',
|
||||
type=InterfaceTypeChoices.TYPE_80211AC,
|
||||
rf_role=WirelessRoleChoices.ROLE_AP,
|
||||
@@ -2959,7 +3008,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
rf_channel_width=22
|
||||
),
|
||||
Interface(
|
||||
device=devices[3],
|
||||
device=devices[4],
|
||||
name='Interface 8',
|
||||
type=InterfaceTypeChoices.TYPE_80211AC,
|
||||
rf_role=WirelessRoleChoices.ROLE_STATION,
|
||||
@@ -2977,8 +3026,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
interfaces[7].vdcs.set([vdcs[1]])
|
||||
|
||||
# Cables
|
||||
Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]]).save()
|
||||
Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]]).save()
|
||||
Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[5]]).save()
|
||||
Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[6]]).save()
|
||||
# Third pair is not connected
|
||||
|
||||
def test_name(self):
|
||||
@@ -2991,7 +3040,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
|
||||
def test_enabled(self):
|
||||
params = {'enabled': 'true'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
|
||||
params = {'enabled': 'false'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
@@ -3011,7 +3060,7 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
params = {'mgmt_only': 'true'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'mgmt_only': 'false'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
|
||||
def test_poe_mode(self):
|
||||
params = {'poe_mode': [InterfacePoEModeChoices.MODE_PD, InterfacePoEModeChoices.MODE_PSE]}
|
||||
@@ -3116,6 +3165,14 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
params = {'device': [devices[0].name, devices[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_virtual_chassis_member(self):
|
||||
# Device 1A & 3 have 1 management interface, Device 1B has 1 interfaces
|
||||
devices = Device.objects.filter(name__in=['Device 1A', 'Device 3'])
|
||||
params = {'virtual_chassis_member_id': [devices[0].pk, devices[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
params = {'virtual_chassis_member': [devices[0].name, devices[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_module(self):
|
||||
modules = Module.objects.all()[:2]
|
||||
params = {'module_id': [modules[0].pk, modules[1].pk]}
|
||||
@@ -3125,23 +3182,23 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
|
||||
params = {'cabled': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'cabled': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
|
||||
def test_occupied(self):
|
||||
params = {'occupied': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'occupied': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
|
||||
def test_connected(self):
|
||||
params = {'connected': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'connected': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
|
||||
def test_kind(self):
|
||||
params = {'kind': 'physical'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
|
||||
params = {'kind': 'virtual'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
|
||||
@@ -4218,6 +4275,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Interface(device=devices[4], name='Interface 10', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(device=devices[5], name='Interface 11', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(device=devices[5], name='Interface 12', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(device=devices[5], name='Interface 13', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
)
|
||||
Interface.objects.bulk_create(interfaces)
|
||||
|
||||
@@ -4233,6 +4291,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Cable(a_terminations=[interfaces[11]], b_terminations=[interfaces[0]], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save()
|
||||
Cable(a_terminations=[console_port], b_terminations=[console_server_port], label='Cable 7').save()
|
||||
|
||||
# Cable for unterminated test
|
||||
Cable(a_terminations=[interfaces[12]], label='Cable 8', type=CableTypeChoices.TYPE_CAT6, status=LinkStatusChoices.STATUS_DECOMMISSIONING).save()
|
||||
|
||||
def test_label(self):
|
||||
params = {'label': ['Cable 1', 'Cable 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@@ -4311,6 +4372,12 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_unterminated(self):
|
||||
params = {'unterminated': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'unterminated': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
|
||||
|
||||
|
||||
class PowerPanelTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = PowerPanel.objects.all()
|
||||
@@ -4645,12 +4712,18 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
addresses = (
|
||||
IPAddress(assigned_object=interfaces[0], address='10.1.1.1/24'),
|
||||
IPAddress(assigned_object=interfaces[1], address='10.1.1.2/24'),
|
||||
IPAddress(assigned_object=None, address='10.1.1.3/24'),
|
||||
IPAddress(assigned_object=interfaces[0], address='2001:db8::1/64'),
|
||||
IPAddress(assigned_object=interfaces[1], address='2001:db8::2/64'),
|
||||
IPAddress(assigned_object=None, address='2001:db8::3/64'),
|
||||
)
|
||||
IPAddress.objects.bulk_create(addresses)
|
||||
|
||||
vdcs[0].primary_ip4 = addresses[0]
|
||||
vdcs[0].primary_ip6 = addresses[3]
|
||||
vdcs[0].save()
|
||||
vdcs[1].primary_ip4 = addresses[1]
|
||||
vdcs[1].primary_ip6 = addresses[4]
|
||||
vdcs[1].save()
|
||||
|
||||
def test_device(self):
|
||||
@@ -4671,3 +4744,17 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'has_primary_ip': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_primary_ip4(self):
|
||||
addresses = IPAddress.objects.filter(address__family=4)
|
||||
params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'primary_ip4_id': [addresses[2].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
|
||||
def test_primary_ip6(self):
|
||||
addresses = IPAddress.objects.filter(address__family=6)
|
||||
params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'primary_ip6_id': [addresses[2].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
|
||||
@@ -17,7 +17,7 @@ from dcim.constants import *
|
||||
from dcim.models import *
|
||||
from ipam.models import ASN, RIR, VLAN, VRF
|
||||
from tenancy.models import Tenant
|
||||
from utilities.choices import ImportFormatChoices
|
||||
from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
|
||||
from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
|
||||
from wireless.models import WirelessLAN
|
||||
|
||||
@@ -2014,6 +2014,7 @@ class ModuleTestCase(
|
||||
'data': {
|
||||
'data': '\n'.join(csv_data),
|
||||
'format': ImportFormatChoices.CSV,
|
||||
'csv_delimiter': CSVDelimiterChoices.AUTO,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2030,6 +2031,7 @@ class ModuleTestCase(
|
||||
'data': {
|
||||
'data': '\n'.join(csv_data),
|
||||
'format': ImportFormatChoices.CSV,
|
||||
'csv_delimiter': CSVDelimiterChoices.AUTO,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2106,6 +2108,7 @@ class ModuleTestCase(
|
||||
'data': {
|
||||
'data': '\n'.join(csv_data),
|
||||
'format': ImportFormatChoices.CSV,
|
||||
'csv_delimiter': CSVDelimiterChoices.AUTO,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import traceback
|
||||
from collections import defaultdict
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
@@ -45,6 +46,15 @@ CABLE_TERMINATION_TYPES = {
|
||||
|
||||
|
||||
class DeviceComponentsView(generic.ObjectChildrenView):
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename', 'bulk_disconnect')
|
||||
action_perms = defaultdict(set, **{
|
||||
'add': {'add'},
|
||||
'import': {'add'},
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
'bulk_rename': {'change'},
|
||||
'bulk_disconnect': {'change'},
|
||||
})
|
||||
queryset = Device.objects.all()
|
||||
|
||||
def get_children(self, request, parent):
|
||||
@@ -112,16 +122,18 @@ class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View)
|
||||
if form.is_valid():
|
||||
|
||||
with transaction.atomic():
|
||||
|
||||
count = 0
|
||||
cable_ids = set()
|
||||
for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
|
||||
if obj.cable is None:
|
||||
continue
|
||||
obj.cable.delete()
|
||||
count += 1
|
||||
if obj.cable:
|
||||
cable_ids.add(obj.cable.pk)
|
||||
count += 1
|
||||
for cable in Cable.objects.filter(pk__in=cable_ids):
|
||||
cable.delete()
|
||||
|
||||
messages.success(request, "Disconnected {} {}".format(
|
||||
count, self.queryset.model._meta.verbose_name_plural
|
||||
messages.success(request, _("Disconnected {count} {type}").format(
|
||||
count=count,
|
||||
type=self.queryset.model._meta.verbose_name_plural
|
||||
))
|
||||
|
||||
return redirect(return_url)
|
||||
@@ -388,32 +400,8 @@ class SiteView(generic.ObjectView):
|
||||
(Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(), 'site_id'),
|
||||
)
|
||||
|
||||
locations = Location.objects.add_related_count(
|
||||
Location.objects.all(),
|
||||
Rack,
|
||||
'location',
|
||||
'rack_count',
|
||||
cumulative=True
|
||||
)
|
||||
locations = Location.objects.add_related_count(
|
||||
locations,
|
||||
Device,
|
||||
'location',
|
||||
'device_count',
|
||||
cumulative=True
|
||||
).restrict(request.user, 'view').filter(site=instance)
|
||||
|
||||
nonracked_devices = Device.objects.filter(
|
||||
site=instance,
|
||||
rack__isnull=True,
|
||||
parent_bay__isnull=True
|
||||
).prefetch_related('device_type__manufacturer', 'parent_bay', 'role')
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'locations': locations,
|
||||
'nonracked_devices': nonracked_devices.order_by('-pk')[:10],
|
||||
'total_nonracked_devices_count': nonracked_devices.count(),
|
||||
}
|
||||
|
||||
|
||||
@@ -485,16 +473,8 @@ class LocationView(generic.ObjectView):
|
||||
(Device.objects.restrict(request.user, 'view').filter(location__in=locations), 'location_id'),
|
||||
)
|
||||
|
||||
nonracked_devices = Device.objects.filter(
|
||||
location=instance,
|
||||
rack__isnull=True,
|
||||
parent_bay__isnull=True
|
||||
).prefetch_related('device_type__manufacturer', 'parent_bay', 'role')
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'nonracked_devices': nonracked_devices.order_by('-pk')[:10],
|
||||
'total_nonracked_devices_count': nonracked_devices.count(),
|
||||
}
|
||||
|
||||
|
||||
@@ -1997,6 +1977,7 @@ class DeviceModuleBaysView(DeviceComponentsView):
|
||||
table = tables.DeviceModuleBayTable
|
||||
filterset = filtersets.ModuleBayFilterSet
|
||||
template_name = 'dcim/device/modulebays.html'
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
||||
tab = ViewTab(
|
||||
label=_('Module Bays'),
|
||||
badge=lambda obj: obj.module_bay_count,
|
||||
@@ -2012,6 +1993,7 @@ class DeviceDeviceBaysView(DeviceComponentsView):
|
||||
table = tables.DeviceDeviceBayTable
|
||||
filterset = filtersets.DeviceBayFilterSet
|
||||
template_name = 'dcim/device/devicebays.html'
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
||||
tab = ViewTab(
|
||||
label=_('Device Bays'),
|
||||
badge=lambda obj: obj.device_bay_count,
|
||||
@@ -2023,6 +2005,7 @@ class DeviceDeviceBaysView(DeviceComponentsView):
|
||||
|
||||
@register_model_view(Device, 'inventory')
|
||||
class DeviceInventoryView(DeviceComponentsView):
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
||||
child_model = InventoryItem
|
||||
table = tables.DeviceInventoryItemTable
|
||||
filterset = filtersets.InventoryItemFilterSet
|
||||
@@ -2042,7 +2025,6 @@ class DeviceConfigContextView(ObjectConfigContextView):
|
||||
base_template = 'dcim/device/base.html'
|
||||
tab = ViewTab(
|
||||
label=_('Config Context'),
|
||||
permission='extras.view_configcontext',
|
||||
weight=2000
|
||||
)
|
||||
|
||||
@@ -2053,7 +2035,6 @@ class DeviceRenderConfigView(generic.ObjectView):
|
||||
template_name = 'dcim/device/render_config.html'
|
||||
tab = ViewTab(
|
||||
label=_('Render Config'),
|
||||
permission='extras.view_configtemplate',
|
||||
weight=2100
|
||||
)
|
||||
|
||||
@@ -2205,6 +2186,15 @@ class ConsolePortListView(generic.ObjectListView):
|
||||
filterset = filtersets.ConsolePortFilterSet
|
||||
filterset_form = forms.ConsolePortFilterForm
|
||||
table = tables.ConsolePortTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
||||
action_perms = defaultdict(set, **{
|
||||
'add': {'add'},
|
||||
'import': {'add'},
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
'bulk_rename': {'change'},
|
||||
})
|
||||
|
||||
|
||||
@register_model_view(ConsolePort)
|
||||
@@ -2268,6 +2258,15 @@ class ConsoleServerPortListView(generic.ObjectListView):
|
||||
filterset = filtersets.ConsoleServerPortFilterSet
|
||||
filterset_form = forms.ConsoleServerPortFilterForm
|
||||
table = tables.ConsoleServerPortTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
||||
action_perms = defaultdict(set, **{
|
||||
'add': {'add'},
|
||||
'import': {'add'},
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
'bulk_rename': {'change'},
|
||||
})
|
||||
|
||||
|
||||
@register_model_view(ConsoleServerPort)
|
||||
@@ -2331,6 +2330,15 @@ class PowerPortListView(generic.ObjectListView):
|
||||
filterset = filtersets.PowerPortFilterSet
|
||||
filterset_form = forms.PowerPortFilterForm
|
||||
table = tables.PowerPortTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
||||
action_perms = defaultdict(set, **{
|
||||
'add': {'add'},
|
||||
'import': {'add'},
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
'bulk_rename': {'change'},
|
||||
})
|
||||
|
||||
|
||||
@register_model_view(PowerPort)
|
||||
@@ -2394,6 +2402,15 @@ class PowerOutletListView(generic.ObjectListView):
|
||||
filterset = filtersets.PowerOutletFilterSet
|
||||
filterset_form = forms.PowerOutletFilterForm
|
||||
table = tables.PowerOutletTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
||||
action_perms = defaultdict(set, **{
|
||||
'add': {'add'},
|
||||
'import': {'add'},
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
'bulk_rename': {'change'},
|
||||
})
|
||||
|
||||
|
||||
@register_model_view(PowerOutlet)
|
||||
@@ -2457,6 +2474,15 @@ class InterfaceListView(generic.ObjectListView):
|
||||
filterset = filtersets.InterfaceFilterSet
|
||||
filterset_form = forms.InterfaceFilterForm
|
||||
table = tables.InterfaceTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
||||
action_perms = defaultdict(set, **{
|
||||
'add': {'add'},
|
||||
'import': {'add'},
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
'bulk_rename': {'change'},
|
||||
})
|
||||
|
||||
|
||||
@register_model_view(Interface)
|
||||
@@ -2568,6 +2594,15 @@ class FrontPortListView(generic.ObjectListView):
|
||||
filterset = filtersets.FrontPortFilterSet
|
||||
filterset_form = forms.FrontPortFilterForm
|
||||
table = tables.FrontPortTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
||||
action_perms = defaultdict(set, **{
|
||||
'add': {'add'},
|
||||
'import': {'add'},
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
'bulk_rename': {'change'},
|
||||
})
|
||||
|
||||
|
||||
@register_model_view(FrontPort)
|
||||
@@ -2631,6 +2666,15 @@ class RearPortListView(generic.ObjectListView):
|
||||
filterset = filtersets.RearPortFilterSet
|
||||
filterset_form = forms.RearPortFilterForm
|
||||
table = tables.RearPortTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
||||
action_perms = defaultdict(set, **{
|
||||
'add': {'add'},
|
||||
'import': {'add'},
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
'bulk_rename': {'change'},
|
||||
})
|
||||
|
||||
|
||||
@register_model_view(RearPort)
|
||||
@@ -2694,6 +2738,15 @@ class ModuleBayListView(generic.ObjectListView):
|
||||
filterset = filtersets.ModuleBayFilterSet
|
||||
filterset_form = forms.ModuleBayFilterForm
|
||||
table = tables.ModuleBayTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
||||
action_perms = defaultdict(set, **{
|
||||
'add': {'add'},
|
||||
'import': {'add'},
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
'bulk_rename': {'change'},
|
||||
})
|
||||
|
||||
|
||||
@register_model_view(ModuleBay)
|
||||
@@ -2749,6 +2802,15 @@ class DeviceBayListView(generic.ObjectListView):
|
||||
filterset = filtersets.DeviceBayFilterSet
|
||||
filterset_form = forms.DeviceBayFilterForm
|
||||
table = tables.DeviceBayTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
||||
action_perms = defaultdict(set, **{
|
||||
'add': {'add'},
|
||||
'import': {'add'},
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
'bulk_rename': {'change'},
|
||||
})
|
||||
|
||||
|
||||
@register_model_view(DeviceBay)
|
||||
@@ -2873,6 +2935,15 @@ class InventoryItemListView(generic.ObjectListView):
|
||||
filterset = filtersets.InventoryItemFilterSet
|
||||
filterset_form = forms.InventoryItemFilterForm
|
||||
table = tables.InventoryItemTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_rename')
|
||||
action_perms = defaultdict(set, **{
|
||||
'add': {'add'},
|
||||
'import': {'add'},
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
'bulk_rename': {'change'},
|
||||
})
|
||||
|
||||
|
||||
@register_model_view(InventoryItem)
|
||||
@@ -2922,6 +2993,25 @@ class InventoryItemBulkDeleteView(generic.BulkDeleteView):
|
||||
template_name = 'dcim/inventoryitem_bulk_delete.html'
|
||||
|
||||
|
||||
@register_model_view(InventoryItem, 'children')
|
||||
class InventoryItemChildrenView(generic.ObjectChildrenView):
|
||||
queryset = InventoryItem.objects.all()
|
||||
child_model = InventoryItem
|
||||
table = tables.InventoryItemTable
|
||||
filterset = filtersets.InventoryItemFilterSet
|
||||
template_name = 'generic/object_children.html'
|
||||
tab = ViewTab(
|
||||
label=_('Children'),
|
||||
badge=lambda obj: obj.child_items.count(),
|
||||
permission='dcim.view_inventoryitem',
|
||||
hide_if_empty=True,
|
||||
weight=5000
|
||||
)
|
||||
|
||||
def get_children(self, request, parent):
|
||||
return parent.child_items.restrict(request.user, 'view')
|
||||
|
||||
|
||||
#
|
||||
# Inventory item roles
|
||||
#
|
||||
|
||||
@@ -239,7 +239,7 @@ class BookmarkSerializer(ValidatedModelSerializer):
|
||||
class TagSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
|
||||
object_types = ContentTypeField(
|
||||
queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()),
|
||||
queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
|
||||
many=True,
|
||||
required=False
|
||||
)
|
||||
@@ -454,7 +454,7 @@ class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer
|
||||
required=False
|
||||
)
|
||||
data_file = NestedDataFileSerializer(
|
||||
read_only=True
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -479,8 +479,13 @@ class ReportSerializer(serializers.Serializer):
|
||||
module = serializers.CharField(max_length=255)
|
||||
name = serializers.CharField(max_length=255)
|
||||
description = serializers.CharField(max_length=255, required=False)
|
||||
test_methods = serializers.ListField(child=serializers.CharField(max_length=255))
|
||||
test_methods = serializers.ListField(child=serializers.CharField(max_length=255), read_only=True)
|
||||
result = NestedJobSerializer()
|
||||
display = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
@extend_schema_field(serializers.CharField())
|
||||
def get_display(self, obj):
|
||||
return f'{obj.name} ({obj.module})'
|
||||
|
||||
|
||||
class ReportDetailSerializer(ReportSerializer):
|
||||
@@ -518,6 +523,7 @@ class ScriptSerializer(serializers.Serializer):
|
||||
description = serializers.CharField(read_only=True)
|
||||
vars = serializers.SerializerMethodField(read_only=True)
|
||||
result = NestedJobSerializer()
|
||||
display = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||
def get_vars(self, instance):
|
||||
@@ -525,6 +531,10 @@ class ScriptSerializer(serializers.Serializer):
|
||||
k: v.__class__.__name__ for k, v in instance._get_vars().items()
|
||||
}
|
||||
|
||||
@extend_schema_field(serializers.CharField())
|
||||
def get_display(self, obj):
|
||||
return f'{obj.name} ({obj.module})'
|
||||
|
||||
|
||||
class ScriptDetailSerializer(ScriptSerializer):
|
||||
result = JobSerializer()
|
||||
|
||||
@@ -80,9 +80,12 @@ class CustomFieldChoiceSetViewSet(NetBoxModelViewSet):
|
||||
# Paginate data
|
||||
if page := self.paginate_queryset(choices):
|
||||
data = [
|
||||
{'value': c[0], 'label': c[1]} for c in page
|
||||
{'id': c[0], 'display': c[1]} for c in page
|
||||
]
|
||||
return self.get_paginated_response(data)
|
||||
else:
|
||||
data = []
|
||||
|
||||
return self.get_paginated_response(data)
|
||||
|
||||
|
||||
#
|
||||
@@ -243,7 +246,7 @@ class ReportViewSet(ViewSet):
|
||||
'request': request,
|
||||
})
|
||||
|
||||
return Response(serializer.data)
|
||||
return Response({'count': len(report_list), 'results': serializer.data})
|
||||
|
||||
def retrieve(self, request, pk):
|
||||
"""
|
||||
@@ -280,7 +283,7 @@ class ReportViewSet(ViewSet):
|
||||
|
||||
# Retrieve and run the Report. This will create a new Job.
|
||||
module, report_cls = self._get_report(pk)
|
||||
report = report_cls()
|
||||
report = report_cls
|
||||
input_serializer = serializers.ReportInputSerializer(
|
||||
data=request.data,
|
||||
context={'report': report}
|
||||
@@ -343,7 +346,7 @@ class ScriptViewSet(ViewSet):
|
||||
|
||||
serializer = serializers.ScriptSerializer(script_list, many=True, context={'request': request})
|
||||
|
||||
return Response(serializer.data)
|
||||
return Response({'count': len(script_list), 'results': serializer.data})
|
||||
|
||||
def retrieve(self, request, pk):
|
||||
module, script = self._get_script(pk)
|
||||
|
||||
@@ -244,3 +244,39 @@ class ChangeActionChoices(ChoiceSet):
|
||||
(ACTION_UPDATE, _('Update'), 'blue'),
|
||||
(ACTION_DELETE, _('Delete'), 'red'),
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Dashboard widgets
|
||||
#
|
||||
|
||||
class DashboardWidgetColorChoices(ChoiceSet):
|
||||
BLUE = 'blue'
|
||||
INDIGO = 'indigo'
|
||||
PURPLE = 'purple'
|
||||
PINK = 'pink'
|
||||
RED = 'red'
|
||||
ORANGE = 'orange'
|
||||
YELLOW = 'yellow'
|
||||
GREEN = 'green'
|
||||
TEAL = 'teal'
|
||||
CYAN = 'cyan'
|
||||
GRAY = 'gray'
|
||||
BLACK = 'black'
|
||||
WHITE = 'white'
|
||||
|
||||
CHOICES = (
|
||||
(BLUE, _('Blue')),
|
||||
(INDIGO, _('Indigo')),
|
||||
(PURPLE, _('Purple')),
|
||||
(PINK, _('Pink')),
|
||||
(RED, _('Red')),
|
||||
(ORANGE, _('Orange')),
|
||||
(YELLOW, _('Yellow')),
|
||||
(GREEN, _('Green')),
|
||||
(TEAL, _('Teal')),
|
||||
(CYAN, _('Cyan')),
|
||||
(GRAY, _('Gray')),
|
||||
(BLACK, _('Black')),
|
||||
(WHITE, _('White')),
|
||||
)
|
||||
|
||||
@@ -19,6 +19,13 @@ WEBHOOK_EVENT_TYPES = {
|
||||
|
||||
# Dashboard
|
||||
DEFAULT_DASHBOARD = [
|
||||
{
|
||||
'widget': 'extras.BookmarksWidget',
|
||||
'width': 4,
|
||||
'height': 5,
|
||||
'title': 'Bookmarks',
|
||||
'color': 'orange',
|
||||
},
|
||||
{
|
||||
'widget': 'extras.ObjectCountsWidget',
|
||||
'width': 4,
|
||||
@@ -32,22 +39,6 @@ DEFAULT_DASHBOARD = [
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
'widget': 'extras.ObjectCountsWidget',
|
||||
'width': 4,
|
||||
'height': 3,
|
||||
'title': 'IPAM',
|
||||
'config': {
|
||||
'models': [
|
||||
'ipam.vrf',
|
||||
'ipam.aggregate',
|
||||
'ipam.prefix',
|
||||
'ipam.iprange',
|
||||
'ipam.ipaddress',
|
||||
'ipam.vlan',
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
'widget': 'extras.NoteWidget',
|
||||
'width': 4,
|
||||
@@ -65,13 +56,16 @@ DEFAULT_DASHBOARD = [
|
||||
{
|
||||
'widget': 'extras.ObjectCountsWidget',
|
||||
'width': 4,
|
||||
'height': 2,
|
||||
'title': 'Circuits',
|
||||
'height': 3,
|
||||
'title': 'IPAM',
|
||||
'config': {
|
||||
'models': [
|
||||
'circuits.provider',
|
||||
'circuits.circuit',
|
||||
'circuits.providernetwork',
|
||||
'ipam.vrf',
|
||||
'ipam.aggregate',
|
||||
'ipam.prefix',
|
||||
'ipam.iprange',
|
||||
'ipam.ipaddress',
|
||||
'ipam.vlan',
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -86,6 +80,20 @@ DEFAULT_DASHBOARD = [
|
||||
'cache_timeout': 14400,
|
||||
}
|
||||
},
|
||||
{
|
||||
'widget': 'extras.ObjectCountsWidget',
|
||||
'width': 4,
|
||||
'height': 3,
|
||||
'title': 'Circuits',
|
||||
'config': {
|
||||
'models': [
|
||||
'circuits.provider',
|
||||
'circuits.circuit',
|
||||
'circuits.providernetwork',
|
||||
'circuits.provideraccount',
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
'widget': 'extras.ObjectCountsWidget',
|
||||
'width': 4,
|
||||
|
||||
@@ -2,9 +2,9 @@ from django import forms
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from extras.choices import DashboardWidgetColorChoices
|
||||
from netbox.registry import registry
|
||||
from utilities.forms import BootstrapMixin, add_blank_choice
|
||||
from utilities.choices import ButtonColorChoices
|
||||
|
||||
__all__ = (
|
||||
'DashboardWidgetAddForm',
|
||||
@@ -21,7 +21,7 @@ class DashboardWidgetForm(BootstrapMixin, forms.Form):
|
||||
required=False
|
||||
)
|
||||
color = forms.ChoiceField(
|
||||
choices=add_blank_choice(ButtonColorChoices),
|
||||
choices=add_blank_choice(DashboardWidgetColorChoices),
|
||||
required=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from django.utils.translation import gettext as _
|
||||
|
||||
from extras.choices import BookmarkOrderingChoices
|
||||
from extras.utils import FeatureQuery
|
||||
from utilities.choices import ButtonColorChoices
|
||||
from utilities.forms import BootstrapMixin
|
||||
from utilities.permissions import get_permission_for_model
|
||||
from utilities.templatetags.builtins.filters import render_markdown
|
||||
@@ -115,6 +116,22 @@ class DashboardWidget:
|
||||
def name(self):
|
||||
return f'{self.__class__.__module__.split(".")[0]}.{self.__class__.__name__}'
|
||||
|
||||
@property
|
||||
def fg_color(self):
|
||||
"""
|
||||
Return the appropriate foreground (text) color for the widget's color.
|
||||
"""
|
||||
if self.color in (
|
||||
ButtonColorChoices.CYAN,
|
||||
ButtonColorChoices.GRAY,
|
||||
ButtonColorChoices.GREY,
|
||||
ButtonColorChoices.TEAL,
|
||||
ButtonColorChoices.WHITE,
|
||||
ButtonColorChoices.YELLOW,
|
||||
):
|
||||
return ButtonColorChoices.BLACK
|
||||
return ButtonColorChoices.WHITE
|
||||
|
||||
@property
|
||||
def form_data(self):
|
||||
return {
|
||||
@@ -346,13 +363,16 @@ class BookmarksWidget(DashboardWidget):
|
||||
def render(self, request):
|
||||
from extras.models import Bookmark
|
||||
|
||||
bookmarks = Bookmark.objects.filter(user=request.user).order_by(self.config['order_by'])
|
||||
if object_types := self.config.get('object_types'):
|
||||
models = get_models_from_content_types(object_types)
|
||||
conent_types = ContentType.objects.get_for_models(*models).values()
|
||||
bookmarks = bookmarks.filter(object_type__in=conent_types)
|
||||
if max_items := self.config.get('max_items'):
|
||||
bookmarks = bookmarks[:max_items]
|
||||
if request.user.is_anonymous:
|
||||
bookmarks = list()
|
||||
else:
|
||||
bookmarks = Bookmark.objects.filter(user=request.user).order_by(self.config['order_by'])
|
||||
if object_types := self.config.get('object_types'):
|
||||
models = get_models_from_content_types(object_types)
|
||||
conent_types = ContentType.objects.get_for_models(*models).values()
|
||||
bookmarks = bookmarks.filter(object_type__in=conent_types)
|
||||
if max_items := self.config.get('max_items'):
|
||||
bookmarks = bookmarks[:max_items]
|
||||
|
||||
return render_to_string(self.template_name, {
|
||||
'bookmarks': bookmarks,
|
||||
|
||||
@@ -122,8 +122,7 @@ class CustomFieldChoiceSetFilterSet(BaseFilterSet):
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(description__icontains=value) |
|
||||
Q(extra_choices__contains=value)
|
||||
Q(description__icontains=value)
|
||||
)
|
||||
|
||||
def filter_by_choice(self, queryset, name, value):
|
||||
|
||||
@@ -164,7 +164,7 @@ class TagImportForm(CSVModelForm):
|
||||
model = Tag
|
||||
fields = ('name', 'slug', 'color', 'description')
|
||||
help_texts = {
|
||||
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
|
||||
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id')),
|
||||
(_('Data'), ('data_source_id', 'data_file_id')),
|
||||
(_('Attributes'), ('content_types', 'mime_type', 'file_extension', 'as_attachment')),
|
||||
(_('Attributes'), ('content_type_id', 'mime_type', 'file_extension', 'as_attachment')),
|
||||
)
|
||||
data_source_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DataSource.objects.all(),
|
||||
@@ -151,10 +151,10 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
||||
'source_id': '$data_source_id'
|
||||
}
|
||||
)
|
||||
content_types = ContentTypeMultipleChoiceField(
|
||||
label=_('Content types'),
|
||||
content_type_id = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
|
||||
required=False
|
||||
required=False,
|
||||
label=_('Content types')
|
||||
)
|
||||
mime_type = forms.CharField(
|
||||
required=False,
|
||||
@@ -180,7 +180,7 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
|
||||
)
|
||||
content_type_id = ContentTypeChoiceField(
|
||||
label=_('Content type'),
|
||||
queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()),
|
||||
queryset=ContentType.objects.filter(FeatureQuery('image_attachments').get_query()),
|
||||
required=False
|
||||
)
|
||||
name = forms.CharField(
|
||||
|
||||
@@ -9,6 +9,7 @@ from utilities.forms.fields import DynamicModelMultipleChoiceField
|
||||
__all__ = (
|
||||
'CustomFieldsMixin',
|
||||
'SavedFiltersMixin',
|
||||
'TagsMixin',
|
||||
)
|
||||
|
||||
|
||||
@@ -72,3 +73,19 @@ class SavedFiltersMixin(forms.Form):
|
||||
'usable': True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TagsMixin(forms.Form):
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False,
|
||||
label=_('Tags'),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Limit tags to those applicable to the object type
|
||||
content_type = ContentType.objects.get_for_model(self._meta.model)
|
||||
if content_type and hasattr(self.fields['tags'].widget, 'add_query_param'):
|
||||
self.fields['tags'].widget.add_query_param('for_object_type_id', content_type.pk)
|
||||
|
||||
@@ -4,6 +4,7 @@ from django import forms
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.forms.mixins import SyncedDataMixin
|
||||
@@ -75,13 +76,15 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
||||
'type': _(
|
||||
"The type of data stored in this field. For object/multi-object fields, select the related object "
|
||||
"type below."
|
||||
)
|
||||
),
|
||||
'description': _("This will be displayed as help text for the form field. Markdown is supported.")
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Disable changing the type of a CustomField as it almost universally causes errors if custom field data is already present.
|
||||
# Disable changing the type of a CustomField as it almost universally causes errors if custom field data
|
||||
# is already present.
|
||||
if self.instance.pk:
|
||||
self.fields['type'].disabled = True
|
||||
|
||||
@@ -89,6 +92,11 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
||||
class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm):
|
||||
extra_choices = forms.CharField(
|
||||
widget=ChoicesWidget(),
|
||||
required=False,
|
||||
help_text=mark_safe(_(
|
||||
'Enter one choice per line. An optional label may be specified for each choice by appending it with a '
|
||||
'comma. Example:'
|
||||
) + ' <code>choice1,First Choice</code>')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -320,7 +328,7 @@ class ConfigContextForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
|
||||
required=False
|
||||
)
|
||||
tenant_groups = DynamicModelMultipleChoiceField(
|
||||
label=_('Tenat groups'),
|
||||
label=_('Tenant groups'),
|
||||
queryset=TenantGroup.objects.all(),
|
||||
required=False
|
||||
)
|
||||
@@ -485,7 +493,9 @@ class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMe
|
||||
(_('Pagination'), ('PAGINATE_COUNT', 'MAX_PAGE_SIZE')),
|
||||
(_('Validation'), ('CUSTOM_VALIDATORS',)),
|
||||
(_('User Preferences'), ('DEFAULT_USER_PREFERENCES',)),
|
||||
(_('Miscellaneous'), ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL')),
|
||||
(_('Miscellaneous'), (
|
||||
'MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL',
|
||||
)),
|
||||
(_('Config Revision'), ('comment',))
|
||||
)
|
||||
|
||||
@@ -508,20 +518,34 @@ class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMe
|
||||
config = get_config()
|
||||
for param in PARAMS:
|
||||
value = getattr(config, param.name)
|
||||
is_static = hasattr(settings, param.name)
|
||||
if value:
|
||||
help_text = self.fields[param.name].help_text
|
||||
if help_text:
|
||||
help_text += '<br />' # Line break
|
||||
help_text += _('Current value: <strong>{value}</strong>').format(value=value)
|
||||
if is_static:
|
||||
help_text += _(' (defined statically)')
|
||||
elif value == param.default:
|
||||
help_text += _(' (default)')
|
||||
self.fields[param.name].help_text = help_text
|
||||
self.fields[param.name].initial = value
|
||||
if is_static:
|
||||
|
||||
# Set the field's initial value, if it can be serialized. (This may not be the case e.g. for
|
||||
# CUSTOM_VALIDATORS, which may reference Python objects.)
|
||||
try:
|
||||
json.dumps(value)
|
||||
if type(value) in (tuple, list):
|
||||
self.fields[param.name].initial = ', '.join(value)
|
||||
else:
|
||||
self.fields[param.name].initial = value
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
# Check whether this parameter is statically configured (e.g. in configuration.py)
|
||||
if hasattr(settings, param.name):
|
||||
self.fields[param.name].disabled = True
|
||||
self.fields[param.name].help_text = _(
|
||||
'This parameter has been defined statically and cannot be modified.'
|
||||
)
|
||||
continue
|
||||
|
||||
# Set the field's help text
|
||||
help_text = self.fields[param.name].help_text
|
||||
if help_text:
|
||||
help_text += '<br />' # Line break
|
||||
help_text += _('Current value: <strong>{value}</strong>').format(value=value or '—')
|
||||
if value == param.default:
|
||||
help_text += _(' (default)')
|
||||
self.fields[param.name].help_text = help_text
|
||||
|
||||
def save(self, commit=True):
|
||||
instance = super().save(commit=False)
|
||||
|
||||
@@ -69,10 +69,7 @@ class Command(BaseCommand):
|
||||
if not kwargs['lazy']:
|
||||
self.stdout.write('Clearing cached values... ', ending='')
|
||||
self.stdout.flush()
|
||||
content_types = [
|
||||
ContentType.objects.get_for_model(model) for model in indexers.keys()
|
||||
]
|
||||
deleted_count = search_backend.clear(content_types)
|
||||
deleted_count = search_backend.clear()
|
||||
self.stdout.write(f'{deleted_count} entries deleted.')
|
||||
|
||||
# Index models
|
||||
|
||||
@@ -93,6 +93,8 @@ class ObjectChange(models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ['-time']
|
||||
verbose_name = _('object change')
|
||||
verbose_name_plural = _('object changes')
|
||||
|
||||
def __str__(self):
|
||||
return '{} {} {} by {}'.format(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.core.validators import ValidationError
|
||||
from django.db import models
|
||||
@@ -8,6 +9,7 @@ from jinja2.sandbox import SandboxedEnvironment
|
||||
|
||||
from extras.querysets import ConfigContextQuerySet
|
||||
from netbox.config import get_config
|
||||
from netbox.registry import registry
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from netbox.models.features import CloningMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
|
||||
from utilities.jinja2 import ConfigTemplateLoader
|
||||
@@ -125,6 +127,8 @@ class ConfigContext(SyncedDataMixin, CloningMixin, ChangeLoggedModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ['weight', 'name']
|
||||
verbose_name = _('config context')
|
||||
verbose_name_plural = _('config contexts')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -142,7 +146,7 @@ class ConfigContext(SyncedDataMixin, CloningMixin, ChangeLoggedModel):
|
||||
# Verify that JSON data is provided as an object
|
||||
if type(self.data) is not dict:
|
||||
raise ValidationError(
|
||||
{'data': _('JSON data must be in object form. Example: {"foo": 123}')}
|
||||
{'data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'}
|
||||
)
|
||||
|
||||
def sync_data(self):
|
||||
@@ -198,7 +202,7 @@ class ConfigContextModel(models.Model):
|
||||
# Verify that JSON data is provided as an object
|
||||
if self.local_context_data and type(self.local_context_data) is not dict:
|
||||
raise ValidationError(
|
||||
{'local_context_data': _('JSON data must be in object form. Example: {"foo": 123}')}
|
||||
{'local_context_data': _('JSON data must be in object form. Example:') + ' {"foo": 123}'}
|
||||
)
|
||||
|
||||
|
||||
@@ -233,6 +237,8 @@ class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLog
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('config template')
|
||||
verbose_name_plural = _('config templates')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -251,7 +257,19 @@ class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLog
|
||||
"""
|
||||
Render the contents of the template.
|
||||
"""
|
||||
context = context or {}
|
||||
_context = dict()
|
||||
|
||||
# Populate the default template context with NetBox model classes, namespaced by app
|
||||
# TODO: Devise a canonical mechanism for identifying the models to include (see #13427)
|
||||
for app, model_names in registry['model_features']['custom_fields'].items():
|
||||
_context.setdefault(app, {})
|
||||
for model_name in model_names:
|
||||
model = apps.get_registered_model(app, model_name)
|
||||
_context[app][model.__name__] = model
|
||||
|
||||
# Add the provided context data, if any
|
||||
if context is not None:
|
||||
_context.update(context)
|
||||
|
||||
# Initialize the Jinja2 environment and instantiate the Template
|
||||
environment = self._get_environment()
|
||||
@@ -259,7 +277,7 @@ class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLog
|
||||
template = environment.get_template(self.data_file.path)
|
||||
else:
|
||||
template = environment.from_string(self.template_code)
|
||||
output = template.render(**context)
|
||||
output = template.render(**_context)
|
||||
|
||||
# Replace CRLF-style line terminators
|
||||
return output.replace('\r\n', '\n')
|
||||
|
||||
@@ -28,6 +28,7 @@ from utilities.forms.fields import (
|
||||
from utilities.forms.utils import add_blank_choice
|
||||
from utilities.forms.widgets import APISelect, APISelectMultiple, DatePicker, DateTimePicker
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.templatetags.builtins.filters import render_markdown
|
||||
from utilities.validators import validate_regex
|
||||
|
||||
__all__ = (
|
||||
@@ -202,6 +203,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ['group_name', 'weight', 'name']
|
||||
verbose_name = _('custom field')
|
||||
verbose_name_plural = _('custom fields')
|
||||
|
||||
def __str__(self):
|
||||
return self.label or self.name.replace('_', ' ').capitalize()
|
||||
@@ -217,7 +220,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Cache instance's original name so we can check later whether it has changed
|
||||
self._name = self.name
|
||||
self._name = self.__dict__.get('name')
|
||||
|
||||
@property
|
||||
def search_type(self):
|
||||
@@ -229,6 +232,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
return self.choice_set.choices
|
||||
return []
|
||||
|
||||
def get_choice_label(self, value):
|
||||
if not hasattr(self, '_choice_map'):
|
||||
self._choice_map = dict(self.choices)
|
||||
return self._choice_map.get(value, value)
|
||||
|
||||
def populate_initial_data(self, content_types):
|
||||
"""
|
||||
Populate initial custom field data upon either a) the creation of a new CustomField, or
|
||||
@@ -280,7 +288,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
raise ValidationError({
|
||||
'default': _(
|
||||
'Invalid default value "{default}": {message}'
|
||||
).format(default=self.default, message=self.message)
|
||||
).format(default=self.default, message=err.message)
|
||||
})
|
||||
|
||||
# Minimum/maximum values can be set only for numeric fields
|
||||
@@ -315,14 +323,6 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
'choice_set': _("Choices may be set only on selection fields.")
|
||||
})
|
||||
|
||||
# A selection field's default (if any) must be present in its available choices
|
||||
if self.type == CustomFieldTypeChoices.TYPE_SELECT and self.default and self.default not in self.choices:
|
||||
raise ValidationError({
|
||||
'default': _(
|
||||
"The specified default value ({default}) is not listed as an available choice."
|
||||
).format(default=self.default)
|
||||
})
|
||||
|
||||
# Object fields must define an object_type; other fields must not
|
||||
if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT):
|
||||
if not self.object_type:
|
||||
@@ -439,18 +439,25 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
if set_initial and default_choice:
|
||||
initial = default_choice
|
||||
|
||||
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||
field_class = CSVChoiceField if for_csv_import else DynamicChoiceField
|
||||
widget_class = APISelect
|
||||
if for_csv_import:
|
||||
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||
field_class = CSVChoiceField
|
||||
else:
|
||||
field_class = CSVMultipleChoiceField
|
||||
field = field_class(choices=choices, required=required, initial=initial)
|
||||
else:
|
||||
field_class = CSVMultipleChoiceField if for_csv_import else DynamicMultipleChoiceField
|
||||
widget_class = APISelectMultiple
|
||||
field = field_class(
|
||||
choices=choices,
|
||||
required=required,
|
||||
initial=initial,
|
||||
widget=widget_class(api_url=f'/api/extras/custom-field-choices/{self.choice_set.pk}/choices/')
|
||||
)
|
||||
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||
field_class = DynamicChoiceField
|
||||
widget_class = APISelect
|
||||
else:
|
||||
field_class = DynamicMultipleChoiceField
|
||||
widget_class = APISelectMultiple
|
||||
field = field_class(
|
||||
choices=choices,
|
||||
required=required,
|
||||
initial=initial,
|
||||
widget=widget_class(api_url=f'/api/extras/custom-field-choice-sets/{self.choice_set.pk}/choices/')
|
||||
)
|
||||
|
||||
# URL
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_URL:
|
||||
@@ -497,7 +504,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
field.model = self
|
||||
field.label = str(self)
|
||||
if self.description:
|
||||
field.help_text = escape(self.description)
|
||||
field.help_text = render_markdown(self.description)
|
||||
|
||||
# Annotate read-only fields
|
||||
if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
|
||||
@@ -641,19 +648,22 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
|
||||
# Validate selected choice
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||
if value not in [c[0] for c in self.choices]:
|
||||
if value not in self.choice_set.values:
|
||||
raise ValidationError(
|
||||
_("Invalid choice ({value}). Available choices are: {choices}").format(
|
||||
value=value, choices=', '.join(self.choices)
|
||||
_("Invalid choice ({value}) for choice set {choiceset}.").format(
|
||||
value=value,
|
||||
choiceset=self.choice_set
|
||||
)
|
||||
)
|
||||
|
||||
# Validate all selected choices
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
|
||||
if not set(value).issubset([c[0] for c in self.choices]):
|
||||
if not set(value).issubset(self.choice_set.values):
|
||||
raise ValidationError(
|
||||
_("Invalid choice(s) ({invalid_choices}). Available choices are: {available_choices}").format(
|
||||
invalid_choices=', '.join(value), available_choices=', '.join(self.choices))
|
||||
_("Invalid choice(s) ({value}) for choice set {choiceset}.").format(
|
||||
value=value,
|
||||
choiceset=self.choice_set
|
||||
)
|
||||
)
|
||||
|
||||
# Validate selected object
|
||||
@@ -710,6 +720,8 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('custom field choice set')
|
||||
verbose_name_plural = _('custom field choice sets')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -736,6 +748,13 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel
|
||||
def choices_count(self):
|
||||
return len(self.choices)
|
||||
|
||||
@property
|
||||
def values(self):
|
||||
"""
|
||||
Returns an iterator of the valid choice values.
|
||||
"""
|
||||
return (x[0] for x in self.choices)
|
||||
|
||||
def clean(self):
|
||||
if not self.base_choices and not self.extra_choices:
|
||||
raise ValidationError(_("Must define base or extra choices."))
|
||||
|
||||
@@ -25,7 +25,8 @@ class Dashboard(models.Model):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
pass
|
||||
verbose_name = _('dashboard')
|
||||
verbose_name_plural = _('dashboards')
|
||||
|
||||
def get_widget(self, id):
|
||||
"""
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import json
|
||||
import urllib.parse
|
||||
|
||||
from django.contrib import admin
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
@@ -12,7 +11,7 @@ from django.http import HttpResponse
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from rest_framework.utils.encoders import JSONEncoder
|
||||
|
||||
from extras.choices import *
|
||||
@@ -165,6 +164,8 @@ class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedMo
|
||||
name='%(app_label)s_%(class)s_unique_payload_url_types'
|
||||
),
|
||||
)
|
||||
verbose_name = _('webhook')
|
||||
verbose_name_plural = _('webhooks')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -284,6 +285,8 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ['group_name', 'weight', 'name']
|
||||
verbose_name = _('custom link')
|
||||
verbose_name_plural = _('custom links')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -312,7 +315,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
text = clean_html(text, allowed_schemes)
|
||||
|
||||
# Sanitize link
|
||||
link = urllib.parse.quote(link, safe='/:?&=%+[]@#,')
|
||||
link = urllib.parse.quote(link, safe='/:?&=%+[]@#,;')
|
||||
|
||||
# Verify link scheme is allowed
|
||||
result = urllib.parse.urlparse(link)
|
||||
@@ -371,6 +374,8 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('export template')
|
||||
verbose_name_plural = _('export templates')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -482,6 +487,8 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ('weight', 'name')
|
||||
verbose_name = _('saved filter')
|
||||
verbose_name_plural = _('saved filters')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -544,6 +551,8 @@ class ImageAttachment(ChangeLoggedModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ('name', 'pk') # name may be non-unique
|
||||
verbose_name = _('image attachment')
|
||||
verbose_name_plural = _('image attachments')
|
||||
|
||||
def __str__(self):
|
||||
if self.name:
|
||||
@@ -622,7 +631,8 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat
|
||||
|
||||
class Meta:
|
||||
ordering = ('-created',)
|
||||
verbose_name_plural = 'journal entries'
|
||||
verbose_name = _('journal entry')
|
||||
verbose_name_plural = _('journal entries')
|
||||
|
||||
def __str__(self):
|
||||
created = timezone.localtime(self.created)
|
||||
@@ -677,6 +687,8 @@ class Bookmark(models.Model):
|
||||
name='%(app_label)s_%(class)s_unique_per_object_and_user'
|
||||
),
|
||||
)
|
||||
verbose_name = _('bookmark')
|
||||
verbose_name_plural = _('bookmarks')
|
||||
|
||||
def __str__(self):
|
||||
if self.object:
|
||||
@@ -707,9 +719,15 @@ class ConfigRevision(models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created']
|
||||
verbose_name = _('config revision')
|
||||
verbose_name_plural = _('config revisions')
|
||||
|
||||
def __str__(self):
|
||||
return f'Config revision #{self.pk} ({self.created})'
|
||||
if not self.pk:
|
||||
return gettext('Default configuration')
|
||||
if self.is_active:
|
||||
return gettext('Current configuration')
|
||||
return gettext('Config revision #{id}').format(id=self.pk)
|
||||
|
||||
def __getattr__(self, item):
|
||||
if item in self.data:
|
||||
@@ -717,6 +735,8 @@ class ConfigRevision(models.Model):
|
||||
return super().__getattribute__(item)
|
||||
|
||||
def get_absolute_url(self):
|
||||
if not self.pk:
|
||||
return reverse('core:config') # Default config view
|
||||
return reverse('extras:configrevision', args=[self.pk])
|
||||
|
||||
def activate(self):
|
||||
@@ -727,6 +747,6 @@ class ConfigRevision(models.Model):
|
||||
cache.set('config_version', self.pk, None)
|
||||
activate.alters_data = True
|
||||
|
||||
@admin.display(boolean=True)
|
||||
@property
|
||||
def is_active(self):
|
||||
return cache.get('config_version') == self.pk
|
||||
|
||||
@@ -4,6 +4,7 @@ from functools import cached_property
|
||||
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.choices import ManagedFileRootPathChoices
|
||||
from core.models import ManagedFile
|
||||
@@ -42,6 +43,8 @@ class ReportModule(PythonModuleMixin, JobsMixin, ManagedFile):
|
||||
|
||||
class Meta:
|
||||
proxy = True
|
||||
verbose_name = _('report module')
|
||||
verbose_name_plural = _('report modules')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('extras:report_list')
|
||||
|
||||
@@ -4,6 +4,7 @@ from functools import cached_property
|
||||
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.choices import ManagedFileRootPathChoices
|
||||
from core.models import ManagedFile
|
||||
@@ -42,6 +43,8 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
|
||||
|
||||
class Meta:
|
||||
proxy = True
|
||||
verbose_name = _('script module')
|
||||
verbose_name_plural = _('script modules')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('extras:script_list')
|
||||
|
||||
@@ -51,6 +51,8 @@ class CachedValue(models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ('weight', 'object_type', 'object_id')
|
||||
verbose_name = _('cached value')
|
||||
verbose_name_plural = _('cached values')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.object_type} {self.object_id}: {self.field}={self.value}'
|
||||
|
||||
@@ -41,6 +41,8 @@ class Branch(ChangeLoggedModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = _('branch')
|
||||
verbose_name_plural = _('branches')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name} ({self.pk})'
|
||||
@@ -89,6 +91,8 @@ class StagedChange(ChangeLoggedModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ('pk',)
|
||||
verbose_name = _('staged change')
|
||||
verbose_name_plural = _('staged changes')
|
||||
|
||||
def __str__(self):
|
||||
action = self.get_action_display()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user