mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-21 01:18:08 +01:00
Compare commits
201 Commits
v3.5-beta2
...
v3.5.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
c9b79ca579 | ||
|
|
fbc7811f56 | ||
|
|
005e3fd692 | ||
|
|
078893e034 | ||
|
|
80fc8db514 | ||
|
|
fa3bedb947 | ||
|
|
c8d9a3b4eb | ||
|
|
311dce0b5f | ||
|
|
23b21246f0 | ||
|
|
92c49669f9 | ||
|
|
2204735e9f | ||
|
|
0df6a5793a | ||
|
|
eeb15ab5d1 | ||
|
|
d5be59ef67 | ||
|
|
0ad88e2431 | ||
|
|
c65b2a080f | ||
|
|
0f44f7eb20 | ||
|
|
e40e9cb406 | ||
|
|
21f4761335 | ||
|
|
39fd64b2ef | ||
|
|
567285d36a | ||
|
|
ff874a24dd | ||
|
|
9b80ec22ba | ||
|
|
cc0c985fec | ||
|
|
4eb5e90ccc | ||
|
|
e71a98499f | ||
|
|
011a936a56 | ||
|
|
556beeee6c | ||
|
|
b7f028fba3 | ||
|
|
2d0ac213c7 | ||
|
|
6b19f15a7b | ||
|
|
57156f0e94 | ||
|
|
4e49f4a434 | ||
|
|
c55c14ea4c | ||
|
|
e1b7a3aeb6 | ||
|
|
2b2c559a37 | ||
|
|
1af3ba9496 | ||
|
|
cb6852bf7a | ||
|
|
259d0e96f2 | ||
|
|
9eeca06115 | ||
|
|
da781b8d28 | ||
|
|
896b19eaa3 | ||
|
|
12bef7623c | ||
|
|
e96cfadd22 | ||
|
|
5f184f2435 | ||
|
|
56a4d0333e | ||
|
|
6794742213 | ||
|
|
a29a07ed26 | ||
|
|
42c80f69e6 | ||
|
|
ca0e7be637 | ||
|
|
42346702a1 | ||
|
|
9909213c0d | ||
|
|
7a38f601de | ||
|
|
abdcfdecf5 | ||
|
|
9d62174e1e | ||
|
|
a96b76a8d1 | ||
|
|
ab69faab87 | ||
|
|
f3826e6235 | ||
|
|
3eba65b5c2 | ||
|
|
683ef30af4 | ||
|
|
46914d9479 | ||
|
|
ea8a0135ad | ||
|
|
25142e037a | ||
|
|
93b912c2da | ||
|
|
4df517e4da | ||
|
|
2c756873aa | ||
|
|
01fa6e28cd | ||
|
|
5036020dc0 | ||
|
|
78ec3a6411 | ||
|
|
24650d9118 | ||
|
|
b14a514b47 | ||
|
|
7aa8434575 | ||
|
|
fbcf4c268b | ||
|
|
a566a56a64 | ||
|
|
cc3b95bdb0 | ||
|
|
2099cd0fdc | ||
|
|
3bdbf67b8f | ||
|
|
20f0464824 | ||
|
|
1952707702 | ||
|
|
9319cffb1c | ||
|
|
261f5e4995 | ||
|
|
8cede0daf8 | ||
|
|
139ef7ef4c | ||
|
|
47f3023401 | ||
|
|
a0f0b29432 | ||
|
|
f86f4f9257 | ||
|
|
3fc7c0edc7 | ||
|
|
528fb21a7e | ||
|
|
6206d226ae | ||
|
|
aabaeec1d7 | ||
|
|
25dc7e234d | ||
|
|
eac7d01977 | ||
|
|
a5bc9d4a2d | ||
|
|
b3efb14176 | ||
|
|
a2f4fce5b3 | ||
|
|
6109bef700 | ||
|
|
72767fb5b7 | ||
|
|
84089ab8c5 | ||
|
|
9a788349a9 | ||
|
|
b7140a0e4a | ||
|
|
d39c796828 | ||
|
|
4a92f6867a | ||
|
|
4355085124 | ||
|
|
5d4ef5e9e5 | ||
|
|
f49e4ee512 | ||
|
|
f867cb3ae0 | ||
|
|
a49fdad5e1 | ||
|
|
1ad029712e | ||
|
|
d87235af2f | ||
|
|
99af126fac | ||
|
|
83cea218b5 | ||
|
|
9eb38ab7b2 | ||
|
|
d3206d9bf9 | ||
|
|
adb9673f09 | ||
|
|
b693123f6e | ||
|
|
e7663b7e39 | ||
|
|
053be952ba | ||
|
|
390619ca99 | ||
|
|
b1130ff9b6 | ||
|
|
89fa546a14 | ||
|
|
c8988bac8a | ||
|
|
55385dd0db | ||
|
|
38a0ed5e24 | ||
|
|
15d80f4e1b | ||
|
|
2fe5592c3c | ||
|
|
8cf0a79dee | ||
|
|
183c5ca667 | ||
|
|
d5c4b1e27c | ||
|
|
2fcdc0ae6a | ||
|
|
12bb0ec1fe | ||
|
|
8b7ee0a0db | ||
|
|
274cd5d56c | ||
|
|
ab3531558a | ||
|
|
dda56f21f3 | ||
|
|
31c909c368 | ||
|
|
164b2a5016 | ||
|
|
7b374e4cf6 | ||
|
|
13625325f5 | ||
|
|
b84ac184c2 | ||
|
|
d5d2431cbd | ||
|
|
c08c7dda50 | ||
|
|
b807198e6d | ||
|
|
9caa7f6b7c |
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-beta2
|
||||
placeholder: v3.5.4
|
||||
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."
|
||||
|
||||
24
.github/ISSUE_TEMPLATE/deprecation.yaml
vendored
Normal file
24
.github/ISSUE_TEMPLATE/deprecation.yaml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: 🗑️ Deprecation
|
||||
description: The removal of an existing feature or resource
|
||||
labels: ["type: deprecation"]
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Proposed Changes
|
||||
description: >
|
||||
Describe in detail the proposed changes. What is being removed?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Justification
|
||||
description: Please provide justification for the proposed change(s).
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Impact
|
||||
description: List all areas of the application that will be affected by this change.
|
||||
validations:
|
||||
required: true
|
||||
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-beta2
|
||||
placeholder: v3.5.4
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
18
README.md
18
README.md
@@ -1,11 +1,10 @@
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
|
||||
|
||||
The premiere source of truth powering network automation
|
||||
<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" />
|
||||
<p></p>
|
||||
</div>
|
||||
|
||||

