mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-13 13:53:31 +01:00
Compare commits
156 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f5005efd5 | ||
|
|
e61795d5c6 | ||
|
|
892c10b1f0 | ||
|
|
ea107b6b86 | ||
|
|
b9b9c065cc | ||
|
|
b583770765 | ||
|
|
37d6f6abca | ||
|
|
be3f48c677 | ||
|
|
5de9d3f15f | ||
|
|
40afe6cf36 | ||
|
|
9fd07b594c | ||
|
|
dc7411e4c5 | ||
|
|
72e1e8fab1 | ||
|
|
dcdb4d27ec | ||
|
|
9b1406a1a7 | ||
|
|
545769ad88 | ||
|
|
f5a1f83f9f | ||
|
|
f9648d8544 | ||
|
|
2236b86c35 | ||
|
|
0dd319d0c8 | ||
|
|
88562d7dcf | ||
|
|
01bb09db67 | ||
|
|
43ce453938 | ||
|
|
7f22c6bf12 | ||
|
|
93a862cded | ||
|
|
9cc295827b | ||
|
|
a807cca29e | ||
|
|
57860f26b7 | ||
|
|
ab916a1819 | ||
|
|
a68831d3a1 | ||
|
|
a4c9cbc6dd | ||
|
|
0b10131564 | ||
|
|
006c353d46 | ||
|
|
6c53ca8909 | ||
|
|
4f984c0831 | ||
|
|
d9dc6cec3a | ||
|
|
90146941b5 | ||
|
|
9d0457fe1a | ||
|
|
2aa51d0d94 | ||
|
|
7158360dfa | ||
|
|
c89193d331 | ||
|
|
eeb069048f | ||
|
|
3e12fbe367 | ||
|
|
4b2922312a | ||
|
|
0276f29067 | ||
|
|
1d52627f71 | ||
|
|
bba4fe437c | ||
|
|
0ab3f979e0 | ||
|
|
5a3d46ac8d | ||
|
|
d075e7a66a | ||
|
|
8b8adfbbbb | ||
|
|
0f0cf683c4 | ||
|
|
ec0dbe33d3 | ||
|
|
1c30a44b4e | ||
|
|
252cc37f97 | ||
|
|
f6fcf776a4 | ||
|
|
73348ee435 | ||
|
|
cab7b76220 | ||
|
|
bc7678c716 | ||
|
|
63c33ff4be | ||
|
|
da239aea13 | ||
|
|
53a75a3dd7 | ||
|
|
74fb707ad3 | ||
|
|
ecb4a084cc | ||
|
|
7419a8e112 | ||
|
|
62bdb90f61 | ||
|
|
8143c6e03b | ||
|
|
ffe4558ec5 | ||
|
|
16ee42ac38 | ||
|
|
860be780ad | ||
|
|
5f0922713f | ||
|
|
4355ee6407 | ||
|
|
07ae7c8a6e | ||
|
|
63ba9fb38c | ||
|
|
3307bd200c | ||
|
|
f69d99ea67 | ||
|
|
3754e00ee0 | ||
|
|
dd6d9bf6e3 | ||
|
|
183c7deb81 | ||
|
|
0a60a3fd2a | ||
|
|
b13f9d27d9 | ||
|
|
6b01b1df40 | ||
|
|
34d32374a8 | ||
|
|
c99e565426 | ||
|
|
16d5107b71 | ||
|
|
f1858a7c23 | ||
|
|
290ffd408a | ||
|
|
74d9fe1ea2 | ||
|
|
d131d9b310 | ||
|
|
32fe9fe8ec | ||
|
|
882f29192c | ||
|
|
27e850a68d | ||
|
|
c83b2499f0 | ||
|
|
79c8219202 | ||
|
|
49af70a77d | ||
|
|
7f96c7fee7 | ||
|
|
13315f36d4 | ||
|
|
70c2b358ad | ||
|
|
9dab3a0d79 | ||
|
|
54622b5f92 | ||
|
|
cdce500d90 | ||
|
|
e11991c7a4 | ||
|
|
6ef333ea68 | ||
|
|
7fc69f3945 | ||
|
|
8aeb31751a | ||
|
|
0b2162569f | ||
|
|
93175888f0 | ||
|
|
4d686e8162 | ||
|
|
0e873a01b8 | ||
|
|
f7b0e48a09 | ||
|
|
c5f71c0c19 | ||
|
|
36e0bf0490 | ||
|
|
28b939c001 | ||
|
|
55e31ef984 | ||
|
|
85e351146d | ||
|
|
d03bfe89c0 | ||
|
|
c8cbced55e | ||
|
|
928a34674e | ||
|
|
96cf95d176 | ||
|
|
2e9586523f | ||
|
|
a81924ac0f | ||
|
|
74c1f7a176 | ||
|
|
22a0ce3f76 | ||
|
|
43235f143d | ||
|
|
e7851399c6 | ||
|
|
82cd6c5f4c | ||
|
|
210879d380 | ||
|
|
01d9e0afb6 | ||
|
|
4a88d5e3d9 | ||
|
|
9fb52be85c | ||
|
|
46d1d5a44a | ||
|
|
dee4aec62d | ||
|
|
9f70407c7d | ||
|
|
852026bf7b | ||
|
|
e7f689bc52 | ||
|
|
1349a25e34 | ||
|
|
dbd3c6de24 | ||
|
|
3e77daff01 | ||
|
|
bd88ee7063 | ||
|
|
a9b0b49ef9 | ||
|
|
8b051ea2f3 | ||
|
|
bca9d0fa8a | ||
|
|
9b8ab1c1f7 | ||
|
|
b3bd03a1e9 | ||
|
|
18c863e393 | ||
|
|
d7ca453f26 | ||
|
|
9b9a559e0c | ||
|
|
1f71d3570a | ||
|
|
5a5fcf7d37 | ||
|
|
5869894a48 | ||
|
|
e2f9a3c07a | ||
|
|
b64b19a3f4 | ||
|
|
24a51dd86e | ||
|
|
bf1c191b2e | ||
|
|
b31b086a4d | ||
|
|
6160e03426 |
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.2
|
||||
placeholder: v3.5.8
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
11
.github/ISSUE_TEMPLATE/config.yml
vendored
11
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -3,10 +3,13 @@ blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 📖 Contributing Policy
|
||||
url: https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md
|
||||
about: "Please read through our contributing policy before opening an issue or pull request"
|
||||
about: "Please read through our contributing policy before opening an issue or pull request."
|
||||
- name: ❓ Discussion
|
||||
url: https://github.com/netbox-community/netbox/discussions
|
||||
about: "If you're just looking for help, try starting a discussion instead"
|
||||
about: "If you're just looking for help, try starting a discussion instead."
|
||||
- name: 💡 Plugin Idea
|
||||
url: https://plugin-ideas.netbox.dev
|
||||
about: "Have an idea for a plugin? Head over to the ideas board!"
|
||||
- name: 💬 Community Slack
|
||||
url: https://netdev.chat/
|
||||
about: "Join #netbox on the NetDev Community Slack for assistance with installation issues and other problems"
|
||||
url: https://netdev.chat
|
||||
about: "Join #netbox on the NetDev Community Slack for assistance with installation issues and other problems."
|
||||
|
||||
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.2
|
||||
placeholder: v3.5.8
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
17
README.md
17
README.md
@@ -1,7 +1,4 @@
|
||||
<div align="center">
|
||||
<strong>The :ballot_box_with_check: <a href="https://forms.gle/zUHrrPo7K34yKaqC9">2023 NetBox Community Survey</a> is now open!</strong>
|
||||
<p>Please take a few minutes to tell us about your NetBox deployment.</p>
|
||||
|
||||
<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>
|
||||
<img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" />
|
||||
@@ -55,10 +52,10 @@ as the cornerstone for network automation in thousands of organizations.
|
||||
## Project Stats
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/netbox-community/netbox/commits"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_timeline.svg" alt="Timeline graph"></a>
|
||||
<a href="https://github.com/netbox-community/netbox/issues"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_issues.svg" alt="Issues graph"></a>
|
||||
<a href="https://github.com/netbox-community/netbox/pulls"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_prs.svg" alt="Pull requests graph"></a>
|
||||
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_users.svg" alt="Top contributors"></a>
|
||||
<a href="https://github.com/netbox-community/netbox/commits"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_timeline.svg" alt="Timeline graph"></a>
|
||||
<a href="https://github.com/netbox-community/netbox/issues"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_issues.svg" alt="Issues graph"></a>
|
||||
<a href="https://github.com/netbox-community/netbox/pulls"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_prs.svg" alt="Pull requests graph"></a>
|
||||
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_users.svg" alt="Top contributors"></a>
|
||||
<br />Stats via <a href="https://repography.com">Repography</a>
|
||||
</div>
|
||||
|
||||
@@ -69,10 +66,12 @@ as the cornerstone for network automation in thousands of organizations.
|
||||
[](https://netboxlabs.com)
|
||||
|
||||
[](https://try.digitalocean.com/developer-cloud)
|
||||
<br />
|
||||
[](https://sentry.io)
|
||||
|
||||
[](https://sentry.io)
|
||||
<br />
|
||||
[](https://metal.equinix.com)
|
||||
|
||||
[](https://onemindservices.com)
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -84,7 +84,8 @@ feedparser
|
||||
|
||||
# Django wrapper for Graphene (GraphQL support)
|
||||
# https://github.com/graphql-python/graphene-django/releases
|
||||
graphene_django
|
||||
# Pinned to v3.0.0 for GraphiQL UI issue (see #12762)
|
||||
graphene_django==3.0.0
|
||||
|
||||
# WSGI HTTP server
|
||||
# https://docs.gunicorn.org/en/latest/news.html
|
||||
|
||||
561
contrib/generated_schema.json
Normal file
561
contrib/generated_schema.json
Normal file
@@ -0,0 +1,561 @@
|
||||
{
|
||||
"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",
|
||||
"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-qsfpdd",
|
||||
"400gbase-x-osfp",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -38,7 +38,7 @@ An example hierarchy might look like this:
|
||||
* 100.64.16.1/24 (address)
|
||||
* 100.64.16.2/24 (address)
|
||||
* 100.64.16.3/24 (address)
|
||||
* 100.64.16.9/24 (prefix)
|
||||
* 100.64.19.0/24 (prefix)
|
||||
* 100.64.32.0/20 (prefix)
|
||||
* 100.64.32.1/24 (address)
|
||||
* 100.64.32.10-99/24 (range)
|
||||
|
||||
@@ -55,6 +55,9 @@ Within the shell, enter the following commands to create the database and user (
|
||||
CREATE DATABASE netbox;
|
||||
CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K';
|
||||
ALTER DATABASE netbox OWNER TO netbox;
|
||||
-- the next two commands are needed on PostgreSQL 15 and later
|
||||
\connect netbox;
|
||||
GRANT CREATE ON SCHEMA public TO netbox;
|
||||
```
|
||||
|
||||
!!! danger "Use a strong password"
|
||||
|
||||
@@ -48,36 +48,40 @@ Download the [latest stable release](https://github.com/netbox-community/netbox/
|
||||
Download and extract the latest version:
|
||||
|
||||
```no-highlight
|
||||
wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz
|
||||
sudo tar -xzf vX.Y.Z.tar.gz -C /opt
|
||||
sudo ln -sfn /opt/netbox-X.Y.Z/ /opt/netbox
|
||||
# Set $NEWVER to the NetBox version being installed
|
||||
NEWVER=3.5.0
|
||||
wget https://github.com/netbox-community/netbox/archive/v$NEWVER.tar.gz
|
||||
sudo tar -xzf v$NEWVER.tar.gz -C /opt
|
||||
sudo ln -sfn /opt/netbox-$NEWVER/ /opt/netbox
|
||||
```
|
||||
|
||||
Copy `local_requirements.txt`, `configuration.py`, and `ldap_config.py` (if present) from the current installation to the new version:
|
||||
|
||||
```no-highlight
|
||||
sudo cp /opt/netbox-X.Y.Z/local_requirements.txt /opt/netbox/
|
||||
sudo cp /opt/netbox-X.Y.Z/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/
|
||||
sudo cp /opt/netbox-X.Y.Z/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/
|
||||
# Set $OLDVER to the NetBox version currently installed
|
||||
NEWVER=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/
|
||||
```
|
||||
|
||||
Be sure to replicate your uploaded media as well. (The exact action necessary will depend on where you choose to store your media, but in general moving or copying the media directory will suffice.)
|
||||
|
||||
```no-highlight
|
||||
sudo cp -pr /opt/netbox-X.Y.Z/netbox/media/ /opt/netbox/netbox/
|
||||
sudo cp -pr /opt/netbox-$OLDVER/netbox/media/ /opt/netbox/netbox/
|
||||
```
|
||||
|
||||
Also make sure to copy or link any custom scripts and reports that you've made. Note that if these are stored outside the project root, you will not need to copy them. (Check the `SCRIPTS_ROOT` and `REPORTS_ROOT` parameters in the configuration file above if you're unsure.)
|
||||
|
||||
```no-highlight
|
||||
sudo cp -r /opt/netbox-X.Y.Z/netbox/scripts /opt/netbox/netbox/
|
||||
sudo cp -r /opt/netbox-X.Y.Z/netbox/reports /opt/netbox/netbox/
|
||||
sudo cp -r /opt/netbox-$OLDVER/netbox/scripts /opt/netbox/netbox/
|
||||
sudo cp -r /opt/netbox-$OLDVER/netbox/reports /opt/netbox/netbox/
|
||||
```
|
||||
|
||||
If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well:
|
||||
|
||||
```no-highlight
|
||||
sudo cp /opt/netbox-X.Y.Z/gunicorn.py /opt/netbox/
|
||||
sudo cp /opt/netbox-$OLDVER/gunicorn.py /opt/netbox/
|
||||
```
|
||||
|
||||
### Option B: Clone the Git Repository
|
||||
|
||||
@@ -68,11 +68,12 @@ Defines how filters are evaluated against custom field values.
|
||||
|
||||
Controls how and whether the custom field is displayed within the NetBox user interface.
|
||||
|
||||
| Option | Description |
|
||||
|------------|--------------------------------------|
|
||||
| Read/write | Display and permit editing (default) |
|
||||
| Read-only | Display field but disallow editing |
|
||||
| Hidden | Do not display field in the UI |
|
||||
| Option | Description |
|
||||
|-------------------|--------------------------------------------------|
|
||||
| Read/write | Display and permit editing (default) |
|
||||
| Read-only | Display field but disallow editing |
|
||||
| Hidden | Do not display field in the UI |
|
||||
| Hidden (if unset) | Display in the UI only when a value has been set |
|
||||
|
||||
### Default
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ class MyModel(models.Model):
|
||||
|
||||
Every model includes by default a numeric primary key. This value is generated automatically by the database, and can be referenced as `pk` or `id`.
|
||||
|
||||
!!! note
|
||||
Model names should adhere to [PEP8](https://www.python.org/dev/peps/pep-0008/#class-names) standards and be CapWords (no underscores). Using underscores in model names will result in problems with permissions.
|
||||
|
||||
## Enabling NetBox Features
|
||||
|
||||
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:
|
||||
|
||||
@@ -1,5 +1,153 @@
|
||||
# NetBox v3.5
|
||||
|
||||
## 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
|
||||
|
||||
---
|
||||
|
||||
## v3.5.7 (2023-07-28)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#11803](https://github.com/netbox-community/netbox/issues/11803) - Move non-rack devices list to a separate tab under the rack view
|
||||
* [#12625](https://github.com/netbox-community/netbox/issues/12625) - Mask sensitive parameters when viewing a configured data source
|
||||
* [#13009](https://github.com/netbox-community/netbox/issues/13009) - Add IEC 10609-1 and NBR 14136 power port & outlet types
|
||||
* [#13097](https://github.com/netbox-community/netbox/issues/13097) - Implement a faster initial poll for report & script results
|
||||
* [#13234](https://github.com/netbox-community/netbox/issues/13234) - Add 100GBASE-X-DSFP and 100GBASE-X-SFPDD interface types
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#13051](https://github.com/netbox-community/netbox/issues/13051) - Fix Markdown support for table cell alignment
|
||||
* [#13167](https://github.com/netbox-community/netbox/issues/13167) - Fix missing script results when fetched via REST API
|
||||
* [#13233](https://github.com/netbox-community/netbox/issues/13233) - Remove extraneous VLAN group field from bulk edit form for interfaces
|
||||
* [#13237](https://github.com/netbox-community/netbox/issues/13237) - Permit unauthenticated access to content types REST API endpoint when `LOGIN_REQUIRED` is false
|
||||
* [#13285](https://github.com/netbox-community/netbox/issues/13285) - Fix exception when importing device type missing rack unit height value
|
||||
|
||||
---
|
||||
|
||||
## v3.5.6 (2023-07-10)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#13061](https://github.com/netbox-community/netbox/issues/13061) - Fix display of last result for scripts & reports with a custom name defined
|
||||
* [#13096](https://github.com/netbox-community/netbox/issues/13096) - Hide scheduling fields for all scripts with scheduling disabled
|
||||
* [#13105](https://github.com/netbox-community/netbox/issues/13105) - Fix exception when attempting to allocate next available IP address from prefix marked as utilized
|
||||
* [#13116](https://github.com/netbox-community/netbox/issues/13116) - Catch ProgrammingError exception when starting NetBox without pre-populated content types
|
||||
|
||||
---
|
||||
|
||||
## v3.5.5 (2023-07-06)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#11738](https://github.com/netbox-community/netbox/issues/11738) - Annotate VLAN group utilization
|
||||
* [#12499](https://github.com/netbox-community/netbox/issues/12499) - Add "copy to clipboard" buttons in UI for IP addresses
|
||||
* [#12945](https://github.com/netbox-community/netbox/issues/12945) - Add 100GE QSFP-DD interface type
|
||||
* [#12955](https://github.com/netbox-community/netbox/issues/12955) - Include additional contact details on contact assignments table
|
||||
* [#13065](https://github.com/netbox-community/netbox/issues/13065) - Associate contact assignments with their objects in the change log
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11335](https://github.com/netbox-community/netbox/issues/11335) - Exclude stale content types when retrieving changelog records
|
||||
* [#12533](https://github.com/netbox-community/netbox/issues/12533) - Fix REST API validation of null values for several interface attributes
|
||||
* [#12579](https://github.com/netbox-community/netbox/issues/12579) - Fix exception when clicking "create and add another" to add a cable
|
||||
* [#12617](https://github.com/netbox-community/netbox/issues/12617) - Populate prechange snapshot on parent object when assigning/removing primary IP address
|
||||
* [#12760](https://github.com/netbox-community/netbox/issues/12760) - Avoid rendering partial HTMX responses when restoring browser tabs
|
||||
* [#12842](https://github.com/netbox-community/netbox/issues/12842) - Improve handling of exceptions when loading reports
|
||||
* [#12849](https://github.com/netbox-community/netbox/issues/12849) - Fix LDAP group permissions assignment for API clients
|
||||
* [#12951](https://github.com/netbox-community/netbox/issues/12951) - Display consistent parent information for each termination under cable view
|
||||
* [#12953](https://github.com/netbox-community/netbox/issues/12953) - Fix designation of primary IP addresses during interface assignment
|
||||
* [#12960](https://github.com/netbox-community/netbox/issues/12960) - Fix OpenAPI schema for various choice fields
|
||||
* [#12961](https://github.com/netbox-community/netbox/issues/12961) - Set correct return URL for object contacts tabs
|
||||
* [#12966](https://github.com/netbox-community/netbox/issues/12966) - Avoid catching database exceptions when maintenance mode is disabled
|
||||
* [#12975](https://github.com/netbox-community/netbox/issues/12975) - Correct URL for VirtualDeviceContext API serializer
|
||||
* [#12977](https://github.com/netbox-community/netbox/issues/12977) - Fix URL parameters for object count dashboard widgets
|
||||
* [#12983](https://github.com/netbox-community/netbox/issues/12983) - Avoid erroneously clearing many-to-many assignments during bulk edit
|
||||
* [#12989](https://github.com/netbox-community/netbox/issues/12989) - Fix bulk import of tags for device & module types
|
||||
* [#13011](https://github.com/netbox-community/netbox/issues/13011) - Do not escape commas when rendering custom links
|
||||
* [#13047](https://github.com/netbox-community/netbox/issues/13047) - Correct ASN count under ASN ranges list
|
||||
* [#13056](https://github.com/netbox-community/netbox/issues/13056) - Add `config_template` field to device API serializer
|
||||
* [#13092](https://github.com/netbox-community/netbox/issues/13092) - Allow nullifying power port max & allocated draw values during bulk edit
|
||||
* [#13100](https://github.com/netbox-community/netbox/issues/13100) - Fix ValueError exception when searching for virtual device context for non-numeric values
|
||||
|
||||
---
|
||||
|
||||
## v3.5.4 (2023-06-20)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#12828](https://github.com/netbox-community/netbox/issues/12828) - Define colors for staged change action choices
|
||||
* [#12847](https://github.com/netbox-community/netbox/issues/12847) - Include "add" button on all device & virtual machine component list views
|
||||
* [#12862](https://github.com/netbox-community/netbox/issues/12862) - Add menu navigation button to add wireless links directly
|
||||
* [#12865](https://github.com/netbox-community/netbox/issues/12865) - Add "add" buttons for reports & scripts to navigation menu
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#12474](https://github.com/netbox-community/netbox/issues/12474) - Update cable terminations when assigning a location to a new site
|
||||
* [#12622](https://github.com/netbox-community/netbox/issues/12622) - Permit the assignment of non-site VLANs to prefixes assigned to a site
|
||||
* [#12682](https://github.com/netbox-community/netbox/issues/12682) - Correct OpenAPI schema for connected device API endpoint
|
||||
* [#12687](https://github.com/netbox-community/netbox/issues/12687) - Allow the assignment of all /31 IP addresses to interfaces
|
||||
* [#12818](https://github.com/netbox-community/netbox/issues/12818) - Fix permissions evaluation when queuing a data sync job
|
||||
* [#12822](https://github.com/netbox-community/netbox/issues/12822) - Fix encoding of whitespace in custom link URLs
|
||||
* [#12838](https://github.com/netbox-community/netbox/issues/12838) - Correct rounding of rack power utilization values
|
||||
* [#12845](https://github.com/netbox-community/netbox/issues/12845) - Fix pagination of objects for related IP addresses table
|
||||
* [#12850](https://github.com/netbox-community/netbox/issues/12850) - Fix table configuration modal for the contact assignments list
|
||||
* [#12885](https://github.com/netbox-community/netbox/issues/12885) - Permit mounting of devices in rack unit 100
|
||||
* [#12914](https://github.com/netbox-community/netbox/issues/12914) - Clear stored ordering from user config when cleared by request
|
||||
|
||||
---
|
||||
|
||||
## v3.5.3 (2023-06-02)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#9876](https://github.com/netbox-community/netbox/issues/9876) - Improve support for matching tags in conditional rules
|
||||
* [#12015](https://github.com/netbox-community/netbox/issues/12015) - Add device type & role filters for device components
|
||||
* [#12470](https://github.com/netbox-community/netbox/issues/12470) - Collapse context data by default when viewing a rendered device configuration
|
||||
* [#12562](https://github.com/netbox-community/netbox/issues/12562) - Record client IP address when logging authentication failures
|
||||
* [#12597](https://github.com/netbox-community/netbox/issues/12597) - Add an option to hide custom fields only if unset
|
||||
* [#12599](https://github.com/netbox-community/netbox/issues/12599) - Apply filter parameters to links in object count dashboard widgets
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#7503](https://github.com/netbox-community/netbox/issues/7503) - Improve rack space validation when creating multiple devices via REST API
|
||||
* [#11539](https://github.com/netbox-community/netbox/issues/11539) - Fix exception when applying "empty" filter lookup with invalid value
|
||||
* [#11934](https://github.com/netbox-community/netbox/issues/11934) - Prevent reassignment of an IP address designated as primary for its parent object
|
||||
* [#12538](https://github.com/netbox-community/netbox/issues/12538) - Redirect user to originating view after editing/deleting an image attachment
|
||||
* [#12627](https://github.com/netbox-community/netbox/issues/12627) - Restore hover preview for embedded image attachment tables
|
||||
* [#12694](https://github.com/netbox-community/netbox/issues/12694) - Strip leading & trailing whitespace from custom link URL & text
|
||||
* [#12702](https://github.com/netbox-community/netbox/issues/12702) - Fix sizing of rear port selection widget on front port template creation form
|
||||
* [#12715](https://github.com/netbox-community/netbox/issues/12715) - Use contact assignments table to display the contacts assigned to an object
|
||||
* [#12730](https://github.com/netbox-community/netbox/issues/12730) - Fix extraneous contacts listed in object contact assignments view
|
||||
* [#12742](https://github.com/netbox-community/netbox/issues/12742) - Object counts dashboard widget should support URL-compatible query filters
|
||||
* [#12762](https://github.com/netbox-community/netbox/issues/12762) - Fix GraphiQL UI by reverting graphene-django to earlier version
|
||||
* [#12745](https://github.com/netbox-community/netbox/issues/12745) - Escape display text in API-backed selection widgets
|
||||
* [#12779](https://github.com/netbox-community/netbox/issues/12779) - Correct arithmetic for converting inches to meters
|
||||
|
||||
---
|
||||
|
||||
## v3.5.2 (2023-05-22)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
import re
|
||||
import typing
|
||||
from collections import OrderedDict
|
||||
|
||||
from drf_spectacular.extensions import OpenApiSerializerFieldExtension
|
||||
from drf_spectacular.openapi import AutoSchema
|
||||
@@ -28,14 +29,19 @@ class ChoiceFieldFix(OpenApiSerializerFieldExtension):
|
||||
target_class = 'netbox.api.fields.ChoiceField'
|
||||
|
||||
def map_serializer_field(self, auto_schema, direction):
|
||||
build_cf = build_choice_field(self.target)
|
||||
|
||||
if direction == 'request':
|
||||
return build_choice_field(self.target)
|
||||
return build_cf
|
||||
|
||||
elif direction == "response":
|
||||
value = build_cf
|
||||
label = {**build_basic_type(OpenApiTypes.STR), "enum": list(OrderedDict.fromkeys(self.target.choices.values()))}
|
||||
|
||||
return build_object_type(
|
||||
properties={
|
||||
"value": build_basic_type(OpenApiTypes.STR),
|
||||
"label": build_basic_type(OpenApiTypes.STR),
|
||||
"value": value,
|
||||
"label": label
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ class DataSourceViewSet(NetBoxModelViewSet):
|
||||
"""
|
||||
Enqueue a job to synchronize the DataSource.
|
||||
"""
|
||||
if not request.user.has_perm('extras.sync_datasource'):
|
||||
if not request.user.has_perm('core.sync_datasource'):
|
||||
raise PermissionDenied("Syncing data sources requires the core.sync_datasource permission.")
|
||||
|
||||
datasource = get_object_or_404(DataSource, pk=pk)
|
||||
|
||||
@@ -41,6 +41,7 @@ def register_backend(name):
|
||||
|
||||
class DataBackend:
|
||||
parameters = {}
|
||||
sensitive_parameters = []
|
||||
|
||||
def __init__(self, url, **kwargs):
|
||||
self.url = url
|
||||
@@ -86,6 +87,7 @@ class GitBackend(DataBackend):
|
||||
widget=forms.TextInput(attrs={'class': 'form-control'})
|
||||
)
|
||||
}
|
||||
sensitive_parameters = ['password']
|
||||
|
||||
@contextmanager
|
||||
def fetch(self):
|
||||
@@ -135,6 +137,7 @@ class S3Backend(DataBackend):
|
||||
widget=forms.TextInput(attrs={'class': 'form-control'})
|
||||
),
|
||||
}
|
||||
sensitive_parameters = ['aws_secret_access_key']
|
||||
|
||||
REGION_REGEX = r's3\.([a-z0-9-]+)\.amazonaws\.com'
|
||||
|
||||
|
||||
@@ -698,7 +698,8 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
|
||||
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
|
||||
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
|
||||
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
|
||||
'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
|
||||
'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'config_template',
|
||||
'created', 'last_updated',
|
||||
]
|
||||
|
||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||
@@ -707,7 +708,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
|
||||
|
||||
|
||||
class VirtualDeviceContextSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True, default=None)
|
||||
primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True)
|
||||
@@ -880,12 +881,12 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
|
||||
parent = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||
bridge = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||
lag = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||
mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True, allow_null=True)
|
||||
mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True)
|
||||
duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True, allow_null=True)
|
||||
rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True, allow_null=True)
|
||||
rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True, allow_null=True)
|
||||
poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True, allow_null=True)
|
||||
poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True, allow_null=True)
|
||||
rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True)
|
||||
rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True)
|
||||
poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True)
|
||||
poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True)
|
||||
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
|
||||
tagged_vlans = SerializedPKRelatedField(
|
||||
queryset=VLAN.objects.all(),
|
||||
@@ -907,9 +908,10 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
|
||||
mac_address = serializers.CharField(
|
||||
required=False,
|
||||
default=None,
|
||||
allow_blank=True,
|
||||
allow_null=True
|
||||
)
|
||||
wwn = serializers.CharField(required=False, default=None)
|
||||
wwn = serializers.CharField(required=False, default=None, allow_blank=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.status import HTTP_400_BAD_REQUEST
|
||||
from rest_framework.routers import APIRootView
|
||||
from rest_framework.status import HTTP_400_BAD_REQUEST
|
||||
from rest_framework.viewsets import ViewSet
|
||||
|
||||
from circuits.models import Circuit
|
||||
@@ -14,7 +14,6 @@ from dcim import filtersets
|
||||
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
|
||||
from dcim.models import *
|
||||
from dcim.svg import CableTraceSVG
|
||||
from extras.api.nested_serializers import NestedConfigTemplateSerializer
|
||||
from extras.api.mixins import ConfigContextQuerySetMixin, ConfigTemplateRenderMixin
|
||||
from ipam.models import Prefix, VLAN
|
||||
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
@@ -22,6 +21,7 @@ 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.mixins import SequentialBulkCreatesMixin
|
||||
from netbox.constants import NESTED_SERIALIZER_PREFIX
|
||||
from utilities.api import get_serializer_for_model
|
||||
from utilities.utils import count_related
|
||||
@@ -386,7 +386,12 @@ class PlatformViewSet(NetBoxModelViewSet):
|
||||
# Devices/modules
|
||||
#
|
||||
|
||||
class DeviceViewSet(ConfigContextQuerySetMixin, ConfigTemplateRenderMixin, NetBoxModelViewSet):
|
||||
class DeviceViewSet(
|
||||
SequentialBulkCreatesMixin,
|
||||
ConfigContextQuerySetMixin,
|
||||
ConfigTemplateRenderMixin,
|
||||
NetBoxModelViewSet
|
||||
):
|
||||
queryset = Device.objects.prefetch_related(
|
||||
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay',
|
||||
'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'config_template', 'tags',
|
||||
@@ -641,7 +646,10 @@ class ConnectedDeviceViewSet(ViewSet):
|
||||
def get_view_name(self):
|
||||
return "Connected Device Locator"
|
||||
|
||||
@extend_schema(responses={200: OpenApiTypes.OBJECT})
|
||||
@extend_schema(
|
||||
parameters=[_device_param, _interface_param],
|
||||
responses={200: serializers.DeviceSerializer}
|
||||
)
|
||||
def list(self, request):
|
||||
|
||||
peer_device_name = request.query_params.get(self._device_param.name)
|
||||
|
||||
@@ -318,6 +318,10 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
TYPE_IEC_3PNE4H = 'iec-60309-3p-n-e-4h'
|
||||
TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h'
|
||||
TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h'
|
||||
# IEC 60906-1
|
||||
TYPE_IEC_60906_1 = 'iec-60906-1'
|
||||
TYPE_NBR_14136_10A = 'nbr-14136-10a'
|
||||
TYPE_NBR_14136_20A = 'nbr-14136-20a'
|
||||
# NEMA non-locking
|
||||
TYPE_NEMA_115P = 'nema-1-15p'
|
||||
TYPE_NEMA_515P = 'nema-5-15p'
|
||||
@@ -429,6 +433,11 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
(TYPE_IEC_3PNE6H, '3P+N+E 6H'),
|
||||
(TYPE_IEC_3PNE9H, '3P+N+E 9H'),
|
||||
)),
|
||||
('IEC 60906-1', (
|
||||
(TYPE_IEC_60906_1, 'IEC 60906-1'),
|
||||
(TYPE_NBR_14136_10A, '2P+T 10A (NBR 14136)'),
|
||||
(TYPE_NBR_14136_20A, '2P+T 20A (NBR 14136)'),
|
||||
)),
|
||||
('NEMA (Non-locking)', (
|
||||
(TYPE_NEMA_115P, 'NEMA 1-15P'),
|
||||
(TYPE_NEMA_515P, 'NEMA 5-15P'),
|
||||
@@ -553,6 +562,10 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
TYPE_IEC_3PNE4H = 'iec-60309-3p-n-e-4h'
|
||||
TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h'
|
||||
TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h'
|
||||
# IEC 60906-1
|
||||
TYPE_IEC_60906_1 = 'iec-60906-1'
|
||||
TYPE_NBR_14136_10A = 'nbr-14136-10a'
|
||||
TYPE_NBR_14136_20A = 'nbr-14136-20a'
|
||||
# NEMA non-locking
|
||||
TYPE_NEMA_115R = 'nema-1-15r'
|
||||
TYPE_NEMA_515R = 'nema-5-15r'
|
||||
@@ -657,6 +670,11 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
(TYPE_IEC_3PNE6H, '3P+N+E 6H'),
|
||||
(TYPE_IEC_3PNE9H, '3P+N+E 9H'),
|
||||
)),
|
||||
('IEC 60906-1', (
|
||||
(TYPE_IEC_60906_1, 'IEC 60906-1'),
|
||||
(TYPE_NBR_14136_10A, '2P+T 10A (NBR 14136)'),
|
||||
(TYPE_NBR_14136_20A, '2P+T 20A (NBR 14136)'),
|
||||
)),
|
||||
('NEMA (Non-locking)', (
|
||||
(TYPE_NEMA_115R, 'NEMA 1-15R'),
|
||||
(TYPE_NEMA_515R, 'NEMA 5-15R'),
|
||||
@@ -809,10 +827,14 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_100GE_CFP4 = '100gbase-x-cfp4'
|
||||
TYPE_100GE_CXP = '100gbase-x-cxp'
|
||||
TYPE_100GE_CPAK = '100gbase-x-cpak'
|
||||
TYPE_100GE_DSFP = '100gbase-x-dsfp'
|
||||
TYPE_100GE_SFP_DD = '100gbase-x-sfpdd'
|
||||
TYPE_100GE_QSFP28 = '100gbase-x-qsfp28'
|
||||
TYPE_100GE_QSFP_DD = '100gbase-x-qsfpdd'
|
||||
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_QSFP_DD = '400gbase-x-qsfpdd'
|
||||
TYPE_400GE_OSFP = '400gbase-x-osfp'
|
||||
TYPE_400GE_CDFP = '400gbase-x-cdfp'
|
||||
@@ -955,10 +977,14 @@ 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)'),
|
||||
(TYPE_100GE_DSFP, 'DSFP (100GE)'),
|
||||
(TYPE_100GE_SFP_DD, 'SFP-DD (100GE)'),
|
||||
(TYPE_100GE_QSFP28, 'QSFP28 (100GE)'),
|
||||
(TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'),
|
||||
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
|
||||
(TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
|
||||
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
|
||||
@@ -1115,6 +1141,8 @@ class InterfaceSpeedChoices(ChoiceSet):
|
||||
(25000000, '25 Gbps'),
|
||||
(40000000, '40 Gbps'),
|
||||
(100000000, '100 Gbps'),
|
||||
(200000000, '200 Gbps'),
|
||||
(400000000, '400 Gbps'),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ DEVICETYPE_IMAGE_FORMATS = 'image/bmp,image/gif,image/jpeg,image/png,image/tiff,
|
||||
#
|
||||
|
||||
RACK_U_HEIGHT_DEFAULT = 42
|
||||
RACK_U_HEIGHT_MAX = 100
|
||||
|
||||
RACK_ELEVATION_BORDER_WIDTH = 2
|
||||
RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30
|
||||
|
||||
@@ -1077,10 +1077,13 @@ class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(identifier=value.strip())
|
||||
).distinct()
|
||||
|
||||
qs_filter = Q(name__icontains=value)
|
||||
try:
|
||||
qs_filter |= Q(identifier=int(value))
|
||||
except ValueError:
|
||||
pass
|
||||
return queryset.filter(qs_filter).distinct()
|
||||
|
||||
def _has_primary_ip(self, queryset, name, value):
|
||||
params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False)
|
||||
@@ -1219,6 +1222,28 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
|
||||
to_field_name='name',
|
||||
label=_('Device (name)'),
|
||||
)
|
||||
device_type_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__device_type',
|
||||
queryset=DeviceType.objects.all(),
|
||||
label=_('Device type (ID)'),
|
||||
)
|
||||
device_type = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__device_type__model',
|
||||
queryset=DeviceType.objects.all(),
|
||||
to_field_name='model',
|
||||
label=_('Device type (model)'),
|
||||
)
|
||||
device_role_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__device_role',
|
||||
queryset=DeviceRole.objects.all(),
|
||||
label=_('Device role (ID)'),
|
||||
)
|
||||
device_role = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__device_role__slug',
|
||||
queryset=DeviceRole.objects.all(),
|
||||
to_field_name='slug',
|
||||
label=_('Device role (slug)'),
|
||||
)
|
||||
virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__virtual_chassis',
|
||||
queryset=VirtualChassis.objects.all(),
|
||||
|
||||
@@ -1106,7 +1106,7 @@ class PowerPortBulkEditForm(
|
||||
(None, ('module', 'type', 'label', 'description', 'mark_connected')),
|
||||
('Power', ('maximum_draw', 'allocated_draw')),
|
||||
)
|
||||
nullable_fields = ('module', 'label', 'description')
|
||||
nullable_fields = ('module', 'label', 'description', 'maximum_draw', 'allocated_draw')
|
||||
|
||||
|
||||
class PowerOutletBulkEditForm(
|
||||
@@ -1259,8 +1259,8 @@ class InterfaceBulkEditForm(
|
||||
)
|
||||
nullable_fields = (
|
||||
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'vdcs', 'mtu', 'description',
|
||||
'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
|
||||
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'wireless_lans'
|
||||
'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',
|
||||
'tagged_vlans', 'vrf', 'wireless_lans'
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -306,7 +306,7 @@ class DeviceTypeImportForm(NetBoxModelImportForm):
|
||||
model = DeviceType
|
||||
fields = [
|
||||
'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
|
||||
'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments',
|
||||
'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments', 'tags',
|
||||
]
|
||||
|
||||
|
||||
@@ -327,7 +327,7 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
|
||||
|
||||
class Meta:
|
||||
model = ModuleType
|
||||
fields = ['manufacturer', 'model', 'part_number', 'description', 'weight', 'weight_unit', 'comments']
|
||||
fields = ['manufacturer', 'model', 'part_number', 'description', 'weight', 'weight_unit', 'comments', 'tags']
|
||||
|
||||
|
||||
class DeviceRoleImportForm(NetBoxModelImportForm):
|
||||
|
||||
@@ -102,13 +102,25 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
|
||||
required=False,
|
||||
label=_('Virtual Chassis')
|
||||
)
|
||||
device_type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DeviceType.objects.all(),
|
||||
required=False,
|
||||
label=_('Device type')
|
||||
)
|
||||
device_role_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DeviceRole.objects.all(),
|
||||
required=False,
|
||||
label=_('Device role')
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site_id',
|
||||
'location_id': '$location_id',
|
||||
'virtual_chassis_id': '$virtual_chassis_id'
|
||||
'virtual_chassis_id': '$virtual_chassis_id',
|
||||
'device_type_id': '$device_type_id',
|
||||
'role_id': '$device_role_id'
|
||||
},
|
||||
label=_('Device')
|
||||
)
|
||||
@@ -1070,7 +1082,8 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
('Attributes', ('name', 'label', 'type', 'speed')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
('Connection', ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@@ -1089,7 +1102,8 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
('Attributes', ('name', 'label', 'type', 'speed')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
('Connection', ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@@ -1108,7 +1122,8 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
('Attributes', ('name', 'label', 'type')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
('Connection', ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@@ -1123,7 +1138,8 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
('Attributes', ('name', 'label', 'type')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
('Connection', ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@@ -1141,8 +1157,8 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
('Addressing', ('vrf_id', 'l2vpn_id', 'mac_address', 'wwn')),
|
||||
('PoE', ('poe_mode', 'poe_type')),
|
||||
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id',
|
||||
'device_id', 'vdc_id')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
|
||||
('Connection', ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
vdc_id = DynamicModelMultipleChoiceField(
|
||||
@@ -1242,7 +1258,8 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
('Attributes', ('name', 'label', 'type', 'color')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
('Cable', ('cabled', 'occupied')),
|
||||
)
|
||||
model = FrontPort
|
||||
@@ -1261,7 +1278,8 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
('Attributes', ('name', 'label', 'type', 'color')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
('Cable', ('cabled', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@@ -1279,7 +1297,8 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
('Attributes', ('name', 'label', 'position')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
position = forms.CharField(
|
||||
@@ -1292,7 +1311,8 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
('Attributes', ('name', 'label')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -1302,7 +1322,8 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
)
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
queryset=InventoryItemRole.objects.all(),
|
||||
|
||||
@@ -1042,6 +1042,9 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
|
||||
queryset=VirtualDeviceContext.objects.all(),
|
||||
required=False,
|
||||
label='Virtual Device Contexts',
|
||||
initial_params={
|
||||
'interfaces': '$parent',
|
||||
},
|
||||
query_params={
|
||||
'device_id': '$device',
|
||||
}
|
||||
|
||||
@@ -52,7 +52,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:
|
||||
@@ -101,6 +104,7 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
|
||||
choices=[],
|
||||
label=_('Rear ports'),
|
||||
help_text=_('Select one rear port assignment for each front port being created.'),
|
||||
widget=forms.SelectMultiple(attrs={'size': 6})
|
||||
)
|
||||
|
||||
# Override fieldsets from FrontPortTemplateForm to omit rear_port_position
|
||||
|
||||
@@ -53,23 +53,23 @@ class LinkPeerType(graphene.Union):
|
||||
|
||||
@classmethod
|
||||
def resolve_type(cls, instance, info):
|
||||
if type(instance) == CircuitTermination:
|
||||
if type(instance) is CircuitTermination:
|
||||
return CircuitTerminationType
|
||||
if type(instance) == ConsolePortType:
|
||||
if type(instance) is ConsolePortType:
|
||||
return ConsolePortType
|
||||
if type(instance) == ConsoleServerPort:
|
||||
if type(instance) is ConsoleServerPort:
|
||||
return ConsoleServerPortType
|
||||
if type(instance) == FrontPort:
|
||||
if type(instance) is FrontPort:
|
||||
return FrontPortType
|
||||
if type(instance) == Interface:
|
||||
if type(instance) is Interface:
|
||||
return InterfaceType
|
||||
if type(instance) == PowerFeed:
|
||||
if type(instance) is PowerFeed:
|
||||
return PowerFeedType
|
||||
if type(instance) == PowerOutlet:
|
||||
if type(instance) is PowerOutlet:
|
||||
return PowerOutletType
|
||||
if type(instance) == PowerPort:
|
||||
if type(instance) is PowerPort:
|
||||
return PowerPortType
|
||||
if type(instance) == RearPort:
|
||||
if type(instance) is RearPort:
|
||||
return RearPortType
|
||||
|
||||
|
||||
@@ -89,23 +89,23 @@ class CableTerminationTerminationType(graphene.Union):
|
||||
|
||||
@classmethod
|
||||
def resolve_type(cls, instance, info):
|
||||
if type(instance) == CircuitTermination:
|
||||
if type(instance) is CircuitTermination:
|
||||
return CircuitTerminationType
|
||||
if type(instance) == ConsolePortType:
|
||||
if type(instance) is ConsolePortType:
|
||||
return ConsolePortType
|
||||
if type(instance) == ConsoleServerPort:
|
||||
if type(instance) is ConsoleServerPort:
|
||||
return ConsoleServerPortType
|
||||
if type(instance) == FrontPort:
|
||||
if type(instance) is FrontPort:
|
||||
return FrontPortType
|
||||
if type(instance) == Interface:
|
||||
if type(instance) is Interface:
|
||||
return InterfaceType
|
||||
if type(instance) == PowerFeed:
|
||||
if type(instance) is PowerFeed:
|
||||
return PowerFeedType
|
||||
if type(instance) == PowerOutlet:
|
||||
if type(instance) is PowerOutlet:
|
||||
return PowerOutletType
|
||||
if type(instance) == PowerPort:
|
||||
if type(instance) is PowerPort:
|
||||
return PowerPortType
|
||||
if type(instance) == RearPort:
|
||||
if type(instance) is RearPort:
|
||||
return RearPortType
|
||||
|
||||
|
||||
@@ -123,19 +123,19 @@ class InventoryItemTemplateComponentType(graphene.Union):
|
||||
|
||||
@classmethod
|
||||
def resolve_type(cls, instance, info):
|
||||
if type(instance) == ConsolePortTemplate:
|
||||
if type(instance) is ConsolePortTemplate:
|
||||
return ConsolePortTemplateType
|
||||
if type(instance) == ConsoleServerPortTemplate:
|
||||
if type(instance) is ConsoleServerPortTemplate:
|
||||
return ConsoleServerPortTemplateType
|
||||
if type(instance) == FrontPortTemplate:
|
||||
if type(instance) is FrontPortTemplate:
|
||||
return FrontPortTemplateType
|
||||
if type(instance) == InterfaceTemplate:
|
||||
if type(instance) is InterfaceTemplate:
|
||||
return InterfaceTemplateType
|
||||
if type(instance) == PowerOutletTemplate:
|
||||
if type(instance) is PowerOutletTemplate:
|
||||
return PowerOutletTemplateType
|
||||
if type(instance) == PowerPortTemplate:
|
||||
if type(instance) is PowerPortTemplate:
|
||||
return PowerPortTemplateType
|
||||
if type(instance) == RearPortTemplate:
|
||||
if type(instance) is RearPortTemplate:
|
||||
return RearPortTemplateType
|
||||
|
||||
|
||||
@@ -153,17 +153,17 @@ class InventoryItemComponentType(graphene.Union):
|
||||
|
||||
@classmethod
|
||||
def resolve_type(cls, instance, info):
|
||||
if type(instance) == ConsolePort:
|
||||
if type(instance) is ConsolePort:
|
||||
return ConsolePortType
|
||||
if type(instance) == ConsoleServerPort:
|
||||
if type(instance) is ConsoleServerPort:
|
||||
return ConsoleServerPortType
|
||||
if type(instance) == FrontPort:
|
||||
if type(instance) is FrontPort:
|
||||
return FrontPortType
|
||||
if type(instance) == Interface:
|
||||
if type(instance) is Interface:
|
||||
return InterfaceType
|
||||
if type(instance) == PowerOutlet:
|
||||
if type(instance) is PowerOutlet:
|
||||
return PowerOutletType
|
||||
if type(instance) == PowerPort:
|
||||
if type(instance) is PowerPort:
|
||||
return PowerPortType
|
||||
if type(instance) == RearPort:
|
||||
if type(instance) is RearPort:
|
||||
return RearPortType
|
||||
|
||||
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)
|
||||
@@ -18,6 +18,6 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='device',
|
||||
name='position',
|
||||
field=models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(99.5)]),
|
||||
field=models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100.5)]),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -232,7 +232,7 @@ class DeviceType(PrimaryModel, WeightMixin):
|
||||
super().clean()
|
||||
|
||||
# U height must be divisible by 0.5
|
||||
if self.u_height % decimal.Decimal(0.5):
|
||||
if decimal.Decimal(self.u_height) % decimal.Decimal(0.5):
|
||||
raise ValidationError({
|
||||
'u_height': "U height must be in increments of 0.5 rack units."
|
||||
})
|
||||
@@ -568,7 +568,7 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
decimal_places=1,
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(99.5)],
|
||||
validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX + 0.5)],
|
||||
verbose_name='Position (U)',
|
||||
help_text=_('The lowest-numbered unit occupied by the device')
|
||||
)
|
||||
|
||||
@@ -126,7 +126,7 @@ class Rack(PrimaryModel, WeightMixin):
|
||||
u_height = models.PositiveSmallIntegerField(
|
||||
default=RACK_U_HEIGHT_DEFAULT,
|
||||
verbose_name='Height (U)',
|
||||
validators=[MinValueValidator(1), MaxValueValidator(100)],
|
||||
validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX)],
|
||||
help_text=_('Height in rack units')
|
||||
)
|
||||
desc_units = models.BooleanField(
|
||||
@@ -466,7 +466,7 @@ class Rack(PrimaryModel, WeightMixin):
|
||||
powerport.get_power_draw()['allocated'] for powerport in powerports
|
||||
])
|
||||
|
||||
return int(allocated_draw / available_power_total * 100)
|
||||
return round(allocated_draw / available_power_total * 100, 1)
|
||||
|
||||
@cached_property
|
||||
def total_weight(self):
|
||||
|
||||
@@ -27,6 +27,7 @@ def handle_location_site_change(instance, created, **kwargs):
|
||||
Rack.objects.filter(location__in=locations).update(site=instance.site)
|
||||
Device.objects.filter(location__in=locations).update(site=instance.site)
|
||||
PowerPanel.objects.filter(location__in=locations).update(site=instance.site)
|
||||
CableTermination.objects.filter(_location__in=locations).update(_site=instance.site)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Rack)
|
||||
|
||||
@@ -545,6 +545,11 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
||||
}
|
||||
)
|
||||
mgmt_only = columns.BooleanColumn()
|
||||
speed_formatted = columns.TemplateColumn(
|
||||
template_code='{% load helpers %}{{ value|humanize_speed }}',
|
||||
accessor=Accessor('speed'),
|
||||
verbose_name='Speed'
|
||||
)
|
||||
wireless_link = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
@@ -568,7 +573,7 @@ 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',
|
||||
|
||||
@@ -1115,7 +1115,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
device_types = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=2),
|
||||
)
|
||||
DeviceType.objects.bulk_create(device_types)
|
||||
|
||||
@@ -1229,6 +1229,39 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_rack_fit(self):
|
||||
"""
|
||||
Check that creating multiple devices with overlapping position fails.
|
||||
"""
|
||||
device = Device.objects.first()
|
||||
device_type = DeviceType.objects.all()[1]
|
||||
data = [
|
||||
{
|
||||
'device_type': device_type.pk,
|
||||
'device_role': device.device_role.pk,
|
||||
'site': device.site.pk,
|
||||
'name': 'Test Device 7',
|
||||
'rack': device.rack.pk,
|
||||
'face': 'front',
|
||||
'position': 1
|
||||
},
|
||||
{
|
||||
'device_type': device_type.pk,
|
||||
'device_role': device.device_role.pk,
|
||||
'site': device.site.pk,
|
||||
'name': 'Test Device 8',
|
||||
'rack': device.rack.pk,
|
||||
'face': 'front',
|
||||
'position': 2
|
||||
}
|
||||
]
|
||||
|
||||
self.add_permissions('dcim.add_device')
|
||||
url = reverse('dcim-api:device-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class ModuleTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Module
|
||||
|
||||
@@ -12,6 +12,23 @@ from virtualization.models import Cluster, ClusterType
|
||||
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
|
||||
|
||||
|
||||
class DeviceComponentFilterSetTests:
|
||||
|
||||
def test_device_type(self):
|
||||
device_types = DeviceType.objects.all()[:2]
|
||||
params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'device_type': [device_types[0].model, device_types[1].model]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_device_role(self):
|
||||
device_role = DeviceRole.objects.all()[:2]
|
||||
params = {'device_role_id': [device_role[0].pk, device_role[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'device_role': [device_role[0].slug, device_role[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class RegionTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = Region.objects.all()
|
||||
filterset = RegionFilterSet
|
||||
@@ -1998,7 +2015,7 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
|
||||
queryset = ConsolePort.objects.all()
|
||||
filterset = ConsolePortFilterSet
|
||||
|
||||
@@ -2027,10 +2044,23 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
|
||||
Site(name='Site X', slug='site-x'),
|
||||
))
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
device_types = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
|
||||
)
|
||||
DeviceType.objects.bulk_create(device_types)
|
||||
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
device_roles = (
|
||||
DeviceRole(name='Device Role 1', slug='device-role-1'),
|
||||
DeviceRole(name='Device Role 2', slug='device-role-2'),
|
||||
DeviceRole(name='Device Role 3', slug='device-role-3'),
|
||||
)
|
||||
DeviceRole.objects.bulk_create(device_roles)
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
@@ -2048,10 +2078,10 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
|
||||
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_types[0], device_role=device_roles[0], site=sites[3]), # For cable connections
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
@@ -2165,7 +2195,7 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
|
||||
class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
|
||||
queryset = ConsoleServerPort.objects.all()
|
||||
filterset = ConsoleServerPortFilterSet
|
||||
|
||||
@@ -2194,10 +2224,23 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
|
||||
Site(name='Site X', slug='site-x'),
|
||||
))
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
device_types = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
|
||||
)
|
||||
DeviceType.objects.bulk_create(device_types)
|
||||
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
device_roles = (
|
||||
DeviceRole(name='Device Role 1', slug='device-role-1'),
|
||||
DeviceRole(name='Device Role 2', slug='device-role-2'),
|
||||
DeviceRole(name='Device Role 3', slug='device-role-3'),
|
||||
)
|
||||
DeviceRole.objects.bulk_create(device_roles)
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
@@ -2215,10 +2258,10 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
|
||||
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
@@ -2332,7 +2375,7 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
|
||||
class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
|
||||
queryset = PowerPort.objects.all()
|
||||
filterset = PowerPortFilterSet
|
||||
|
||||
@@ -2361,10 +2404,23 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
|
||||
Site(name='Site X', slug='site-x'),
|
||||
))
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
device_types = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
|
||||
)
|
||||
DeviceType.objects.bulk_create(device_types)
|
||||
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
device_roles = (
|
||||
DeviceRole(name='Device Role 1', slug='device-role-1'),
|
||||
DeviceRole(name='Device Role 2', slug='device-role-2'),
|
||||
DeviceRole(name='Device Role 3', slug='device-role-3'),
|
||||
)
|
||||
DeviceRole.objects.bulk_create(device_roles)
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
@@ -2382,10 +2438,10 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
|
||||
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
@@ -2507,7 +2563,7 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
|
||||
class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
|
||||
queryset = PowerOutlet.objects.all()
|
||||
filterset = PowerOutletFilterSet
|
||||
|
||||
@@ -2536,10 +2592,23 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
|
||||
Site(name='Site X', slug='site-x'),
|
||||
))
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
device_types = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
|
||||
)
|
||||
DeviceType.objects.bulk_create(device_types)
|
||||
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
device_roles = (
|
||||
DeviceRole(name='Device Role 1', slug='device-role-1'),
|
||||
DeviceRole(name='Device Role 2', slug='device-role-2'),
|
||||
DeviceRole(name='Device Role 3', slug='device-role-3'),
|
||||
)
|
||||
DeviceRole.objects.bulk_create(device_roles)
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
@@ -2557,10 +2626,10 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
|
||||
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
@@ -2678,7 +2747,7 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
|
||||
class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
|
||||
queryset = Interface.objects.all()
|
||||
filterset = InterfaceFilterSet
|
||||
|
||||
@@ -2707,10 +2776,23 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
|
||||
Site(name='Site X', slug='site-x'),
|
||||
))
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
device_types = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
|
||||
)
|
||||
DeviceType.objects.bulk_create(device_types)
|
||||
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
device_roles = (
|
||||
DeviceRole(name='Device Role 1', slug='device-role-1'),
|
||||
DeviceRole(name='Device Role 2', slug='device-role-2'),
|
||||
DeviceRole(name='Device Role 3', slug='device-role-3'),
|
||||
)
|
||||
DeviceRole.objects.bulk_create(device_roles)
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
@@ -2728,10 +2810,10 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
|
||||
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
@@ -3101,7 +3183,7 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
|
||||
class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
|
||||
queryset = FrontPort.objects.all()
|
||||
filterset = FrontPortFilterSet
|
||||
|
||||
@@ -3130,10 +3212,23 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
|
||||
Site(name='Site X', slug='site-x'),
|
||||
))
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
device_types = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
|
||||
)
|
||||
DeviceType.objects.bulk_create(device_types)
|
||||
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
device_roles = (
|
||||
DeviceRole(name='Device Role 1', slug='device-role-1'),
|
||||
DeviceRole(name='Device Role 2', slug='device-role-2'),
|
||||
DeviceRole(name='Device Role 3', slug='device-role-3'),
|
||||
)
|
||||
DeviceRole.objects.bulk_create(device_roles)
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
@@ -3151,10 +3246,10 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
|
||||
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
@@ -3277,7 +3372,7 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
|
||||
queryset = RearPort.objects.all()
|
||||
filterset = RearPortFilterSet
|
||||
|
||||
@@ -3306,10 +3401,23 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
|
||||
Site(name='Site X', slug='site-x'),
|
||||
))
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
device_types = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
|
||||
)
|
||||
DeviceType.objects.bulk_create(device_types)
|
||||
|
||||
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
device_roles = (
|
||||
DeviceRole(name='Device Role 1', slug='device-role-1'),
|
||||
DeviceRole(name='Device Role 2', slug='device-role-2'),
|
||||
DeviceRole(name='Device Role 3', slug='device-role-3'),
|
||||
)
|
||||
DeviceRole.objects.bulk_create(device_roles)
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
@@ -3327,10 +3435,10 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
|
||||
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
@@ -3447,7 +3555,7 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
|
||||
queryset = ModuleBay.objects.all()
|
||||
filterset = ModuleBayFilterSet
|
||||
|
||||
@@ -3476,9 +3584,21 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
|
||||
Site(name='Site X', slug='site-x'),
|
||||
))
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
device_types = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
|
||||
)
|
||||
DeviceType.objects.bulk_create(device_types)
|
||||
|
||||
device_roles = (
|
||||
DeviceRole(name='Device Role 1', slug='device-role-1'),
|
||||
DeviceRole(name='Device Role 2', slug='device-role-2'),
|
||||
DeviceRole(name='Device Role 3', slug='device-role-3'),
|
||||
)
|
||||
DeviceRole.objects.bulk_create(device_roles)
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
@@ -3496,9 +3616,9 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
@@ -3564,7 +3684,7 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
|
||||
queryset = DeviceBay.objects.all()
|
||||
filterset = DeviceBayFilterSet
|
||||
|
||||
@@ -3593,9 +3713,21 @@ class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
|
||||
Site(name='Site X', slug='site-x'),
|
||||
))
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
device_types = (
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
|
||||
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
|
||||
)
|
||||
DeviceType.objects.bulk_create(device_types)
|
||||
|
||||
device_roles = (
|
||||
DeviceRole(name='Device Role 1', slug='device-role-1'),
|
||||
DeviceRole(name='Device Role 2', slug='device-role-2'),
|
||||
DeviceRole(name='Device Role 3', slug='device-role-3'),
|
||||
)
|
||||
DeviceRole.objects.bulk_create(device_roles)
|
||||
|
||||
locations = (
|
||||
Location(name='Location 1', slug='location-1', site=sites[0]),
|
||||
@@ -3613,9 +3745,9 @@ class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
@@ -3694,8 +3826,19 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
Manufacturer.objects.bulk_create(manufacturers)
|
||||
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturers[0], model='Model 1', slug='model-1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
device_types = (
|
||||
DeviceType(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1'),
|
||||
DeviceType(manufacturer=manufacturers[0], model='Device Type 2', slug='device-type-2'),
|
||||
DeviceType(manufacturer=manufacturers[0], model='Device Type 3', slug='device-type-3'),
|
||||
)
|
||||
DeviceType.objects.bulk_create(device_types)
|
||||
|
||||
device_roles = (
|
||||
DeviceRole(name='Device Role 1', slug='device-role-1'),
|
||||
DeviceRole(name='Device Role 2', slug='device-role-2'),
|
||||
DeviceRole(name='Device Role 3', slug='device-role-3'),
|
||||
)
|
||||
DeviceRole.objects.bulk_create(device_roles)
|
||||
|
||||
regions = (
|
||||
Region(name='Region 1', slug='region-1'),
|
||||
@@ -3736,9 +3879,9 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Rack.objects.bulk_create(racks)
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
|
||||
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
|
||||
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
|
||||
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
@@ -3829,6 +3972,20 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'rack': [racks[0].name, racks[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_device_type(self):
|
||||
device_types = DeviceType.objects.all()[:2]
|
||||
params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'device_type': [device_types[0].model, device_types[1].model]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_device_role(self):
|
||||
device_role = DeviceRole.objects.all()[:2]
|
||||
params = {'device_role_id': [device_role[0].pk, device_role[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'device_role': [device_role[0].slug, device_role[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_device(self):
|
||||
devices = Device.objects.all()[:2]
|
||||
params = {'device_id': [devices[0].pk, devices[1].pk]}
|
||||
|
||||
@@ -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):
|
||||
@@ -681,13 +691,6 @@ class RackView(generic.ObjectView):
|
||||
(PowerFeed.objects.restrict(request.user).filter(rack=instance), 'rack_id'),
|
||||
)
|
||||
|
||||
# Get 0U devices located within the rack
|
||||
nonracked_devices = Device.objects.filter(
|
||||
rack=instance,
|
||||
position__isnull=True,
|
||||
parent_bay__isnull=True
|
||||
).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role')
|
||||
|
||||
peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site)
|
||||
|
||||
if instance.location:
|
||||
@@ -704,7 +707,6 @@ class RackView(generic.ObjectView):
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'nonracked_devices': nonracked_devices,
|
||||
'next_rack': next_rack,
|
||||
'prev_rack': prev_rack,
|
||||
'svg_extra': svg_extra,
|
||||
@@ -731,6 +733,26 @@ class RackRackReservationsView(generic.ObjectChildrenView):
|
||||
return parent.reservations.restrict(request.user, 'view')
|
||||
|
||||
|
||||
@register_model_view(Rack, 'nonracked_devices', 'nonracked-devices')
|
||||
class RackNonRackedView(generic.ObjectChildrenView):
|
||||
queryset = Rack.objects.all()
|
||||
child_model = Device
|
||||
table = tables.DeviceTable
|
||||
filterset = filtersets.DeviceFilterSet
|
||||
template_name = 'dcim/rack/non_racked_devices.html'
|
||||
tab = ViewTab(
|
||||
label=_('Non-Racked Devices'),
|
||||
badge=lambda obj: obj.devices.filter(rack=obj, position__isnull=True, parent_bay__isnull=True).count(),
|
||||
weight=500,
|
||||
permission='dcim.view_device',
|
||||
)
|
||||
|
||||
def get_children(self, request, parent):
|
||||
return parent.devices.restrict(request.user, 'view').filter(
|
||||
rack=parent, position__isnull=True, parent_bay__isnull=True
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(Rack, 'edit')
|
||||
class RackEditView(generic.ObjectEditView):
|
||||
queryset = Rack.objects.all()
|
||||
@@ -1985,6 +2007,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.modulebays.count(),
|
||||
@@ -2000,6 +2023,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.devicebays.count(),
|
||||
@@ -2011,6 +2035,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
|
||||
@@ -2193,7 +2218,6 @@ class ConsolePortListView(generic.ObjectListView):
|
||||
filterset = filtersets.ConsolePortFilterSet
|
||||
filterset_form = forms.ConsolePortFilterForm
|
||||
table = tables.ConsolePortTable
|
||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
||||
|
||||
|
||||
@register_model_view(ConsolePort)
|
||||
@@ -2257,7 +2281,6 @@ class ConsoleServerPortListView(generic.ObjectListView):
|
||||
filterset = filtersets.ConsoleServerPortFilterSet
|
||||
filterset_form = forms.ConsoleServerPortFilterForm
|
||||
table = tables.ConsoleServerPortTable
|
||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
||||
|
||||
|
||||
@register_model_view(ConsoleServerPort)
|
||||
@@ -2321,7 +2344,6 @@ class PowerPortListView(generic.ObjectListView):
|
||||
filterset = filtersets.PowerPortFilterSet
|
||||
filterset_form = forms.PowerPortFilterForm
|
||||
table = tables.PowerPortTable
|
||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
||||
|
||||
|
||||
@register_model_view(PowerPort)
|
||||
@@ -2385,7 +2407,6 @@ class PowerOutletListView(generic.ObjectListView):
|
||||
filterset = filtersets.PowerOutletFilterSet
|
||||
filterset_form = forms.PowerOutletFilterForm
|
||||
table = tables.PowerOutletTable
|
||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
||||
|
||||
|
||||
@register_model_view(PowerOutlet)
|
||||
@@ -2449,7 +2470,6 @@ class InterfaceListView(generic.ObjectListView):
|
||||
filterset = filtersets.InterfaceFilterSet
|
||||
filterset_form = forms.InterfaceFilterForm
|
||||
table = tables.InterfaceTable
|
||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
||||
|
||||
|
||||
@register_model_view(Interface)
|
||||
@@ -2559,7 +2579,6 @@ class FrontPortListView(generic.ObjectListView):
|
||||
filterset = filtersets.FrontPortFilterSet
|
||||
filterset_form = forms.FrontPortFilterForm
|
||||
table = tables.FrontPortTable
|
||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
||||
|
||||
|
||||
@register_model_view(FrontPort)
|
||||
@@ -2623,7 +2642,6 @@ class RearPortListView(generic.ObjectListView):
|
||||
filterset = filtersets.RearPortFilterSet
|
||||
filterset_form = forms.RearPortFilterForm
|
||||
table = tables.RearPortTable
|
||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
||||
|
||||
|
||||
@register_model_view(RearPort)
|
||||
@@ -2687,7 +2705,6 @@ class ModuleBayListView(generic.ObjectListView):
|
||||
filterset = filtersets.ModuleBayFilterSet
|
||||
filterset_form = forms.ModuleBayFilterForm
|
||||
table = tables.ModuleBayTable
|
||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
||||
|
||||
|
||||
@register_model_view(ModuleBay)
|
||||
@@ -2743,7 +2760,6 @@ class DeviceBayListView(generic.ObjectListView):
|
||||
filterset = filtersets.DeviceBayFilterSet
|
||||
filterset_form = forms.DeviceBayFilterForm
|
||||
table = tables.DeviceBayTable
|
||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
||||
|
||||
|
||||
@register_model_view(DeviceBay)
|
||||
@@ -2868,7 +2884,6 @@ class InventoryItemListView(generic.ObjectListView):
|
||||
filterset = filtersets.InventoryItemFilterSet
|
||||
filterset_form = forms.InventoryItemFilterForm
|
||||
table = tables.InventoryItemTable
|
||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
||||
|
||||
|
||||
@register_model_view(InventoryItem)
|
||||
@@ -3141,6 +3156,19 @@ class CableEditView(generic.ObjectEditView):
|
||||
|
||||
return obj
|
||||
|
||||
def get_extra_addanother_params(self, request):
|
||||
|
||||
params = {
|
||||
'a_terminations_type': request.GET.get('a_terminations_type'),
|
||||
'b_terminations_type': request.GET.get('b_terminations_type')
|
||||
}
|
||||
|
||||
for key in request.POST:
|
||||
if 'device' in key or 'power_panel' in key or 'circuit' in key:
|
||||
params.update({key: request.POST.get(key)})
|
||||
|
||||
return params
|
||||
|
||||
|
||||
@register_model_view(Cable, 'delete')
|
||||
class CableDeleteView(generic.ObjectDeleteView):
|
||||
|
||||
@@ -6,7 +6,6 @@ from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.generics import RetrieveUpdateDestroyAPIView
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.routers import APIRootView
|
||||
@@ -303,7 +302,7 @@ class ScriptViewSet(ViewSet):
|
||||
|
||||
# Attach Job objects to each script (if any)
|
||||
for script in script_list:
|
||||
script.result = results.get(script.name, None)
|
||||
script.result = results.get(script.class_name, None)
|
||||
|
||||
serializer = serializers.ScriptSerializer(script_list, many=True, context={'request': request})
|
||||
|
||||
@@ -314,7 +313,7 @@ class ScriptViewSet(ViewSet):
|
||||
object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')
|
||||
script.result = Job.objects.filter(
|
||||
object_type=object_type,
|
||||
name=script.name,
|
||||
name=script.class_name,
|
||||
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).first()
|
||||
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
|
||||
@@ -368,7 +367,7 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
|
||||
Retrieve a list of recent changes.
|
||||
"""
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = ObjectChange.objects.prefetch_related('user')
|
||||
queryset = ObjectChange.objects.valid_models().prefetch_related('user')
|
||||
serializer_class = serializers.ObjectChangeSerializer
|
||||
filterset_class = filtersets.ObjectChangeFilterSet
|
||||
|
||||
@@ -381,7 +380,7 @@ class ContentTypeViewSet(ReadOnlyModelViewSet):
|
||||
"""
|
||||
Read-only list of ContentTypes. Limit results to ContentTypes pertinent to NetBox objects.
|
||||
"""
|
||||
permission_classes = (IsAuthenticated,)
|
||||
permission_classes = [IsAuthenticatedOrLoginNotRequired]
|
||||
queryset = ContentType.objects.order_by('app_label', 'model')
|
||||
serializer_class = serializers.ContentTypeSerializer
|
||||
filterset_class = filtersets.ContentTypeFilterSet
|
||||
|
||||
@@ -56,11 +56,13 @@ class CustomFieldVisibilityChoices(ChoiceSet):
|
||||
VISIBILITY_READ_WRITE = 'read-write'
|
||||
VISIBILITY_READ_ONLY = 'read-only'
|
||||
VISIBILITY_HIDDEN = 'hidden'
|
||||
VISIBILITY_HIDDEN_IFUNSET = 'hidden-ifunset'
|
||||
|
||||
CHOICES = (
|
||||
(VISIBILITY_READ_WRITE, 'Read/Write'),
|
||||
(VISIBILITY_READ_ONLY, 'Read-only'),
|
||||
(VISIBILITY_HIDDEN, 'Hidden'),
|
||||
(VISIBILITY_HIDDEN_IFUNSET, 'Hidden (if unset)'),
|
||||
)
|
||||
|
||||
|
||||
@@ -208,7 +210,7 @@ class ChangeActionChoices(ChoiceSet):
|
||||
ACTION_DELETE = 'delete'
|
||||
|
||||
CHOICES = (
|
||||
(ACTION_CREATE, 'Create'),
|
||||
(ACTION_UPDATE, 'Update'),
|
||||
(ACTION_DELETE, 'Delete'),
|
||||
(ACTION_CREATE, 'Create', 'green'),
|
||||
(ACTION_UPDATE, 'Update', 'blue'),
|
||||
(ACTION_DELETE, 'Delete', 'red'),
|
||||
)
|
||||
|
||||
@@ -65,8 +65,14 @@ class Condition:
|
||||
"""
|
||||
Evaluate the provided data to determine whether it matches the condition.
|
||||
"""
|
||||
def _get(obj, key):
|
||||
if isinstance(obj, list):
|
||||
return [dict.get(i, key) for i in obj]
|
||||
|
||||
return dict.get(obj, key)
|
||||
|
||||
try:
|
||||
value = functools.reduce(dict.get, self.attr.split('.'), data)
|
||||
value = functools.reduce(_get, self.attr.split('.'), data)
|
||||
except TypeError:
|
||||
# Invalid key path
|
||||
value = None
|
||||
|
||||
@@ -11,14 +11,14 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Q
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import NoReverseMatch, reverse
|
||||
from django.urls import NoReverseMatch, resolve, reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from extras.utils import FeatureQuery
|
||||
from utilities.forms import BootstrapMixin
|
||||
from utilities.permissions import get_permission_for_model
|
||||
from utilities.templatetags.builtins.filters import render_markdown
|
||||
from utilities.utils import content_type_identifier, content_type_name, get_viewname
|
||||
from utilities.utils import content_type_identifier, content_type_name, dict_to_querydict, get_viewname
|
||||
from .utils import register_widget
|
||||
|
||||
__all__ = (
|
||||
@@ -149,7 +149,7 @@ class ObjectCountsWidget(DashboardWidget):
|
||||
filters = forms.JSONField(
|
||||
required=False,
|
||||
label='Object filters',
|
||||
help_text=_("Only objects matching the specified filters will be counted")
|
||||
help_text=_("Filters to apply when counting the number of objects")
|
||||
)
|
||||
|
||||
def clean_filters(self):
|
||||
@@ -158,13 +158,6 @@ class ObjectCountsWidget(DashboardWidget):
|
||||
dict(data)
|
||||
except TypeError:
|
||||
raise forms.ValidationError("Invalid format. Object filters must be passed as a dictionary.")
|
||||
for model in get_models_from_content_types(self.cleaned_data.get('models')):
|
||||
try:
|
||||
# Validate the filters by creating a QuerySet
|
||||
model.objects.filter(**data).none()
|
||||
except Exception:
|
||||
model_name = model._meta.verbose_name_plural
|
||||
raise forms.ValidationError(f"Invalid filter specification for {model_name}.")
|
||||
return data
|
||||
|
||||
def render(self, request):
|
||||
@@ -172,13 +165,18 @@ class ObjectCountsWidget(DashboardWidget):
|
||||
for model in get_models_from_content_types(self.config['models']):
|
||||
permission = get_permission_for_model(model, 'view')
|
||||
if request.user.has_perm(permission):
|
||||
url = reverse(get_viewname(model, 'list'))
|
||||
qs = model.objects.restrict(request.user, 'view')
|
||||
# Apply any specified filters
|
||||
if filters := self.config.get('filters'):
|
||||
qs = qs.filter(**filters)
|
||||
params = dict_to_querydict(filters)
|
||||
filterset = getattr(resolve(url).func.view_class, 'filterset', None)
|
||||
qs = filterset(params, qs).qs
|
||||
url = f'{url}?{params.urlencode()}'
|
||||
object_count = qs.count
|
||||
counts.append((model, object_count))
|
||||
counts.append((model, object_count, url))
|
||||
else:
|
||||
counts.append((model, None))
|
||||
counts.append((model, None, None))
|
||||
|
||||
return render_to_string(self.template_name, {
|
||||
'counts': counts,
|
||||
|
||||
@@ -56,10 +56,3 @@ class ScriptForm(BootstrapMixin, forms.Form):
|
||||
self.cleaned_data['_schedule_at'] = local_now()
|
||||
|
||||
return self.cleaned_data
|
||||
|
||||
@property
|
||||
def requires_input(self):
|
||||
"""
|
||||
A boolean indicating whether the form requires user input (ignore the built-in fields).
|
||||
"""
|
||||
return bool(len(self.fields) > 3)
|
||||
|
||||
@@ -7,12 +7,14 @@ class Empty(Lookup):
|
||||
Filter on whether a string is empty.
|
||||
"""
|
||||
lookup_name = 'empty'
|
||||
prepare_rhs = False
|
||||
|
||||
def as_sql(self, qn, connection):
|
||||
lhs, lhs_params = self.process_lhs(qn, connection)
|
||||
rhs, rhs_params = self.process_rhs(qn, connection)
|
||||
params = lhs_params + rhs_params
|
||||
return 'CAST(LENGTH(%s) AS BOOLEAN) != %s' % (lhs, rhs), params
|
||||
def as_sql(self, compiler, connection):
|
||||
sql, params = compiler.compile(self.lhs)
|
||||
if self.rhs:
|
||||
return f"CAST(LENGTH({sql}) AS BOOLEAN) IS NOT TRUE", params
|
||||
else:
|
||||
return f"CAST(LENGTH({sql}) AS BOOLEAN) IS TRUE", params
|
||||
|
||||
|
||||
class NetContainsOrEquals(Lookup):
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
from extras.choices import *
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from ..querysets import ObjectChangeQuerySet
|
||||
|
||||
__all__ = (
|
||||
'ObjectChange',
|
||||
@@ -82,7 +82,7 @@ class ObjectChange(models.Model):
|
||||
null=True
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
objects = ObjectChangeQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['-time']
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.cache import cache
|
||||
from django.core.validators import ValidationError
|
||||
from django.db import models
|
||||
from django.http import HttpResponse, QueryDict
|
||||
from django.http import HttpResponse
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.formats import date_format
|
||||
@@ -26,7 +26,7 @@ from netbox.models.features import (
|
||||
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin,
|
||||
)
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.utils import clean_html, render_jinja2
|
||||
from utilities.utils import clean_html, dict_to_querydict, render_jinja2
|
||||
|
||||
__all__ = (
|
||||
'ConfigRevision',
|
||||
@@ -274,10 +274,10 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
|
||||
:param context: The context passed to Jinja2
|
||||
"""
|
||||
text = render_jinja2(self.link_text, context)
|
||||
text = render_jinja2(self.link_text, context).strip()
|
||||
if not text:
|
||||
return {}
|
||||
link = render_jinja2(self.link_url, context)
|
||||
link = render_jinja2(self.link_url, context).strip()
|
||||
link_target = ' target="_blank"' if self.new_window else ''
|
||||
|
||||
# Sanitize link text
|
||||
@@ -285,7 +285,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
text = clean_html(text, allowed_schemes)
|
||||
|
||||
# Sanitize link
|
||||
link = urllib.parse.quote_plus(link, safe='/:?&=%+[]@#')
|
||||
link = urllib.parse.quote(link, safe='/:?&=%+[]@#,;')
|
||||
|
||||
# Verify link scheme is allowed
|
||||
result = urllib.parse.urlparse(link)
|
||||
@@ -462,8 +462,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
|
||||
@property
|
||||
def url_params(self):
|
||||
qd = QueryDict(mutable=True)
|
||||
qd.update(self.parameters)
|
||||
qd = dict_to_querydict(self.parameters)
|
||||
return qd.urlencode()
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import inspect
|
||||
import logging
|
||||
from functools import cached_property
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
@@ -12,6 +12,8 @@ from netbox.models.features import JobsMixin, WebhooksMixin
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from .mixins import PythonModuleMixin
|
||||
|
||||
logger = logging.getLogger('netbox.reports')
|
||||
|
||||
__all__ = (
|
||||
'Report',
|
||||
'ReportModule',
|
||||
@@ -56,7 +58,8 @@ class ReportModule(PythonModuleMixin, JobsMixin, ManagedFile):
|
||||
|
||||
try:
|
||||
module = self.get_module()
|
||||
except ImportError:
|
||||
except (ImportError, SyntaxError) as e:
|
||||
logger.error(f"Unable to load report module {self.name}, exception: {e}")
|
||||
return {}
|
||||
reports = {}
|
||||
ordered = getattr(module, 'report_order', [])
|
||||
|
||||
@@ -112,3 +112,6 @@ class StagedChange(ChangeLoggedModel):
|
||||
instance = self.model.objects.get(pk=self.object_id)
|
||||
logger.info(f'Deleting {self.model._meta.verbose_name} {instance}')
|
||||
instance.delete()
|
||||
|
||||
def get_action_color(self):
|
||||
return ChangeActionChoices.colors.get(self.action)
|
||||
|
||||
@@ -2,7 +2,6 @@ import collections
|
||||
from importlib import import_module
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.utils.module_loading import import_string
|
||||
from packaging import version
|
||||
@@ -146,23 +145,3 @@ class PluginConfig(AppConfig):
|
||||
for setting, value in cls.default_settings.items():
|
||||
if setting not in user_config:
|
||||
user_config[setting] = value
|
||||
|
||||
|
||||
#
|
||||
# Utilities
|
||||
#
|
||||
|
||||
def get_plugin_config(plugin_name, parameter, default=None):
|
||||
"""
|
||||
Return the value of the specified plugin configuration parameter.
|
||||
|
||||
Args:
|
||||
plugin_name: The name of the plugin
|
||||
parameter: The name of the configuration parameter
|
||||
default: The value to return if the parameter is not defined (default: None)
|
||||
"""
|
||||
try:
|
||||
plugin_config = settings.PLUGINS_CONFIG[plugin_name]
|
||||
return plugin_config.get(parameter, default)
|
||||
except KeyError:
|
||||
raise ImproperlyConfigured(f"Plugin {plugin_name} is not registered.")
|
||||
|
||||
37
netbox/extras/plugins/utils.py
Normal file
37
netbox/extras/plugins/utils.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
__all__ = (
|
||||
'get_installed_plugins',
|
||||
'get_plugin_config',
|
||||
)
|
||||
|
||||
|
||||
def get_installed_plugins():
|
||||
"""
|
||||
Return a dictionary mapping the names of installed plugins to their versions.
|
||||
"""
|
||||
plugins = {}
|
||||
for plugin_name in settings.PLUGINS:
|
||||
plugin_name = plugin_name.rsplit('.', 1)[-1]
|
||||
plugin_config = apps.get_app_config(plugin_name)
|
||||
plugins[plugin_name] = getattr(plugin_config, 'version', None)
|
||||
|
||||
return dict(sorted(plugins.items()))
|
||||
|
||||
|
||||
def get_plugin_config(plugin_name, parameter, default=None):
|
||||
"""
|
||||
Return the value of the specified plugin configuration parameter.
|
||||
|
||||
Args:
|
||||
plugin_name: The name of the plugin
|
||||
parameter: The name of the configuration parameter
|
||||
default: The value to return if the parameter is not defined (default: None)
|
||||
"""
|
||||
try:
|
||||
plugin_config = settings.PLUGINS_CONFIG[plugin_name]
|
||||
return plugin_config.get(parameter, default)
|
||||
except KeyError:
|
||||
raise ImproperlyConfigured(f"Plugin {plugin_name} is not registered.")
|
||||
@@ -1,5 +1,8 @@
|
||||
from django.apps import apps
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.postgres.aggregates import JSONBAgg
|
||||
from django.db.models import OuterRef, Subquery, Q
|
||||
from django.db.utils import ProgrammingError
|
||||
|
||||
from extras.models.tags import TaggedItem
|
||||
from utilities.query_functions import EmptyGroupByJSONBAgg
|
||||
@@ -151,3 +154,20 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
|
||||
)
|
||||
|
||||
return base_query
|
||||
|
||||
|
||||
class ObjectChangeQuerySet(RestrictedQuerySet):
|
||||
|
||||
def valid_models(self):
|
||||
# Exclude any change records which refer to an instance of a model that's no longer installed. This
|
||||
# can happen when a plugin is removed but its data remains in the database, for example.
|
||||
try:
|
||||
content_types = ContentType.objects.get_for_models(*apps.get_models()).values()
|
||||
except ProgrammingError:
|
||||
# Handle the case where the database schema has not yet been initialized
|
||||
content_types = ContentType.objects.none()
|
||||
|
||||
content_type_ids = set(
|
||||
ct.pk for ct in content_types
|
||||
)
|
||||
return self.filter(changed_object_type_id__in=content_type_ids)
|
||||
|
||||
@@ -214,20 +214,18 @@ class Report(object):
|
||||
self.active_test = method_name
|
||||
test_method = getattr(self, method_name)
|
||||
test_method()
|
||||
job.data = self._results
|
||||
if self.failed:
|
||||
self.logger.warning("Report failed")
|
||||
job.status = JobStatusChoices.STATUS_FAILED
|
||||
job.terminate(status=JobStatusChoices.STATUS_FAILED)
|
||||
else:
|
||||
self.logger.info("Report completed successfully")
|
||||
job.status = JobStatusChoices.STATUS_COMPLETED
|
||||
job.terminate()
|
||||
except Exception as e:
|
||||
stacktrace = traceback.format_exc()
|
||||
self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
|
||||
logger.error(f"Exception raised during report execution: {e}")
|
||||
job.terminate(status=JobStatusChoices.STATUS_ERRORED)
|
||||
finally:
|
||||
job.data = self._results
|
||||
job.terminate()
|
||||
|
||||
# Perform any post-run tasks
|
||||
self.post_run()
|
||||
|
||||
@@ -366,7 +366,7 @@ class BaseScript:
|
||||
if self.fieldsets:
|
||||
fieldsets.extend(self.fieldsets)
|
||||
else:
|
||||
fields = (name for name, _ in self._get_vars().items())
|
||||
fields = list(name for name, _ in self._get_vars().items())
|
||||
fieldsets.append(('Script Data', fields))
|
||||
|
||||
# Append the default fieldset if defined in the Meta class
|
||||
@@ -390,6 +390,11 @@ class BaseScript:
|
||||
# Set initial "commit" checkbox state based on the script's Meta parameter
|
||||
form.fields['_commit'].initial = self.commit_default
|
||||
|
||||
# Hide fields if scheduling has been disabled
|
||||
if not self.scheduling_enabled:
|
||||
form.fields['_schedule_at'].widget = forms.HiddenInput()
|
||||
form.fields['_interval'].widget = forms.HiddenInput()
|
||||
|
||||
return form
|
||||
|
||||
# Logging
|
||||
|
||||
@@ -22,6 +22,14 @@ __all__ = (
|
||||
'WebhookTable',
|
||||
)
|
||||
|
||||
IMAGEATTACHMENT_IMAGE = '''
|
||||
{% if record.image %}
|
||||
<a class="image-preview" href="{{ record.image.url }}" target="_blank">{{ record }}</a>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
'''
|
||||
|
||||
|
||||
class CustomFieldTable(NetBoxTable):
|
||||
name = tables.Column(
|
||||
@@ -96,6 +104,9 @@ class ImageAttachmentTable(NetBoxTable):
|
||||
parent = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
image = tables.TemplateColumn(
|
||||
template_code=IMAGEATTACHMENT_IMAGE,
|
||||
)
|
||||
size = tables.Column(
|
||||
orderable=False,
|
||||
verbose_name='Size (bytes)'
|
||||
|
||||
@@ -8,7 +8,6 @@ from rest_framework import status
|
||||
|
||||
from core.choices import ManagedFileRootPathChoices
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
|
||||
from extras.api.views import ReportViewSet, ScriptViewSet
|
||||
from extras.models import *
|
||||
from extras.reports import Report
|
||||
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
|
||||
@@ -579,6 +578,7 @@ class ReportTest(APITestCase):
|
||||
super().setUp()
|
||||
|
||||
# Monkey-patch the API viewset's _get_report() method to return our test Report above
|
||||
from extras.api.views import ReportViewSet
|
||||
ReportViewSet._get_report = self.get_test_report
|
||||
|
||||
def test_get_report(self):
|
||||
@@ -621,6 +621,7 @@ class ScriptTest(APITestCase):
|
||||
super().setUp()
|
||||
|
||||
# Monkey-patch the API viewset's _get_script() method to return our test Script above
|
||||
from extras.api.views import ScriptViewSet
|
||||
ScriptViewSet._get_script = self.get_test_script
|
||||
|
||||
def test_get_script(self):
|
||||
|
||||
@@ -5,8 +5,9 @@ from django.core.exceptions import ImproperlyConfigured
|
||||
from django.test import Client, TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from extras.plugins import PluginMenu, get_plugin_config
|
||||
from extras.plugins import PluginMenu
|
||||
from extras.tests.dummy_plugin import config as dummy_config
|
||||
from extras.plugins.utils import get_plugin_config
|
||||
from netbox.graphql.schema import Query
|
||||
from netbox.registry import registry
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@ class WebhookTest(APITestCase):
|
||||
def setUpTestData(cls):
|
||||
|
||||
site_ct = ContentType.objects.get_for_model(Site)
|
||||
DUMMY_URL = "http://localhost/"
|
||||
DUMMY_SECRET = "LOOKATMEIMASECRETSTRING"
|
||||
DUMMY_URL = 'http://localhost:9000/'
|
||||
DUMMY_SECRET = 'LOOKATMEIMASECRETSTRING'
|
||||
|
||||
webhooks = Webhook.objects.bulk_create((
|
||||
Webhook(name='Webhook 1', type_create=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET, additional_headers='X-Foo: Bar'),
|
||||
@@ -259,7 +259,7 @@ class WebhookTest(APITestCase):
|
||||
name='Conditional Webhook',
|
||||
type_create=True,
|
||||
type_update=True,
|
||||
payload_url='http://localhost/',
|
||||
payload_url='http://localhost:9000/',
|
||||
conditions={
|
||||
'and': [
|
||||
{
|
||||
|
||||
@@ -511,7 +511,7 @@ class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView):
|
||||
#
|
||||
|
||||
class ObjectChangeListView(generic.ObjectListView):
|
||||
queryset = ObjectChange.objects.all()
|
||||
queryset = ObjectChange.objects.valid_models()
|
||||
filterset = filtersets.ObjectChangeFilterSet
|
||||
filterset_form = forms.ObjectChangeFilterForm
|
||||
table = tables.ObjectChangeTable
|
||||
@@ -521,10 +521,10 @@ class ObjectChangeListView(generic.ObjectListView):
|
||||
|
||||
@register_model_view(ObjectChange)
|
||||
class ObjectChangeView(generic.ObjectView):
|
||||
queryset = ObjectChange.objects.all()
|
||||
queryset = ObjectChange.objects.valid_models()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_changes = ObjectChange.objects.restrict(request.user, 'view').filter(
|
||||
related_changes = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
|
||||
request_id=instance.request_id
|
||||
).exclude(
|
||||
pk=instance.pk
|
||||
@@ -534,7 +534,7 @@ class ObjectChangeView(generic.ObjectView):
|
||||
orderable=False
|
||||
)
|
||||
|
||||
objectchanges = ObjectChange.objects.restrict(request.user, 'view').filter(
|
||||
objectchanges = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
|
||||
changed_object_type=instance.changed_object_type,
|
||||
changed_object_id=instance.changed_object_id,
|
||||
)
|
||||
|
||||
@@ -218,12 +218,13 @@ class VLANGroupSerializer(NetBoxModelSerializer):
|
||||
scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
|
||||
scope = serializers.SerializerMethodField(read_only=True)
|
||||
vlan_count = serializers.IntegerField(read_only=True)
|
||||
utilization = serializers.CharField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = VLANGroup
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'max_vid',
|
||||
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count',
|
||||
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization'
|
||||
]
|
||||
validators = []
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.db.models import F
|
||||
from django.db.models.functions import Round
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django_pglocks import advisory_lock
|
||||
from drf_spectacular.utils import extend_schema
|
||||
@@ -145,9 +147,7 @@ class FHRPGroupAssignmentViewSet(NetBoxModelViewSet):
|
||||
|
||||
|
||||
class VLANGroupViewSet(NetBoxModelViewSet):
|
||||
queryset = VLANGroup.objects.annotate(
|
||||
vlan_count=count_related(VLAN, 'group')
|
||||
).prefetch_related('tags')
|
||||
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
|
||||
serializer_class = serializers.VLANGroupSerializer
|
||||
filterset_class = filtersets.VLANGroupFilterSet
|
||||
|
||||
@@ -224,7 +224,10 @@ class AvailableASNsView(ObjectValidationMixin, APIView):
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(methods=["post"], responses={201: serializers.ASNSerializer(many=True)})
|
||||
@extend_schema(methods=["post"],
|
||||
responses={201: serializers.ASNSerializer(many=True)},
|
||||
request=serializers.ASNSerializer(many=True),
|
||||
)
|
||||
@advisory_lock(ADVISORY_LOCK_KEYS['available-asns'])
|
||||
def post(self, request, pk):
|
||||
self.queryset = self.queryset.restrict(request.user, 'add')
|
||||
@@ -293,7 +296,10 @@ class AvailablePrefixesView(ObjectValidationMixin, APIView):
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(methods=["post"], responses={201: serializers.PrefixSerializer(many=True)})
|
||||
@extend_schema(methods=["post"],
|
||||
responses={201: serializers.PrefixSerializer(many=True)},
|
||||
request=serializers.PrefixSerializer(many=True),
|
||||
)
|
||||
@advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
|
||||
def post(self, request, pk):
|
||||
self.queryset = self.queryset.restrict(request.user, 'add')
|
||||
@@ -388,7 +394,10 @@ class AvailableIPAddressesView(ObjectValidationMixin, APIView):
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(methods=["post"], responses={201: serializers.IPAddressSerializer(many=True)})
|
||||
@extend_schema(methods=["post"],
|
||||
responses={201: serializers.IPAddressSerializer(many=True)},
|
||||
request=serializers.IPAddressSerializer(many=True),
|
||||
)
|
||||
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
|
||||
def post(self, request, pk):
|
||||
self.queryset = self.queryset.restrict(request.user, 'add')
|
||||
@@ -468,7 +477,10 @@ class AvailableVLANsView(ObjectValidationMixin, APIView):
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(methods=["post"], responses={201: serializers.VLANSerializer(many=True)})
|
||||
@extend_schema(methods=["post"],
|
||||
responses={201: serializers.VLANSerializer(many=True)},
|
||||
request=serializers.VLANSerializer(many=True),
|
||||
)
|
||||
@advisory_lock(ADVISORY_LOCK_KEYS['available-vlans'])
|
||||
def post(self, request, pk):
|
||||
self.queryset = self.queryset.restrict(request.user, 'add')
|
||||
|
||||
@@ -4,6 +4,8 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from netaddr.core import AddrFormatError
|
||||
|
||||
from dcim.models import Device, Interface, Region, Site, SiteGroup
|
||||
@@ -414,6 +416,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
except (AddrFormatError, ValueError):
|
||||
return queryset.none()
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def filter_present_in_vrf(self, queryset, name, vrf):
|
||||
if vrf is None:
|
||||
return queryset.none
|
||||
@@ -588,6 +591,10 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
method='_assigned_to_interface',
|
||||
label=_('Is assigned to an interface'),
|
||||
)
|
||||
assigned = django_filters.BooleanFilter(
|
||||
method='_assigned',
|
||||
label=_('Is assigned'),
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=IPAddressStatusChoices,
|
||||
null_value=None
|
||||
@@ -659,6 +666,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
return queryset
|
||||
return queryset.filter(address__net_mask_length=value)
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def filter_present_in_vrf(self, queryset, name, vrf):
|
||||
if vrf is None:
|
||||
return queryset.none
|
||||
@@ -702,6 +710,18 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
assigned_object_id__isnull=False
|
||||
)
|
||||
|
||||
def _assigned(self, queryset, name, value):
|
||||
if value:
|
||||
return queryset.exclude(
|
||||
assigned_object_type__isnull=True,
|
||||
assigned_object_id__isnull=True
|
||||
)
|
||||
else:
|
||||
return queryset.filter(
|
||||
assigned_object_type__isnull=True,
|
||||
assigned_object_id__isnull=True
|
||||
)
|
||||
|
||||
|
||||
class FHRPGroupFilterSet(NetBoxModelFilterSet):
|
||||
protocol = django_filters.MultipleChoiceFilter(
|
||||
@@ -727,6 +747,7 @@ class FHRPGroupFilterSet(NetBoxModelFilterSet):
|
||||
Q(name__icontains=value)
|
||||
)
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def filter_related_ip(self, queryset, name, value):
|
||||
"""
|
||||
Filter by VRF & prefix of assigned IP addresses.
|
||||
@@ -941,9 +962,11 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
pass
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def get_for_device(self, queryset, name, value):
|
||||
return queryset.get_for_device(value)
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def get_for_virtualmachine(self, queryset, name, value):
|
||||
return queryset.get_for_virtualmachine(value)
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ from ipam.constants import *
|
||||
from ipam.models import *
|
||||
from netbox.forms import NetBoxModelImportForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField
|
||||
from utilities.forms.fields import (
|
||||
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField
|
||||
)
|
||||
from virtualization.models import VirtualMachine, VMInterface
|
||||
|
||||
__all__ = (
|
||||
@@ -40,10 +42,25 @@ class VRFImportForm(NetBoxModelImportForm):
|
||||
to_field_name='name',
|
||||
help_text=_('Assigned tenant')
|
||||
)
|
||||
import_targets = CSVModelMultipleChoiceField(
|
||||
queryset=RouteTarget.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_('Import route targets')
|
||||
)
|
||||
export_targets = CSVModelMultipleChoiceField(
|
||||
queryset=RouteTarget.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text=_('Export route targets')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = VRF
|
||||
fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description', 'comments', 'tags')
|
||||
fields = (
|
||||
'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'comments',
|
||||
'tags',
|
||||
)
|
||||
|
||||
|
||||
class RouteTargetImportForm(NetBoxModelImportForm):
|
||||
@@ -181,16 +198,31 @@ class PrefixImportForm(NetBoxModelImportForm):
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
super().__init__(data, *args, **kwargs)
|
||||
|
||||
if data:
|
||||
if not data:
|
||||
return
|
||||
|
||||
# Limit VLAN queryset by assigned site and/or group (if specified)
|
||||
params = {}
|
||||
if data.get('site'):
|
||||
params[f"site__{self.fields['site'].to_field_name}"] = data.get('site')
|
||||
if data.get('vlan_group'):
|
||||
params[f"group__{self.fields['vlan_group'].to_field_name}"] = data.get('vlan_group')
|
||||
if params:
|
||||
self.fields['vlan'].queryset = self.fields['vlan'].queryset.filter(**params)
|
||||
site = data.get('site')
|
||||
vlan_group = data.get('vlan_group')
|
||||
|
||||
# Limit VLAN queryset by assigned site and/or group (if specified)
|
||||
query = Q()
|
||||
|
||||
if site:
|
||||
query |= Q(**{
|
||||
f"site__{self.fields['site'].to_field_name}": site
|
||||
})
|
||||
# Don't Forget to include VLANs without a site in the filter
|
||||
query |= Q(**{
|
||||
f"site__{self.fields['site'].to_field_name}__isnull": True
|
||||
})
|
||||
|
||||
if vlan_group:
|
||||
query &= Q(**{
|
||||
f"group__{self.fields['vlan_group'].to_field_name}": vlan_group
|
||||
})
|
||||
|
||||
queryset = self.fields['vlan'].queryset.filter(query)
|
||||
self.fields['vlan'].queryset = queryset
|
||||
|
||||
|
||||
class IPRangeImportForm(NetBoxModelImportForm):
|
||||
|
||||
@@ -253,7 +253,7 @@ class IPRangeFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
model = IPRange
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
('Attriubtes', ('family', 'vrf_id', 'status', 'role_id', 'mark_utilized')),
|
||||
('Attributes', ('family', 'vrf_id', 'status', 'role_id', 'mark_utilized')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
)
|
||||
family = forms.ChoiceField(
|
||||
|
||||
@@ -211,10 +211,8 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
|
||||
vlan = DynamicModelChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False,
|
||||
selector=True,
|
||||
label=_('VLAN'),
|
||||
query_params={
|
||||
'site_id': '$site',
|
||||
}
|
||||
)
|
||||
role = DynamicModelChoiceField(
|
||||
queryset=Role.objects.all(),
|
||||
@@ -328,6 +326,12 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
):
|
||||
self.initial['primary_for_parent'] = True
|
||||
|
||||
# Disable object assignment fields if the IP address is designated as primary
|
||||
if self.initial.get('primary_for_parent'):
|
||||
self.fields['interface'].disabled = True
|
||||
self.fields['vminterface'].disabled = True
|
||||
self.fields['fhrpgroup'].disabled = True
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
@@ -340,7 +344,12 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
selected_objects[1]: "An IP address can only be assigned to a single object."
|
||||
})
|
||||
elif selected_objects:
|
||||
self.instance.assigned_object = self.cleaned_data[selected_objects[0]]
|
||||
assigned_object = self.cleaned_data[selected_objects[0]]
|
||||
if self.instance.pk and self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object:
|
||||
raise ValidationError(
|
||||
"Cannot reassign IP address while it is designated as the primary IP for the parent object"
|
||||
)
|
||||
self.instance.assigned_object = assigned_object
|
||||
else:
|
||||
self.instance.assigned_object = None
|
||||
|
||||
@@ -359,7 +368,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
raise ValidationError(msg)
|
||||
if address.version == 6 and address.prefixlen not in (127, 128):
|
||||
raise ValidationError(msg)
|
||||
if address.ip == address.broadcast:
|
||||
if address.version == 4 and address.ip == address.broadcast and address.prefixlen not in (31, 32):
|
||||
msg = f"{address} is a broadcast address, which may not be assigned to an interface."
|
||||
raise ValidationError(msg)
|
||||
|
||||
@@ -370,6 +379,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
interface = self.instance.assigned_object
|
||||
if type(interface) in (Interface, VMInterface):
|
||||
parent = interface.parent_object
|
||||
parent.snapshot()
|
||||
if self.cleaned_data['primary_for_parent']:
|
||||
if ipaddress.address.version == 4:
|
||||
parent.primary_ip4 = ipaddress
|
||||
|
||||
@@ -24,11 +24,11 @@ class IPAddressAssignmentType(graphene.Union):
|
||||
|
||||
@classmethod
|
||||
def resolve_type(cls, instance, info):
|
||||
if type(instance) == Interface:
|
||||
if type(instance) is Interface:
|
||||
return InterfaceType
|
||||
if type(instance) == FHRPGroup:
|
||||
if type(instance) is FHRPGroup:
|
||||
return FHRPGroupType
|
||||
if type(instance) == VMInterface:
|
||||
if type(instance) is VMInterface:
|
||||
return VMInterfaceType
|
||||
|
||||
|
||||
@@ -42,11 +42,11 @@ class L2VPNAssignmentType(graphene.Union):
|
||||
|
||||
@classmethod
|
||||
def resolve_type(cls, instance, info):
|
||||
if type(instance) == Interface:
|
||||
if type(instance) is Interface:
|
||||
return InterfaceType
|
||||
if type(instance) == VLAN:
|
||||
if type(instance) is VLAN:
|
||||
return VLANType
|
||||
if type(instance) == VMInterface:
|
||||
if type(instance) is VMInterface:
|
||||
return VMInterfaceType
|
||||
|
||||
|
||||
@@ -59,9 +59,9 @@ class FHRPGroupInterfaceType(graphene.Union):
|
||||
|
||||
@classmethod
|
||||
def resolve_type(cls, instance, info):
|
||||
if type(instance) == Interface:
|
||||
if type(instance) is Interface:
|
||||
return InterfaceType
|
||||
if type(instance) == VMInterface:
|
||||
if type(instance) is VMInterface:
|
||||
return VMInterfaceType
|
||||
|
||||
|
||||
@@ -79,17 +79,17 @@ class VLANGroupScopeType(graphene.Union):
|
||||
|
||||
@classmethod
|
||||
def resolve_type(cls, instance, info):
|
||||
if type(instance) == Cluster:
|
||||
if type(instance) is Cluster:
|
||||
return ClusterType
|
||||
if type(instance) == ClusterGroup:
|
||||
if type(instance) is ClusterGroup:
|
||||
return ClusterGroupType
|
||||
if type(instance) == Location:
|
||||
if type(instance) is Location:
|
||||
return LocationType
|
||||
if type(instance) == Rack:
|
||||
if type(instance) is Rack:
|
||||
return RackType
|
||||
if type(instance) == Region:
|
||||
if type(instance) is Region:
|
||||
return RegionType
|
||||
if type(instance) == Site:
|
||||
if type(instance) is Site:
|
||||
return SiteType
|
||||
if type(instance) == SiteGroup:
|
||||
if type(instance) is SiteGroup:
|
||||
return SiteGroupType
|
||||
|
||||
@@ -4,6 +4,7 @@ from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from ipam.fields import ASNField
|
||||
from ipam.querysets import ASNRangeQuerySet
|
||||
from netbox.models import OrganizationalModel, PrimaryModel
|
||||
|
||||
__all__ = (
|
||||
@@ -37,6 +38,8 @@ class ASNRange(OrganizationalModel):
|
||||
null=True
|
||||
)
|
||||
|
||||
objects = ASNRangeQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
verbose_name = 'ASN range'
|
||||
|
||||
@@ -406,7 +406,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
|
||||
Return all available IPs within this prefix as an IPSet.
|
||||
"""
|
||||
if self.mark_utilized:
|
||||
return list()
|
||||
return netaddr.IPSet()
|
||||
|
||||
prefix = netaddr.IPSet(self.prefix)
|
||||
child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.utils.translation import gettext as _
|
||||
from dcim.models import Interface
|
||||
from ipam.choices import *
|
||||
from ipam.constants import *
|
||||
from ipam.querysets import VLANQuerySet
|
||||
from ipam.querysets import VLANQuerySet, VLANGroupQuerySet
|
||||
from netbox.models import OrganizationalModel, PrimaryModel
|
||||
from virtualization.models import VMInterface
|
||||
|
||||
@@ -63,6 +63,8 @@ class VLANGroup(OrganizationalModel):
|
||||
help_text=_('Highest permissible ID of a child VLAN')
|
||||
)
|
||||
|
||||
objects = VLANGroupQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ('name', 'pk') # Name may be non-unique
|
||||
constraints = (
|
||||
|
||||
@@ -1,8 +1,34 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
from django.db.models import Count, F, OuterRef, Q, Subquery, Value
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.db.models.functions import Round
|
||||
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.utils import count_related
|
||||
|
||||
__all__ = (
|
||||
'ASNRangeQuerySet',
|
||||
'PrefixQuerySet',
|
||||
'VLANQuerySet',
|
||||
)
|
||||
|
||||
|
||||
class ASNRangeQuerySet(RestrictedQuerySet):
|
||||
|
||||
def annotate_asn_counts(self):
|
||||
"""
|
||||
Annotate the number of ASNs which appear within each range.
|
||||
"""
|
||||
from .models import ASN
|
||||
|
||||
# Because ASN does not have a foreign key to ASNRange, we create a fake column "_" with a consistent value
|
||||
# that we can use to count ASNs and return a single value per ASNRange.
|
||||
asns = ASN.objects.filter(
|
||||
asn__gte=OuterRef('start'),
|
||||
asn__lte=OuterRef('end')
|
||||
).order_by().annotate(_=Value(1)).values('_').annotate(c=Count('*')).values('c')
|
||||
|
||||
return self.annotate(asn_count=Subquery(asns))
|
||||
|
||||
|
||||
class PrefixQuerySet(RestrictedQuerySet):
|
||||
@@ -30,6 +56,17 @@ class PrefixQuerySet(RestrictedQuerySet):
|
||||
)
|
||||
|
||||
|
||||
class VLANGroupQuerySet(RestrictedQuerySet):
|
||||
|
||||
def annotate_utilization(self):
|
||||
from .models import VLAN
|
||||
|
||||
return self.annotate(
|
||||
vlan_count=count_related(VLAN, 'group'),
|
||||
utilization=Round(F('vlan_count') / (F('max_vid') - F('min_vid') + 1.0) * 100, 2)
|
||||
)
|
||||
|
||||
|
||||
class VLANQuerySet(RestrictedQuerySet):
|
||||
|
||||
def get_for_device(self, device):
|
||||
|
||||
@@ -21,10 +21,8 @@ class ASNRangeTable(TenancyColumnsMixin, NetBoxTable):
|
||||
tags = columns.TagColumn(
|
||||
url_name='ipam:asnrange_list'
|
||||
)
|
||||
asn_count = columns.LinkedCountColumn(
|
||||
viewname='ipam:asn_list',
|
||||
url_params={'asn_id': 'pk'},
|
||||
verbose_name=_('ASN Count')
|
||||
asn_count = tables.Column(
|
||||
verbose_name=_('ASNs')
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
@@ -59,7 +57,8 @@ class ASNTable(TenancyColumnsMixin, NetBoxTable):
|
||||
verbose_name=_('Provider Count')
|
||||
)
|
||||
sites = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
linkify_item=True,
|
||||
verbose_name=_('Sites')
|
||||
)
|
||||
comments = columns.MarkdownColumn()
|
||||
tags = columns.TagColumn(
|
||||
|
||||
@@ -19,14 +19,22 @@ __all__ = (
|
||||
|
||||
AVAILABLE_LABEL = mark_safe('<span class="badge bg-success">Available</span>')
|
||||
|
||||
AGGREGATE_COPY_BUTTON = """
|
||||
{% copy_content record.pk prefix="aggregate_" %}
|
||||
"""
|
||||
|
||||
PREFIX_LINK = """
|
||||
{% if record.pk %}
|
||||
<a href="{{ record.get_absolute_url }}">{{ record.prefix }}</a>
|
||||
<a href="{{ record.get_absolute_url }}" id="prefix_{{ record.pk }}">{{ record.prefix }}</a>
|
||||
{% else %}
|
||||
<a href="{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.site %}&site={{ object.site.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}">{{ record.prefix }}</a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
PREFIX_COPY_BUTTON = """
|
||||
{% copy_content record.pk prefix="prefix_" %}
|
||||
"""
|
||||
|
||||
PREFIX_LINK_WITH_DEPTH = """
|
||||
{% load helpers %}
|
||||
{% if record.depth %}
|
||||
@@ -40,7 +48,7 @@ PREFIX_LINK_WITH_DEPTH = """
|
||||
|
||||
IPADDRESS_LINK = """
|
||||
{% if record.pk %}
|
||||
<a href="{{ record.get_absolute_url }}">{{ record.address }}</a>
|
||||
<a href="{{ record.get_absolute_url }}" id="ipaddress_{{ record.pk }}">{{ record.address }}</a>
|
||||
{% elif perms.ipam.add_ipaddress %}
|
||||
<a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.tenant %}&tenant={{ object.tenant.pk }}{% endif %}" class="btn btn-sm btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available</a>
|
||||
{% else %}
|
||||
@@ -48,6 +56,10 @@ IPADDRESS_LINK = """
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
IPADDRESS_COPY_BUTTON = """
|
||||
{% copy_content record.pk prefix="ipaddress_" %}
|
||||
"""
|
||||
|
||||
IPADDRESS_ASSIGN_LINK = """
|
||||
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?{% if request.GET.interface %}interface={{ request.GET.interface }}{% elif request.GET.vminterface %}vminterface={{ request.GET.vminterface }}{% endif %}&return_url={{ request.GET.return_url }}">{{ record }}</a>
|
||||
"""
|
||||
@@ -99,7 +111,11 @@ class RIRTable(NetBoxTable):
|
||||
class AggregateTable(TenancyColumnsMixin, NetBoxTable):
|
||||
prefix = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='Aggregate'
|
||||
verbose_name='Aggregate',
|
||||
attrs={
|
||||
# Allow the aggregate to be copied to the clipboard
|
||||
'a': {'id': lambda record: f"aggregate_{record.pk}"}
|
||||
}
|
||||
)
|
||||
date_added = tables.DateColumn(
|
||||
format="Y-m-d",
|
||||
@@ -116,6 +132,9 @@ class AggregateTable(TenancyColumnsMixin, NetBoxTable):
|
||||
tags = columns.TagColumn(
|
||||
url_name='ipam:aggregate_list'
|
||||
)
|
||||
actions = columns.ActionsColumn(
|
||||
extra_buttons=AGGREGATE_COPY_BUTTON
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Aggregate
|
||||
@@ -242,6 +261,9 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable):
|
||||
tags = columns.TagColumn(
|
||||
url_name='ipam:prefix_list'
|
||||
)
|
||||
actions = columns.ActionsColumn(
|
||||
extra_buttons=PREFIX_COPY_BUTTON
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Prefix
|
||||
@@ -348,6 +370,9 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
|
||||
tags = columns.TagColumn(
|
||||
url_name='ipam:ipaddress_list'
|
||||
)
|
||||
actions = columns.ActionsColumn(
|
||||
extra_buttons=IPADDRESS_COPY_BUTTON
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = IPAddress
|
||||
|
||||
@@ -70,6 +70,10 @@ class VLANGroupTable(NetBoxTable):
|
||||
url_params={'group_id': 'pk'},
|
||||
verbose_name='VLANs'
|
||||
)
|
||||
utilization = columns.UtilizationColumn(
|
||||
orderable=False,
|
||||
verbose_name='Utilization'
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='ipam:vlangroup_list'
|
||||
)
|
||||
@@ -81,9 +85,9 @@ class VLANGroupTable(NetBoxTable):
|
||||
model = VLANGroup
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'scope_type', 'scope', 'min_vid', 'max_vid', 'vlan_count', 'slug', 'description',
|
||||
'tags', 'created', 'last_updated', 'actions',
|
||||
'tags', 'created', 'last_updated', 'actions', 'utilization',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description')
|
||||
default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'utilization', 'description')
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -992,6 +992,12 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'fhrpgroup_id': [fhrp_groups[0].pk, fhrp_groups[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_assigned(self):
|
||||
params = {'assigned': 'true'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
|
||||
params = {'assigned': 'false'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_assigned_to_interface(self):
|
||||
params = {'assigned_to_interface': 'true'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
|
||||
@@ -495,6 +495,65 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk})
|
||||
self.assertHttpStatus(self.client.get(url), 200)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_prefix_import(self):
|
||||
"""
|
||||
Custom import test for YAML-based imports (versus CSV)
|
||||
"""
|
||||
IMPORT_DATA = """
|
||||
prefix: 10.1.1.0/24
|
||||
status: active
|
||||
vlan: 101
|
||||
site: Site 1
|
||||
"""
|
||||
# Note, a site is not tied to the VLAN to verify the fix for #12622
|
||||
VLAN.objects.create(vid=101, name='VLAN101')
|
||||
|
||||
# Add all required permissions to the test user
|
||||
self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
|
||||
|
||||
form_data = {
|
||||
'data': IMPORT_DATA,
|
||||
'format': 'yaml'
|
||||
}
|
||||
response = self.client.post(reverse('ipam:prefix_import'), data=form_data, follow=True)
|
||||
self.assertHttpStatus(response, 200)
|
||||
|
||||
prefix = Prefix.objects.get(prefix='10.1.1.0/24')
|
||||
self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE)
|
||||
self.assertEqual(prefix.vlan.vid, 101)
|
||||
self.assertEqual(prefix.site.name, "Site 1")
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_prefix_import_with_vlan_group(self):
|
||||
"""
|
||||
This test covers a unique import edge case where VLAN group is specified during the import.
|
||||
"""
|
||||
IMPORT_DATA = """
|
||||
prefix: 10.1.2.0/24
|
||||
status: active
|
||||
vlan: 102
|
||||
site: Site 1
|
||||
vlan_group: Group 1
|
||||
"""
|
||||
vlan_group = VLANGroup.objects.create(name='Group 1', slug='group-1', scope=Site.objects.get(name="Site 1"))
|
||||
VLAN.objects.create(vid=102, name='VLAN102', group=vlan_group)
|
||||
|
||||
# Add all required permissions to the test user
|
||||
self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
|
||||
|
||||
form_data = {
|
||||
'data': IMPORT_DATA,
|
||||
'format': 'yaml'
|
||||
}
|
||||
response = self.client.post(reverse('ipam:prefix_import'), data=form_data, follow=True)
|
||||
self.assertHttpStatus(response, 200)
|
||||
|
||||
prefix = Prefix.objects.get(prefix='10.1.2.0/24')
|
||||
self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE)
|
||||
self.assertEqual(prefix.vlan.vid, 102)
|
||||
self.assertEqual(prefix.site.name, "Site 1")
|
||||
|
||||
|
||||
class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
model = IPRange
|
||||
|
||||
@@ -121,7 +121,7 @@ def add_available_vlans(vlans, vlan_group=None):
|
||||
})
|
||||
|
||||
vlans = list(vlans) + new_vlans
|
||||
vlans.sort(key=lambda v: v.vid if type(v) == VLAN else v['vid'])
|
||||
vlans.sort(key=lambda v: v.vid if type(v) is VLAN else v['vid'])
|
||||
|
||||
return vlans
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Prefetch
|
||||
from django.db.models import F, Prefetch
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.db.models.functions import Round
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -198,7 +199,7 @@ class RIRBulkDeleteView(generic.BulkDeleteView):
|
||||
#
|
||||
|
||||
class ASNRangeListView(generic.ObjectListView):
|
||||
queryset = ASNRange.objects.all()
|
||||
queryset = ASNRange.objects.annotate_asn_counts()
|
||||
filterset = filtersets.ASNRangeFilterSet
|
||||
filterset_form = forms.ASNRangeFilterForm
|
||||
table = tables.ASNRangeTable
|
||||
@@ -215,7 +216,7 @@ class ASNRangeASNsView(generic.ObjectChildrenView):
|
||||
child_model = ASN
|
||||
table = tables.ASNTable
|
||||
filterset = filtersets.ASNFilterSet
|
||||
template_name = 'ipam/asnrange/asns.html'
|
||||
template_name = 'generic/object_children.html'
|
||||
tab = ViewTab(
|
||||
label=_('ASNs'),
|
||||
badge=lambda x: x.get_child_asns().count(),
|
||||
@@ -247,18 +248,14 @@ class ASNRangeBulkImportView(generic.BulkImportView):
|
||||
|
||||
|
||||
class ASNRangeBulkEditView(generic.BulkEditView):
|
||||
queryset = ASNRange.objects.annotate(
|
||||
site_count=count_related(Site, 'asns')
|
||||
)
|
||||
queryset = ASNRange.objects.annotate_asn_counts()
|
||||
filterset = filtersets.ASNRangeFilterSet
|
||||
table = tables.ASNRangeTable
|
||||
form = forms.ASNRangeBulkEditForm
|
||||
|
||||
|
||||
class ASNRangeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ASNRange.objects.annotate(
|
||||
site_count=count_related(Site, 'asns')
|
||||
)
|
||||
queryset = ASNRange.objects.annotate_asn_counts()
|
||||
filterset = filtersets.ASNRangeFilterSet
|
||||
table = tables.ASNRangeTable
|
||||
|
||||
@@ -819,7 +816,6 @@ class IPAddressAssignView(generic.ObjectView):
|
||||
table = None
|
||||
|
||||
if form.is_valid():
|
||||
|
||||
addresses = self.queryset.prefetch_related('vrf', 'tenant')
|
||||
# Limit to 100 results
|
||||
addresses = filtersets.IPAddressFilterSet(request.POST, addresses).qs[:100]
|
||||
@@ -869,7 +865,7 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
|
||||
child_model = IPAddress
|
||||
table = tables.IPAddressTable
|
||||
filterset = filtersets.IPAddressFilterSet
|
||||
template_name = 'ipam/ipaddress/ip_addresses.html'
|
||||
template_name = 'generic/object_children.html'
|
||||
tab = ViewTab(
|
||||
label=_('Related IPs'),
|
||||
badge=lambda x: x.get_related_ips().count(),
|
||||
@@ -886,9 +882,7 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
|
||||
#
|
||||
|
||||
class VLANGroupListView(generic.ObjectListView):
|
||||
queryset = VLANGroup.objects.annotate(
|
||||
vlan_count=count_related(VLAN, 'group')
|
||||
)
|
||||
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
|
||||
filterset = filtersets.VLANGroupFilterSet
|
||||
filterset_form = forms.VLANGroupFilterForm
|
||||
table = tables.VLANGroupTable
|
||||
@@ -896,7 +890,7 @@ class VLANGroupListView(generic.ObjectListView):
|
||||
|
||||
@register_model_view(VLANGroup)
|
||||
class VLANGroupView(generic.ObjectView):
|
||||
queryset = VLANGroup.objects.all()
|
||||
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
related_models = (
|
||||
@@ -938,18 +932,14 @@ class VLANGroupBulkImportView(generic.BulkImportView):
|
||||
|
||||
|
||||
class VLANGroupBulkEditView(generic.BulkEditView):
|
||||
queryset = VLANGroup.objects.annotate(
|
||||
vlan_count=count_related(VLAN, 'group')
|
||||
)
|
||||
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
|
||||
filterset = filtersets.VLANGroupFilterSet
|
||||
table = tables.VLANGroupTable
|
||||
form = forms.VLANGroupBulkEditForm
|
||||
|
||||
|
||||
class VLANGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = VLANGroup.objects.annotate(
|
||||
vlan_count=count_related(VLAN, 'group')
|
||||
)
|
||||
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
|
||||
filterset = filtersets.VLANGroupFilterSet
|
||||
table = tables.VLANGroupTable
|
||||
|
||||
@@ -972,7 +962,6 @@ class FHRPGroupView(generic.ObjectView):
|
||||
queryset = FHRPGroup.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
|
||||
# Get assigned interfaces
|
||||
members_table = tables.FHRPGroupAssignmentTable(
|
||||
data=FHRPGroupAssignment.objects.restrict(request.user, 'view').filter(group=instance),
|
||||
@@ -1086,7 +1075,7 @@ class VLANInterfacesView(generic.ObjectChildrenView):
|
||||
child_model = Interface
|
||||
table = tables.VLANDevicesTable
|
||||
filterset = InterfaceFilterSet
|
||||
template_name = 'ipam/vlan/interfaces.html'
|
||||
template_name = 'generic/object_children.html'
|
||||
tab = ViewTab(
|
||||
label=_('Device Interfaces'),
|
||||
badge=lambda x: x.get_interfaces().count(),
|
||||
@@ -1104,7 +1093,7 @@ class VLANVMInterfacesView(generic.ObjectChildrenView):
|
||||
child_model = VMInterface
|
||||
table = tables.VLANVirtualMachinesTable
|
||||
filterset = VMInterfaceFilterSet
|
||||
template_name = 'ipam/vlan/vminterfaces.html'
|
||||
template_name = 'generic/object_children.html'
|
||||
tab = ViewTab(
|
||||
label=_('VM Interfaces'),
|
||||
badge=lambda x: x.get_vminterfaces().count(),
|
||||
|
||||
@@ -60,7 +60,7 @@ class TokenAuthentication(authentication.TokenAuthentication):
|
||||
|
||||
user = token.user
|
||||
# When LDAP authentication is active try to load user data from LDAP directory
|
||||
if settings.REMOTE_AUTH_BACKEND == 'netbox.authentication.LDAPBackend':
|
||||
if 'netbox.authentication.LDAPBackend' in settings.REMOTE_AUTH_BACKEND:
|
||||
from netbox.authentication import LDAPBackend
|
||||
ldap_backend = LDAPBackend()
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from rest_framework.reverse import reverse
|
||||
from rest_framework.views import APIView
|
||||
from rq.worker import Worker
|
||||
|
||||
from extras.plugins.utils import get_installed_plugins
|
||||
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
|
||||
|
||||
@@ -61,19 +62,11 @@ class StatusView(APIView):
|
||||
installed_apps[app_config.name] = version
|
||||
installed_apps = {k: v for k, v in sorted(installed_apps.items())}
|
||||
|
||||
# Gather installed plugins
|
||||
plugins = {}
|
||||
for plugin_name in settings.PLUGINS:
|
||||
plugin_name = plugin_name.rsplit('.', 1)[-1]
|
||||
plugin_config = apps.get_app_config(plugin_name)
|
||||
plugins[plugin_name] = getattr(plugin_config, 'version', None)
|
||||
plugins = {k: v for k, v in sorted(plugins.items())}
|
||||
|
||||
return Response({
|
||||
'django-version': DJANGO_VERSION,
|
||||
'installed-apps': installed_apps,
|
||||
'netbox-version': settings.VERSION,
|
||||
'plugins': plugins,
|
||||
'plugins': get_installed_plugins(),
|
||||
'python-version': platform.python_version(),
|
||||
'rq-workers-running': Worker.count(get_connection('default')),
|
||||
})
|
||||
|
||||
@@ -15,11 +15,12 @@ from utilities.api import get_serializer_for_model
|
||||
|
||||
__all__ = (
|
||||
'BriefModeMixin',
|
||||
'BulkDestroyModelMixin',
|
||||
'BulkUpdateModelMixin',
|
||||
'CustomFieldsMixin',
|
||||
'ExportTemplatesMixin',
|
||||
'BulkDestroyModelMixin',
|
||||
'ObjectValidationMixin',
|
||||
'SequentialBulkCreatesMixin',
|
||||
)
|
||||
|
||||
|
||||
@@ -94,6 +95,30 @@ class ExportTemplatesMixin:
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
||||
|
||||
class SequentialBulkCreatesMixin:
|
||||
"""
|
||||
Perform bulk creation of new objects sequentially, rather than all at once. This ensures that any validation
|
||||
which depends on the evaluation of existing objects (such as checking for free space within a rack) functions
|
||||
appropriately.
|
||||
"""
|
||||
@transaction.atomic
|
||||
def create(self, request, *args, **kwargs):
|
||||
if not isinstance(request.data, list):
|
||||
# Creating a single object
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
return_data = []
|
||||
for data in request.data:
|
||||
serializer = self.get_serializer(data=data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_create(serializer)
|
||||
return_data.append(serializer.data)
|
||||
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
|
||||
return Response(return_data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
|
||||
class BulkUpdateModelMixin:
|
||||
"""
|
||||
Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one
|
||||
|
||||
@@ -177,7 +177,8 @@ class BaseFilterSet(django_filters.FilterSet):
|
||||
# create the new filter with the same type because there is no guarantee the defined type
|
||||
# is the same as the default type for the field
|
||||
resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid
|
||||
new_filter = type(existing_filter)(
|
||||
filter_cls = django_filters.BooleanFilter if lookup_expr == 'empty' else type(existing_filter)
|
||||
new_filter = filter_cls(
|
||||
field_name=field_name,
|
||||
lookup_expr=lookup_expr,
|
||||
label=existing_filter.label,
|
||||
@@ -224,6 +225,14 @@ class BaseFilterSet(django_filters.FilterSet):
|
||||
|
||||
return filters
|
||||
|
||||
@classmethod
|
||||
def filter_for_lookup(cls, field, lookup_type):
|
||||
|
||||
if lookup_type == 'empty':
|
||||
return django_filters.BooleanFilter, {}
|
||||
|
||||
return super().filter_for_lookup(field, lookup_type)
|
||||
|
||||
|
||||
class ChangeLoggedModelFilterSet(BaseFilterSet):
|
||||
"""
|
||||
|
||||
@@ -78,7 +78,10 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
|
||||
|
||||
def _get_custom_fields(self, content_type):
|
||||
return CustomField.objects.filter(content_types=content_type).filter(
|
||||
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE
|
||||
ui_visibility__in=[
|
||||
CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE,
|
||||
CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET,
|
||||
]
|
||||
)
|
||||
|
||||
def _get_form_field(self, customfield):
|
||||
|
||||
@@ -49,6 +49,9 @@ class CoreMiddleware:
|
||||
# Attach the unique request ID as an HTTP header.
|
||||
response['X-Request-ID'] = request.id
|
||||
|
||||
# Enable the Vary header to help with caching of HTMX responses
|
||||
response['Vary'] = 'HX-Request'
|
||||
|
||||
# If this is an API request, attach an HTTP header annotating the API version (e.g. '3.5').
|
||||
if is_api_request(request):
|
||||
response['API-Version'] = settings.REST_FRAMEWORK_VERSION
|
||||
@@ -203,7 +206,7 @@ class MaintenanceModeMiddleware:
|
||||
"""
|
||||
Prevent any write-related database operations if an exception is raised.
|
||||
"""
|
||||
if isinstance(exception, InternalError):
|
||||
if get_config().MAINTENANCE_MODE and isinstance(exception, InternalError):
|
||||
error_message = 'NetBox is currently operating in maintenance mode and is unable to perform write ' \
|
||||
'operations. Please try again later.'
|
||||
|
||||
|
||||
@@ -197,11 +197,15 @@ class CustomFieldsMixin(models.Model):
|
||||
data = {}
|
||||
|
||||
for field in CustomField.objects.get_for_model(self):
|
||||
# Skip fields that are hidden if 'omit_hidden' is set
|
||||
if omit_hidden and field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN:
|
||||
continue
|
||||
|
||||
value = self.custom_field_data.get(field.name)
|
||||
|
||||
# Skip fields that are hidden if 'omit_hidden' is set
|
||||
if omit_hidden:
|
||||
if field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN:
|
||||
continue
|
||||
if field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET and not value:
|
||||
continue
|
||||
|
||||
data[field] = field.deserialize(value)
|
||||
|
||||
return data
|
||||
@@ -227,6 +231,8 @@ class CustomFieldsMixin(models.Model):
|
||||
|
||||
for cf in visible_custom_fields:
|
||||
value = self.custom_field_data.get(cf.name)
|
||||
if value in (None, []) and cf.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET:
|
||||
continue
|
||||
value = cf.deserialize(value)
|
||||
groups[cf.group_name][cf] = value
|
||||
|
||||
@@ -436,6 +442,19 @@ class SyncedDataMixin(models.Model):
|
||||
|
||||
return ret
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
from core.models import AutoSyncRecord
|
||||
|
||||
# Delete AutoSyncRecord
|
||||
content_type = ContentType.objects.get_for_model(self)
|
||||
AutoSyncRecord.objects.filter(
|
||||
datafile=self.data_file,
|
||||
object_type=content_type,
|
||||
object_id=self.pk
|
||||
).delete()
|
||||
|
||||
return super().delete(*args, **kwargs)
|
||||
|
||||
def resolve_data_file(self):
|
||||
"""
|
||||
Determine the designated DataFile object identified by its parent DataSource and its path. Returns None if
|
||||
|
||||
@@ -46,7 +46,7 @@ ORGANIZATION_MENU = Menu(
|
||||
get_model_item('tenancy', 'contact', _('Contacts')),
|
||||
get_model_item('tenancy', 'contactgroup', _('Contact Groups')),
|
||||
get_model_item('tenancy', 'contactrole', _('Contact Roles')),
|
||||
get_model_item('tenancy', 'contactassignment', _('Contact Assignments'), actions=[]),
|
||||
get_model_item('tenancy', 'contactassignment', _('Contact Assignments'), actions=['import']),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -102,7 +102,7 @@ CONNECTIONS_MENU = Menu(
|
||||
label=_('Connections'),
|
||||
items=(
|
||||
get_model_item('dcim', 'cable', _('Cables'), actions=['import']),
|
||||
get_model_item('wireless', 'wirelesslink', _('Wireless Links'), actions=['import']),
|
||||
get_model_item('wireless', 'wirelesslink', _('Wireless Links')),
|
||||
MenuItem(
|
||||
link='dcim:interface_connections_list',
|
||||
link_text=_('Interface Connections'),
|
||||
@@ -301,12 +301,14 @@ CUSTOMIZATION_MENU = Menu(
|
||||
MenuItem(
|
||||
link='extras:report_list',
|
||||
link_text=_('Reports'),
|
||||
permissions=['extras.view_report']
|
||||
permissions=['extras.view_report'],
|
||||
buttons=get_model_buttons('extras', "reportmodule", actions=['add'])
|
||||
),
|
||||
MenuItem(
|
||||
link='extras:script_list',
|
||||
link_text=_('Scripts'),
|
||||
permissions=['extras.view_script']
|
||||
permissions=['extras.view_script'],
|
||||
buttons=get_model_buttons('extras', "scriptmodule", actions=['add'])
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.5.2'
|
||||
VERSION = '3.5.8'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@@ -461,8 +461,6 @@ LOGIN_REDIRECT_URL = f'/{BASE_PATH}'
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
TEST_RUNNER = "django_rich.test.RichRunner"
|
||||
|
||||
# Exclude potentially sensitive models from wildcard view exemption. These may still be exempted
|
||||
# by specifying the model individually in the EXEMPT_VIEW_PERMISSIONS configuration parameter.
|
||||
EXEMPT_EXCLUDE_MODELS = (
|
||||
|
||||
@@ -234,8 +234,12 @@ class ActionsColumn(tables.Column):
|
||||
return ''
|
||||
|
||||
model = table.Meta.model
|
||||
request = getattr(table, 'context', {}).get('request')
|
||||
url_appendix = f'?return_url={quote(request.get_full_path())}' if request else ''
|
||||
if request := getattr(table, 'context', {}).get('request'):
|
||||
return_url = request.GET.get('return_url', request.get_full_path())
|
||||
url_appendix = f'?return_url={quote(return_url)}'
|
||||
else:
|
||||
url_appendix = ''
|
||||
|
||||
html = ''
|
||||
|
||||
# Compile actions menu
|
||||
@@ -500,9 +504,9 @@ class CustomLinkColumn(tables.Column):
|
||||
"""
|
||||
def __init__(self, customlink, *args, **kwargs):
|
||||
self.customlink = customlink
|
||||
kwargs['accessor'] = Accessor('pk')
|
||||
if 'verbose_name' not in kwargs:
|
||||
kwargs['verbose_name'] = customlink.name
|
||||
kwargs.setdefault('accessor', Accessor('pk'))
|
||||
kwargs.setdefault('orderable', False)
|
||||
kwargs.setdefault('verbose_name', customlink.name)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ class BaseTable(tables.Table):
|
||||
# 3. Meta.fields
|
||||
selected_columns = None
|
||||
if user is not None and not isinstance(user, AnonymousUser):
|
||||
selected_columns = user.config.get(f"tables.{self.__class__.__name__}.columns")
|
||||
selected_columns = user.config.get(f"tables.{self.name}.columns")
|
||||
if not selected_columns:
|
||||
selected_columns = getattr(self.Meta, 'default_columns', self.Meta.fields)
|
||||
|
||||
@@ -113,6 +113,10 @@ class BaseTable(tables.Table):
|
||||
columns.append((name, column.verbose_name))
|
||||
return columns
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.__class__.__name__
|
||||
|
||||
@property
|
||||
def available_columns(self):
|
||||
return self._get_columns(visible=False)
|
||||
@@ -138,13 +142,16 @@ class BaseTable(tables.Table):
|
||||
"""
|
||||
# Save ordering preference
|
||||
if request.user.is_authenticated:
|
||||
table_name = self.__class__.__name__
|
||||
if self.prefixed_order_by_field in request.GET:
|
||||
# If an ordering has been specified as a query parameter, save it as the
|
||||
# user's preferred ordering for this table.
|
||||
ordering = request.GET.getlist(self.prefixed_order_by_field)
|
||||
request.user.config.set(f'tables.{table_name}.ordering', ordering, commit=True)
|
||||
elif ordering := request.user.config.get(f'tables.{table_name}.ordering'):
|
||||
if request.GET[self.prefixed_order_by_field]:
|
||||
# If an ordering has been specified as a query parameter, save it as the
|
||||
# user's preferred ordering for this table.
|
||||
ordering = request.GET.getlist(self.prefixed_order_by_field)
|
||||
request.user.config.set(f'tables.{self.name}.ordering', ordering, commit=True)
|
||||
else:
|
||||
# If the ordering has been set to none (empty), clear any existing preference.
|
||||
request.user.config.clear(f'tables.{self.name}.ordering', commit=True)
|
||||
elif ordering := request.user.config.get(f'tables.{self.name}.ordering'):
|
||||
# If no ordering has been specified, set the preferred ordering (if any).
|
||||
self.order_by = ordering
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found
|
||||
from django.views.generic import View
|
||||
from sentry_sdk import capture_message
|
||||
|
||||
from extras.plugins.utils import get_installed_plugins
|
||||
|
||||
__all__ = (
|
||||
'handler_404',
|
||||
'handler_500',
|
||||
@@ -53,4 +55,5 @@ def handler_500(request, template_name=ERROR_500_TEMPLATE_NAME):
|
||||
'exception': str(type_),
|
||||
'netbox_version': settings.VERSION,
|
||||
'python_version': platform.python_version(),
|
||||
'plugins': get_installed_plugins(),
|
||||
}))
|
||||
|
||||
@@ -551,7 +551,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
for name, m2m_field in m2m_fields.items():
|
||||
if name in form.nullable_fields and name in nullified_fields:
|
||||
getattr(obj, name).clear()
|
||||
else:
|
||||
elif form.cleaned_data[name]:
|
||||
getattr(obj, name).set(form.cleaned_data[name])
|
||||
|
||||
# Add/remove tags
|
||||
|
||||
@@ -143,9 +143,12 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
|
||||
return render(request, self.get_template_name(), {
|
||||
'object': instance,
|
||||
'child_model': self.child_model,
|
||||
'base_template': f'{instance._meta.app_label}/{instance._meta.model_name}.html',
|
||||
'table': table,
|
||||
'table_config': f'{table.name}_config',
|
||||
'actions': actions,
|
||||
'tab': self.tab,
|
||||
'return_url': request.get_full_path(),
|
||||
**self.get_extra_context(request, instance),
|
||||
})
|
||||
|
||||
|
||||
2
netbox/project-static/dist/netbox-dark.css
vendored
2
netbox/project-static/dist/netbox-dark.css
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox-light.css
vendored
2
netbox/project-static/dist/netbox-light.css
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox-print.css
vendored
2
netbox/project-static/dist/netbox-print.css
vendored
File diff suppressed because one or more lines are too long
29
netbox/project-static/dist/netbox.js
vendored
29
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
4
netbox/project-static/dist/netbox.js.map
vendored
4
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -30,6 +30,7 @@
|
||||
"dayjs": "^1.11.5",
|
||||
"flatpickr": "4.6.13",
|
||||
"gridstack": "^7.2.3",
|
||||
"html-entities": "^2.3.3",
|
||||
"htmx.org": "^1.8.0",
|
||||
"just-debounce-it": "^3.1.1",
|
||||
"query-string": "^7.1.1",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getElement, getElements, findFirstAdjacent } from '../util';
|
||||
import { getElements, findFirstAdjacent } from '../util';
|
||||
|
||||
/**
|
||||
* If any PK checkbox is checked, uncheck the select all table checkbox and the select all
|
||||
@@ -63,29 +63,6 @@ function handleSelectAllToggle(event: Event): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize the select all confirmation checkbox state with the select all confirmation button
|
||||
* disabled state. If the select all confirmation checkbox is checked, the buttons should be
|
||||
* enabled. If not, the buttons should be disabled.
|
||||
*
|
||||
* @param event Change Event
|
||||
*/
|
||||
function handleSelectAll(event: Event): void {
|
||||
const target = event.currentTarget as HTMLInputElement;
|
||||
const selectAllBox = getElement<HTMLDivElement>('select-all-box');
|
||||
if (selectAllBox !== null) {
|
||||
for (const button of selectAllBox.querySelectorAll<HTMLButtonElement>(
|
||||
'button[type="submit"]',
|
||||
)) {
|
||||
if (target.checked) {
|
||||
button.disabled = false;
|
||||
} else {
|
||||
button.disabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize table select all elements.
|
||||
*/
|
||||
@@ -98,9 +75,4 @@ export function initSelectAll(): void {
|
||||
for (const element of getElements<HTMLInputElement>('input[type="checkbox"][name="pk"]')) {
|
||||
element.addEventListener('change', handlePkCheck);
|
||||
}
|
||||
const selectAll = getElement<HTMLInputElement>('select-all');
|
||||
|
||||
if (selectAll !== null) {
|
||||
selectAll.addEventListener('change', handleSelectAll);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import Clipboard from 'clipboard';
|
||||
import { getElements } from './util';
|
||||
|
||||
export function initClipboard(): void {
|
||||
for (const element of getElements('a.copy-token', 'button.copy-secret')) {
|
||||
for (const element of getElements('a.copy-content')) {
|
||||
new Clipboard(element);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import { getElements, isTruthy } from './util';
|
||||
import { initButtons } from './buttons';
|
||||
import { initSelect } from './select';
|
||||
import { initObjectSelector } from './objectSelector';
|
||||
import { initBootstrap } from './bs';
|
||||
|
||||
function initDepedencies(): void {
|
||||
for (const init of [initButtons, initSelect, initObjectSelector]) {
|
||||
for (const init of [initButtons, initSelect, initObjectSelector, initBootstrap]) {
|
||||
init();
|
||||
}
|
||||
}
|
||||
@@ -22,4 +23,8 @@ export function initHtmx(): void {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const element of getElements('[hx-trigger=load]')) {
|
||||
element.addEventListener('htmx:afterSettle', initDepedencies);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readableColor } from 'color2k';
|
||||
import debounce from 'just-debounce-it';
|
||||
import { encode } from 'html-entities';
|
||||
import queryString from 'query-string';
|
||||
import SlimSelect from 'slim-select';
|
||||
import { createToast } from '../../bs';
|
||||
@@ -446,7 +447,7 @@ export class APISelect {
|
||||
// Build SlimSelect options from all already-selected options.
|
||||
const preSelectedOptions = preSelected.map(option => ({
|
||||
value: option.value,
|
||||
text: option.innerText,
|
||||
text: encode(option.innerText),
|
||||
selected: true,
|
||||
disabled: false,
|
||||
})) as Option[];
|
||||
@@ -454,7 +455,7 @@ export class APISelect {
|
||||
let options = [] as Option[];
|
||||
|
||||
for (const result of data.results) {
|
||||
let text = result.display;
|
||||
let text = encode(result.display);
|
||||
|
||||
if (typeof result._depth === 'number' && result._depth > 0) {
|
||||
// If the object has a `_depth` property, indent its display text.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user