|
||||
|
||||
NetBox is the leading solution for modeling and documenting modern networks. By
|
||||
combining the traditional disciplines of IP address management (IPAM) and
|
||||
datacenter infrastructure management (DCIM) with powerful APIs and extensions,
|
||||
@@ -29,9 +28,18 @@ as the cornerstone for network automation in thousands of organizations.
|
||||
|
||||
## Getting Started
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/netbox-community/netbox)
|
||||
|
||||
[](https://github.com/netbox-community/netbox-docker)
|
||||
|
||||
[](https://netboxlabs.com/netbox-cloud/)
|
||||
|
||||
</div>
|
||||
|
||||
* Just want to explore? Check out [our public demo](https://demo.netbox.dev/) right now!
|
||||
* The [official documentation](https://docs.netbox.dev) offers a comprehensive introduction.
|
||||
* Choose your deployment: [self-hosted](https://github.com/netbox-community/netbox), [Docker](https://github.com/netbox-community/netbox-docker), or [NetBox Cloud](https://netboxlabs.com/netbox-cloud/).
|
||||
* Check out [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions) for even more projects to get the most out of NetBox!
|
||||
|
||||
## Get Involved
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# HTML sanitizer
|
||||
# https://github.com/mozilla/bleach/blob/main/CHANGES
|
||||
bleach<6.0
|
||||
bleach
|
||||
|
||||
# Python client for Amazon AWS API
|
||||
# https://github.com/boto/boto3/blob/develop/CHANGELOG.rst
|
||||
@@ -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
|
||||
@@ -137,8 +138,7 @@ social-auth-core
|
||||
|
||||
# Django app for social-auth-core
|
||||
# https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md
|
||||
# See https://github.com/python-social-auth/social-app-django/issues/429
|
||||
social-auth-app-django==5.0.0
|
||||
social-auth-app-django
|
||||
|
||||
# SVG image rendering (used for rack elevations)
|
||||
# hhttps://github.com/mozman/svgwrite/blob/master/NEWS.rst
|
||||
|
||||
17
contrib/netbox-housekeeping.service
Normal file
17
contrib/netbox-housekeeping.service
Normal file
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=NetBox Housekeeping Service
|
||||
Documentation=https://docs.netbox.dev/
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
User=netbox
|
||||
Group=netbox
|
||||
WorkingDirectory=/opt/netbox
|
||||
|
||||
ExecStart=/opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py housekeeping
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
13
contrib/netbox-housekeeping.timer
Normal file
13
contrib/netbox-housekeeping.timer
Normal file
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=NetBox Housekeeping Timer
|
||||
Documentation=https://docs.netbox.dev/
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
AccuracySec=1h
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -26,6 +26,8 @@ REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend'
|
||||
|
||||
Another option for remote authentication in NetBox is to enable HTTP header-based user assignment. The front end HTTP server (e.g. nginx or Apache) performs client authentication as a process external to NetBox, and passes information about the authenticated user via HTTP headers. By default, the user is assigned via the `REMOTE_USER` header, but this can be customized via the `REMOTE_AUTH_HEADER` configuration parameter.
|
||||
|
||||
Optionally, user profile information can be supplied by `REMOTE_USER_FIRST_NAME`, `REMOTE_USER_LAST_NAME` and `REMOTE_USER_EMAIL` headers. These are saved to the users profile during the authentication process. These headers can be customized like the `REMOTE_USER` header.
|
||||
|
||||
### Single Sign-On (SSO)
|
||||
|
||||
```python
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
### Enabling Error Reporting
|
||||
|
||||
NetBox v3.2.3 and later support native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, simply set `SENTRY_ENABLED` to True in `configuration.py`. Errors will be sent to a Sentry ingestor maintained by the NetBox team for analysis.
|
||||
NetBox supports native integration with [Sentry](https://sentry.io/) for automatic error reporting. To enable this functionality, simply set `SENTRY_ENABLED` to True in `configuration.py`. Errors will be sent to a Sentry ingestor maintained by the NetBox team for analysis.
|
||||
|
||||
```python
|
||||
SENTRY_ENABLED = True
|
||||
|
||||
@@ -7,7 +7,13 @@ NetBox includes a `housekeeping` management command that should be run nightly.
|
||||
* Deleting job result records older than the configured [retention time](../configuration/miscellaneous.md#job_retention)
|
||||
* Check for new NetBox releases (if [`RELEASE_CHECK_URL`](../configuration/miscellaneous.md#release_check_url) is set)
|
||||
|
||||
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
|
||||
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`.
|
||||
|
||||
## Scheduling
|
||||
|
||||
### Using Cron
|
||||
|
||||
This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
|
||||
|
||||
```shell
|
||||
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
|
||||
@@ -16,4 +22,28 @@ sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-hou
|
||||
!!! note
|
||||
On Debian-based systems, be sure to omit the `.sh` file extension when linking to the script from within a cron directory. Otherwise, the task may not run.
|
||||
|
||||
The `housekeeping` command can also be run manually at any time: Running the command outside scheduled execution times will not interfere with its operation.
|
||||
### Using Systemd
|
||||
|
||||
First, create symbolic links for the systemd service and timer files. Link the existing service and timer files from the `/opt/netbox/contrib/` directory to the `/etc/systemd/system/` directory:
|
||||
|
||||
```bash
|
||||
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.service /etc/systemd/system/netbox-housekeeping.service
|
||||
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.timer /etc/systemd/system/netbox-housekeeping.timer
|
||||
```
|
||||
|
||||
Then, reload the systemd configuration and enable the timer to start automatically at boot:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now netbox-housekeeping.timer
|
||||
```
|
||||
|
||||
Check the status of your timer by running:
|
||||
|
||||
```bash
|
||||
sudo systemctl list-timers --all
|
||||
```
|
||||
|
||||
This command will show a list of all timers, including your `netbox-housekeeping.timer`. Make sure the timer is active and properly scheduled.
|
||||
|
||||
That's it! Your NetBox housekeeping service is now configured to run daily using systemd.
|
||||
|
||||
@@ -153,15 +153,10 @@ New objects can be created by instantiating the desired model, defining values f
|
||||
```
|
||||
>>> lab1 = Site.objects.get(pk=7)
|
||||
>>> myvlan = VLAN(vid=123, name='MyNewVLAN', site=lab1)
|
||||
>>> myvlan.full_clean()
|
||||
>>> myvlan.save()
|
||||
```
|
||||
|
||||
Alternatively, the above can be performed as a single operation. (Note, however, that `save()` does _not_ return the new instance for reuse.)
|
||||
|
||||
```
|
||||
>>> VLAN(vid=123, name='MyNewVLAN', site=Site.objects.get(pk=7)).save()
|
||||
```
|
||||
|
||||
To modify an existing object, we retrieve it, update the desired field(s), and call `save()` again.
|
||||
|
||||
```
|
||||
@@ -169,6 +164,7 @@ To modify an existing object, we retrieve it, update the desired field(s), and c
|
||||
>>> vlan.name
|
||||
'MyNewVLAN'
|
||||
>>> vlan.name = 'BetterName'
|
||||
>>> vlan.full_clean()
|
||||
>>> vlan.save()
|
||||
>>> VLAN.objects.get(pk=1280).name
|
||||
'BetterName'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Object-Based Permissions
|
||||
|
||||
NetBox v2.9 introduced a new object-based permissions framework, which replaces Django's built-in permissions model. Object-based permissions enable an administrator to grant users or groups the ability to perform an action on arbitrary subsets of objects in NetBox, rather than all objects of a certain type. For example, it is possible to grant a user permission to view only sites within a particular region, or to modify only VLANs with a numeric ID within a certain range.
|
||||
NetBox employs a new object-based permissions framework, which replaces Django's built-in permissions model. Object-based permissions enable an administrator to grant users or groups the ability to perform an action on arbitrary subsets of objects in NetBox, rather than all objects of a certain type. For example, it is possible to grant a user permission to view only sites within a particular region, or to modify only VLANs with a numeric ID within a certain range.
|
||||
|
||||
A permission in NetBox represents a relationship shared by several components:
|
||||
|
||||
@@ -20,7 +20,7 @@ There are four core actions that can be permitted for each type of object within
|
||||
* **Change** - Modify an existing object
|
||||
* **Delete** - Delete an existing object
|
||||
|
||||
In addition to these, permissions can also grant custom actions that may be required by a specific model or plugin. For example, the `napalm_read` permission on the device model allows a user to execute NAPALM queries on a device via NetBox's REST API. These can be specified when granting a permission in the "additional actions" field.
|
||||
In addition to these, permissions can also grant custom actions that may be required by a specific model or plugin. For example, the `run` permission for scripts allows a user to execute custom scripts. These can be specified when granting a permission in the "additional actions" field.
|
||||
|
||||
!!! note
|
||||
Internally, all actions granted by a permission (both built-in and custom) are stored as strings in an array field named `actions`.
|
||||
|
||||
@@ -30,10 +30,6 @@ Some configuration parameters are primarily controlled via NetBox's admin interf
|
||||
* [`MAINTENANCE_MODE`](./miscellaneous.md#maintenance_mode)
|
||||
* [`MAPS_URL`](./miscellaneous.md#maps_url)
|
||||
* [`MAX_PAGE_SIZE`](./miscellaneous.md#max_page_size)
|
||||
* [`NAPALM_ARGS`](./napalm.md#napalm_args)
|
||||
* [`NAPALM_PASSWORD`](./napalm.md#napalm_password)
|
||||
* [`NAPALM_TIMEOUT`](./napalm.md#napalm_timeout)
|
||||
* [`NAPALM_USERNAME`](./napalm.md#napalm_username)
|
||||
* [`PAGINATE_COUNT`](./default-values.md#paginate_count)
|
||||
* [`POWERFEED_DEFAULT_AMPERAGE`](./default-values.md#powerfeed_default_amperage)
|
||||
* [`POWERFEED_DEFAULT_MAX_UTILIZATION`](./default-values.md#powerfeed_default_max_utilization)
|
||||
|
||||
@@ -29,6 +29,17 @@ This defines custom content to be displayed on the login page above the login fo
|
||||
|
||||
---
|
||||
|
||||
## BANNER_MAINTENANCE
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
|
||||
!!! note
|
||||
This parameter was added in NetBox v3.5.
|
||||
|
||||
This adds a banner to the top of every page when maintenance mode is enabled. HTML is allowed.
|
||||
|
||||
---
|
||||
|
||||
## BANNER_TOP
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
@@ -45,6 +56,16 @@ Sets content for the top banner in the user interface.
|
||||
|
||||
---
|
||||
|
||||
## CENSUS_REPORTING_ENABLED
|
||||
|
||||
Default: True
|
||||
|
||||
Enables anonymous census reporting. To opt out of census reporting, set this to False.
|
||||
|
||||
This data enables the project maintainers to estimate how many NetBox deployments exist and track the adoption of new versions over time. Census reporting effects a single HTTP request each time a worker starts. The only data reported by this function are the NetBox version, Python version, and a pseudorandom unique identifier.
|
||||
|
||||
---
|
||||
|
||||
## CHANGELOG_RETENTION
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
@@ -119,7 +140,7 @@ Setting this to True will display a "maintenance mode" banner at the top of ever
|
||||
|
||||
Default: `https://maps.google.com/?q=` (Google Maps)
|
||||
|
||||
This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it.
|
||||
This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it. Set this to `None` to disable the "map it" button within the UI.
|
||||
|
||||
---
|
||||
|
||||
@@ -183,3 +204,25 @@ This parameter defines the URL of the repository that will be checked for new Ne
|
||||
Default: `300`
|
||||
|
||||
The maximum execution time of a background task (such as running a custom script), in seconds.
|
||||
|
||||
---
|
||||
|
||||
## RQ_RETRY_INTERVAL
|
||||
|
||||
!!! note
|
||||
This parameter was added in NetBox v3.5.
|
||||
|
||||
Default: `60`
|
||||
|
||||
This parameter controls how frequently a failed job is retried, up to the maximum number of times specified by `RQ_RETRY_MAX`. This must be either an integer specifying the number of seconds to wait between successive attempts, or a list of such values. For example, `[60, 300, 3600]` will retry the task after 1 minute, 5 minutes, and 1 hour.
|
||||
|
||||
---
|
||||
|
||||
## RQ_RETRY_MAX
|
||||
|
||||
!!! note
|
||||
This parameter was added in NetBox v3.5.
|
||||
|
||||
Default: `0` (retries disabled)
|
||||
|
||||
The maximum number of times a background task will be retried before being marked as failed.
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
# NAPALM Parameters
|
||||
|
||||
!!! **Note:** As of NetBox v3.5, NAPALM integration has been moved to a plugin and these configuration parameters are now deprecated.
|
||||
|
||||
## NAPALM_USERNAME
|
||||
|
||||
## NAPALM_PASSWORD
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
|
||||
NetBox will use these credentials when authenticating to remote devices via the supported [NAPALM integration](../integrations/napalm.md), if installed. Both parameters are optional.
|
||||
|
||||
!!! note
|
||||
If SSH public key authentication has been set up on the remote device(s) for the system account under which NetBox runs, these parameters are not needed.
|
||||
|
||||
---
|
||||
|
||||
## NAPALM_ARGS
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
|
||||
A dictionary of optional arguments to pass to NAPALM when instantiating a network driver. See the NAPALM documentation for a [complete list of optional arguments](https://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example:
|
||||
|
||||
```python
|
||||
NAPALM_ARGS = {
|
||||
'api_key': '472071a93b60a1bd1fafb401d9f8ef41',
|
||||
'port': 2222,
|
||||
}
|
||||
```
|
||||
|
||||
Some platforms (e.g. Cisco IOS) require an argument named `secret` to be passed in addition to the normal password. If desired, you can use the configured `NAPALM_PASSWORD` as the value for this argument:
|
||||
|
||||
```python
|
||||
NAPALM_USERNAME = 'username'
|
||||
NAPALM_PASSWORD = 'MySecretPassword'
|
||||
NAPALM_ARGS = {
|
||||
'secret': NAPALM_PASSWORD,
|
||||
# Include any additional args here
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## NAPALM_TIMEOUT
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
|
||||
Default: 30 seconds
|
||||
|
||||
The amount of time (in seconds) to wait for NAPALM to connect to a device.
|
||||
|
||||
---
|
||||
|
||||
@@ -4,6 +4,14 @@ The configuration parameters listed here control remote authentication for NetBo
|
||||
|
||||
---
|
||||
|
||||
## REMOTE_AUTH_AUTO_CREATE_GROUPS
|
||||
|
||||
Default: `False`
|
||||
|
||||
If true, NetBox will automatically create groups specified in the `REMOTE_AUTH_GROUP_HEADER` header if they don't already exist. (Requires `REMOTE_AUTH_ENABLED`.)
|
||||
|
||||
---
|
||||
|
||||
## REMOTE_AUTH_AUTO_CREATE_USER
|
||||
|
||||
Default: `False`
|
||||
@@ -79,6 +87,30 @@ When remote user authentication is in use, this is the name of the HTTP header w
|
||||
|
||||
---
|
||||
|
||||
## REMOTE_AUTH_USER_EMAIL
|
||||
|
||||
Default: `'HTTP_REMOTE_USER_EMAIL'`
|
||||
|
||||
When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the email address of the currently authenticated user. For example, to use the request header `X-Remote-User-Email` it needs to be set to `HTTP_X_REMOTE_USER_EMAIL`. (Requires `REMOTE_AUTH_ENABLED`.)
|
||||
|
||||
---
|
||||
|
||||
## REMOTE_AUTH_USER_FIRST_NAME
|
||||
|
||||
Default: `'HTTP_REMOTE_USER_FIRST_NAME'`
|
||||
|
||||
When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the first name of the currently authenticated user. For example, to use the request header `X-Remote-User-First-Name` it needs to be set to `HTTP_X_REMOTE_USER_FIRST_NAME`. (Requires `REMOTE_AUTH_ENABLED`.)
|
||||
|
||||
---
|
||||
|
||||
## REMOTE_AUTH_USER_LAST_NAME
|
||||
|
||||
Default: `'HTTP_REMOTE_USER_LAST_NAME'`
|
||||
|
||||
When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the last name of the currently authenticated user. For example, to use the request header `X-Remote-User-Last-Name` it needs to be set to `HTTP_X_REMOTE_USER_LAST_NAME`. (Requires `REMOTE_AUTH_ENABLED`.)
|
||||
|
||||
---
|
||||
|
||||
## REMOTE_AUTH_SUPERUSER_GROUPS
|
||||
|
||||
Default: `[]` (Empty list)
|
||||
|
||||
@@ -33,11 +33,13 @@ NetBox requires access to a PostgreSQL 11 or later database service to store dat
|
||||
* `HOST` - Name or IP address of the database server (use `localhost` if running locally)
|
||||
* `PORT` - TCP port of the PostgreSQL service; leave blank for default port (TCP/5432)
|
||||
* `CONN_MAX_AGE` - Lifetime of a [persistent database connection](https://docs.djangoproject.com/en/stable/ref/databases/#persistent-connections), in seconds (300 is the default)
|
||||
* `ENGINE` - The database backend to use; must be a PostgreSQL-compatible backend (e.g. `django.db.backends.postgresql`)
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
DATABASE = {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'NAME': 'netbox', # Database name
|
||||
'USER': 'netbox', # PostgreSQL username
|
||||
'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
|
||||
@@ -50,6 +52,9 @@ DATABASE = {
|
||||
!!! note
|
||||
NetBox supports all PostgreSQL database options supported by the underlying Django framework. For a complete list of available parameters, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#databases).
|
||||
|
||||
!!! warning
|
||||
Make sure to use a PostgreSQL-compatible backend for the ENGINE setting. If you don't specify an ENGINE, the default will be django.db.backends.postgresql.
|
||||
|
||||
---
|
||||
|
||||
## REDIS
|
||||
@@ -144,8 +149,6 @@ REDIS = {
|
||||
|
||||
## SECRET_KEY
|
||||
|
||||
This is a secret, random string used to assist in the creation new cryptographic hashes for passwords and HTTP cookies. The key defined here should not be shared outside of the configuration file. `SECRET_KEY` can be changed at any time, however be aware that doing so will invalidate all existing sessions.
|
||||
This is a secret, pseudorandom string used to assist in the creation new cryptographic hashes for passwords and HTTP cookies. The key defined here should not be shared outside the configuration file. `SECRET_KEY` can be changed at any time without impacting stored data, however be aware that doing so will invalidate all existing user sessions. NetBox deployments comprising multiple nodes must have the same secret key configured on all nodes.
|
||||
|
||||
Please note that this key is **not** used directly for hashing user passwords or for the encrypted storage of secret data in NetBox.
|
||||
|
||||
`SECRET_KEY` should be at least 50 characters in length and contain a random mix of letters, digits, and symbols. The script located at `$INSTALL_ROOT/netbox/generate_secret_key.py` may be used to generate a suitable key.
|
||||
`SECRET_KEY` **must** be at least 50 characters in length, and should contain a mix of letters, digits, and symbols. The script located at `$INSTALL_ROOT/netbox/generate_secret_key.py` may be used to generate a suitable key. Please note that this key is **not** used directly for hashing user passwords or for the encrypted storage of secret data in NetBox.
|
||||
|
||||
@@ -67,6 +67,12 @@ The name of the cookie to use for the cross-site request forgery (CSRF) authenti
|
||||
|
||||
---
|
||||
|
||||
## CSRF_COOKIE_SECURE
|
||||
|
||||
Default: False
|
||||
|
||||
If true, the cookie employed for cross-site request forgery (CSRF) protection will be marked as secure, meaning that it can only be sent across an HTTPS connection.
|
||||
|
||||
---
|
||||
|
||||
## CSRF_TRUSTED_ORIGINS
|
||||
@@ -145,6 +151,17 @@ The view name or URL to which a user is redirected after logging out.
|
||||
|
||||
---
|
||||
|
||||
## SECURE_SSL_REDIRECT
|
||||
|
||||
Default: False
|
||||
|
||||
If true, all non-HTTPS requests will be automatically redirected to use HTTPS.
|
||||
|
||||
!!! warning
|
||||
Ensure that your frontend HTTP daemon has been configured to forward the HTTP scheme correctly before enabling this option. An incorrectly configured frontend may result in a looping redirect.
|
||||
|
||||
---
|
||||
|
||||
## SESSION_COOKIE_NAME
|
||||
|
||||
Default: `sessionid`
|
||||
@@ -153,6 +170,14 @@ The name used for the session cookie. See the [Django documentation](https://doc
|
||||
|
||||
---
|
||||
|
||||
## SESSION_COOKIE_SECURE
|
||||
|
||||
Default: False
|
||||
|
||||
If true, the cookie employed for session authentication will be marked as secure, meaning that it can only be sent across an HTTPS connection.
|
||||
|
||||
---
|
||||
|
||||
## SESSION_FILE_PATH
|
||||
|
||||
Default: None
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside NetBox. For example, you might create a custom link on the device view which links to the current device in a Network Monitoring System (NMS).
|
||||
|
||||
Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link has display text and a URL, and data from the NetBox item being viewed can be included in the link using [Jinja2 template code](https://jinja2docs.readthedocs.io/en/stable/) through the variable `obj`, and custom fields through `obj.cf`.
|
||||
Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link has display text and a URL, and data from the NetBox item being viewed can be included in the link using [Jinja2 template code](https://jinja2docs.readthedocs.io/en/stable/) through the variable `object`, and custom fields through `object.cf`.
|
||||
|
||||
For example, you might define a link like this:
|
||||
|
||||
* Text: `View NMS`
|
||||
* URL: `https://nms.example.com/nodes/?name={{ obj.name }}`
|
||||
* URL: `https://nms.example.com/nodes/?name={{ object.name }}`
|
||||
|
||||
When viewing a device named Router4, this link would render as:
|
||||
|
||||
@@ -27,7 +27,6 @@ The following context data is available within the template when rendering a cus
|
||||
| Variable | Description |
|
||||
|-----------|-------------------------------------------------------------------------------------------------------------------|
|
||||
| `object` | The NetBox object being displayed |
|
||||
| `obj` | Same as `object`; maintained for backward compatability until NetBox v3.5 |
|
||||
| `debug` | A boolean indicating whether debugging is enabled |
|
||||
| `request` | The current WSGI request |
|
||||
| `user` | The current user (if authenticated) |
|
||||
@@ -44,7 +43,7 @@ Only links which render with non-empty text are included on the page. You can em
|
||||
For example, if you only want to display a link for active devices, you could set the link text to
|
||||
|
||||
```jinja2
|
||||
{% if obj.status == 'active' %}View NMS{% endif %}
|
||||
{% if object.status == 'active' %}View NMS{% endif %}
|
||||
```
|
||||
|
||||
The link will not appear when viewing a device with any status other than "active."
|
||||
@@ -52,7 +51,7 @@ The link will not appear when viewing a device with any status other than "activ
|
||||
As another example, if you wanted to show only devices belonging to a certain manufacturer, you could do something like this:
|
||||
|
||||
```jinja2
|
||||
{% if obj.device_type.manufacturer.name == 'Cisco' %}View NMS{% endif %}
|
||||
{% if object.device_type.manufacturer.name == 'Cisco' %}View NMS{% endif %}
|
||||
```
|
||||
|
||||
The link will only appear when viewing a device with a manufacturer name of "Cisco."
|
||||
|
||||
@@ -35,12 +35,9 @@ class MyScript(Script):
|
||||
|
||||
The `run()` method should accept two arguments:
|
||||
|
||||
* `data` - A dictionary containing all of the variable data passed via the web form.
|
||||
* `data` - A dictionary containing all the variable data passed via the web form.
|
||||
* `commit` - A boolean indicating whether database changes will be committed.
|
||||
|
||||
!!! note
|
||||
The `commit` argument was introduced in NetBox v2.7.8. Backward compatibility is maintained for scripts which accept only the `data` argument, however beginning with v2.10 NetBox will require the `run()` method of every script to accept both arguments. (Either argument may still be ignored within the method.)
|
||||
|
||||
Defining script variables is optional: You may create a script with only a `run()` method if no user input is needed.
|
||||
|
||||
Any output generated by the script during its execution will be displayed under the "output" tab in the UI.
|
||||
@@ -381,6 +378,7 @@ class NewBranchScript(Script):
|
||||
slug=slugify(data['site_name']),
|
||||
status=SiteStatusChoices.STATUS_PLANNED
|
||||
)
|
||||
site.full_clean()
|
||||
site.save()
|
||||
self.log_success(f"Created new site: {site}")
|
||||
|
||||
@@ -394,6 +392,7 @@ class NewBranchScript(Script):
|
||||
status=DeviceStatusChoices.STATUS_PLANNED,
|
||||
device_role=switch_role
|
||||
)
|
||||
switch.full_clean()
|
||||
switch.save()
|
||||
self.log_success(f"Created new switch: {switch}")
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ These are considered the "core" application models which are used to model netwo
|
||||
|
||||
* [circuits.Circuit](../models/circuits/circuit.md)
|
||||
* [circuits.Provider](../models/circuits/provider.md)
|
||||
* [circuits.ProviderAccount](../models/circuits/provideracount.md)
|
||||
* [circuits.ProviderAccount](../models/circuits/provideraccount.md)
|
||||
* [circuits.ProviderNetwork](../models/circuits/providernetwork.md)
|
||||
* [core.DataSource](../models/core/datasource.md)
|
||||
* [dcim.Cable](../models/dcim/cable.md)
|
||||
|
||||
@@ -30,14 +30,6 @@ A webhook is a mechanism for conveying to some external system a change that too
|
||||
|
||||
To learn more about this feature, check out the [webhooks documentation](../integrations/webhooks.md).
|
||||
|
||||
## NAPALM
|
||||
|
||||
[NAPALM](https://github.com/napalm-automation/napalm) is a Python library which enables direct interaction with network devices of various platforms. When configured, NetBox supports fetching live operational and status data directly from network devices to be compared to what has been defined in NetBox. This allows for easily validating the device's operational state against its desired state. Additionally, NetBox's REST API can act as a sort of proxy for NAPALM commands, allowing external clients to interact with network devices by sending HTTP requests to the appropriate API endpoint.
|
||||
|
||||
To learn more about this feature, check out the [NAPALM documentation](../integrations/napalm.md).
|
||||
|
||||
As of NetBox v3.5, NAPALM integration has been moved to a plugin. Please see the [netbox_napalm_plugin](https://github.com/netbox-community/netbox-napalm) for installation instructions.
|
||||
|
||||
## Prometheus Metrics
|
||||
|
||||
NetBox includes a special `/metrics` view which exposes metrics for a [Prometheus](https://prometheus.io/) scraper, powered by the open source [django-prometheus](https://github.com/korfuri/django-prometheus) library. To learn more about this feature, check out the [Prometheus metrics documentation](../integrations/prometheus-metrics.md).
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Configuration Rendering
|
||||
|
||||
!!! info "This feature was introduced in NetBox v3.5."
|
||||
|
||||
One of the critical aspects of operating a network is ensuring that every network node is configured correctly. By leveraging configuration templates and [context data](./context-data.md), NetBox can render complete configuration files for each device on your network.
|
||||
|
||||
```mermaid
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
|
||||
[Redis](https://redis.io/) is an in-memory key-value store which NetBox employs for caching and queuing. This section entails the installation and configuration of a local Redis instance. If you already have a Redis service in place, skip to [the next section](3-netbox.md).
|
||||
|
||||
!!! warning "Redis v4.0 or later required"
|
||||
NetBox v2.9.0 and later require Redis v4.0 or higher. If your distribution does not offer a recent enough release, you will need to build Redis from source. Please see [the Redis installation documentation](https://github.com/redis/redis) for further details.
|
||||
|
||||
=== "Ubuntu"
|
||||
|
||||
```no-highlight
|
||||
|
||||
@@ -100,6 +100,8 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s
|
||||
```
|
||||
sudo adduser --system --group netbox
|
||||
sudo chown --recursive netbox /opt/netbox/netbox/media/
|
||||
sudo chown --recursive netbox /opt/netbox/netbox/reports/
|
||||
sudo chown --recursive netbox /opt/netbox/netbox/scripts/
|
||||
```
|
||||
|
||||
=== "CentOS"
|
||||
@@ -108,6 +110,8 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s
|
||||
sudo groupadd --system netbox
|
||||
sudo adduser --system -g netbox netbox
|
||||
sudo chown --recursive netbox /opt/netbox/netbox/media/
|
||||
sudo chown --recursive netbox /opt/netbox/netbox/reports/
|
||||
sudo chown --recursive netbox /opt/netbox/netbox/scripts/
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -15,7 +15,7 @@ sudo apt install -y libldap2-dev libsasl2-dev libssl-dev
|
||||
On CentOS:
|
||||
|
||||
```no-highlight
|
||||
sudo yum install -y openldap-devel
|
||||
sudo yum install -y openldap-devel python3-devel
|
||||
```
|
||||
|
||||
### Install django-auth-ldap
|
||||
|
||||
@@ -97,7 +97,7 @@ sudo git pull origin master
|
||||
|
||||
## 4. Run the Upgrade Script
|
||||
|
||||
Once the new code is in place, verify that any optional Python packages required by your deployment (e.g. `napalm` or `django-auth-ldap`) are listed in `local_requirements.txt`. Then, run the upgrade script:
|
||||
Once the new code is in place, verify that any optional Python packages required by your deployment (e.g. `django-auth-ldap`) are listed in `local_requirements.txt`. Then, run the upgrade script:
|
||||
|
||||
```no-highlight
|
||||
sudo ./upgrade.sh
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# NAPALM
|
||||
|
||||
As of NetBox v3.5, NAPALM integration has been moved to a plugin. Please see the [netbox_napalm_plugin](https://github.com/netbox-community/netbox-napalm) for installation instructions. **Note:** All previously entered NAPALM configuration data will be saved and automatically imported by the new plugin.
|
||||
@@ -63,7 +63,7 @@ Each attribute of the IP address is expressed as an attribute of the JSON object
|
||||
|
||||
## Interactive Documentation
|
||||
|
||||
Comprehensive, interactive documentation of all REST API endpoints is available on a running NetBox instance at `/api/docs/`. This interface provides a convenient sandbox for researching and experimenting with specific endpoints and request types. The API itself can also be explored using a web browser by navigating to its root at `/api/`.
|
||||
Comprehensive, interactive documentation of all REST API endpoints is available on a running NetBox instance at `/api/schema/swagger-ui/`. This interface provides a convenient sandbox for researching and experimenting with specific endpoints and request types. The API itself can also be explored using a web browser by navigating to its root at `/api/`.
|
||||
|
||||
## Endpoint Hierarchy
|
||||
|
||||
@@ -638,7 +638,7 @@ $ curl -X POST \
|
||||
https://netbox/api/users/tokens/provision/ \
|
||||
--data '{
|
||||
"username": "hankhill",
|
||||
"password": "I<3C3H8",
|
||||
"password": "I<3C3H8"
|
||||
}'
|
||||
```
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ NetBox was built specifically to serve the needs of network engineers and operat
|
||||
* Robust object-based permissions
|
||||
* Detailed, automatic change logging
|
||||
* Global search engine
|
||||
* NAPALM integration
|
||||
|
||||
## What NetBox Is Not
|
||||
|
||||
@@ -78,4 +77,3 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
|
||||
| Application | Django/Python |
|
||||
| Database | PostgreSQL 11+ |
|
||||
| Task queuing | Redis/django-rq |
|
||||
| Live device access | NAPALM (optional) |
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 173 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 171 KiB |
@@ -1,17 +1,19 @@
|
||||
# Provider Accounts
|
||||
|
||||
This model can be used to represent individual accounts associated with a provider.
|
||||
|
||||
## Fields
|
||||
|
||||
### Provider
|
||||
|
||||
The [provider](./provider.md) the account belongs to.
|
||||
|
||||
### Name
|
||||
|
||||
A human-friendly name, unique to the provider.
|
||||
|
||||
### Account Number
|
||||
|
||||
The administrative account identifier tied to this provider for your organization.
|
||||
# Provider Accounts
|
||||
|
||||
!!! info "This model was introduced in NetBox v3.5."
|
||||
|
||||
This model can be used to represent individual accounts associated with a provider.
|
||||
|
||||
## Fields
|
||||
|
||||
### Provider
|
||||
|
||||
The [provider](./provider.md) the account belongs to.
|
||||
|
||||
### Name
|
||||
|
||||
A human-friendly name, unique to the provider.
|
||||
|
||||
### Account Number
|
||||
|
||||
The administrative account identifier tied to this provider for your organization.
|
||||
|
||||
@@ -4,8 +4,6 @@ A platform defines the type of software running on a [device](./device.md) or [v
|
||||
|
||||
Platforms may optionally be limited by [manufacturer](./manufacturer.md): If a platform is assigned to a particular manufacturer, it can only be assigned to devices with a type belonging to that manufacturer.
|
||||
|
||||
The platform model is also used to indicate which [NAPALM driver](../../integrations/napalm.md) (if any) and any associated arguments NetBox should use when connecting to a remote device. The name of the driver along with optional parameters are stored with the platform.
|
||||
|
||||
The assignment of platforms to devices is an optional feature, and may be disregarded if not desired.
|
||||
|
||||
## Fields
|
||||
@@ -28,8 +26,14 @@ The default [configuration template](../extras/configtemplate.md) for devices as
|
||||
|
||||
### NAPALM Driver
|
||||
|
||||
!!! warning "Deprecated Field"
|
||||
NAPALM integration was removed from NetBox core in v3.5 and is now available as a [plugin](https://github.com/netbox-community/netbox-napalm). This field will be removed in NetBox v3.6.
|
||||
|
||||
The [NAPALM driver](https://napalm.readthedocs.io/en/latest/support/index.html) associated with this platform.
|
||||
|
||||
### NAPALM Arguments
|
||||
|
||||
!!! warning "Deprecated Field"
|
||||
NAPALM integration was removed from NetBox core in v3.5 and is now available as a [plugin](https://github.com/netbox-community/netbox-napalm). This field will be removed in NetBox v3.6.
|
||||
|
||||
Any additional arguments to send when invoking the NAPALM driver assigned to this platform.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# ASN Ranges
|
||||
|
||||
!!! info "This model was introduced in NetBox v3.5."
|
||||
|
||||
Ranges can be defined to group [AS numbers](./asn.md) numerically and to facilitate their automatic provisioning. Each range must be assigned to a [RIR](./rir.md).
|
||||
|
||||
## Fields
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Dashboard Widgets
|
||||
|
||||
!!! note "Introduced in v3.5"
|
||||
Support for custom dashboard widgets was introduced in NetBox v3.5.
|
||||
!!! info "This feature was introduced in NetBox v3.5."
|
||||
|
||||
Each NetBox user can customize his or her personal dashboard by adding and removing widgets and by manipulating the size and position of each. Plugins can register their own dashboard widgets to complement those already available natively.
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
Plugins are packaged [Django](https://docs.djangoproject.com/) apps that can be installed alongside NetBox to provide custom functionality not present in the core application. Plugins can introduce their own models and views, but cannot interfere with existing components. A NetBox user may opt to install plugins provided by the community or build his or her own.
|
||||
|
||||
Plugins are supported on NetBox v2.8 and later.
|
||||
|
||||
## Capabilities
|
||||
|
||||
The NetBox plugin architecture allows for the following:
|
||||
|
||||
@@ -10,6 +10,16 @@ Minor releases are published in April, August, and December of each calendar yea
|
||||
|
||||
This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
|
||||
|
||||
#### [Version 3.5](./version-3.5.md) (April 2023)
|
||||
|
||||
* Customizable Dashboard ([#9416](https://github.com/netbox-community/netbox/issues/9416))
|
||||
* Remote Data Sources ([#11558](https://github.com/netbox-community/netbox/issues/11558))
|
||||
* Configuration Template Rendering ([#11559](https://github.com/netbox-community/netbox/issues/11559))
|
||||
* NAPALM Integration Plugin ([#10520](https://github.com/netbox-community/netbox/issues/10520))
|
||||
* ASN Ranges ([#8550](https://github.com/netbox-community/netbox/issues/8550))
|
||||
* Provider Accounts ([#9047](https://github.com/netbox-community/netbox/issues/9047))
|
||||
* Job-Triggered Webhooks ([#8958](https://github.com/netbox-community/netbox/issues/8958))
|
||||
|
||||
#### [Version 3.4](./version-3.4.md) (December 2022)
|
||||
|
||||
* New Global Search ([#10560](https://github.com/netbox-community/netbox/issues/10560))
|
||||
|
||||
@@ -1,6 +1,34 @@
|
||||
# NetBox v3.4
|
||||
|
||||
## v3.4.9 (FUTURE)
|
||||
## v3.4.10 (2023-04-27)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11607](https://github.com/netbox-community/netbox/issues/11607) - Fix custom object field assignments made via REST API for for cables
|
||||
* [#12252](https://github.com/netbox-community/netbox/issues/12252) - Fix ordering of search results when sorting by object name
|
||||
* [#12355](https://github.com/netbox-community/netbox/issues/12355) - Fix escaping of certain characters in URL when rendering custom links
|
||||
|
||||
---
|
||||
|
||||
## v3.4.9 (2023-04-26)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#10987](https://github.com/netbox-community/netbox/issues/10987) - Show peer racks as a dropdown list under rack view
|
||||
* [#11386](https://github.com/netbox-community/netbox/issues/11386) - Introduce `CSRF_COOKIE_SECURE`, `SECURE_SSL_REDIRECT`, and `SESSION_COOKIE_SECURE` configuration parameters
|
||||
* [#11623](https://github.com/netbox-community/netbox/issues/11623) - Hide PSK strings under wireless LAN & link views
|
||||
* [#12205](https://github.com/netbox-community/netbox/issues/12205) - Sanitize rendered custom links to mitigate malicious links
|
||||
* [#12226](https://github.com/netbox-community/netbox/issues/12226) - Enable setting user name & email values via remote authenticate headers
|
||||
* [#12337](https://github.com/netbox-community/netbox/issues/12337) - Enable anonymized reporting of census data
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11383](https://github.com/netbox-community/netbox/issues/11383) - Fix ordering of global search results by object type
|
||||
* [#11902](https://github.com/netbox-community/netbox/issues/11902) - Fix import of inventory items for devices with duplicated names
|
||||
* [#12238](https://github.com/netbox-community/netbox/issues/12238) - Improve error message for API token IP prefix validation failures
|
||||
* [#12255](https://github.com/netbox-community/netbox/issues/12255) - Restore the ability to move inventory items among devices
|
||||
* [#12270](https://github.com/netbox-community/netbox/issues/12270) - Fix pre-population of list values when creating a saved filter
|
||||
* [#12296](https://github.com/netbox-community/netbox/issues/12296) - Fix "mark connected" form field for bulk editing front & rear ports
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,13 +1,147 @@
|
||||
# NetBox v3.5
|
||||
|
||||
## v3.5-beta2 (2023-04-18)
|
||||
## 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
|
||||
|
||||
* [#7671](https://github.com/netbox-community/netbox/issues/7671) - Introduce `REMOTE_AUTH_AUTO_CREATE_GROUPS` config parameter to enable the automatic creation of new groups when remote authentication is in use
|
||||
* [#9068](https://github.com/netbox-community/netbox/issues/9068) - Disallow the assignment of network/broadcast IP addresses to interfaces
|
||||
* [#11017](https://github.com/netbox-community/netbox/issues/11017) - Increase the maximum values for allocated and maximum power draws
|
||||
* [#11233](https://github.com/netbox-community/netbox/issues/11233) - Intercept and cleanly report errors upon attempted database writes when maintenance mode is enabled
|
||||
* [#11599](https://github.com/netbox-community/netbox/issues/11599) - Move contacts panels to separate tabs under object views
|
||||
* [#11670](https://github.com/netbox-community/netbox/issues/11670) - Enable setting device type & module type weight via bulk import
|
||||
* [#11900](https://github.com/netbox-community/netbox/issues/11900) - Add an outline to the reservation markers on rack elevations
|
||||
* [#12131](https://github.com/netbox-community/netbox/issues/12131) - Show custom field description as an icon tooltip under object views
|
||||
* [#12223](https://github.com/netbox-community/netbox/issues/12223) - Add columns for parent device bay and position to devices list
|
||||
* [#12233](https://github.com/netbox-community/netbox/issues/12233) - Move related IP addresses table to a separate tab
|
||||
* [#12286](https://github.com/netbox-community/netbox/issues/12286) - Show height and total weight under device view
|
||||
* [#12323](https://github.com/netbox-community/netbox/issues/12323) - Add 100GE CXP interface type
|
||||
* [#12327](https://github.com/netbox-community/netbox/issues/12327) - Introduce the ability to automatically retry failed background jobs
|
||||
* [#12498](https://github.com/netbox-community/netbox/issues/12498) - Hide map button if `MAPS_URL` is empty
|
||||
* [#12548](https://github.com/netbox-community/netbox/issues/12548) - Optimize REST API performance when retrieving interfaces with L2VPN assignments
|
||||
* [#12554](https://github.com/netbox-community/netbox/issues/12554) - Allow customization or disabling of the maintenance mode banner
|
||||
* [#12605](https://github.com/netbox-community/netbox/issues/12605) - Add LX.5 port types
|
||||
* [#12629](https://github.com/netbox-community/netbox/issues/12629) - Add 400GE CDFP and CFP8 interface types
|
||||
* [#12678](https://github.com/netbox-community/netbox/issues/12678) - Add 200GE QSFP-DD interface type
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#10686](https://github.com/netbox-community/netbox/issues/10686) - Enable specifying termination object by virtual chassis master when importing cables
|
||||
* [#11619](https://github.com/netbox-community/netbox/issues/11619) - Enable assigning VLANs without a site to interfaces during bulk edit
|
||||
* [#12468](https://github.com/netbox-community/netbox/issues/12468) - Custom field names should not permit double underscores
|
||||
* [#12550](https://github.com/netbox-community/netbox/issues/12550) - Fix rear port selection widget under front port creation form
|
||||
* [#12570](https://github.com/netbox-community/netbox/issues/12570) - Disable ordering of synchronized object tables by the "synced" attribute
|
||||
* [#12594](https://github.com/netbox-community/netbox/issues/12594) - Enable selecting config context as object type in object counts dashboard widget
|
||||
* [#12642](https://github.com/netbox-community/netbox/issues/12642) - Fix bulk tenant assignment via cluster import form
|
||||
|
||||
---
|
||||
|
||||
## v3.5.1 (2023-05-05)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#10759](https://github.com/netbox-community/netbox/issues/10759) - Support Markdown rendering for custom field descriptions
|
||||
* [#11190](https://github.com/netbox-community/netbox/issues/11190) - Including systemd service & timer configurations for housekeeping tasks
|
||||
* [#11422](https://github.com/netbox-community/netbox/issues/11422) - Match on power panel name when searching for power feeds
|
||||
* [#11504](https://github.com/netbox-community/netbox/issues/11504) - Add filter to select individual racks under rack elevations view
|
||||
* [#11652](https://github.com/netbox-community/netbox/issues/11652) - Add a module status column to module bay tables
|
||||
* [#11791](https://github.com/netbox-community/netbox/issues/11791) - Enable configuration of custom database backend via `ENGINE` parameter
|
||||
* [#11801](https://github.com/netbox-community/netbox/issues/11801) - Include device description within rack elevation tooltip
|
||||
* [#11932](https://github.com/netbox-community/netbox/issues/11932) - Introduce a list view for image attachments, orderable by date and other attributes
|
||||
* [#12122](https://github.com/netbox-community/netbox/issues/12122) - Enable bulk import oj journal entries
|
||||
* [#12245](https://github.com/netbox-community/netbox/issues/12245) - Enable the assignment of wireless LANs to interfaces under bulk edit
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#10757](https://github.com/netbox-community/netbox/issues/10757) - Simplify IP address interface and NAT IP assignment form fields to avoid confusion
|
||||
* [#11715](https://github.com/netbox-community/netbox/issues/11715) - Prefix within a VRF should list global prefixes as parents only if they are containers
|
||||
* [#12363](https://github.com/netbox-community/netbox/issues/12363) - Fix whitespace for paragraph elements in Markdown-rendered table columns
|
||||
* [#12367](https://github.com/netbox-community/netbox/issues/12367) - Fix `RelatedObjectDoesNotExist` exception under certain conditions (regression from #11550)
|
||||
* [#12380](https://github.com/netbox-community/netbox/issues/12380) - Allow selecting object change as model under object list widget configuration
|
||||
* [#12384](https://github.com/netbox-community/netbox/issues/12384) - Add a three-second timeout for RSS reader widget
|
||||
* [#12395](https://github.com/netbox-community/netbox/issues/12395) - Fix "create & add another" action for objects with custom fields
|
||||
* [#12396](https://github.com/netbox-community/netbox/issues/12396) - Provider account should not be a required field in REST API serializer
|
||||
* [#12400](https://github.com/netbox-community/netbox/issues/12400) - Validate default values for object and multi-object custom fields
|
||||
* [#12401](https://github.com/netbox-community/netbox/issues/12401) - Support the creation of front ports without a pre-populated device ID
|
||||
* [#12405](https://github.com/netbox-community/netbox/issues/12405) - Fix filtering for VLAN groups displayed under site view
|
||||
* [#12410](https://github.com/netbox-community/netbox/issues/12410) - Fix base path for OpenAPI schema (fixes Swagger UI requests)
|
||||
* [#12416](https://github.com/netbox-community/netbox/issues/12416) - Fix `FileNotFoundError` exception when a managed script file is missing from disk
|
||||
* [#12412](https://github.com/netbox-community/netbox/issues/12412) - Device/VM interface MAC addresses can be nullified via REST API
|
||||
* [#12415](https://github.com/netbox-community/netbox/issues/12415) - Fix `ImportError` exception when running RQ worker
|
||||
* [#12433](https://github.com/netbox-community/netbox/issues/12433) - Correct the application of URL query parameters for object list dashboard widgets
|
||||
* [#12436](https://github.com/netbox-community/netbox/issues/12436) - Remove extraneous "add" button from contact assignments list
|
||||
* [#12463](https://github.com/netbox-community/netbox/issues/12463) - Fix the association of completed jobs with reports & scripts in the REST API
|
||||
* [#12464](https://github.com/netbox-community/netbox/issues/12464) - Apply credentials for git data source only when connecting via HTTP/S
|
||||
* [#12476](https://github.com/netbox-community/netbox/issues/12476) - Fix `TypeError` exception when running the `runscript` management command
|
||||
* [#12483](https://github.com/netbox-community/netbox/issues/12483) - Fix git remote data syncing when with HTTP proxies defined
|
||||
* [#12496](https://github.com/netbox-community/netbox/issues/12496) - Remove obsolete account field from provider UI view
|
||||
|
||||
---
|
||||
|
||||
## v3.5.0 (2023-04-27)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
* The `account` field has been removed from the provider model. This information is now tracked using the new provider account model. Multiple accounts can be assigned per provider.
|
||||
* A minimum length of 50 characters is now enforced for the `SECRET_KEY` configuration parameter.
|
||||
* The JobResult model has been moved from the `extras` app to `core` and renamed to Job. Accordingly, its REST API endpoint has been moved from `/api/extras/job-results/` to `/api/core/jobs/`.
|
||||
* The `obj_type` field on the Job model (previously JobResult) has been renamed to `object_type` for consistency with other models.
|
||||
* The `JOBRESULT_RETENTION` configuration parameter has been renamed to `JOB_RETENTION`.
|
||||
* The `obj` context variable is no longer passed when rendering custom links: Use `object` instead.
|
||||
* The REST API schema is now generated using the OpenAPI 3.0 spec
|
||||
* The URLs for the REST API schema documentation have changed:
|
||||
* `/api/docs/` is now `/api/schema/swagger-ui/`
|
||||
@@ -27,7 +161,7 @@ NetBox now has the ability to synchronize arbitrary data from external sources t
|
||||
|
||||
This release introduces the ability to render device configurations from Jinja2 templates natively within NetBox, via both the UI and REST API. The new [ConfigTemplate](../models/extras/configtemplate.md) model stores template code (which may be defined locally or sourced from remote data files). The rendering engine passes data gleaned from both config contexts and request parameters to generate complete configurations suitable for direct application to network devices.
|
||||
|
||||
#### NAPALM Plugin ([#10520](https://github.com/netbox-community/netbox/issues/10520))
|
||||
#### NAPALM Integration Plugin ([#10520](https://github.com/netbox-community/netbox/issues/10520))
|
||||
|
||||
The NAPALM integration feature found in previous NetBox releases has been moved from the core application to a [dedicated plugin](https://github.com/netbox-community/netbox-napalm). This allows greater control over the feature's configuration and will unlock additional potential as a separate project.
|
||||
|
||||
@@ -70,21 +204,17 @@ Two new webhook trigger events have been introduced: `job_start` and `job_end`.
|
||||
* [#11968](https://github.com/netbox-community/netbox/issues/11968) - Add navigation menu buttons to create device & VM components
|
||||
* [#12068](https://github.com/netbox-community/netbox/issues/12068) - Enable generic foreign key relationships from jobs to NetBox objects
|
||||
* [#12085](https://github.com/netbox-community/netbox/issues/12085) - Add a file source view for reports
|
||||
* [#12218](https://github.com/netbox-community/netbox/issues/12218) - Provide more relevant API endpoint descriptions in schema
|
||||
* [#12343](https://github.com/netbox-community/netbox/issues/12343) - Enforce a minimum length for `SECRET_KEY` configuration parameter
|
||||
|
||||
### Bug Fixes (From Beta1)
|
||||
### Bug Fixes (From Beta2)
|
||||
|
||||
* [#12103](https://github.com/netbox-community/netbox/issues/12103) - Limit the types of objects available for object count & list widgets
|
||||
* [#12105](https://github.com/netbox-community/netbox/issues/12105) - Prevent data sources from becoming stuck in "syncing" status when an exception is raised
|
||||
* [#12106](https://github.com/netbox-community/netbox/issues/12106) - Fix exception when saving dashboard widget with minimum width/height
|
||||
* [#12108](https://github.com/netbox-community/netbox/issues/12108) - Limit the draggable area of widgets to their headers
|
||||
* [#12109](https://github.com/netbox-community/netbox/issues/12109) - Fix migration error when replicating more than 100 job results
|
||||
* [#12112](https://github.com/netbox-community/netbox/issues/12112) - Do not link data source URL for local paths
|
||||
* [#12115](https://github.com/netbox-community/netbox/issues/12115) - Fix rendering config templates from a data file
|
||||
* [#12144](https://github.com/netbox-community/netbox/issues/12144) - Ensure consistent treatment of context data when rendering config templates via UI & API
|
||||
* [#12145](https://github.com/netbox-community/netbox/issues/12145) - Employ `HTMXSelect` widget to fix inclusion of `<select>` field values during form regeneration
|
||||
* [#12146](https://github.com/netbox-community/netbox/issues/12146) - Do not display object selector for disabled fields
|
||||
* [#12151](https://github.com/netbox-community/netbox/issues/12151) - Remove incorrect OpenAPI string mapping for choice fields
|
||||
* [#12167](https://github.com/netbox-community/netbox/issues/12167) - Catch and report on exceptions raised when rendering a config template
|
||||
* [#12149](https://github.com/netbox-community/netbox/issues/12149) - Fix OpenAPI schema warnings relating to enum collisions
|
||||
* [#12195](https://github.com/netbox-community/netbox/issues/12195) - Fix exception when setting IP address role to null via REST API
|
||||
* [#12256](https://github.com/netbox-community/netbox/issues/12256) - Fix OpenAPI schema warnings relating to nested serializers
|
||||
* [#12278](https://github.com/netbox-community/netbox/issues/12278) - Fix schema warnings related to IPAddressField
|
||||
* [#12288](https://github.com/netbox-community/netbox/issues/12288) - Include `servers` definition in OpenAPI spec
|
||||
* [#12299](https://github.com/netbox-community/netbox/issues/12299) - Fix object list widget support for filtering by multiple values
|
||||
|
||||
### Other Changes
|
||||
|
||||
|
||||
@@ -108,7 +108,6 @@ nav:
|
||||
- Default Values: 'configuration/default-values.md'
|
||||
- Error Reporting: 'configuration/error-reporting.md'
|
||||
- Plugins: 'configuration/plugins.md'
|
||||
- NAPALM: 'configuration/napalm.md'
|
||||
- Date & Time: 'configuration/date-time.md'
|
||||
- Miscellaneous: 'configuration/miscellaneous.md'
|
||||
- Development: 'configuration/development.md'
|
||||
@@ -124,7 +123,6 @@ nav:
|
||||
- GraphQL API: 'integrations/graphql-api.md'
|
||||
- Webhooks: 'integrations/webhooks.md'
|
||||
- Synchronized Data: 'integrations/synchronized-data.md'
|
||||
- NAPALM: 'integrations/napalm.md'
|
||||
- Prometheus Metrics: 'integrations/prometheus-metrics.md'
|
||||
- Plugins:
|
||||
- Using Plugins: 'plugins/index.md'
|
||||
|
||||
@@ -106,7 +106,7 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
|
||||
class CircuitSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
|
||||
provider = NestedProviderSerializer()
|
||||
provider_account = NestedProviderAccountSerializer()
|
||||
provider_account = NestedProviderAccountSerializer(required=False, allow_null=True)
|
||||
status = ChoiceField(choices=CircuitStatusChoices, required=False)
|
||||
type = NestedCircuitTypeSerializer()
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
|
||||
@@ -25,6 +25,22 @@ class CircuitStatusChoices(ChoiceSet):
|
||||
]
|
||||
|
||||
|
||||
class CircuitCommitRateChoices(ChoiceSet):
|
||||
key = 'Circuit.commit_rate'
|
||||
|
||||
CHOICES = [
|
||||
(10000, '10 Mbps'),
|
||||
(100000, '100 Mbps'),
|
||||
(1000000, '1 Gbps'),
|
||||
(10000000, '10 Gbps'),
|
||||
(25000000, '25 Gbps'),
|
||||
(40000000, '40 Gbps'),
|
||||
(100000000, '100 Gbps'),
|
||||
(1544, 'T1 (1.544 Mbps)'),
|
||||
(2048, 'E1 (2.048 Mbps)'),
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
# CircuitTerminations
|
||||
#
|
||||
@@ -38,3 +54,19 @@ class CircuitTerminationSideChoices(ChoiceSet):
|
||||
(SIDE_A, 'A'),
|
||||
(SIDE_Z, 'Z')
|
||||
)
|
||||
|
||||
|
||||
class CircuitTerminationPortSpeedChoices(ChoiceSet):
|
||||
key = 'CircuitTermination.port_speed'
|
||||
|
||||
CHOICES = [
|
||||
(10000, '10 Mbps'),
|
||||
(100000, '100 Mbps'),
|
||||
(1000000, '1 Gbps'),
|
||||
(10000000, '10 Gbps'),
|
||||
(25000000, '25 Gbps'),
|
||||
(40000000, '40 Gbps'),
|
||||
(100000000, '100 Gbps'),
|
||||
(1544, 'T1 (1.544 Mbps)'),
|
||||
(2048, 'E1 (2.048 Mbps)'),
|
||||
]
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
from django import forms
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from circuits.choices import CircuitStatusChoices
|
||||
from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices
|
||||
from circuits.models import *
|
||||
from ipam.models import ASN
|
||||
from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import add_blank_choice
|
||||
from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||
from utilities.forms.widgets import DatePicker
|
||||
from utilities.forms.widgets import DatePicker, NumberWithOptions
|
||||
|
||||
__all__ = (
|
||||
'CircuitBulkEditForm',
|
||||
@@ -139,7 +139,10 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
|
||||
)
|
||||
commit_rate = forms.IntegerField(
|
||||
required=False,
|
||||
label=_('Commit rate (Kbps)')
|
||||
label=_('Commit rate (Kbps)'),
|
||||
widget=NumberWithOptions(
|
||||
options=CircuitCommitRateChoices
|
||||
)
|
||||
)
|
||||
description = forms.CharField(
|
||||
max_length=100,
|
||||
|
||||
@@ -74,7 +74,8 @@ class CircuitImportForm(NetBoxModelImportForm):
|
||||
provider_account = CSVModelChoiceField(
|
||||
queryset=ProviderAccount.objects.all(),
|
||||
to_field_name='name',
|
||||
help_text=_('Assigned provider account')
|
||||
help_text=_('Assigned provider account'),
|
||||
required=False
|
||||
)
|
||||
type = CSVModelChoiceField(
|
||||
queryset=CircuitType.objects.all(),
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
from django import forms
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from circuits.choices import CircuitStatusChoices
|
||||
from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices
|
||||
from circuits.models import *
|
||||
from dcim.models import Region, Site, SiteGroup
|
||||
from ipam.models import ASN
|
||||
from netbox.forms import NetBoxModelFilterSetForm
|
||||
from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
|
||||
from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField
|
||||
from utilities.forms.widgets import DatePicker
|
||||
from utilities.forms.widgets import DatePicker, NumberWithOptions
|
||||
|
||||
__all__ = (
|
||||
'CircuitFilterForm',
|
||||
@@ -168,6 +168,9 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
|
||||
commit_rate = forms.IntegerField(
|
||||
required=False,
|
||||
min_value=0,
|
||||
label=_('Commit rate (Kbps)')
|
||||
label=_('Commit rate (Kbps)'),
|
||||
widget=NumberWithOptions(
|
||||
options=CircuitCommitRateChoices
|
||||
)
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from circuits.choices import CircuitCommitRateChoices, CircuitTerminationPortSpeedChoices
|
||||
from circuits.models import *
|
||||
from dcim.models import Site
|
||||
from ipam.models import ASN
|
||||
from netbox.forms import NetBoxModelForm
|
||||
from tenancy.forms import TenancyForm
|
||||
from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
|
||||
from utilities.forms.widgets import DatePicker, SelectSpeedWidget
|
||||
from utilities.forms.widgets import DatePicker, NumberWithOptions
|
||||
|
||||
__all__ = (
|
||||
'CircuitForm',
|
||||
@@ -116,7 +117,9 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
|
||||
widgets = {
|
||||
'install_date': DatePicker(),
|
||||
'termination_date': DatePicker(),
|
||||
'commit_rate': SelectSpeedWidget(),
|
||||
'commit_rate': NumberWithOptions(
|
||||
options=CircuitCommitRateChoices
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -143,6 +146,10 @@ class CircuitTerminationForm(NetBoxModelForm):
|
||||
'xconnect_id', 'pp_info', 'description', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'port_speed': SelectSpeedWidget(),
|
||||
'upstream_speed': SelectSpeedWidget(),
|
||||
'port_speed': NumberWithOptions(
|
||||
options=CircuitTerminationPortSpeedChoices
|
||||
),
|
||||
'upstream_speed': NumberWithOptions(
|
||||
options=CircuitTerminationPortSpeedChoices
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from django.contrib import messages
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
|
||||
from dcim.views import PathTraceView
|
||||
from netbox.views import generic
|
||||
from tenancy.views import ObjectContactsView
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.utils import count_related
|
||||
from utilities.views import register_model_view
|
||||
@@ -73,6 +73,11 @@ class ProviderBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.ProviderTable
|
||||
|
||||
|
||||
@register_model_view(Provider, 'contacts')
|
||||
class ProviderContactsView(ObjectContactsView):
|
||||
queryset = Provider.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# ProviderAccounts
|
||||
#
|
||||
@@ -134,6 +139,11 @@ class ProviderAccountBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.ProviderAccountTable
|
||||
|
||||
|
||||
@register_model_view(ProviderAccount, 'contacts')
|
||||
class ProviderAccountContactsView(ObjectContactsView):
|
||||
queryset = ProviderAccount.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# Provider networks
|
||||
#
|
||||
@@ -389,6 +399,11 @@ class CircuitSwapTerminations(generic.ObjectEditView):
|
||||
})
|
||||
|
||||
|
||||
@register_model_view(Circuit, 'contacts')
|
||||
class CircuitContactsView(ObjectContactsView):
|
||||
queryset = Circuit.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# Circuit terminations
|
||||
#
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
import re
|
||||
import typing
|
||||
|
||||
from drf_spectacular.extensions import (
|
||||
OpenApiSerializerFieldExtension,
|
||||
OpenApiViewExtension,
|
||||
)
|
||||
from drf_spectacular.extensions import OpenApiSerializerFieldExtension
|
||||
from drf_spectacular.openapi import AutoSchema
|
||||
from drf_spectacular.plumbing import (
|
||||
ComponentRegistry,
|
||||
ResolvedComponent,
|
||||
build_basic_type,
|
||||
build_media_type_object,
|
||||
build_object_type,
|
||||
is_serializer,
|
||||
build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc,
|
||||
)
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework.relations import ManyRelatedField
|
||||
|
||||
from netbox.api.fields import ChoiceField, SerializedPKRelatedField
|
||||
@@ -38,7 +29,7 @@ class ChoiceFieldFix(OpenApiSerializerFieldExtension):
|
||||
|
||||
def map_serializer_field(self, auto_schema, direction):
|
||||
if direction == 'request':
|
||||
return build_basic_type(OpenApiTypes.STR)
|
||||
return build_choice_field(self.target)
|
||||
|
||||
elif direction == "response":
|
||||
return build_object_type(
|
||||
@@ -150,8 +141,12 @@ class NetBoxAutoSchema(AutoSchema):
|
||||
def get_writable_class(self, serializer):
|
||||
properties = {}
|
||||
fields = {} if hasattr(serializer, 'child') else serializer.fields
|
||||
remove_fields = []
|
||||
|
||||
for child_name, child in fields.items():
|
||||
# read_only fields don't need to be in writable (write only) serializers
|
||||
if 'read_only' in dir(child) and child.read_only:
|
||||
remove_fields.append(child_name)
|
||||
if isinstance(child, (ChoiceField, WritableNestedSerializer)):
|
||||
properties[child_name] = None
|
||||
elif isinstance(child, ManyRelatedField) and isinstance(child.child_relation, SerializedPKRelatedField):
|
||||
@@ -165,7 +160,12 @@ class NetBoxAutoSchema(AutoSchema):
|
||||
meta_class = getattr(type(serializer), 'Meta', None)
|
||||
if meta_class:
|
||||
ref_name = 'Writable' + self.get_serializer_ref_name(serializer)
|
||||
writable_meta = type('Meta', (meta_class,), {'ref_name': ref_name})
|
||||
# remove read_only fields from write-only serializers
|
||||
fields = list(meta_class.fields)
|
||||
for field in remove_fields:
|
||||
fields.remove(field)
|
||||
writable_meta = type('Meta', (meta_class,), {'ref_name': ref_name, 'fields': fields})
|
||||
|
||||
properties['Meta'] = writable_meta
|
||||
|
||||
self.writable_serializers[type(serializer)] = type(writable_name, (type(serializer),), properties)
|
||||
@@ -222,3 +222,31 @@ class NetBoxAutoSchema(AutoSchema):
|
||||
if request_body_required:
|
||||
request_body['required'] = request_body_required
|
||||
return request_body
|
||||
|
||||
def get_description(self):
|
||||
"""
|
||||
Return a string description for the ViewSet.
|
||||
"""
|
||||
|
||||
# If a docstring is provided, use it.
|
||||
if self.view.__doc__:
|
||||
return get_doc(self.view.__class__)
|
||||
|
||||
# When the action method is decorated with @action, use the docstring of the method.
|
||||
action_or_method = getattr(self.view, getattr(self.view, 'action', self.method.lower()), None)
|
||||
if action_or_method and action_or_method.__doc__:
|
||||
return get_doc(action_or_method)
|
||||
|
||||
# Else, generate a description from the class name.
|
||||
return self._generate_description()
|
||||
|
||||
def _generate_description(self):
|
||||
"""
|
||||
Generate a docstring for the method. It also takes into account whether the method is for list or detail.
|
||||
"""
|
||||
model_name = self.view.queryset.model._meta.verbose_name
|
||||
|
||||
# Determine if the method is for list or detail.
|
||||
if '{id}' in self.path:
|
||||
return f"{self.method.capitalize()} a {model_name} object."
|
||||
return f"{self.method.capitalize()} a list of {model_name} objects."
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -12,7 +12,7 @@ from django import forms
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext as _
|
||||
from dulwich import porcelain
|
||||
from dulwich.config import StackedConfig
|
||||
from dulwich.config import ConfigDict
|
||||
|
||||
from netbox.registry import registry
|
||||
from .choices import DataSourceTypeChoices
|
||||
@@ -31,6 +31,7 @@ def register_backend(name):
|
||||
"""
|
||||
Decorator for registering a DataBackend class.
|
||||
"""
|
||||
|
||||
def _wrapper(cls):
|
||||
registry['data_backends'][name] = cls
|
||||
return cls
|
||||
@@ -56,7 +57,6 @@ class DataBackend:
|
||||
|
||||
@register_backend(DataSourceTypeChoices.LOCAL)
|
||||
class LocalBackend(DataBackend):
|
||||
|
||||
@contextmanager
|
||||
def fetch(self):
|
||||
logger.debug(f"Data source type is local; skipping fetch")
|
||||
@@ -71,12 +71,14 @@ class GitBackend(DataBackend):
|
||||
'username': forms.CharField(
|
||||
required=False,
|
||||
label=_('Username'),
|
||||
widget=forms.TextInput(attrs={'class': 'form-control'})
|
||||
widget=forms.TextInput(attrs={'class': 'form-control'}),
|
||||
help_text=_("Only used for cloning with HTTP / HTTPS"),
|
||||
),
|
||||
'password': forms.CharField(
|
||||
required=False,
|
||||
label=_('Password'),
|
||||
widget=forms.TextInput(attrs={'class': 'form-control'})
|
||||
widget=forms.TextInput(attrs={'class': 'form-control'}),
|
||||
help_text=_("Only used for cloning with HTTP / HTTPS"),
|
||||
),
|
||||
'branch': forms.CharField(
|
||||
required=False,
|
||||
@@ -89,10 +91,22 @@ class GitBackend(DataBackend):
|
||||
def fetch(self):
|
||||
local_path = tempfile.TemporaryDirectory()
|
||||
|
||||
username = self.params.get('username')
|
||||
password = self.params.get('password')
|
||||
branch = self.params.get('branch')
|
||||
config = StackedConfig.default()
|
||||
config = ConfigDict()
|
||||
clone_args = {
|
||||
"branch": self.params.get('branch'),
|
||||
"config": config,
|
||||
"depth": 1,
|
||||
"errstream": porcelain.NoneStream(),
|
||||
"quiet": True,
|
||||
}
|
||||
|
||||
if self.url_scheme in ('http', 'https'):
|
||||
clone_args.update(
|
||||
{
|
||||
"username": self.params.get('username'),
|
||||
"password": self.params.get('password'),
|
||||
}
|
||||
)
|
||||
|
||||
if settings.HTTP_PROXIES and self.url_scheme in ('http', 'https'):
|
||||
if proxy := settings.HTTP_PROXIES.get(self.url_scheme):
|
||||
@@ -100,10 +114,7 @@ class GitBackend(DataBackend):
|
||||
|
||||
logger.debug(f"Cloning git repo: {self.url}")
|
||||
try:
|
||||
porcelain.clone(
|
||||
self.url, local_path.name, depth=1, branch=branch, username=username, password=password,
|
||||
config=config, quiet=True, errstream=porcelain.NoneStream()
|
||||
)
|
||||
porcelain.clone(self.url, local_path.name, **clone_args)
|
||||
except BaseException as e:
|
||||
raise SyncError(f"Fetching remote data failed ({type(e).__name__}): {e}")
|
||||
|
||||
|
||||
25
netbox/core/forms/mixins.py
Normal file
25
netbox/core/forms/mixins.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from django import forms
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from core.models import DataFile, DataSource
|
||||
from utilities.forms.fields import DynamicModelChoiceField
|
||||
|
||||
__all__ = (
|
||||
'SyncedDataMixin',
|
||||
)
|
||||
|
||||
|
||||
class SyncedDataMixin(forms.Form):
|
||||
data_source = DynamicModelChoiceField(
|
||||
queryset=DataSource.objects.all(),
|
||||
required=False,
|
||||
label=_('Data source')
|
||||
)
|
||||
data_file = DynamicModelChoiceField(
|
||||
queryset=DataFile.objects.all(),
|
||||
required=False,
|
||||
label=_('File'),
|
||||
query_params={
|
||||
'source_id': '$data_source',
|
||||
}
|
||||
)
|
||||
@@ -2,8 +2,8 @@ import copy
|
||||
|
||||
from django import forms
|
||||
|
||||
from core.forms.mixins import SyncedDataMixin
|
||||
from core.models import *
|
||||
from extras.forms.mixins import SyncedDataMixin
|
||||
from netbox.forms import NetBoxModelForm
|
||||
from netbox.registry import registry
|
||||
from utilities.forms import get_field_value
|
||||
|
||||
@@ -16,7 +16,7 @@ from extras.utils import FeatureQuery
|
||||
from netbox.config import get_config
|
||||
from netbox.constants import RQ_QUEUE_DEFAULT
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.rqworker import get_queue_for_model
|
||||
from utilities.rqworker import get_queue_for_model, get_rq_retry
|
||||
|
||||
__all__ = (
|
||||
'Job',
|
||||
@@ -219,5 +219,6 @@ class Job(models.Model):
|
||||
event=event,
|
||||
data=self.data,
|
||||
timestamp=str(timezone.now()),
|
||||
username=self.user.username
|
||||
username=self.user.username,
|
||||
retry=get_rq_retry()
|
||||
)
|
||||
|
||||
@@ -456,7 +456,7 @@ class NestedInventoryItemRoleSerializer(WritableNestedSerializer):
|
||||
# Cables
|
||||
#
|
||||
|
||||
class NestedCableSerializer(BaseModelSerializer):
|
||||
class NestedCableSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -904,7 +904,11 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
|
||||
)
|
||||
count_ipaddresses = serializers.IntegerField(read_only=True)
|
||||
count_fhrp_groups = serializers.IntegerField(read_only=True)
|
||||
mac_address = serializers.CharField(required=False, default=None)
|
||||
mac_address = serializers.CharField(
|
||||
required=False,
|
||||
default=None,
|
||||
allow_null=True
|
||||
)
|
||||
wwn = serializers.CharField(required=False, default=None)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -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',
|
||||
@@ -493,7 +498,8 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
||||
class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
|
||||
queryset = Interface.objects.prefetch_related(
|
||||
'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path', 'cable__terminations', 'wireless_lans',
|
||||
'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags'
|
||||
'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags', 'l2vpn_terminations',
|
||||
'vdcs',
|
||||
)
|
||||
serializer_class = serializers.InterfaceSerializer
|
||||
filterset_class = filtersets.InterfaceFilterSet
|
||||
@@ -640,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)
|
||||
|
||||
@@ -807,12 +807,16 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_100GE_CFP = '100gbase-x-cfp'
|
||||
TYPE_100GE_CFP2 = '100gbase-x-cfp2'
|
||||
TYPE_100GE_CFP4 = '100gbase-x-cfp4'
|
||||
TYPE_100GE_CXP = '100gbase-x-cxp'
|
||||
TYPE_100GE_CPAK = '100gbase-x-cpak'
|
||||
TYPE_100GE_QSFP28 = '100gbase-x-qsfp28'
|
||||
TYPE_200GE_CFP2 = '200gbase-x-cfp2'
|
||||
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
|
||||
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
|
||||
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
|
||||
TYPE_400GE_OSFP = '400gbase-x-osfp'
|
||||
TYPE_400GE_CDFP = '400gbase-x-cdfp'
|
||||
TYPE_400GE_CFP8 = '400gbase-x-cfp8'
|
||||
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
|
||||
TYPE_800GE_OSFP = '800gbase-x-osfp'
|
||||
|
||||
@@ -952,11 +956,15 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_100GE_CFP2, 'CFP2 (100GE)'),
|
||||
(TYPE_200GE_CFP2, 'CFP2 (200GE)'),
|
||||
(TYPE_100GE_CFP4, 'CFP4 (100GE)'),
|
||||
(TYPE_100GE_CXP, 'CXP (100GE)'),
|
||||
(TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),
|
||||
(TYPE_100GE_QSFP28, 'QSFP28 (100GE)'),
|
||||
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
|
||||
(TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
|
||||
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
|
||||
(TYPE_400GE_OSFP, 'OSFP (400GE)'),
|
||||
(TYPE_400GE_CDFP, 'CDFP (400GE)'),
|
||||
(TYPE_400GE_CFP8, 'CPF8 (400GE)'),
|
||||
(TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
|
||||
(TYPE_800GE_OSFP, 'OSFP (800GE)'),
|
||||
)
|
||||
@@ -1096,6 +1104,20 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
)
|
||||
|
||||
|
||||
class InterfaceSpeedChoices(ChoiceSet):
|
||||
key = 'Interface.speed'
|
||||
|
||||
CHOICES = [
|
||||
(10000, '10 Mbps'),
|
||||
(100000, '100 Mbps'),
|
||||
(1000000, '1 Gbps'),
|
||||
(10000000, '10 Gbps'),
|
||||
(25000000, '25 Gbps'),
|
||||
(40000000, '40 Gbps'),
|
||||
(100000000, '100 Gbps'),
|
||||
]
|
||||
|
||||
|
||||
class InterfaceDuplexChoices(ChoiceSet):
|
||||
|
||||
DUPLEX_HALF = 'half'
|
||||
@@ -1207,6 +1229,10 @@ class PortTypeChoices(ChoiceSet):
|
||||
TYPE_LSH_PC = 'lsh-pc'
|
||||
TYPE_LSH_UPC = 'lsh-upc'
|
||||
TYPE_LSH_APC = 'lsh-apc'
|
||||
TYPE_LX5 = 'lx5'
|
||||
TYPE_LX5_PC = 'lx5-pc'
|
||||
TYPE_LX5_UPC = 'lx5-upc'
|
||||
TYPE_LX5_APC = 'lx5-apc'
|
||||
TYPE_SPLICE = 'splice'
|
||||
TYPE_CS = 'cs'
|
||||
TYPE_SN = 'sn'
|
||||
@@ -1253,6 +1279,10 @@ class PortTypeChoices(ChoiceSet):
|
||||
(TYPE_LSH_PC, 'LSH/PC'),
|
||||
(TYPE_LSH_UPC, 'LSH/UPC'),
|
||||
(TYPE_LSH_APC, 'LSH/APC'),
|
||||
(TYPE_LX5, 'LX.5'),
|
||||
(TYPE_LX5_PC, 'LX.5/PC'),
|
||||
(TYPE_LX5_UPC, 'LX.5/UPC'),
|
||||
(TYPE_LX5_APC, 'LX.5/APC'),
|
||||
(TYPE_MPO, 'MPO'),
|
||||
(TYPE_MTRJ, 'MTRJ'),
|
||||
(TYPE_SC, 'SC'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -811,7 +811,7 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
fields = ['id', 'name', 'slug', 'napalm_driver', 'description']
|
||||
|
||||
|
||||
class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
|
||||
@@ -1219,6 +1219,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(),
|
||||
@@ -1900,6 +1922,7 @@ class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpoi
|
||||
return queryset
|
||||
qs_filter = (
|
||||
Q(name__icontains=value) |
|
||||
Q(power_panel__name__icontains=value) |
|
||||
Q(comments__icontains=value)
|
||||
)
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils.translation import gettext as _
|
||||
from timezone_field import TimeZoneFormField
|
||||
@@ -12,7 +13,8 @@ from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import BulkEditForm, add_blank_choice, form_from_model
|
||||
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||
from utilities.forms.widgets import BulkEditNullBooleanSelect, SelectSpeedWidget
|
||||
from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions
|
||||
from wireless.models import WirelessLAN, WirelessLANGroup
|
||||
|
||||
__all__ = (
|
||||
'CableBulkEditForm',
|
||||
@@ -470,6 +472,10 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm):
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False
|
||||
)
|
||||
napalm_driver = forms.CharField(
|
||||
max_length=50,
|
||||
required=False
|
||||
)
|
||||
config_template = DynamicModelChoiceField(
|
||||
queryset=ConfigTemplate.objects.all(),
|
||||
required=False
|
||||
@@ -481,9 +487,9 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
model = Platform
|
||||
fieldsets = (
|
||||
(None, ('manufacturer', 'config_template', 'description')),
|
||||
(None, ('manufacturer', 'config_template', 'napalm_driver', 'description')),
|
||||
)
|
||||
nullable_fields = ('manufacturer', 'config_template', 'description')
|
||||
nullable_fields = ('manufacturer', 'config_template', 'napalm_driver', 'description')
|
||||
|
||||
|
||||
class DeviceBulkEditForm(NetBoxModelBulkEditForm):
|
||||
@@ -1135,7 +1141,7 @@ class InterfaceBulkEditForm(
|
||||
form_from_model(Interface, [
|
||||
'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'mgmt_only',
|
||||
'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width',
|
||||
'tx_power',
|
||||
'tx_power', 'wireless_lans'
|
||||
]),
|
||||
ComponentBulkEditForm
|
||||
):
|
||||
@@ -1169,8 +1175,9 @@ class InterfaceBulkEditForm(
|
||||
)
|
||||
speed = forms.IntegerField(
|
||||
required=False,
|
||||
widget=SelectSpeedWidget(),
|
||||
label=_('Speed')
|
||||
widget=NumberWithOptions(
|
||||
options=InterfaceSpeedChoices
|
||||
)
|
||||
)
|
||||
mgmt_only = forms.NullBooleanField(
|
||||
required=False,
|
||||
@@ -1224,6 +1231,19 @@ class InterfaceBulkEditForm(
|
||||
required=False,
|
||||
label=_('VRF')
|
||||
)
|
||||
wireless_lan_group = DynamicModelChoiceField(
|
||||
queryset=WirelessLANGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Wireless LAN group')
|
||||
)
|
||||
wireless_lans = DynamicModelMultipleChoiceField(
|
||||
queryset=WirelessLAN.objects.all(),
|
||||
required=False,
|
||||
label=_('Wireless LANs'),
|
||||
query_params={
|
||||
'group_id': '$wireless_lan_group',
|
||||
}
|
||||
)
|
||||
|
||||
model = Interface
|
||||
fieldsets = (
|
||||
@@ -1233,12 +1253,14 @@ class InterfaceBulkEditForm(
|
||||
('PoE', ('poe_mode', 'poe_type')),
|
||||
('Related Interfaces', ('parent', 'bridge', 'lag')),
|
||||
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
|
||||
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')),
|
||||
('Wireless', (
|
||||
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans',
|
||||
)),
|
||||
)
|
||||
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',
|
||||
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'wireless_lans'
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -1271,8 +1293,13 @@ class InterfaceBulkEditForm(
|
||||
break
|
||||
|
||||
if site is not None:
|
||||
self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk)
|
||||
self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk)
|
||||
# Query for VLANs assigned to the same site and VLANs with no site assigned (null).
|
||||
self.fields['untagged_vlan'].widget.add_query_param(
|
||||
'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE]
|
||||
)
|
||||
self.fields['tagged_vlans'].widget.add_query_param(
|
||||
'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE]
|
||||
)
|
||||
|
||||
self.fields['parent'].choices = ()
|
||||
self.fields['parent'].widget.attrs['disabled'] = True
|
||||
@@ -1305,6 +1332,11 @@ class FrontPortBulkEditForm(
|
||||
form_from_model(FrontPort, ['label', 'type', 'color', 'mark_connected', 'description']),
|
||||
ComponentBulkEditForm
|
||||
):
|
||||
mark_connected = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect
|
||||
)
|
||||
|
||||
model = FrontPort
|
||||
fieldsets = (
|
||||
(None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')),
|
||||
@@ -1316,6 +1348,11 @@ class RearPortBulkEditForm(
|
||||
form_from_model(RearPort, ['label', 'type', 'color', 'mark_connected', 'description']),
|
||||
ComponentBulkEditForm
|
||||
):
|
||||
mark_connected = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect
|
||||
)
|
||||
|
||||
model = RearPort
|
||||
fieldsets = (
|
||||
(None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')),
|
||||
|
||||
@@ -292,12 +292,21 @@ class DeviceTypeImportForm(NetBoxModelImportForm):
|
||||
required=False,
|
||||
help_text=_('The default platform for devices of this type (optional)')
|
||||
)
|
||||
weight = forms.DecimalField(
|
||||
required=False,
|
||||
help_text=_('Device weight'),
|
||||
)
|
||||
weight_unit = CSVChoiceField(
|
||||
choices=WeightUnitChoices,
|
||||
required=False,
|
||||
help_text=_('Unit for device weight')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = DeviceType
|
||||
fields = [
|
||||
'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
|
||||
'subdevice_role', 'airflow', 'description', 'comments',
|
||||
'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments',
|
||||
]
|
||||
|
||||
|
||||
@@ -306,10 +315,19 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
|
||||
queryset=Manufacturer.objects.all(),
|
||||
to_field_name='name'
|
||||
)
|
||||
weight = forms.DecimalField(
|
||||
required=False,
|
||||
help_text=_('Module weight'),
|
||||
)
|
||||
weight_unit = CSVChoiceField(
|
||||
choices=WeightUnitChoices,
|
||||
required=False,
|
||||
help_text=_('Unit for module weight')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ModuleType
|
||||
fields = ['manufacturer', 'model', 'part_number', 'description', 'comments']
|
||||
fields = ['manufacturer', 'model', 'part_number', 'description', 'weight', 'weight_unit', 'comments']
|
||||
|
||||
|
||||
class DeviceRoleImportForm(NetBoxModelImportForm):
|
||||
@@ -347,7 +365,7 @@ class PlatformImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = (
|
||||
'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
|
||||
'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags',
|
||||
)
|
||||
|
||||
|
||||
@@ -947,7 +965,7 @@ class InventoryItemImportForm(NetBoxModelImportForm):
|
||||
component_name = self.cleaned_data.get('component_name')
|
||||
device = self.cleaned_data.get("device")
|
||||
|
||||
if not device and hasattr(self, 'instance'):
|
||||
if not device and hasattr(self, 'instance') and hasattr(self.instance, 'device'):
|
||||
device = self.instance.device
|
||||
|
||||
if not all([device, content_type, component_name]):
|
||||
@@ -1060,7 +1078,11 @@ class CableImportForm(NetBoxModelImportForm):
|
||||
|
||||
model = content_type.model_class()
|
||||
try:
|
||||
termination_object = model.objects.get(device=device, name=name)
|
||||
if device.virtual_chassis and device.virtual_chassis.master == device and \
|
||||
model.objects.filter(device=device, name=name).count() == 0:
|
||||
termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name)
|
||||
else:
|
||||
termination_object = model.objects.get(device=device, name=name)
|
||||
if termination_object.cable is not None:
|
||||
raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
|
||||
except ObjectDoesNotExist:
|
||||
|
||||
@@ -12,7 +12,7 @@ from netbox.forms import NetBoxModelFilterSetForm
|
||||
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
|
||||
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
|
||||
from utilities.forms.widgets import APISelectMultiple, SelectSpeedWidget
|
||||
from utilities.forms.widgets import APISelectMultiple, NumberWithOptions
|
||||
from wireless.choices import *
|
||||
|
||||
__all__ = (
|
||||
@@ -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')
|
||||
)
|
||||
@@ -298,6 +310,15 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
|
||||
|
||||
|
||||
class RackElevationFilterForm(RackFilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'id')),
|
||||
('Function', ('status', 'role_id')),
|
||||
('Hardware', ('type', 'width', 'serial', 'asset_tag')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
('Weight', ('weight', 'max_weight', 'weight_unit')),
|
||||
)
|
||||
id = DynamicModelMultipleChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
label=_('Rack'),
|
||||
@@ -1061,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(
|
||||
@@ -1080,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(
|
||||
@@ -1099,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(
|
||||
@@ -1114,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(
|
||||
@@ -1132,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(
|
||||
@@ -1154,8 +1179,9 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
)
|
||||
speed = forms.IntegerField(
|
||||
required=False,
|
||||
label='Speed',
|
||||
widget=SelectSpeedWidget()
|
||||
widget=NumberWithOptions(
|
||||
options=InterfaceSpeedChoices
|
||||
)
|
||||
)
|
||||
duplex = forms.MultipleChoiceField(
|
||||
choices=InterfaceDuplexChoices,
|
||||
@@ -1232,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
|
||||
@@ -1251,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(
|
||||
@@ -1269,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(
|
||||
@@ -1282,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)
|
||||
|
||||
@@ -1292,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(),
|
||||
|
||||
@@ -16,7 +16,7 @@ from utilities.forms.fields import (
|
||||
CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField,
|
||||
NumericArrayField, SlugField,
|
||||
)
|
||||
from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, SelectSpeedWidget, SelectWithPK
|
||||
from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK
|
||||
from virtualization.models import Cluster
|
||||
from wireless.models import WirelessLAN, WirelessLANGroup
|
||||
from .common import InterfaceCommonForm, ModuleCommonForm
|
||||
@@ -361,15 +361,18 @@ class PlatformForm(NetBoxModelForm):
|
||||
|
||||
fieldsets = (
|
||||
('Platform', (
|
||||
'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
|
||||
'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags',
|
||||
)),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = [
|
||||
'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
|
||||
'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'napalm_args': forms.Textarea(),
|
||||
}
|
||||
|
||||
|
||||
class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||
@@ -1136,7 +1139,9 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
|
||||
'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'speed': SelectSpeedWidget(),
|
||||
'speed': NumberWithOptions(
|
||||
options=InterfaceSpeedChoices
|
||||
),
|
||||
'mode': HTMXSelect(),
|
||||
}
|
||||
labels = {
|
||||
@@ -1209,7 +1214,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
|
||||
installed_device = forms.ModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
label=_('Child Device'),
|
||||
help_text=_("Child devices must first be created and assigned to the site/rack of the parent device.")
|
||||
help_text=_("Child devices must first be created and assigned to the site and rack of the parent device.")
|
||||
)
|
||||
|
||||
def __init__(self, device_bay, *args, **kwargs):
|
||||
|
||||
@@ -4,6 +4,7 @@ from django.utils.translation import gettext as _
|
||||
from dcim.models import *
|
||||
from netbox.forms import NetBoxModelForm
|
||||
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
|
||||
from utilities.forms.widgets import APISelect
|
||||
from . import model_forms
|
||||
|
||||
__all__ = (
|
||||
@@ -100,6 +101,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
|
||||
@@ -225,10 +227,23 @@ class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm):
|
||||
|
||||
|
||||
class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
selector=True,
|
||||
widget=APISelect(
|
||||
# TODO: Clean up the application of HTMXSelect attributes
|
||||
attrs={
|
||||
'hx-get': '.',
|
||||
'hx-include': f'#form_fields',
|
||||
'hx-target': f'#form_fields',
|
||||
}
|
||||
)
|
||||
)
|
||||
rear_port = forms.MultipleChoiceField(
|
||||
choices=[],
|
||||
label=_('Rear ports'),
|
||||
help_text=_('Select one rear port assignment for each front port being created.'),
|
||||
widget=forms.SelectMultiple(attrs={'size': 6})
|
||||
)
|
||||
|
||||
# Override fieldsets from FrontPortForm to omit rear_port_position
|
||||
@@ -244,9 +259,10 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
device = Device.objects.get(
|
||||
pk=self.initial.get('device') or self.data.get('device')
|
||||
)
|
||||
if device_id := self.data.get('device') or self.initial.get('device'):
|
||||
device = Device.objects.get(pk=device_id)
|
||||
else:
|
||||
return
|
||||
|
||||
# Determine which rear port positions are occupied. These will be excluded from the list of available
|
||||
# mappings.
|
||||
|
||||
@@ -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)]),
|
||||
),
|
||||
]
|
||||
|
||||
42
netbox/dcim/migrations/0172_larger_power_draw_values.py
Normal file
42
netbox/dcim/migrations/0172_larger_power_draw_values.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# Generated by Django 4.1.9 on 2023-05-12 18:46
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0171_cabletermination_change_logging'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='powerport',
|
||||
name='allocated_draw',
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='powerport',
|
||||
name='maximum_draw',
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='powerporttemplate',
|
||||
name='allocated_draw',
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='powerporttemplate',
|
||||
name='maximum_draw',
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -80,11 +80,25 @@ class ComponentTemplateModel(ChangeLoggedModel):
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Cache the original DeviceType ID for reference under clean()
|
||||
self._original_device_type = self.device_type_id
|
||||
|
||||
def to_objectchange(self, action):
|
||||
objectchange = super().to_objectchange(action)
|
||||
objectchange.related_object = self.device_type
|
||||
return objectchange
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if self.pk is not None and self._original_device_type != self.device_type_id:
|
||||
raise ValidationError({
|
||||
"device_type": "Component templates cannot be moved to a different device type."
|
||||
})
|
||||
|
||||
|
||||
class ModularComponentTemplateModel(ComponentTemplateModel):
|
||||
"""
|
||||
@@ -119,12 +133,6 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
|
||||
),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Cache the original DeviceType ID for reference under clean()
|
||||
self._original_device_type = self.device_type_id
|
||||
|
||||
def to_objectchange(self, action):
|
||||
objectchange = super().to_objectchange(action)
|
||||
if self.device_type is not None:
|
||||
@@ -136,11 +144,6 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if self.pk is not None and self._original_device_type != self.device_type_id:
|
||||
raise ValidationError({
|
||||
"device_type": "Component templates cannot be moved to a different device type."
|
||||
})
|
||||
|
||||
# A component template must belong to a DeviceType *or* to a ModuleType
|
||||
if self.device_type and self.module_type:
|
||||
raise ValidationError(
|
||||
@@ -229,13 +232,13 @@ class PowerPortTemplate(ModularComponentTemplateModel):
|
||||
choices=PowerPortTypeChoices,
|
||||
blank=True
|
||||
)
|
||||
maximum_draw = models.PositiveSmallIntegerField(
|
||||
maximum_draw = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text=_("Maximum power draw (watts)")
|
||||
)
|
||||
allocated_draw = models.PositiveSmallIntegerField(
|
||||
allocated_draw = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MinValueValidator(1)],
|
||||
|
||||
@@ -97,7 +97,8 @@ class ComponentModel(NetBoxModel):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if self.pk is not None and self._original_device != self.device_id:
|
||||
# Check list of Modules that allow device field to be changed
|
||||
if (type(self) not in [InventoryItem]) and (self.pk is not None) and (self._original_device != self.device_id):
|
||||
raise ValidationError({
|
||||
"device": "Components cannot be moved to a different device."
|
||||
})
|
||||
@@ -328,13 +329,13 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
|
||||
blank=True,
|
||||
help_text=_('Physical port type')
|
||||
)
|
||||
maximum_draw = models.PositiveSmallIntegerField(
|
||||
maximum_draw = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text=_("Maximum power draw (watts)")
|
||||
)
|
||||
allocated_draw = models.PositiveSmallIntegerField(
|
||||
allocated_draw = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MinValueValidator(1)],
|
||||
|
||||
@@ -184,6 +184,8 @@ class DeviceType(PrimaryModel, WeightMixin):
|
||||
'subdevice_role': self.subdevice_role,
|
||||
'airflow': self.airflow,
|
||||
'comments': self.comments,
|
||||
'weight': float(self.weight) if self.weight is not None else None,
|
||||
'weight_unit': self.weight_unit,
|
||||
}
|
||||
|
||||
# Component templates
|
||||
@@ -361,6 +363,8 @@ class ModuleType(PrimaryModel, WeightMixin):
|
||||
'model': self.model,
|
||||
'part_number': self.part_number,
|
||||
'comments': self.comments,
|
||||
'weight': float(self.weight) if self.weight is not None else None,
|
||||
'weight_unit': self.weight_unit,
|
||||
}
|
||||
|
||||
# Component templates
|
||||
@@ -564,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):
|
||||
|
||||
@@ -172,6 +172,7 @@ class PlatformIndex(SearchIndex):
|
||||
fields = (
|
||||
('name', 100),
|
||||
('slug', 110),
|
||||
('napalm_driver', 300),
|
||||
('description', 500),
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -22,6 +22,11 @@ __all__ = (
|
||||
'RackElevationSVG',
|
||||
)
|
||||
|
||||
GRADIENT_RESERVED = '#b0b0ff'
|
||||
GRADIENT_OCCUPIED = '#d7d7d7'
|
||||
GRADIENT_BLOCKED = '#ffc0c0'
|
||||
STROKE_RESERVED = '#4d4dff'
|
||||
|
||||
|
||||
def get_device_name(device):
|
||||
if device.virtual_chassis:
|
||||
@@ -37,15 +42,28 @@ def get_device_name(device):
|
||||
|
||||
|
||||
def get_device_description(device):
|
||||
return '{} ({}) — {} {} ({}U) {} {}'.format(
|
||||
device.name,
|
||||
device.device_role,
|
||||
device.device_type.manufacturer.name,
|
||||
device.device_type.model,
|
||||
floatformat(device.device_type.u_height),
|
||||
device.asset_tag or '',
|
||||
device.serial or ''
|
||||
)
|
||||
"""
|
||||
Return a description for a device to be rendered in the rack elevation in the following format
|
||||
|
||||
Name: <name>
|
||||
Role: <device_role>
|
||||
Device Type: <manufacturer> <model> (<u_height>)
|
||||
Asset tag: <asset_tag> (if defined)
|
||||
Serial: <serial> (if defined)
|
||||
Description: <description> (if defined)
|
||||
"""
|
||||
description = f'Name: {device.name}'
|
||||
description += f'\nRole: {device.device_role}'
|
||||
u_height = f'{floatformat(device.device_type.u_height)}U'
|
||||
description += f'\nDevice Type: {device.device_type.manufacturer.name} {device.device_type.model} ({u_height})'
|
||||
if device.asset_tag:
|
||||
description += f'\nAsset tag: {device.asset_tag}'
|
||||
if device.serial:
|
||||
description += f'\nSerial: {device.serial}'
|
||||
if device.description:
|
||||
description += f'\nDescription: {device.description}'
|
||||
|
||||
return description
|
||||
|
||||
|
||||
class RackElevationSVG:
|
||||
@@ -119,9 +137,9 @@ class RackElevationSVG:
|
||||
drawing.defs.add(drawing.style(css_file.read()))
|
||||
|
||||
# Add gradients
|
||||
RackElevationSVG._add_gradient(drawing, 'reserved', '#b0b0ff')
|
||||
RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
|
||||
RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
|
||||
RackElevationSVG._add_gradient(drawing, 'reserved', GRADIENT_RESERVED)
|
||||
RackElevationSVG._add_gradient(drawing, 'occupied', GRADIENT_OCCUPIED)
|
||||
RackElevationSVG._add_gradient(drawing, 'blocked', GRADIENT_BLOCKED)
|
||||
|
||||
return drawing
|
||||
|
||||
@@ -233,13 +251,13 @@ class RackElevationSVG:
|
||||
coords = self._get_device_coords(segment[0], u_height)
|
||||
coords = (coords[0] + self.unit_width + RACK_ELEVATION_BORDER_WIDTH * 2, coords[1])
|
||||
size = (
|
||||
self.margin_width,
|
||||
self.margin_width - 3,
|
||||
u_height * self.unit_height
|
||||
)
|
||||
link = Hyperlink(href=f'{self.base_url}{reservation.get_absolute_url()}', target='_parent')
|
||||
link.set_desc(f'Reservation #{reservation.pk}: {reservation.description}')
|
||||
link.add(
|
||||
Rect(coords, size, class_='reservation')
|
||||
Rect(coords, size, class_='reservation', stroke=STROKE_RESERVED, stroke_width=2)
|
||||
)
|
||||
self.drawing.add(link)
|
||||
|
||||
|
||||
@@ -39,6 +39,10 @@ __all__ = (
|
||||
'VirtualDeviceContextTable'
|
||||
)
|
||||
|
||||
MODULEBAY_STATUS = """
|
||||
{% badge record.installed_module.get_status_display bg_color=record.installed_module.get_status_color %}
|
||||
"""
|
||||
|
||||
|
||||
def get_cabletermination_row_class(record):
|
||||
if record.mark_connected:
|
||||
@@ -133,11 +137,11 @@ class PlatformTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = models.Platform
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'config_template', 'description',
|
||||
'tags', 'actions', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'config_template', 'napalm_driver',
|
||||
'napalm_args', 'description', 'tags', 'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'description',
|
||||
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description',
|
||||
)
|
||||
|
||||
|
||||
@@ -212,6 +216,16 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
config_template = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
parent_device = tables.Column(
|
||||
verbose_name='Parent Device',
|
||||
linkify=True,
|
||||
accessor='parent_bay__device'
|
||||
)
|
||||
device_bay_position = tables.Column(
|
||||
verbose_name='Position (Device Bay)',
|
||||
accessor='parent_bay',
|
||||
linkify=True
|
||||
)
|
||||
comments = columns.MarkdownColumn()
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:device_list'
|
||||
@@ -221,9 +235,10 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
model = models.Device
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type',
|
||||
'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'position', 'face',
|
||||
'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position',
|
||||
'vc_priority', 'description', 'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated',
|
||||
'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device',
|
||||
'device_bay_position', 'position', 'face', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster',
|
||||
'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'comments', 'contacts',
|
||||
'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',
|
||||
@@ -781,14 +796,17 @@ class ModuleBayTable(DeviceComponentTable):
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:modulebay_list'
|
||||
)
|
||||
module_status = columns.TemplateColumn(
|
||||
template_code=MODULEBAY_STATUS
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = models.ModuleBay
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'label', 'position', 'installed_module', 'module_serial', 'module_asset_tag',
|
||||
'description', 'tags',
|
||||
'pk', 'id', 'name', 'device', 'label', 'position', 'installed_module', 'module_status', 'module_serial',
|
||||
'module_asset_tag', 'description', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'installed_module', 'description')
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'installed_module', 'module_status', 'description')
|
||||
|
||||
|
||||
class DeviceModuleBayTable(ModuleBayTable):
|
||||
@@ -799,10 +817,10 @@ class DeviceModuleBayTable(ModuleBayTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = models.ModuleBay
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'label', 'position', 'installed_module', 'module_serial', 'module_asset_tag',
|
||||
'pk', 'id', 'name', 'label', 'position', 'installed_module', 'module_status', 'module_serial', 'module_asset_tag',
|
||||
'description', 'tags', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'label', 'installed_module', 'description')
|
||||
default_columns = ('pk', 'name', 'label', 'installed_module', 'module_status', 'description')
|
||||
|
||||
|
||||
class InventoryItemTable(DeviceComponentTable):
|
||||
|
||||
@@ -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
|
||||
@@ -1498,9 +1515,9 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Manufacturer.objects.bulk_create(manufacturers)
|
||||
|
||||
platforms = (
|
||||
Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0], description='A'),
|
||||
Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1], description='B'),
|
||||
Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], description='C'),
|
||||
Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0], napalm_driver='driver-1', description='A'),
|
||||
Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1], napalm_driver='driver-2', description='B'),
|
||||
Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], napalm_driver='driver-3', description='C'),
|
||||
)
|
||||
Platform.objects.bulk_create(platforms)
|
||||
|
||||
@@ -1516,6 +1533,10 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'description': ['A', 'B']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_napalm_driver(self):
|
||||
params = {'napalm_driver': ['driver-1', 'driver-2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_manufacturer(self):
|
||||
manufacturers = Manufacturer.objects.all()[:2]
|
||||
params = {'manufacturer_id': [manufacturers[0].pk, manufacturers[1].pk]}
|
||||
@@ -1994,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
|
||||
|
||||
@@ -2023,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]),
|
||||
@@ -2044,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)
|
||||
|
||||
@@ -2161,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
|
||||
|
||||
@@ -2190,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]),
|
||||
@@ -2211,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)
|
||||
|
||||
@@ -2328,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
|
||||
|
||||
@@ -2357,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]),
|
||||
@@ -2378,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)
|
||||
|
||||
@@ -2503,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
|
||||
|
||||
@@ -2532,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]),
|
||||
@@ -2553,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)
|
||||
|
||||
@@ -2674,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
|
||||
|
||||
@@ -2703,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]),
|
||||
@@ -2724,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)
|
||||
|
||||
@@ -3097,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
|
||||
|
||||
@@ -3126,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]),
|
||||
@@ -3147,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)
|
||||
|
||||
@@ -3273,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
|
||||
|
||||
@@ -3302,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]),
|
||||
@@ -3323,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)
|
||||
|
||||
@@ -3443,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
|
||||
|
||||
@@ -3472,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]),
|
||||
@@ -3492,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)
|
||||
|
||||
@@ -3560,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
|
||||
|
||||
@@ -3589,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]),
|
||||
@@ -3609,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)
|
||||
|
||||
@@ -3690,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'),
|
||||
@@ -3732,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)
|
||||
|
||||
@@ -3825,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]}
|
||||
|
||||
@@ -681,11 +681,15 @@ class DeviceTypeTestCase(
|
||||
"""
|
||||
IMPORT_DATA = """
|
||||
manufacturer: Generic
|
||||
default_platform: Platform
|
||||
model: TEST-1000
|
||||
slug: test-1000
|
||||
default_platform: Platform
|
||||
u_height: 2
|
||||
is_full_depth: false
|
||||
airflow: front-to-rear
|
||||
subdevice_role: parent
|
||||
weight: 10
|
||||
weight_unit: kg
|
||||
comments: Test comment
|
||||
console-ports:
|
||||
- name: Console Port 1
|
||||
@@ -794,8 +798,16 @@ inventory-items:
|
||||
self.assertHttpStatus(response, 200)
|
||||
|
||||
device_type = DeviceType.objects.get(model='TEST-1000')
|
||||
self.assertEqual(device_type.comments, 'Test comment')
|
||||
self.assertEqual(device_type.manufacturer.pk, manufacturer.pk)
|
||||
self.assertEqual(device_type.default_platform.pk, platform.pk)
|
||||
self.assertEqual(device_type.slug, 'test-1000')
|
||||
self.assertEqual(device_type.u_height, 2)
|
||||
self.assertFalse(device_type.is_full_depth)
|
||||
self.assertEqual(device_type.airflow, DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR)
|
||||
self.assertEqual(device_type.subdevice_role, SubdeviceRoleChoices.ROLE_PARENT)
|
||||
self.assertEqual(device_type.weight, 10)
|
||||
self.assertEqual(device_type.weight_unit, WeightUnitChoices.UNIT_KILOGRAM)
|
||||
self.assertEqual(device_type.comments, 'Test comment')
|
||||
|
||||
# Verify all of the components were created
|
||||
self.assertEqual(device_type.consoleporttemplates.count(), 3)
|
||||
@@ -1019,6 +1031,8 @@ class ModuleTypeTestCase(
|
||||
IMPORT_DATA = """
|
||||
manufacturer: Generic
|
||||
model: TEST-1000
|
||||
weight: 10
|
||||
weight_unit: lb
|
||||
comments: Test comment
|
||||
console-ports:
|
||||
- name: Console Port 1
|
||||
@@ -1082,7 +1096,8 @@ front-ports:
|
||||
"""
|
||||
|
||||
# Create the manufacturer
|
||||
Manufacturer(name='Generic', slug='generic').save()
|
||||
manufacturer = Manufacturer(name='Generic', slug='generic')
|
||||
manufacturer.save()
|
||||
|
||||
# Add all required permissions to the test user
|
||||
self.add_permissions(
|
||||
@@ -1105,6 +1120,9 @@ front-ports:
|
||||
self.assertHttpStatus(response, 200)
|
||||
|
||||
module_type = ModuleType.objects.get(model='TEST-1000')
|
||||
self.assertEqual(module_type.manufacturer.pk, manufacturer.pk)
|
||||
self.assertEqual(module_type.weight, 10)
|
||||
self.assertEqual(module_type.weight_unit, WeightUnitChoices.UNIT_POUND)
|
||||
self.assertEqual(module_type.comments, 'Test comment')
|
||||
|
||||
# Verify all the components were created
|
||||
@@ -1591,6 +1609,8 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
'name': 'Platform X',
|
||||
'slug': 'platform-x',
|
||||
'manufacturer': manufacturer.pk,
|
||||
'napalm_driver': 'junos',
|
||||
'napalm_args': None,
|
||||
'description': 'A new platform',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
@@ -1610,6 +1630,7 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'napalm_driver': 'ios',
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@@ -2886,6 +2907,7 @@ class CableTestCase(
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
|
||||
devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
vc = VirtualChassis.objects.create(name='Virtual Chassis')
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole),
|
||||
@@ -2895,6 +2917,10 @@ class CableTestCase(
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
vc.members.set((devices[0], devices[1], devices[2]))
|
||||
vc.master = devices[0]
|
||||
vc.save()
|
||||
|
||||
interfaces = (
|
||||
Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(device=devices[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
@@ -2908,6 +2934,10 @@ class CableTestCase(
|
||||
Interface(device=devices[3], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(device=devices[3], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(device=devices[3], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(device=devices[1], name='Device 2 Interface', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(device=devices[2], name='Device 3 Interface', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(device=devices[3], name='Interface 4', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(device=devices[3], name='Interface 5', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
)
|
||||
Interface.objects.bulk_create(interfaces)
|
||||
|
||||
@@ -2940,6 +2970,8 @@ class CableTestCase(
|
||||
"Device 3,dcim.interface,Interface 1,Device 4,dcim.interface,Interface 1",
|
||||
"Device 3,dcim.interface,Interface 2,Device 4,dcim.interface,Interface 2",
|
||||
"Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3",
|
||||
"Device 1,dcim.interface,Device 2 Interface,Device 4,dcim.interface,Interface 4",
|
||||
"Device 1,dcim.interface,Device 3 Interface,Device 4,dcim.interface,Interface 5",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
|
||||
@@ -20,6 +20,7 @@ from extras.views import ObjectConfigContextView
|
||||
from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
|
||||
from ipam.tables import InterfaceVLANTable
|
||||
from netbox.views import generic
|
||||
from tenancy.views import ObjectContactsView
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from utilities.permissions import get_permission_for_model
|
||||
@@ -267,6 +268,11 @@ class RegionBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.RegionTable
|
||||
|
||||
|
||||
@register_model_view(Region, 'contacts')
|
||||
class RegionContactsView(ObjectContactsView):
|
||||
queryset = Region.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# Site groups
|
||||
#
|
||||
@@ -342,6 +348,11 @@ class SiteGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.SiteGroupTable
|
||||
|
||||
|
||||
@register_model_view(SiteGroup, 'contacts')
|
||||
class SiteGroupContactsView(ObjectContactsView):
|
||||
queryset = SiteGroup.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# Sites
|
||||
#
|
||||
@@ -371,7 +382,7 @@ class SiteView(generic.ObjectView):
|
||||
(VLANGroup.objects.restrict(request.user, 'view').filter(
|
||||
scope_type=ContentType.objects.get_for_model(Site),
|
||||
scope_id=instance.pk
|
||||
), 'site_id'),
|
||||
), 'site'),
|
||||
(VLAN.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
|
||||
# Circuits
|
||||
(Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(), 'site_id'),
|
||||
@@ -435,6 +446,11 @@ class SiteBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.SiteTable
|
||||
|
||||
|
||||
@register_model_view(Site, 'contacts')
|
||||
class SiteContactsView(ObjectContactsView):
|
||||
queryset = Site.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# Locations
|
||||
#
|
||||
@@ -523,6 +539,11 @@ class LocationBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.LocationTable
|
||||
|
||||
|
||||
@register_model_view(Location, 'contacts')
|
||||
class LocationContactsView(ObjectContactsView):
|
||||
queryset = Location.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# Rack roles
|
||||
#
|
||||
@@ -687,6 +708,7 @@ class RackView(generic.ObjectView):
|
||||
'next_rack': next_rack,
|
||||
'prev_rack': prev_rack,
|
||||
'svg_extra': svg_extra,
|
||||
'peer_racks': peer_racks,
|
||||
}
|
||||
|
||||
|
||||
@@ -739,6 +761,11 @@ class RackBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.RackTable
|
||||
|
||||
|
||||
@register_model_view(Rack, 'contacts')
|
||||
class RackContactsView(ObjectContactsView):
|
||||
queryset = Rack.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# Rack reservations
|
||||
#
|
||||
@@ -873,6 +900,11 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.ManufacturerTable
|
||||
|
||||
|
||||
@register_model_view(Manufacturer, 'contacts')
|
||||
class ManufacturerContactsView(ObjectContactsView):
|
||||
queryset = Manufacturer.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# Device types
|
||||
#
|
||||
@@ -2087,6 +2119,11 @@ class DeviceBulkRenameView(generic.BulkRenameView):
|
||||
table = tables.DeviceTable
|
||||
|
||||
|
||||
@register_model_view(Device, 'contacts')
|
||||
class DeviceContactsView(ObjectContactsView):
|
||||
queryset = Device.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# Modules
|
||||
#
|
||||
@@ -2156,7 +2193,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)
|
||||
@@ -2220,7 +2256,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)
|
||||
@@ -2284,7 +2319,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)
|
||||
@@ -2348,7 +2382,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)
|
||||
@@ -2412,7 +2445,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)
|
||||
@@ -2522,7 +2554,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)
|
||||
@@ -2586,7 +2617,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)
|
||||
@@ -2650,7 +2680,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)
|
||||
@@ -2706,7 +2735,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)
|
||||
@@ -2831,7 +2859,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)
|
||||
@@ -3468,6 +3495,11 @@ class PowerPanelBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.PowerPanelTable
|
||||
|
||||
|
||||
@register_model_view(PowerPanel, 'contacts')
|
||||
class PowerPanelContactsView(ObjectContactsView):
|
||||
queryset = PowerPanel.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# Power feeds
|
||||
#
|
||||
|
||||
@@ -25,7 +25,7 @@ class ConfigRevisionAdmin(admin.ModelAdmin):
|
||||
'fields': ('ALLOWED_URL_SCHEMES',),
|
||||
}),
|
||||
('Banners', {
|
||||
'fields': ('BANNER_LOGIN', 'BANNER_TOP', 'BANNER_BOTTOM'),
|
||||
'fields': ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM'),
|
||||
'classes': ('monospace',),
|
||||
}),
|
||||
('Pagination', {
|
||||
|
||||
@@ -187,11 +187,10 @@ class ReportViewSet(ViewSet):
|
||||
"""
|
||||
Compile all reports and their related results (if any). Result data is deferred in the list view.
|
||||
"""
|
||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||
results = {
|
||||
r.name: r
|
||||
for r in Job.objects.filter(
|
||||
object_type=report_content_type,
|
||||
job.name: job
|
||||
for job in Job.objects.filter(
|
||||
object_type=ContentType.objects.get(app_label='extras', model='reportmodule'),
|
||||
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).order_by('name', '-created').distinct('name').defer('data')
|
||||
}
|
||||
@@ -202,7 +201,7 @@ class ReportViewSet(ViewSet):
|
||||
|
||||
# Attach Job objects to each report (if any)
|
||||
for report in report_list:
|
||||
report.result = results.get(report.full_name, None)
|
||||
report.result = results.get(report.name, None)
|
||||
|
||||
serializer = serializers.ReportSerializer(report_list, many=True, context={
|
||||
'request': request,
|
||||
@@ -290,12 +289,10 @@ class ScriptViewSet(ViewSet):
|
||||
return module, script
|
||||
|
||||
def list(self, request):
|
||||
|
||||
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||
results = {
|
||||
r.name: r
|
||||
for r in Job.objects.filter(
|
||||
object_type=script_content_type,
|
||||
job.name: job
|
||||
for job in Job.objects.filter(
|
||||
object_type=ContentType.objects.get(app_label='extras', model='scriptmodule'),
|
||||
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).order_by('name', '-created').distinct('name').defer('data')
|
||||
}
|
||||
@@ -306,7 +303,7 @@ class ScriptViewSet(ViewSet):
|
||||
|
||||
# Attach Job objects to each script (if any)
|
||||
for script in script_list:
|
||||
script.result = results.get(script.full_name, None)
|
||||
script.result = results.get(script.name, None)
|
||||
|
||||
serializer = serializers.ScriptSerializer(script_list, many=True, context={'request': request})
|
||||
|
||||
|
||||
@@ -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)'),
|
||||
)
|
||||
|
||||
|
||||
@@ -116,7 +118,7 @@ class JournalEntryKindChoices(ChoiceSet):
|
||||
|
||||
|
||||
#
|
||||
# Log Levels for Reports and Scripts
|
||||
# Reports and Scripts
|
||||
#
|
||||
|
||||
class LogLevelChoices(ChoiceSet):
|
||||
@@ -136,6 +138,17 @@ class LogLevelChoices(ChoiceSet):
|
||||
)
|
||||
|
||||
|
||||
class DurationChoices(ChoiceSet):
|
||||
|
||||
CHOICES = (
|
||||
(60, 'Hourly'),
|
||||
(720, '12 hours'),
|
||||
(1440, 'Daily'),
|
||||
(10080, 'Weekly'),
|
||||
(43200, '30 days'),
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Job results
|
||||
#
|
||||
@@ -197,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
|
||||
|
||||
@@ -4,12 +4,15 @@ from hashlib import sha256
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import feedparser
|
||||
import requests
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Q
|
||||
from django.http import QueryDict
|
||||
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
|
||||
@@ -33,7 +36,8 @@ def get_content_type_labels():
|
||||
return [
|
||||
(content_type_identifier(ct), content_type_name(ct))
|
||||
for ct in ContentType.objects.filter(
|
||||
FeatureQuery('export_templates').get_query()
|
||||
FeatureQuery('export_templates').get_query() | Q(app_label='extras', model='objectchange') |
|
||||
Q(app_label='extras', model='configcontext')
|
||||
).order_by('app_label', 'model')
|
||||
]
|
||||
|
||||
@@ -146,7 +150,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):
|
||||
@@ -155,13 +159,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):
|
||||
@@ -169,13 +166,19 @@ 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 = QueryDict(mutable=True)
|
||||
params.update(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,
|
||||
@@ -227,16 +230,19 @@ class ObjectListWidget(DashboardWidget):
|
||||
htmx_url = reverse(viewname)
|
||||
except NoReverseMatch:
|
||||
htmx_url = None
|
||||
if parameters := self.config.get('url_params'):
|
||||
parameters = self.config.get('url_params') or {}
|
||||
if page_size := self.config.get('page_size'):
|
||||
parameters['per_page'] = page_size
|
||||
|
||||
if parameters:
|
||||
try:
|
||||
htmx_url = f'{htmx_url}?{urlencode(parameters)}'
|
||||
htmx_url = f'{htmx_url}?{urlencode(parameters, doseq=True)}'
|
||||
except ValueError:
|
||||
pass
|
||||
return render_to_string(self.template_name, {
|
||||
'viewname': viewname,
|
||||
'has_permission': has_permission,
|
||||
'htmx_url': htmx_url,
|
||||
'page_size': self.config.get('page_size'),
|
||||
})
|
||||
|
||||
|
||||
@@ -268,12 +274,9 @@ class RSSFeedWidget(DashboardWidget):
|
||||
)
|
||||
|
||||
def render(self, request):
|
||||
url = self.config['feed_url']
|
||||
feed = self.get_feed()
|
||||
|
||||
return render_to_string(self.template_name, {
|
||||
'url': url,
|
||||
'feed': feed,
|
||||
'url': self.config['feed_url'],
|
||||
**self.get_feed()
|
||||
})
|
||||
|
||||
@cached_property
|
||||
@@ -285,17 +288,33 @@ class RSSFeedWidget(DashboardWidget):
|
||||
def get_feed(self):
|
||||
# Fetch RSS content from cache if available
|
||||
if feed_content := cache.get(self.cache_key):
|
||||
feed = feedparser.FeedParserDict(feed_content)
|
||||
else:
|
||||
feed = feedparser.parse(
|
||||
self.config['feed_url'],
|
||||
request_headers={'User-Agent': f'NetBox/{settings.VERSION}'}
|
||||
)
|
||||
if not feed.bozo:
|
||||
# Cap number of entries
|
||||
max_entries = self.config.get('max_entries')
|
||||
feed['entries'] = feed['entries'][:max_entries]
|
||||
# Cache the feed content
|
||||
cache.set(self.cache_key, dict(feed), self.config.get('cache_timeout'))
|
||||
return {
|
||||
'feed': feedparser.FeedParserDict(feed_content),
|
||||
}
|
||||
|
||||
return feed
|
||||
# Fetch feed content from remote server
|
||||
try:
|
||||
response = requests.get(
|
||||
url=self.config['feed_url'],
|
||||
headers={'User-Agent': f'NetBox/{settings.VERSION}'},
|
||||
proxies=settings.HTTP_PROXIES,
|
||||
timeout=3
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
return {
|
||||
'error': e,
|
||||
}
|
||||
|
||||
# Parse feed content
|
||||
feed = feedparser.parse(response.content)
|
||||
if not feed.bozo:
|
||||
# Cap number of entries
|
||||
max_entries = self.config.get('max_entries')
|
||||
feed['entries'] = feed['entries'][:max_entries]
|
||||
# Cache the feed content
|
||||
cache.set(self.cache_key, dict(feed), self.config.get('cache_timeout'))
|
||||
|
||||
return {
|
||||
'feed': feed,
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ from django.contrib.postgres.forms import SimpleArrayField
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from extras.choices import CustomFieldVisibilityChoices, CustomFieldTypeChoices
|
||||
from extras.choices import CustomFieldVisibilityChoices, CustomFieldTypeChoices, JournalEntryKindChoices
|
||||
from extras.models import *
|
||||
from extras.utils import FeatureQuery
|
||||
from netbox.forms import NetBoxModelImportForm
|
||||
from utilities.forms import CSVModelForm
|
||||
from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVMultipleContentTypeField, SlugField
|
||||
|
||||
@@ -15,6 +16,7 @@ __all__ = (
|
||||
'CustomFieldImportForm',
|
||||
'CustomLinkImportForm',
|
||||
'ExportTemplateImportForm',
|
||||
'JournalEntryImportForm',
|
||||
'SavedFilterImportForm',
|
||||
'TagImportForm',
|
||||
'WebhookImportForm',
|
||||
@@ -132,3 +134,20 @@ class TagImportForm(CSVModelForm):
|
||||
help_texts = {
|
||||
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
|
||||
}
|
||||
|
||||
|
||||
class JournalEntryImportForm(NetBoxModelImportForm):
|
||||
assigned_object_type = CSVContentTypeField(
|
||||
queryset=ContentType.objects.all(),
|
||||
label=_('Assigned object type'),
|
||||
)
|
||||
kind = CSVChoiceField(
|
||||
choices=JournalEntryKindChoices,
|
||||
help_text=_('The classification of entry')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = JournalEntry
|
||||
fields = (
|
||||
'assigned_object_type', 'assigned_object_id', 'created_by', 'kind', 'comments', 'tags'
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ from extras.utils import FeatureQuery
|
||||
from netbox.forms.base import NetBoxModelFilterSetForm
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
|
||||
from utilities.forms.fields import ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField
|
||||
from utilities.forms.fields import ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField
|
||||
from utilities.forms.widgets import APISelectMultiple, DateTimePicker
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
from .mixins import SavedFiltersMixin
|
||||
@@ -22,6 +22,7 @@ __all__ = (
|
||||
'CustomFieldFilterForm',
|
||||
'CustomLinkFilterForm',
|
||||
'ExportTemplateFilterForm',
|
||||
'ImageAttachmentFilterForm',
|
||||
'JournalEntryFilterForm',
|
||||
'LocalConfigContextFilterForm',
|
||||
'ObjectChangeFilterForm',
|
||||
@@ -137,6 +138,20 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
||||
)
|
||||
|
||||
|
||||
class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id')),
|
||||
('Attributes', ('content_type_id', 'name',)),
|
||||
)
|
||||
content_type_id = ContentTypeChoiceField(
|
||||
queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()),
|
||||
required=False
|
||||
)
|
||||
name = forms.CharField(
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id')),
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from core.models import DataFile, DataSource
|
||||
from extras.models import *
|
||||
from extras.choices import CustomFieldVisibilityChoices
|
||||
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||
from extras.models import *
|
||||
from utilities.forms.fields import DynamicModelMultipleChoiceField
|
||||
|
||||
__all__ = (
|
||||
'CustomFieldsMixin',
|
||||
'SavedFiltersMixin',
|
||||
'SyncedDataMixin',
|
||||
)
|
||||
|
||||
|
||||
@@ -74,19 +72,3 @@ class SavedFiltersMixin(forms.Form):
|
||||
'usable': True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SyncedDataMixin(forms.Form):
|
||||
data_source = DynamicModelChoiceField(
|
||||
queryset=DataSource.objects.all(),
|
||||
required=False,
|
||||
label=_('Data source')
|
||||
)
|
||||
data_file = DynamicModelChoiceField(
|
||||
queryset=DataFile.objects.all(),
|
||||
required=False,
|
||||
label=_('File'),
|
||||
query_params={
|
||||
'source_id': '$data_source',
|
||||
}
|
||||
)
|
||||
|
||||
@@ -5,9 +5,9 @@ from django.db.models import Q
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from core.forms.mixins import SyncedDataMixin
|
||||
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
|
||||
from extras.choices import *
|
||||
from extras.forms.mixins import SyncedDataMixin
|
||||
from extras.models import *
|
||||
from extras.utils import FeatureQuery
|
||||
from netbox.forms import NetBoxModelForm
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from django import forms
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from extras.choices import DurationChoices
|
||||
from utilities.forms import BootstrapMixin
|
||||
from utilities.forms.widgets import DateTimePicker, SelectDurationWidget
|
||||
from utilities.forms.widgets import DateTimePicker, NumberWithOptions
|
||||
from utilities.utils import local_now
|
||||
|
||||
__all__ = (
|
||||
@@ -21,7 +22,9 @@ class ReportForm(BootstrapMixin, forms.Form):
|
||||
required=False,
|
||||
min_value=1,
|
||||
label=_("Recurs every"),
|
||||
widget=SelectDurationWidget(),
|
||||
widget=NumberWithOptions(
|
||||
options=DurationChoices
|
||||
),
|
||||
help_text=_("Interval at which this report is re-run (in minutes)")
|
||||
)
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from django import forms
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from extras.choices import DurationChoices
|
||||
from utilities.forms import BootstrapMixin
|
||||
from utilities.forms.widgets import DateTimePicker, SelectDurationWidget
|
||||
from utilities.forms.widgets import DateTimePicker, NumberWithOptions
|
||||
from utilities.utils import local_now
|
||||
|
||||
__all__ = (
|
||||
@@ -27,7 +28,9 @@ class ScriptForm(BootstrapMixin, forms.Form):
|
||||
required=False,
|
||||
min_value=1,
|
||||
label=_("Recurs every"),
|
||||
widget=SelectDurationWidget(),
|
||||
widget=NumberWithOptions(
|
||||
options=DurationChoices
|
||||
),
|
||||
help_text=_("Interval at which this script is re-run (in minutes)")
|
||||
)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -111,7 +111,7 @@ class Command(BaseCommand):
|
||||
|
||||
# Create the job
|
||||
job = Job.objects.create(
|
||||
instance=module,
|
||||
object=module,
|
||||
name=script.name,
|
||||
user=User.objects.filter(is_superuser=True).order_by('pk')[0],
|
||||
job_id=uuid.uuid4()
|
||||
|
||||
@@ -13,6 +13,22 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='customfield',
|
||||
name='name',
|
||||
field=models.CharField(max_length=50, unique=True, validators=[django.core.validators.RegexValidator(flags=re.RegexFlag['IGNORECASE'], message='Only alphanumeric characters and underscores are allowed.', regex='^[a-z0-9_]+$')]),
|
||||
field=models.CharField(
|
||||
max_length=50,
|
||||
unique=True,
|
||||
validators=[
|
||||
django.core.validators.RegexValidator(
|
||||
flags=re.RegexFlag['IGNORECASE'],
|
||||
message='Only alphanumeric characters and underscores are allowed.',
|
||||
regex='^[a-z0-9_]+$',
|
||||
),
|
||||
django.core.validators.RegexValidator(
|
||||
flags=re.RegexFlag['IGNORECASE'],
|
||||
inverse_match=True,
|
||||
message='Double underscores are not permitted in custom field names.',
|
||||
regex=r'__',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -85,6 +85,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
message="Only alphanumeric characters and underscores are allowed.",
|
||||
flags=re.IGNORECASE
|
||||
),
|
||||
RegexValidator(
|
||||
regex=r'__',
|
||||
message="Double underscores are not permitted in custom field names.",
|
||||
flags=re.IGNORECASE,
|
||||
inverse_match=True
|
||||
),
|
||||
)
|
||||
)
|
||||
label = models.CharField(
|
||||
@@ -606,5 +612,18 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
f"Invalid choice(s) ({', '.join(value)}). Available choices are: {', '.join(self.choices)}"
|
||||
)
|
||||
|
||||
# Validate selected object
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
||||
if type(value) is not int:
|
||||
raise ValidationError(f"Value must be an object ID, not {type(value).__name__}")
|
||||
|
||||
# Validate selected objects
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
|
||||
if type(value) is not list:
|
||||
raise ValidationError(f"Value must be a list of object IDs, not {type(value).__name__}")
|
||||
for id in value:
|
||||
if type(id) is not int:
|
||||
raise ValidationError(f"Found invalid object ID: {id}")
|
||||
|
||||
elif self.required:
|
||||
raise ValidationError("Required field cannot be empty.")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import urllib.parse
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
@@ -19,12 +20,13 @@ from extras.choices import *
|
||||
from extras.conditions import ConditionSet
|
||||
from extras.constants import *
|
||||
from extras.utils import FeatureQuery, image_upload
|
||||
from netbox.config import get_config
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from netbox.models.features import (
|
||||
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin,
|
||||
)
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.utils import render_jinja2
|
||||
from utilities.utils import clean_html, render_jinja2
|
||||
|
||||
__all__ = (
|
||||
'ConfigRevision',
|
||||
@@ -272,12 +274,24 @@ 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
|
||||
allowed_schemes = get_config().ALLOWED_URL_SCHEMES
|
||||
text = clean_html(text, allowed_schemes)
|
||||
|
||||
# Sanitize link
|
||||
link = urllib.parse.quote(link, safe='/:?&=%+[]@#')
|
||||
|
||||
# Verify link scheme is allowed
|
||||
result = urllib.parse.urlparse(link)
|
||||
if result.scheme and result.scheme not in allowed_schemes:
|
||||
link = ""
|
||||
|
||||
return {
|
||||
'text': text,
|
||||
'link': link,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import inspect
|
||||
import logging
|
||||
from functools import cached_property
|
||||
|
||||
from django.db import models
|
||||
@@ -16,6 +17,8 @@ __all__ = (
|
||||
'ScriptModule',
|
||||
)
|
||||
|
||||
logger = logging.getLogger('netbox.data_backends')
|
||||
|
||||
|
||||
class Script(WebhooksMixin, models.Model):
|
||||
"""
|
||||
@@ -53,7 +56,12 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
|
||||
# For child objects in submodules use the full import path w/o the root module as the name
|
||||
return cls.full_name.split(".", maxsplit=1)[1]
|
||||
|
||||
module = self.get_module()
|
||||
try:
|
||||
module = self.get_module()
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to load script: {self.python_name} error: {e}")
|
||||
module = None
|
||||
|
||||
scripts = {}
|
||||
ordered = getattr(module, 'script_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)
|
||||
|
||||
@@ -13,6 +13,7 @@ __all__ = (
|
||||
'CustomFieldTable',
|
||||
'CustomLinkTable',
|
||||
'ExportTemplateTable',
|
||||
'ImageAttachmentTable',
|
||||
'JournalEntryTable',
|
||||
'ObjectChangeTable',
|
||||
'SavedFilterTable',
|
||||
@@ -21,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(
|
||||
@@ -29,6 +38,7 @@ class CustomFieldTable(NetBoxTable):
|
||||
content_types = columns.ContentTypesColumn()
|
||||
required = columns.BooleanColumn()
|
||||
ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility")
|
||||
description = columns.MarkdownColumn()
|
||||
is_cloneable = columns.BooleanColumn()
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
@@ -71,6 +81,7 @@ class ExportTemplateTable(NetBoxTable):
|
||||
linkify=True
|
||||
)
|
||||
is_synced = columns.BooleanColumn(
|
||||
orderable=False,
|
||||
verbose_name='Synced'
|
||||
)
|
||||
|
||||
@@ -85,6 +96,31 @@ class ExportTemplateTable(NetBoxTable):
|
||||
)
|
||||
|
||||
|
||||
class ImageAttachmentTable(NetBoxTable):
|
||||
id = tables.Column(
|
||||
linkify=False
|
||||
)
|
||||
content_type = columns.ContentTypeColumn()
|
||||
parent = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
image = tables.TemplateColumn(
|
||||
template_code=IMAGEATTACHMENT_IMAGE,
|
||||
)
|
||||
size = tables.Column(
|
||||
orderable=False,
|
||||
verbose_name='Size (bytes)'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ImageAttachment
|
||||
fields = (
|
||||
'pk', 'content_type', 'parent', 'image', 'name', 'image_height', 'image_width', 'size', 'created',
|
||||
'last_updated',
|
||||
)
|
||||
default_columns = ('content_type', 'parent', 'image', 'name', 'size', 'created')
|
||||
|
||||
|
||||
class SavedFilterTable(NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
@@ -194,6 +230,7 @@ class ConfigContextTable(NetBoxTable):
|
||||
verbose_name='Active'
|
||||
)
|
||||
is_synced = columns.BooleanColumn(
|
||||
orderable=False,
|
||||
verbose_name='Synced'
|
||||
)
|
||||
|
||||
@@ -218,6 +255,7 @@ class ConfigTemplateTable(NetBoxTable):
|
||||
linkify=True
|
||||
)
|
||||
is_synced = columns.BooleanColumn(
|
||||
orderable=False,
|
||||
verbose_name='Synced'
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user