mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-21 01:18:08 +01:00
Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ba50d0cf2 | ||
|
|
a6f0154803 | ||
|
|
9b2dc89c6c | ||
|
|
dc7da4f0f6 | ||
|
|
ab33b3aa84 | ||
|
|
bf836d4d46 | ||
|
|
c3f2cc7648 | ||
|
|
d2c4c7fb10 | ||
|
|
39f4dea051 | ||
|
|
7cf4095983 | ||
|
|
0d27abc6fc | ||
|
|
b39019cec9 | ||
|
|
f2cba78549 | ||
|
|
f845eeb117 | ||
|
|
c53990c739 | ||
|
|
7d52b4d9a5 | ||
|
|
a69d61c124 | ||
|
|
798220cf9a | ||
|
|
7598b891c1 | ||
|
|
1f0b1dd10e | ||
|
|
f0bac741cc | ||
|
|
2424f684b9 | ||
|
|
6dcb2de28f | ||
|
|
cbd155406b | ||
|
|
70cc368225 | ||
|
|
6d28eae5dc | ||
|
|
ca932ecadc | ||
|
|
04d763d814 | ||
|
|
97ea15e978 | ||
|
|
28c17f33ab | ||
|
|
db87a69488 | ||
|
|
2b154dcfdf | ||
|
|
1000039c74 | ||
|
|
2ccc543542 | ||
|
|
8ac2ee7b49 | ||
|
|
db3fd27da2 | ||
|
|
b871d2a8c4 | ||
|
|
02f348b15f | ||
|
|
4672f16fda | ||
|
|
6404657f99 | ||
|
|
2f3557c18f | ||
|
|
047f03a58c | ||
|
|
606b015314 | ||
|
|
478d82196a | ||
|
|
034ff2fb3f | ||
|
|
9bfb89fffd | ||
|
|
26ff33c41a | ||
|
|
9e84e3b83b | ||
|
|
82f5d0070e | ||
|
|
22d2289ed2 | ||
|
|
3ba18633de | ||
|
|
dde52309d2 | ||
|
|
87c13a876b | ||
|
|
0d01a3fce8 | ||
|
|
d4b96a5a27 | ||
|
|
1bbfc6da25 | ||
|
|
1aabb2ed5d | ||
|
|
f53810ebb2 | ||
|
|
7545599493 | ||
|
|
eecd51e08b | ||
|
|
3047208e6a | ||
|
|
a796e29c98 | ||
|
|
26abd1f4e2 | ||
|
|
5f0e04aebe | ||
|
|
918ca4fdcb | ||
|
|
24e5dd0c78 | ||
|
|
f35715683e | ||
|
|
b5a65bc66c | ||
|
|
3a21f7ab26 | ||
|
|
a2c012d2c4 | ||
|
|
4896059162 | ||
|
|
eadecf3eda | ||
|
|
39e4ab164e | ||
|
|
5089df3ffd | ||
|
|
9738257aa2 | ||
|
|
693b32077a | ||
|
|
47ea75348c | ||
|
|
cd9c425d9a | ||
|
|
6612e0107e | ||
|
|
ae0a481c64 | ||
|
|
1a43ef82d9 | ||
|
|
f2bc824a8e | ||
|
|
23a9cb2fad | ||
|
|
22a80c5558 | ||
|
|
14ef7fa869 | ||
|
|
068803dc86 | ||
|
|
ee7f43abe1 | ||
|
|
015e25c618 | ||
|
|
5c8694ad65 | ||
|
|
ff42c89cc4 | ||
|
|
856500b014 | ||
|
|
380f59ac0b | ||
|
|
d30874e0de |
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -11,7 +11,7 @@ about: Report a reproducible bug in the current release of NetBox
|
||||
NetBox installation, or if you have a general question, DO NOT open an
|
||||
issue. Instead, post to our mailing list:
|
||||
|
||||
https://groups.google.com/forum/#!forum/netbox-discuss
|
||||
https://groups.google.com/g/netbox-discuss
|
||||
|
||||
Please describe the environment in which you are running NetBox. Be sure
|
||||
that you are running an unmodified instance of the latest stable release
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -5,5 +5,5 @@ contact_links:
|
||||
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
|
||||
- name: 💬 Discussion Group
|
||||
url: https://groups.google.com/forum/#!forum/netbox-discuss
|
||||
url: https://groups.google.com/g/netbox-discuss
|
||||
about: Join our discussion group for assistance with installation issues and other problems
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/feature_request.md
vendored
6
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -11,7 +11,7 @@ about: Propose a new NetBox feature or enhancement
|
||||
If you have a general idea or question, please post to our mailing list
|
||||
instead of opening an issue:
|
||||
|
||||
https://groups.google.com/forum/#!forum/netbox-discuss
|
||||
https://groups.google.com/g/netbox-discuss
|
||||
|
||||
NOTE: Due to an excessive backlog of feature requests, we are not currently
|
||||
accepting any proposals which significantly extend NetBox's feature scope.
|
||||
@@ -21,8 +21,8 @@ about: Propose a new NetBox feature or enhancement
|
||||
before submitting a bug report.
|
||||
-->
|
||||
### Environment
|
||||
* Python version: <!-- Example: 3.6.9 -->
|
||||
* NetBox version: <!-- Example: 2.7.3 -->
|
||||
* Python version:
|
||||
* NetBox version:
|
||||
|
||||
<!--
|
||||
Describe in detail the new functionality you are proposing. Include any
|
||||
|
||||
23
.github/lock.yml
vendored
23
.github/lock.yml
vendored
@@ -1,23 +0,0 @@
|
||||
# Configuration for Lock (https://github.com/apps/lock)
|
||||
|
||||
# Number of days of inactivity before a closed issue or pull request is locked
|
||||
daysUntilLock: 90
|
||||
|
||||
# Skip issues and pull requests created before a given timestamp. Timestamp must
|
||||
# follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable
|
||||
skipCreatedBefore: false
|
||||
|
||||
# Issues and pull requests with these labels will be ignored. Set to `[]` to disable
|
||||
exemptLabels: []
|
||||
|
||||
# Label to add before locking, such as `outdated`. Set to `false` to disable
|
||||
lockLabel: false
|
||||
|
||||
# Comment to post before locking. Set to `false` to disable
|
||||
lockComment: false
|
||||
|
||||
# Assign `resolved` as the reason for locking. Set to `false` to disable
|
||||
setLockReason: true
|
||||
|
||||
# Limit to only `issues` or `pulls`
|
||||
# only: issues
|
||||
21
.github/workflows/lock.yml
vendored
Normal file
21
.github/workflows/lock.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# lock-threads (https://github.com/marketplace/actions/lock-threads)
|
||||
name: 'Lock threads'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 3 * * *'
|
||||
|
||||
jobs:
|
||||
lock:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v2
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
issue-lock-inactive-days: '90'
|
||||
issue-exclude-created-before: ''
|
||||
issue-exclude-labels: ''
|
||||
issue-lock-labels: ''
|
||||
issue-lock-comment: ''
|
||||
issue-lock-reason: 'resolved'
|
||||
process-only: 'issues'
|
||||
@@ -8,7 +8,7 @@ except to report bugs or request features.
|
||||
|
||||
We have established a Google Groups Mailing List for issues and general
|
||||
discussion. This is the best forum for obtaining assistance with NetBox
|
||||
installation. You can find us [here](https://groups.google.com/forum/#!forum/netbox-discuss).
|
||||
installation. You can find us [here](https://groups.google.com/g/netbox-discuss).
|
||||
|
||||
### Slack
|
||||
|
||||
@@ -164,7 +164,7 @@ overlooked.
|
||||
* Official channels for communication include:
|
||||
|
||||
* GitHub issues/pull requests
|
||||
* The [netbox-discuss](https://groups.google.com/forum/#!forum/netbox-discuss) mailing list
|
||||
* The [netbox-discuss](https://groups.google.com/g/netbox-discuss) mailing list
|
||||
* The **#netbox** channel on [NetworkToCode Slack](https://networktocode.slack.com/)
|
||||
|
||||
* Maintainers with no substantial recorded activity in a 60-day period will be
|
||||
|
||||
@@ -12,7 +12,7 @@ complete list of requirements, see `requirements.txt`. The code is available [on
|
||||
|
||||
The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/).
|
||||
|
||||
Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/forum/#!forum/netbox-discuss),
|
||||
Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/g/netbox-discuss),
|
||||
or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode.slack.com/)!
|
||||
|
||||
### Build Status
|
||||
@@ -44,7 +44,7 @@ and run `upgrade.sh`.
|
||||
|
||||
Feature requests and bug reports must be submitted as GiHub issues. (Please be
|
||||
sure to use the [appropriate template](https://github.com/netbox-community/netbox/issues/new/choose).)
|
||||
For general discussion, please consider joining our [mailing list](https://groups.google.com/forum/#!forum/netbox-discuss).
|
||||
For general discussion, please consider joining our [mailing list](https://groups.google.com/g/netbox-discuss).
|
||||
|
||||
If you are interested in contributing to the development of NetBox, please read
|
||||
our [contributing guide](CONTRIBUTING.md) prior to beginning any work.
|
||||
|
||||
@@ -17,6 +17,18 @@ When viewing a device named Router4, this link would render as:
|
||||
|
||||
Custom links appear as buttons at the top right corner of the page. Numeric weighting can be used to influence the ordering of links.
|
||||
|
||||
## Context Data
|
||||
|
||||
The following context data is available within the template when rendering a custom link's text or URL.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `obj` | The NetBox object being displayed |
|
||||
| `debug` | A boolean indicating whether debugging is enabled |
|
||||
| `request` | The current WSGI request |
|
||||
| `user` | The current user (if authenticated) |
|
||||
| `perms` | The [permissions](https://docs.djangoproject.com/en/stable/topics/auth/default/#permissions) assigned to the user |
|
||||
|
||||
## Conditional Rendering
|
||||
|
||||
Only links which render with non-empty text are included on the page. You can employ conditional Jinja2 logic to control the conditions under which a link gets rendered.
|
||||
|
||||
@@ -231,6 +231,30 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
|
||||
* `min_prefix_length` - Minimum length of the mask
|
||||
* `max_prefix_length` - Maximum length of the mask
|
||||
|
||||
## Running Custom Scripts
|
||||
|
||||
!!! note
|
||||
To run a custom script, a user must be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below.
|
||||
|
||||

|
||||
|
||||
### Via the Web UI
|
||||
|
||||
Custom scripts can be run via the web UI by navigating to the script, completing any required form data, and clicking the "run script" button.
|
||||
|
||||
### Via the API
|
||||
|
||||
To run a script via the REST API, issue a POST request to the script's endpoint specifying the form data and commitment. For example, to run a script named `example.MyReport`, we would make a request such as the following:
|
||||
|
||||
```no-highlight
|
||||
curl -X POST \
|
||||
-H "Authorization: Token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json; indent=4" \
|
||||
http://netbox/api/extras/scripts/example.MyReport/ \
|
||||
--data '{"data": {"foo": "somevalue", "bar": 123}, "commit": true}'
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
Below is an example script that creates new objects for a planned site. The user is prompted for three variables:
|
||||
|
||||
@@ -101,11 +101,14 @@ Once you have created a report, it will appear in the reports list. Initially, r
|
||||
|
||||
## Running Reports
|
||||
|
||||
!!! note
|
||||
To run a report, a user must be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.
|
||||
|
||||

|
||||
|
||||
### Via the Web UI
|
||||
|
||||
Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Note that a user must have permission to create ReportResults in order to run reports. (Permissions can be assigned through the admin UI.)
|
||||
|
||||
Once a report has been run, its associated results will be included in the report view.
|
||||
Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Once a report has been run, its associated results will be included in the report view.
|
||||
|
||||
### Via the API
|
||||
|
||||
|
||||
@@ -73,8 +73,9 @@ tar -xf netbox_media.tar.gz
|
||||
|
||||
## Cache Invalidation
|
||||
|
||||
If you are migrating your instance of NetBox to a different machine, be sure to first invalidate the cache by performing this command:
|
||||
If you are migrating your instance of NetBox to a different machine, be sure to first invalidate the cache on the original instance by issuing the `invalidate all` management command (within the Python virtual environment):
|
||||
|
||||
```no-highlight
|
||||
python3 manage.py invalidate all
|
||||
# source /opt/netbox/venv/bin/activate
|
||||
(venv) # python3 manage.py invalidate all
|
||||
```
|
||||
|
||||
@@ -7,7 +7,7 @@ NetBox is maintained as a [GitHub project](https://github.com/netbox-community/n
|
||||
Communication among developers should always occur via public channels:
|
||||
|
||||
* [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in an issue.
|
||||
* [The mailing list](https://groups.google.com/forum/#!forum/netbox-discuss) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
|
||||
* [The mailing list](https://groups.google.com/g/netbox-discuss) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
|
||||
* [#netbox on NetworkToCode](http://slack.networktocode.com/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.
|
||||
|
||||
## Governance
|
||||
|
||||
@@ -89,7 +89,3 @@ On the `develop` branch, update `VERSION` in `settings.py` to point to the next
|
||||
```
|
||||
VERSION = 'v2.3.5-dev'
|
||||
```
|
||||
|
||||
### Announce the Release
|
||||
|
||||
Announce the release on the [mailing list](https://groups.google.com/forum/#!forum/netbox-discuss). Include a link to the release and the (HTML-formatted) release notes.
|
||||
|
||||
@@ -143,17 +143,28 @@ AUTH_LDAP_CACHE_TIMEOUT = 3600
|
||||
|
||||
`systemctl restart netbox` restarts the Netbox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/messages`.
|
||||
|
||||
For troubleshooting LDAP user/group queries, add the following lines to the start of `ldap_config.py` after `import ldap`.
|
||||
For troubleshooting LDAP user/group queries, add or merge the following [logging](/configuration/optional-settings.md#logging) configuration to `configuration.py`:
|
||||
|
||||
```python
|
||||
import logging, logging.handlers
|
||||
logfile = "/opt/netbox/logs/django-ldap-debug.log"
|
||||
my_logger = logging.getLogger('django_auth_ldap')
|
||||
my_logger.setLevel(logging.DEBUG)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
logfile, maxBytes=1024 * 500, backupCount=5
|
||||
)
|
||||
my_logger.addHandler(handler)
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'handlers': {
|
||||
'netbox_auth_log': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'logging.handlers.RotatingFileHandler',
|
||||
'filename': '/opt/netbox/logs/django-ldap-debug.log',
|
||||
'maxBytes': 1024 * 500,
|
||||
'backupCount': 5,
|
||||
},
|
||||
},
|
||||
'loggers': {
|
||||
'django_auth_ldap': {
|
||||
'handlers': ['netbox_auth_log'],
|
||||
'level': 'DEBUG',
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Ensure the file and path specified in logfile exist and are writable and executable by the application service account. Restart the netbox service and attempt to log into the site to trigger log entries to this file.
|
||||
|
||||
@@ -38,14 +38,14 @@ You can use the command `systemctl status netbox` to verify that the WSGI servic
|
||||
# systemctl status netbox.service
|
||||
● netbox.service - NetBox WSGI Service
|
||||
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled)
|
||||
Active: active (running) since Thu 2019-12-12 19:23:40 UTC; 25s ago
|
||||
Active: active (running) since Sat 2020-10-24 19:23:40 UTC; 25s ago
|
||||
Docs: https://netbox.readthedocs.io/en/stable/
|
||||
Main PID: 11993 (gunicorn)
|
||||
Tasks: 6 (limit: 2362)
|
||||
CGroup: /system.slice/netbox.service
|
||||
├─11993 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
|
||||
├─12015 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
|
||||
├─12016 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
|
||||
├─11993 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
|
||||
├─12015 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
|
||||
├─12016 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
|
||||
...
|
||||
```
|
||||
|
||||
|
||||
@@ -28,44 +28,44 @@ Download and extract the latest version:
|
||||
# wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz
|
||||
# tar -xzf vX.Y.Z.tar.gz -C /opt
|
||||
# cd /opt/
|
||||
# ln -sfn netbox-X.Y.Z/ netbox
|
||||
# ln -sfn /opt/netbox-X.Y.Z/ /opt/netbox
|
||||
```
|
||||
|
||||
Copy the 'configuration.py' you created when first installing to the new version:
|
||||
|
||||
```no-highlight
|
||||
# cp netbox-X.Y.Z/netbox/netbox/configuration.py netbox/netbox/netbox/configuration.py
|
||||
# cp /opt/netbox-X.Y.Z/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/configuration.py
|
||||
```
|
||||
|
||||
Copy your local requirements file if used:
|
||||
|
||||
```no-highlight
|
||||
# cp netbox-X.Y.Z/local_requirements.txt netbox/local_requirements.txt
|
||||
# cp /opt/netbox-X.Y.Z/local_requirements.txt /opt/netbox/local_requirements.txt
|
||||
```
|
||||
|
||||
Also copy the LDAP configuration if using LDAP:
|
||||
|
||||
```no-highlight
|
||||
# cp netbox-X.Y.Z/netbox/netbox/ldap_config.py netbox/netbox/netbox/ldap_config.py
|
||||
# cp /opt/netbox-X.Y.Z/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/ldap_config.py
|
||||
```
|
||||
|
||||
Be sure to replicate your uploaded media as well. (The exact action necessary will depend on where you choose to store your media, but in general moving or copying the media directory will suffice.)
|
||||
|
||||
```no-highlight
|
||||
# cp -pr netbox-X.Y.Z/netbox/media/ netbox/netbox/
|
||||
# cp -pr /opt/netbox-X.Y.Z/netbox/media/ /opt/netbox/netbox/
|
||||
```
|
||||
|
||||
Also make sure to copy over any custom scripts and reports that you've made. Note that if these are stored outside the project root, you will not need to copy them. (Check the `SCRIPTS_ROOT` and `REPORTS_ROOT` parameters in the configuration file above if you're unsure.)
|
||||
|
||||
```no-highlight
|
||||
# cp -r /opt/netbox-X.Y.Z/netbox/scripts /opt/netbox/netbox/scripts/
|
||||
# cp -r /opt/netbox-X.Y.Z/netbox/reports /opt/netbox/netbox/reports/
|
||||
# cp -r /opt/netbox-X.Y.Z/netbox/scripts /opt/netbox/netbox/
|
||||
# cp -r /opt/netbox-X.Y.Z/netbox/reports /opt/netbox/netbox/
|
||||
```
|
||||
|
||||
If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well:
|
||||
|
||||
```no-highlight
|
||||
# cp netbox-X.Y.Z/gunicorn.py netbox/gunicorn.py
|
||||
# cp /opt/netbox-X.Y.Z/gunicorn.py /opt/netbox/gunicorn.py
|
||||
```
|
||||
|
||||
### Option B: Clone the Git Repository
|
||||
|
||||
BIN
docs/media/admin_ui_run_permission.png
Normal file
BIN
docs/media/admin_ui_run_permission.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
@@ -12,6 +12,9 @@ Plugins can do a lot, including:
|
||||
|
||||
However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models.
|
||||
|
||||
!!! warning
|
||||
While very powerful, the NetBox plugins API is necessarily limited in its scope. The plugins API is discussed here in its entirety: Any part of the NetBox code base not documented here is _not_ part of the supported plugins API, and should not be employed by a plugin. Internal elements of NetBox are subject to change at any time and without warning. Plugin authors are **strongly** encouraged to develop plugins using only the officially supported components discussed here and those provided by the underlying Django framework so as to avoid breaking changes in future releases.
|
||||
|
||||
## Initial Setup
|
||||
|
||||
## Plugin Structure
|
||||
@@ -198,26 +201,37 @@ class RandomAnimalView(View):
|
||||
})
|
||||
```
|
||||
|
||||
This view retrieves a random animal from the database and and passes it as a context variable when rendering a template named `animal.html`, which doesn't exist yet. To create this template, first create a directory named `templates/netbox_animal_sounds/` within the plugin source directory. (We use the plugin's name as a subdirectory to guard against naming collisions with other plugins.) Then, create `animal.html`:
|
||||
This view retrieves a random animal from the database and and passes it as a context variable when rendering a template named `animal.html`, which doesn't exist yet. To create this template, first create a directory named `templates/netbox_animal_sounds/` within the plugin source directory. (We use the plugin's name as a subdirectory to guard against naming collisions with other plugins.) Then, create a template named `animal.html` as described below.
|
||||
|
||||
### Extending the Base Template
|
||||
|
||||
NetBox provides a base template to ensure a consistent user experience, which plugins can extend with their own content. This template includes four content blocks:
|
||||
|
||||
* `title` - The page title
|
||||
* `header` - The upper portion of the page
|
||||
* `content` - The main page body
|
||||
* `javascript` - A section at the end of the page for including Javascript code
|
||||
|
||||
For more information on how template blocks work, consult the [Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#block).
|
||||
|
||||
```jinja2
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
{% with config=settings.PLUGINS_CONFIG.netbox_animal_sounds %}
|
||||
<h2 class="text-center" style="margin-top: 200px">
|
||||
{% if animal %}
|
||||
The {{ animal.name|lower }} says
|
||||
{% if config.loud %}
|
||||
{{ animal.sound|upper }}!
|
||||
{% else %}
|
||||
{{ animal.sound }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
No animals have been created yet!
|
||||
{% endif %}
|
||||
</h2>
|
||||
{% endwith %}
|
||||
{% with config=settings.PLUGINS_CONFIG.netbox_animal_sounds %}
|
||||
<h2 class="text-center" style="margin-top: 200px">
|
||||
{% if animal %}
|
||||
The {{ animal.name|lower }} says
|
||||
{% if config.loud %}
|
||||
{{ animal.sound|upper }}!
|
||||
{% else %}
|
||||
{{ animal.sound }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
No animals have been created yet!
|
||||
{% endif %}
|
||||
</h2>
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
```
|
||||
|
||||
@@ -64,6 +64,15 @@ PLUGINS_CONFIG = {
|
||||
}
|
||||
```
|
||||
|
||||
### Run Database Migrations
|
||||
|
||||
If the plugin introduces new database models, run the provided schema migrations:
|
||||
|
||||
```no-highlight
|
||||
(venv) $ cd /opt/netbox/netbox/
|
||||
(venv) $ python3 manage.py migrate
|
||||
```
|
||||
|
||||
### Collect Static Files
|
||||
|
||||
Plugins may package static files to be served directly by the HTTP front end. Ensure that these are copied to the static root directory with the `collectstatic` management command:
|
||||
|
||||
@@ -1,5 +1,84 @@
|
||||
# NetBox v2.9
|
||||
|
||||
## v2.9.9 (2020-11-09)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#5304](https://github.com/netbox-community/netbox/issues/5304) - Return server error messages as JSON when handling REST API requests
|
||||
* [#5310](https://github.com/netbox-community/netbox/issues/5310) - Link to rack groups within rack list table
|
||||
* [#5327](https://github.com/netbox-community/netbox/issues/5327) - Be more strict when capturing anticipated ImportError exceptions
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5271](https://github.com/netbox-community/netbox/issues/5271) - Fix auto-population of region field when editing a device
|
||||
* [#5314](https://github.com/netbox-community/netbox/issues/5314) - Fix config context rendering when multiple tags are assigned to an object
|
||||
* [#5316](https://github.com/netbox-community/netbox/issues/5316) - Dry running scripts should not trigger webhooks
|
||||
* [#5324](https://github.com/netbox-community/netbox/issues/5324) - Add missing template extension tags for plugins for VM interface view
|
||||
* [#5328](https://github.com/netbox-community/netbox/issues/5328) - Fix CreatedUpdatedFilterTest when running in non-UTC timezone
|
||||
* [#5331](https://github.com/netbox-community/netbox/issues/5331) - Fix filtering of sites by null region
|
||||
|
||||
|
||||
---
|
||||
|
||||
## v2.9.8 (2020-10-30)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#4559](https://github.com/netbox-community/netbox/issues/4559) - Improve device/VM context data rendering performance
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#3672](https://github.com/netbox-community/netbox/issues/3672) - Fix a caching issue causing incorrect related object counts in API responses
|
||||
* [#5113](https://github.com/netbox-community/netbox/issues/5113) - Fix incorrect caching of permission object assignments to user groups in the admin panel
|
||||
* [#5243](https://github.com/netbox-community/netbox/issues/5243) - Redirect user to appropriate tab after modifying device components
|
||||
* [#5273](https://github.com/netbox-community/netbox/issues/5273) - Fix exception when validating a new permission with no models selected
|
||||
* [#5282](https://github.com/netbox-community/netbox/issues/5282) - Fix high CPU load when LDAP authentication is enabled
|
||||
* [#5285](https://github.com/netbox-community/netbox/issues/5285) - Plugins no longer need to define `app_name` for API URLs to be included in the root view
|
||||
|
||||
---
|
||||
|
||||
## v2.9.7 (2020-10-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5231](https://github.com/netbox-community/netbox/issues/5231) - Fix KeyError exception when viewing object with custom link and debugging is disabled
|
||||
|
||||
---
|
||||
|
||||
## v2.9.6 (2020-10-09)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5229](https://github.com/netbox-community/netbox/issues/5229) - Fix AttributeError exception when LDAP authentication is enabled
|
||||
|
||||
---
|
||||
|
||||
## v2.9.5 (2020-10-09)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#5202](https://github.com/netbox-community/netbox/issues/5202) - Extend the available context data when rendering custom links
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#4523](https://github.com/netbox-community/netbox/issues/4523) - Populate site vlan list when bulk editing interfaces under certain circumstances
|
||||
* [#5174](https://github.com/netbox-community/netbox/issues/5174) - Ensure consistent alignment of rack elevations
|
||||
* [#5175](https://github.com/netbox-community/netbox/issues/5175) - Fix toggling of rack elevation order
|
||||
* [#5184](https://github.com/netbox-community/netbox/issues/5184) - Fix missing Power Utilization
|
||||
* [#5197](https://github.com/netbox-community/netbox/issues/5197) - Limit duplicate IPs shown on IP address view
|
||||
* [#5199](https://github.com/netbox-community/netbox/issues/5199) - Change default LDAP logging to INFO
|
||||
* [#5201](https://github.com/netbox-community/netbox/issues/5201) - Fix missing querystring when bulk editing/deleting VLAN Group VLANs when selecting "select all x items matching query"
|
||||
* [#5206](https://github.com/netbox-community/netbox/issues/5206) - Apply user pagination preferences to all paginated object lists
|
||||
* [#5211](https://github.com/netbox-community/netbox/issues/5211) - Add missing `has_primary_ip` filter for virtual machines
|
||||
* [#5217](https://github.com/netbox-community/netbox/issues/5217) - Prevent erroneous removal of prefetched GenericForeignKey data from tables
|
||||
* [#5218](https://github.com/netbox-community/netbox/issues/5218) - Raise validation error if a power port's `allocated_draw` exceeds its `maximum_draw`
|
||||
* [#5220](https://github.com/netbox-community/netbox/issues/5220) - Fix API patch request against IP Address endpoint with null assigned_object_type
|
||||
* [#5221](https://github.com/netbox-community/netbox/issues/5221) - Fix bulk component creation for virtual machines
|
||||
* [#5224](https://github.com/netbox-community/netbox/issues/5224) - Don't allow a rear port to have fewer positions than the number of mapped front ports
|
||||
* [#5226](https://github.com/netbox-community/netbox/issues/5226) - Custom choice fields should be blank initially if no default choice has been designated
|
||||
|
||||
---
|
||||
|
||||
## v2.9.4 (2020-09-23)
|
||||
|
||||
**NOTE:** This release removes support for the `DEFAULT_TIMEOUT` parameter under `REDIS` database configuration. Set `RQ_DEFAULT_TIMEOUT` as a global configuration parameter instead.
|
||||
|
||||
@@ -303,14 +303,24 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
|
||||
#
|
||||
|
||||
class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all()
|
||||
queryset=Site.objects.all(),
|
||||
query_params={
|
||||
'region_id': '$region'
|
||||
}
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CircuitTermination
|
||||
fields = [
|
||||
'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description',
|
||||
'term_side', 'region', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description',
|
||||
]
|
||||
help_texts = {
|
||||
'port_speed': "Physical circuit speed",
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Prefetch
|
||||
from django.db.models import Count
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django_tables2 import RequestConfig
|
||||
|
||||
from extras.models import Graph
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from utilities.views import (
|
||||
BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||
)
|
||||
@@ -45,7 +44,7 @@ class ProviderView(ObjectView):
|
||||
|
||||
paginate = {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
'per_page': get_paginate_count(request)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(circuits_table)
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ from dcim.models import (
|
||||
VirtualChassis,
|
||||
)
|
||||
from extras.api.serializers import RenderedGraphSerializer
|
||||
from extras.api.views import CustomFieldModelViewSet
|
||||
from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet
|
||||
from extras.models import Graph
|
||||
from ipam.models import Prefix, VLAN
|
||||
from utilities.api import (
|
||||
@@ -336,7 +336,7 @@ class PlatformViewSet(ModelViewSet):
|
||||
# Devices
|
||||
#
|
||||
|
||||
class DeviceViewSet(CustomFieldModelViewSet):
|
||||
class DeviceViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin):
|
||||
queryset = Device.objects.prefetch_related(
|
||||
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay',
|
||||
'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
|
||||
@@ -396,9 +396,7 @@ class DeviceViewSet(CustomFieldModelViewSet):
|
||||
if device.platform is None:
|
||||
raise ServiceUnavailable("No platform is configured for this device.")
|
||||
if not device.platform.napalm_driver:
|
||||
raise ServiceUnavailable("No NAPALM driver is configured for this device's platform {}.".format(
|
||||
device.platform
|
||||
))
|
||||
raise ServiceUnavailable(f"No NAPALM driver is configured for this device's platform: {device.platform}.")
|
||||
|
||||
# Check for primary IP address from NetBox object
|
||||
if device.primary_ip:
|
||||
@@ -407,21 +405,25 @@ class DeviceViewSet(CustomFieldModelViewSet):
|
||||
# Raise exception for no IP address and no Name if device.name does not exist
|
||||
if not device.name:
|
||||
raise ServiceUnavailable(
|
||||
"This device does not have a primary IP address or device name to lookup configured.")
|
||||
"This device does not have a primary IP address or device name to lookup configured."
|
||||
)
|
||||
try:
|
||||
# Attempt to complete a DNS name resolution if no primary_ip is set
|
||||
host = socket.gethostbyname(device.name)
|
||||
except socket.gaierror:
|
||||
# Name lookup failure
|
||||
raise ServiceUnavailable(
|
||||
f"Name lookup failure, unable to resolve IP address for {device.name}. Please set Primary IP or setup name resolution.")
|
||||
f"Name lookup failure, unable to resolve IP address for {device.name}. Please set Primary IP or "
|
||||
f"setup name resolution.")
|
||||
|
||||
# Check that NAPALM is installed
|
||||
try:
|
||||
import napalm
|
||||
from napalm.base.exceptions import ModuleImportError
|
||||
except ImportError:
|
||||
raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.")
|
||||
except ModuleNotFoundError as e:
|
||||
if getattr(e, 'name') == 'napalm':
|
||||
raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.")
|
||||
raise e
|
||||
|
||||
# Validate the configured driver
|
||||
try:
|
||||
|
||||
@@ -662,16 +662,10 @@ class DeviceFilterSet(
|
||||
).distinct()
|
||||
|
||||
def _has_primary_ip(self, queryset, name, value):
|
||||
params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False)
|
||||
if value:
|
||||
return queryset.filter(
|
||||
Q(primary_ip4__isnull=False) |
|
||||
Q(primary_ip6__isnull=False)
|
||||
)
|
||||
else:
|
||||
return queryset.exclude(
|
||||
Q(primary_ip4__isnull=False) |
|
||||
Q(primary_ip6__isnull=False)
|
||||
)
|
||||
return queryset.filter(params)
|
||||
return queryset.exclude(params)
|
||||
|
||||
def _virtual_chassis_member(self, queryset, name, value):
|
||||
return queryset.exclude(virtual_chassis__isnull=value)
|
||||
|
||||
@@ -352,8 +352,18 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
|
||||
#
|
||||
|
||||
class RackGroupForm(BootstrapMixin, forms.ModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all()
|
||||
queryset=Site.objects.all(),
|
||||
query_params={
|
||||
'region_id': '$region'
|
||||
}
|
||||
)
|
||||
parent = DynamicModelChoiceField(
|
||||
queryset=RackGroup.objects.all(),
|
||||
@@ -367,7 +377,7 @@ class RackGroupForm(BootstrapMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = RackGroup
|
||||
fields = (
|
||||
'site', 'parent', 'name', 'slug', 'description',
|
||||
'region', 'site', 'parent', 'name', 'slug', 'description',
|
||||
)
|
||||
|
||||
|
||||
@@ -447,14 +457,17 @@ class RackRoleCSVForm(CSVModelForm):
|
||||
#
|
||||
|
||||
class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all()
|
||||
)
|
||||
group = DynamicModelChoiceField(
|
||||
queryset=RackGroup.objects.all(),
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
query_params={
|
||||
'site_id': '$site'
|
||||
'region_id': '$region'
|
||||
}
|
||||
)
|
||||
role = DynamicModelChoiceField(
|
||||
@@ -470,8 +483,9 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = [
|
||||
'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial', 'asset_tag',
|
||||
'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments', 'tags',
|
||||
'region', 'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial',
|
||||
'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
|
||||
'comments', 'tags',
|
||||
]
|
||||
help_texts = {
|
||||
'site': "The site at which the rack exists",
|
||||
@@ -548,9 +562,19 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
|
||||
queryset=Rack.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
)
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region'
|
||||
}
|
||||
)
|
||||
group = DynamicModelChoiceField(
|
||||
queryset=RackGroup.objects.all(),
|
||||
@@ -691,9 +715,19 @@ class RackElevationFilterForm(RackFilterForm):
|
||||
#
|
||||
|
||||
class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region'
|
||||
}
|
||||
)
|
||||
rack_group = DynamicModelChoiceField(
|
||||
queryset=RackGroup.objects.all(),
|
||||
@@ -707,7 +741,7 @@ class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
|
||||
display_field='display_name',
|
||||
query_params={
|
||||
'site_id': '$site',
|
||||
'group_id': 'rack',
|
||||
'group_id': '$rack',
|
||||
}
|
||||
)
|
||||
units = NumericArrayField(
|
||||
@@ -809,15 +843,23 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditFor
|
||||
|
||||
class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
|
||||
model = RackReservation
|
||||
field_order = ['q', 'site', 'group_id', 'tenant_group', 'tenant']
|
||||
field_order = ['q', 'region', 'site', 'group_id', 'tenant_group', 'tenant']
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
label='Search'
|
||||
)
|
||||
region = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False
|
||||
)
|
||||
site = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'region': '$region'
|
||||
}
|
||||
)
|
||||
group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=RackGroup.objects.prefetch_related('site'),
|
||||
@@ -1672,7 +1714,10 @@ class PlatformCSVForm(CSVModelForm):
|
||||
class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
@@ -1686,6 +1731,9 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
display_field='display_name',
|
||||
query_params={
|
||||
'site_id': '$site'
|
||||
},
|
||||
initial_params={
|
||||
'racks': '$rack'
|
||||
}
|
||||
)
|
||||
rack = DynamicModelChoiceField(
|
||||
@@ -1711,7 +1759,10 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
)
|
||||
manufacturer = DynamicModelChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
initial_params={
|
||||
'device_types': '$device_type'
|
||||
}
|
||||
)
|
||||
device_type = DynamicModelChoiceField(
|
||||
queryset=DeviceType.objects.all(),
|
||||
@@ -1733,7 +1784,10 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
cluster_group = DynamicModelChoiceField(
|
||||
queryset=ClusterGroup.objects.all(),
|
||||
required=False,
|
||||
null_option='None'
|
||||
null_option='None',
|
||||
initial_params={
|
||||
'clusters': '$cluster'
|
||||
}
|
||||
)
|
||||
cluster = DynamicModelChoiceField(
|
||||
queryset=Cluster.objects.all(),
|
||||
@@ -1772,27 +1826,6 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
# Initialize helper selectors
|
||||
instance = kwargs.get('instance')
|
||||
if 'initial' not in kwargs:
|
||||
kwargs['initial'] = {}
|
||||
# Using hasattr() instead of "is not None" to avoid RelatedObjectDoesNotExist on required field
|
||||
if instance and hasattr(instance, 'device_type'):
|
||||
kwargs['initial']['manufacturer'] = instance.device_type.manufacturer
|
||||
if instance and instance.cluster is not None:
|
||||
kwargs['initial']['cluster_group'] = instance.cluster.group
|
||||
|
||||
if 'device_type' in kwargs['initial'] and 'manufacturer' not in kwargs['initial']:
|
||||
device_type_id = kwargs['initial']['device_type']
|
||||
manufacturer_id = DeviceType.objects.filter(pk=device_type_id).values_list('manufacturer__pk', flat=True).first()
|
||||
kwargs['initial']['manufacturer'] = manufacturer_id
|
||||
|
||||
if 'cluster' in kwargs['initial'] and 'cluster_group' not in kwargs['initial']:
|
||||
cluster_id = kwargs['initial']['cluster']
|
||||
cluster_group_id = Cluster.objects.filter(pk=cluster_id).values_list('group__pk', flat=True).first()
|
||||
kwargs['initial']['cluster_group'] = cluster_group_id
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if self.instance.pk:
|
||||
@@ -2844,6 +2877,24 @@ class InterfaceBulkEditForm(
|
||||
self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk)
|
||||
self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk)
|
||||
else:
|
||||
# See 4523
|
||||
if 'pk' in self.initial:
|
||||
site = None
|
||||
interfaces = Interface.objects.filter(pk__in=self.initial['pk']).prefetch_related('device__site')
|
||||
|
||||
# Check interface sites. First interface should set site, further interfaces will either continue the
|
||||
# loop or reset back to no site and break the loop.
|
||||
for interface in interfaces:
|
||||
if site is None:
|
||||
site = interface.device.site
|
||||
elif interface.device.site is not site:
|
||||
site = None
|
||||
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)
|
||||
|
||||
self.fields['lag'].choices = ()
|
||||
self.fields['lag'].widget.attrs['disabled'] = True
|
||||
|
||||
@@ -3408,10 +3459,18 @@ class ConnectCableToDeviceForm(BootstrapMixin, forms.ModelForm):
|
||||
"""
|
||||
Base form for connecting a Cable to a Device component
|
||||
"""
|
||||
termination_b_region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
label='Region',
|
||||
required=False
|
||||
)
|
||||
termination_b_site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
label='Site',
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$termination_b_region'
|
||||
}
|
||||
)
|
||||
termination_b_rack = DynamicModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
@@ -3437,8 +3496,8 @@ class ConnectCableToDeviceForm(BootstrapMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = Cable
|
||||
fields = [
|
||||
'termination_b_site', 'termination_b_rack', 'termination_b_device', 'termination_b_id', 'type', 'status',
|
||||
'label', 'color', 'length', 'length_unit',
|
||||
'termination_b_region', 'termination_b_site', 'termination_b_rack', 'termination_b_device',
|
||||
'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit',
|
||||
]
|
||||
widgets = {
|
||||
'status': StaticSelect2,
|
||||
@@ -3535,10 +3594,18 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm):
|
||||
label='Provider',
|
||||
required=False
|
||||
)
|
||||
termination_b_region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
label='Region',
|
||||
required=False
|
||||
)
|
||||
termination_b_site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
label='Site',
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$termination_b_region'
|
||||
}
|
||||
)
|
||||
termination_b_circuit = DynamicModelChoiceField(
|
||||
queryset=Circuit.objects.all(),
|
||||
@@ -3562,8 +3629,8 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = Cable
|
||||
fields = [
|
||||
'termination_b_provider', 'termination_b_site', 'termination_b_circuit', 'termination_b_id', 'type',
|
||||
'status', 'label', 'color', 'length', 'length_unit',
|
||||
'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit',
|
||||
'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit',
|
||||
]
|
||||
|
||||
def clean_termination_b_id(self):
|
||||
@@ -3572,11 +3639,18 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
|
||||
class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm):
|
||||
termination_b_region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
label='Region',
|
||||
required=False
|
||||
)
|
||||
termination_b_site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
label='Site',
|
||||
required=False,
|
||||
display_field='cid'
|
||||
query_params={
|
||||
'region_id': '$termination_b_region'
|
||||
}
|
||||
)
|
||||
termination_b_rackgroup = DynamicModelChoiceField(
|
||||
queryset=RackGroup.objects.all(),
|
||||
@@ -3818,10 +3892,18 @@ class CableFilterForm(BootstrapMixin, forms.Form):
|
||||
required=False,
|
||||
label='Search'
|
||||
)
|
||||
region = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False
|
||||
)
|
||||
site = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'region': '$region'
|
||||
}
|
||||
)
|
||||
tenant = DynamicModelMultipleChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
@@ -3870,10 +3952,18 @@ class CableFilterForm(BootstrapMixin, forms.Form):
|
||||
#
|
||||
|
||||
class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
|
||||
region = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False
|
||||
)
|
||||
site = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'region': '$region'
|
||||
}
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
@@ -3886,10 +3976,18 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
|
||||
|
||||
|
||||
class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
|
||||
region = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False
|
||||
)
|
||||
site = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'region': '$region'
|
||||
}
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
@@ -3902,10 +4000,18 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
|
||||
|
||||
|
||||
class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
|
||||
region = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False
|
||||
)
|
||||
site = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'region': '$region'
|
||||
}
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
@@ -3929,9 +4035,19 @@ class DeviceSelectionForm(forms.Form):
|
||||
|
||||
|
||||
class VirtualChassisCreateForm(BootstrapMixin, forms.ModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region'
|
||||
}
|
||||
)
|
||||
rack = DynamicModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
@@ -3964,7 +4080,7 @@ class VirtualChassisCreateForm(BootstrapMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = VirtualChassis
|
||||
fields = [
|
||||
'name', 'domain', 'site', 'rack', 'members', 'initial_position', 'tags',
|
||||
'name', 'domain', 'region', 'site', 'rack', 'members', 'initial_position', 'tags',
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
@@ -4061,9 +4177,19 @@ class DeviceVCMembershipForm(forms.ModelForm):
|
||||
|
||||
|
||||
class VCMemberSelectForm(BootstrapMixin, forms.Form):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region'
|
||||
}
|
||||
)
|
||||
rack = DynamicModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
@@ -4162,8 +4288,18 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
#
|
||||
|
||||
class PowerPanelForm(BootstrapMixin, forms.ModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all()
|
||||
queryset=Site.objects.all(),
|
||||
query_params={
|
||||
'region_id': '$region'
|
||||
}
|
||||
)
|
||||
rack_group = DynamicModelChoiceField(
|
||||
queryset=RackGroup.objects.all(),
|
||||
@@ -4180,7 +4316,7 @@ class PowerPanelForm(BootstrapMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = PowerPanel
|
||||
fields = [
|
||||
'site', 'rack_group', 'name', 'tags',
|
||||
'region', 'site', 'rack_group', 'name', 'tags',
|
||||
]
|
||||
|
||||
|
||||
@@ -4215,9 +4351,19 @@ class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
queryset=PowerPanel.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
)
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region'
|
||||
}
|
||||
)
|
||||
rack_group = DynamicModelChoiceField(
|
||||
queryset=RackGroup.objects.all(),
|
||||
@@ -4269,9 +4415,22 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
#
|
||||
|
||||
class PowerFeedForm(BootstrapMixin, CustomFieldModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites__powerpanel': '$power_panel'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
initial_params={
|
||||
'powerpanel': '$power_panel'
|
||||
},
|
||||
query_params={
|
||||
'region_id': '$region'
|
||||
}
|
||||
)
|
||||
power_panel = DynamicModelChoiceField(
|
||||
queryset=PowerPanel.objects.all(),
|
||||
@@ -4296,7 +4455,7 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class Meta:
|
||||
model = PowerFeed
|
||||
fields = [
|
||||
'site', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage',
|
||||
'region', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage',
|
||||
'max_utilization', 'comments', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
@@ -4306,14 +4465,6 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm):
|
||||
'phase': StaticSelect2(),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Initialize site field
|
||||
if self.instance and hasattr(self.instance, 'power_panel'):
|
||||
self.initial['site'] = self.instance.power_panel.site
|
||||
|
||||
|
||||
class PowerFeedCSVForm(CustomFieldModelCSVForm):
|
||||
site = CSVModelChoiceField(
|
||||
|
||||
@@ -405,6 +405,14 @@ class PowerPort(CableTermination, ComponentModel):
|
||||
self.description,
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
|
||||
if self.maximum_draw is not None and self.allocated_draw is not None:
|
||||
if self.allocated_draw > self.maximum_draw:
|
||||
raise ValidationError({
|
||||
'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
|
||||
})
|
||||
|
||||
@property
|
||||
def connected_endpoint(self):
|
||||
"""
|
||||
@@ -843,17 +851,16 @@ class FrontPort(CableTermination, ComponentModel):
|
||||
|
||||
# Validate rear port assignment
|
||||
if self.rear_port.device != self.device:
|
||||
raise ValidationError(
|
||||
"Rear port ({}) must belong to the same device".format(self.rear_port)
|
||||
)
|
||||
raise ValidationError({
|
||||
"rear_port": f"Rear port ({self.rear_port}) must belong to the same device"
|
||||
})
|
||||
|
||||
# Validate rear port position assignment
|
||||
if self.rear_port_position > self.rear_port.positions:
|
||||
raise ValidationError(
|
||||
"Invalid rear port position ({}); rear port {} has only {} positions".format(
|
||||
self.rear_port_position, self.rear_port.name, self.rear_port.positions
|
||||
)
|
||||
)
|
||||
raise ValidationError({
|
||||
"rear_port_position": f"Invalid rear port position ({self.rear_port_position}): Rear port "
|
||||
f"{self.rear_port.name} has only {self.rear_port.positions} positions"
|
||||
})
|
||||
|
||||
|
||||
@extras_features('webhooks')
|
||||
@@ -883,6 +890,16 @@ class RearPort(CableTermination, ComponentModel):
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:rearport', kwargs={'pk': self.pk})
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Check that positions count is greater than or equal to the number of associated FrontPorts
|
||||
frontport_count = self.frontports.count()
|
||||
if self.positions < frontport_count:
|
||||
raise ValidationError({
|
||||
"positions": f"The number of positions cannot be less than the number of mapped front ports "
|
||||
f"({frontport_count})"
|
||||
})
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.device.identifier,
|
||||
|
||||
@@ -15,6 +15,7 @@ from taggit.managers import TaggableManager
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, TaggedItem
|
||||
from extras.querysets import ConfigContextModelQuerySet
|
||||
from extras.utils import extras_features
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
@@ -594,7 +595,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
objects = ConfigContextModelQuerySet.as_manager()
|
||||
|
||||
csv_headers = [
|
||||
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
|
||||
|
||||
@@ -262,12 +262,15 @@ class RackRoleTable(BaseTable):
|
||||
|
||||
class RackTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn(
|
||||
order_by=('_name',)
|
||||
name = tables.Column(
|
||||
order_by=('_name',),
|
||||
linkify=True
|
||||
)
|
||||
site = tables.LinkColumn(
|
||||
viewname='dcim:site',
|
||||
args=[Accessor('site__slug')]
|
||||
group = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
site = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=COL_TENANT
|
||||
@@ -432,7 +435,8 @@ class ComponentTemplateTable(BaseTable):
|
||||
class ConsolePortTemplateTable(ComponentTemplateTable):
|
||||
actions = ButtonsColumn(
|
||||
model=ConsolePortTemplate,
|
||||
buttons=('edit', 'delete')
|
||||
buttons=('edit', 'delete'),
|
||||
return_url_extra='%23tab_consoleports'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
@@ -444,7 +448,8 @@ class ConsolePortTemplateTable(ComponentTemplateTable):
|
||||
class ConsoleServerPortTemplateTable(ComponentTemplateTable):
|
||||
actions = ButtonsColumn(
|
||||
model=ConsoleServerPortTemplate,
|
||||
buttons=('edit', 'delete')
|
||||
buttons=('edit', 'delete'),
|
||||
return_url_extra='%23tab_consoleserverports'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
@@ -456,7 +461,8 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable):
|
||||
class PowerPortTemplateTable(ComponentTemplateTable):
|
||||
actions = ButtonsColumn(
|
||||
model=PowerPortTemplate,
|
||||
buttons=('edit', 'delete')
|
||||
buttons=('edit', 'delete'),
|
||||
return_url_extra='%23tab_powerports'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
@@ -468,7 +474,8 @@ class PowerPortTemplateTable(ComponentTemplateTable):
|
||||
class PowerOutletTemplateTable(ComponentTemplateTable):
|
||||
actions = ButtonsColumn(
|
||||
model=PowerOutletTemplate,
|
||||
buttons=('edit', 'delete')
|
||||
buttons=('edit', 'delete'),
|
||||
return_url_extra='%23tab_poweroutlets'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
@@ -483,7 +490,8 @@ class InterfaceTemplateTable(ComponentTemplateTable):
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=InterfaceTemplate,
|
||||
buttons=('edit', 'delete')
|
||||
buttons=('edit', 'delete'),
|
||||
return_url_extra='%23tab_interfaces'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
@@ -498,7 +506,8 @@ class FrontPortTemplateTable(ComponentTemplateTable):
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=FrontPortTemplate,
|
||||
buttons=('edit', 'delete')
|
||||
buttons=('edit', 'delete'),
|
||||
return_url_extra='%23tab_frontports'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
@@ -510,7 +519,8 @@ class FrontPortTemplateTable(ComponentTemplateTable):
|
||||
class RearPortTemplateTable(ComponentTemplateTable):
|
||||
actions = ButtonsColumn(
|
||||
model=RearPortTemplate,
|
||||
buttons=('edit', 'delete')
|
||||
buttons=('edit', 'delete'),
|
||||
return_url_extra='%23tab_rearports'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
@@ -522,7 +532,8 @@ class RearPortTemplateTable(ComponentTemplateTable):
|
||||
class DeviceBayTemplateTable(ComponentTemplateTable):
|
||||
actions = ButtonsColumn(
|
||||
model=DeviceBayTemplate,
|
||||
buttons=('edit', 'delete')
|
||||
buttons=('edit', 'delete'),
|
||||
return_url_extra='%23tab_devicebays'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
|
||||
@@ -100,23 +100,6 @@ class DeviceTestCase(TestCase):
|
||||
self.assertIn('face', form.errors)
|
||||
self.assertIn('position', form.errors)
|
||||
|
||||
def test_initial_data_population(self):
|
||||
device_type = DeviceType.objects.first()
|
||||
cluster = Cluster.objects.first()
|
||||
test = DeviceForm(initial={
|
||||
'device_type': device_type.pk,
|
||||
'device_role': DeviceRole.objects.first().pk,
|
||||
'status': DeviceStatusChoices.STATUS_ACTIVE,
|
||||
'site': Site.objects.first().pk,
|
||||
'cluster': cluster.pk,
|
||||
})
|
||||
|
||||
# Check that the initial value for the manufacturer is set automatically when assigning the device type
|
||||
self.assertEqual(test.initial['manufacturer'], device_type.manufacturer.pk)
|
||||
|
||||
# Check that the initial value for the cluster group is set automatically when assigning the cluster
|
||||
self.assertEqual(test.initial['cluster_group'], cluster.group.pk)
|
||||
|
||||
|
||||
class LabelTestCase(TestCase):
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.paginator import EmptyPage, PageNotAnInteger
|
||||
@@ -20,7 +19,7 @@ from ipam.models import IPAddress, Prefix, Service, VLAN
|
||||
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
|
||||
from secrets.models import Secret
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from utilities.permissions import get_permission_for_model
|
||||
from utilities.utils import csv_format, get_subquery
|
||||
from utilities.views import (
|
||||
@@ -320,7 +319,7 @@ class RackElevationListView(ObjectListView):
|
||||
racks = racks.reverse()
|
||||
|
||||
# Pagination
|
||||
per_page = request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
per_page = get_paginate_count(request)
|
||||
page_number = request.GET.get('page', 1)
|
||||
paginator = EnhancedPaginator(racks, per_page)
|
||||
try:
|
||||
@@ -1164,7 +1163,7 @@ class DeviceConfigView(ObjectView):
|
||||
|
||||
|
||||
class DeviceConfigContextView(ObjectConfigContextView):
|
||||
queryset = Device.objects.all()
|
||||
queryset = Device.objects.annotate_config_context_data()
|
||||
base_template = 'dcim/device.html'
|
||||
|
||||
|
||||
@@ -2044,8 +2043,11 @@ class CableCreateView(ObjectEditView):
|
||||
initial_data = {k: request.GET[k] for k in request.GET}
|
||||
|
||||
# Set initial site and rack based on side A termination (if not already set)
|
||||
termination_a_site = getattr(obj.termination_a.parent, 'site', None)
|
||||
if termination_a_site and 'termination_b_region' not in initial_data:
|
||||
initial_data['termination_b_region'] = termination_a_site.region
|
||||
if 'termination_b_site' not in initial_data:
|
||||
initial_data['termination_b_site'] = getattr(obj.termination_a.parent, 'site', None)
|
||||
initial_data['termination_b_site'] = termination_a_site
|
||||
if 'termination_b_rack' not in initial_data:
|
||||
initial_data['termination_b_rack'] = getattr(obj.termination_a.parent, 'rack', None)
|
||||
|
||||
|
||||
@@ -26,6 +26,29 @@ from utilities.utils import copy_safe_request
|
||||
from . import serializers
|
||||
|
||||
|
||||
class ConfigContextQuerySetMixin:
|
||||
"""
|
||||
Used by views that work with config context models (device and virtual machine).
|
||||
Provides a get_queryset() method which deals with adding the config context
|
||||
data annotation or not.
|
||||
"""
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Build the proper queryset based on the request context
|
||||
|
||||
If the `brief` query param equates to True or the `exclude` query param
|
||||
includes `config_context` as a value, return the base queryset.
|
||||
|
||||
Else, return the queryset annotated with config context data
|
||||
"""
|
||||
|
||||
request = self.get_serializer_context()['request']
|
||||
if request.query_params.get('brief') or 'config_context' in request.query_params.get('exclude', []):
|
||||
return self.queryset
|
||||
return self.queryset.annotate_config_context_data()
|
||||
|
||||
|
||||
class ExtrasRootView(APIRootView):
|
||||
"""
|
||||
Extras API root view
|
||||
|
||||
@@ -200,15 +200,14 @@ class CustomField(models.Model):
|
||||
# Select
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||
choices = [(cfc.pk, cfc.value) for cfc in self.choices.all()]
|
||||
default_choice = self.choices.filter(value=self.default).first()
|
||||
|
||||
if not required:
|
||||
if not required or default_choice is None:
|
||||
choices = add_blank_choice(choices)
|
||||
|
||||
# Set the initial value to the PK of the default choice, if any
|
||||
if set_initial:
|
||||
default_choice = self.choices.filter(value=self.default).first()
|
||||
if default_choice:
|
||||
initial = default_choice.pk
|
||||
if set_initial and default_choice:
|
||||
initial = default_choice.pk
|
||||
|
||||
field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
|
||||
field = field_class(
|
||||
|
||||
@@ -542,8 +542,16 @@ class ConfigContextModel(models.Model):
|
||||
|
||||
# Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs
|
||||
data = OrderedDict()
|
||||
for context in ConfigContext.objects.get_for_object(self):
|
||||
data = deepmerge(data, context.data)
|
||||
|
||||
if not hasattr(self, 'config_context_data'):
|
||||
# The annotation is not available, so we fall back to manually querying for the config context objects
|
||||
config_context_data = ConfigContext.objects.get_for_object(self, aggregate_data=True)
|
||||
else:
|
||||
# The attribute may exist, but the annotated value could be None if there is no config context data
|
||||
config_context_data = self.config_context_data or []
|
||||
|
||||
for context in config_context_data:
|
||||
data = deepmerge(data, context)
|
||||
|
||||
# If the object has local config context data defined, merge it last
|
||||
if self.local_context_data:
|
||||
|
||||
@@ -10,8 +10,6 @@ from rest_framework.response import Response
|
||||
from rest_framework.reverse import reverse
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from extras.plugins.utils import import_object
|
||||
|
||||
|
||||
class InstalledPluginsAdminView(View):
|
||||
"""
|
||||
@@ -62,11 +60,7 @@ class PluginsAPIRootView(APIView):
|
||||
@staticmethod
|
||||
def _get_plugin_entry(plugin, app_config, request, format):
|
||||
# Check if the plugin specifies any API URLs
|
||||
api_app_name = import_object(f"{plugin}.api.urls.app_name")
|
||||
if api_app_name is None:
|
||||
# Plugin does not expose an API
|
||||
return None
|
||||
|
||||
api_app_name = f'{app_config.name}-api'
|
||||
try:
|
||||
entry = (getattr(app_config, 'base_url', app_config.label), reverse(
|
||||
f"plugins-api:{api_app_name}:api-root",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.db.models import Q, QuerySet
|
||||
from django.db.models import OuterRef, Subquery, Q
|
||||
|
||||
from utilities.query_functions import EmptyGroupByJSONBAgg, OrderableJSONBAgg
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
|
||||
|
||||
@@ -23,9 +24,12 @@ class CustomFieldQueryset:
|
||||
|
||||
class ConfigContextQuerySet(RestrictedQuerySet):
|
||||
|
||||
def get_for_object(self, obj):
|
||||
def get_for_object(self, obj, aggregate_data=False):
|
||||
"""
|
||||
Return all applicable ConfigContexts for a given object. Only active ConfigContexts will be included.
|
||||
|
||||
Args:
|
||||
aggregate_data: If True, use the JSONBAgg aggregate function to return only the list of JSON data objects
|
||||
"""
|
||||
|
||||
# `device_role` for Device; `role` for VirtualMachine
|
||||
@@ -45,7 +49,7 @@ class ConfigContextQuerySet(RestrictedQuerySet):
|
||||
else:
|
||||
regions = []
|
||||
|
||||
return self.filter(
|
||||
queryset = self.filter(
|
||||
Q(regions__in=regions) | Q(regions=None),
|
||||
Q(sites=obj.site) | Q(sites=None),
|
||||
Q(roles=role) | Q(roles=None),
|
||||
@@ -56,4 +60,73 @@ class ConfigContextQuerySet(RestrictedQuerySet):
|
||||
Q(tenants=obj.tenant) | Q(tenants=None),
|
||||
Q(tags__slug__in=obj.tags.slugs()) | Q(tags=None),
|
||||
is_active=True,
|
||||
).order_by('weight', 'name')
|
||||
).order_by('weight', 'name').distinct()
|
||||
|
||||
if aggregate_data:
|
||||
return queryset.aggregate(
|
||||
config_context_data=OrderableJSONBAgg('data', ordering=['weight', 'name'])
|
||||
)['config_context_data']
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class ConfigContextModelQuerySet(RestrictedQuerySet):
|
||||
"""
|
||||
QuerySet manager used by models which support ConfigContext (device and virtual machine).
|
||||
|
||||
Includes a method which appends an annotation of aggregated config context JSON data objects. This is
|
||||
implemented as a subquery which performs all the joins necessary to filter relevant config context objects.
|
||||
This offers a substantial performance gain over ConfigContextQuerySet.get_for_object() when dealing with
|
||||
multiple objects.
|
||||
|
||||
This allows the annotation to be entirely optional.
|
||||
"""
|
||||
|
||||
def annotate_config_context_data(self):
|
||||
"""
|
||||
Attach the subquery annotation to the base queryset
|
||||
"""
|
||||
from extras.models import ConfigContext
|
||||
return self.annotate(
|
||||
config_context_data=Subquery(
|
||||
ConfigContext.objects.filter(
|
||||
self._get_config_context_filters()
|
||||
).annotate(
|
||||
_data=EmptyGroupByJSONBAgg('data', ordering=['weight', 'name'])
|
||||
).values("_data")
|
||||
)
|
||||
).distinct()
|
||||
|
||||
def _get_config_context_filters(self):
|
||||
# Construct the set of Q objects for the specific object types
|
||||
base_query = Q(
|
||||
Q(platforms=OuterRef('platform')) | Q(platforms=None),
|
||||
Q(tenant_groups=OuterRef('tenant__group')) | Q(tenant_groups=None),
|
||||
Q(tenants=OuterRef('tenant')) | Q(tenants=None),
|
||||
Q(tags=OuterRef('tags')) | Q(tags=None),
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
if self.model._meta.model_name == 'device':
|
||||
base_query.add((Q(roles=OuterRef('device_role')) | Q(roles=None)), Q.AND)
|
||||
base_query.add((Q(sites=OuterRef('site')) | Q(sites=None)), Q.AND)
|
||||
region_field = 'site__region'
|
||||
|
||||
elif self.model._meta.model_name == 'virtualmachine':
|
||||
base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND)
|
||||
base_query.add((Q(cluster_groups=OuterRef('cluster__group')) | Q(cluster_groups=None)), Q.AND)
|
||||
base_query.add((Q(clusters=OuterRef('cluster')) | Q(clusters=None)), Q.AND)
|
||||
base_query.add((Q(sites=OuterRef('cluster__site')) | Q(sites=None)), Q.AND)
|
||||
region_field = 'cluster__site__region'
|
||||
|
||||
base_query.add(
|
||||
(Q(
|
||||
regions__tree_id=OuterRef(f'{region_field}__tree_id'),
|
||||
regions__level__lte=OuterRef(f'{region_field}__level'),
|
||||
regions__lft__lte=OuterRef(f'{region_field}__lft'),
|
||||
regions__rght__gte=OuterRef(f'{region_field}__rght'),
|
||||
) | Q(regions=None)),
|
||||
Q.AND
|
||||
)
|
||||
|
||||
return base_query
|
||||
|
||||
@@ -441,8 +441,11 @@ def run_script(data, request, commit=True, *args, **kwargs):
|
||||
f"with NetBox v2.10."
|
||||
)
|
||||
|
||||
with change_logging(request):
|
||||
|
||||
def _run_script():
|
||||
"""
|
||||
Core script execution task. We capture this within a subfunction to allow for conditionally wrapping it with
|
||||
the change_logging context manager (which is bypassed if commit == False).
|
||||
"""
|
||||
try:
|
||||
with transaction.atomic():
|
||||
script.output = script.run(**kwargs)
|
||||
@@ -469,6 +472,14 @@ def run_script(data, request, commit=True, *args, **kwargs):
|
||||
|
||||
logger.info(f"Script completed in {job_result.duration}")
|
||||
|
||||
# Execute the script. If commit is True, wrap it with the change_logging context manager to ensure we process
|
||||
# change logging, webhooks, etc.
|
||||
if commit:
|
||||
with change_logging(request):
|
||||
_run_script()
|
||||
else:
|
||||
_run_script()
|
||||
|
||||
# Delete any previous terminal state results
|
||||
JobResult.objects.filter(
|
||||
obj_type=job_result.obj_type,
|
||||
|
||||
@@ -35,10 +35,8 @@ OBJECTCHANGE_ACTION = """
|
||||
"""
|
||||
|
||||
OBJECTCHANGE_OBJECT = """
|
||||
{% if record.action != 3 and record.changed_object.get_absolute_url %}
|
||||
{% if record.changed_object.get_absolute_url %}
|
||||
<a href="{{ record.changed_object.get_absolute_url }}">{{ record.object_repr }}</a>
|
||||
{% elif record.action != 3 and record.related_object.get_absolute_url %}
|
||||
<a href="{{ record.related_object.get_absolute_url }}">{{ record.object_repr }}</a>
|
||||
{% else %}
|
||||
{{ record.object_repr }}
|
||||
{% endif %}
|
||||
|
||||
@@ -16,12 +16,12 @@ GROUP_BUTTON = '<div class="btn-group">\n' \
|
||||
'{} <span class="caret"></span>\n' \
|
||||
'</button>\n' \
|
||||
'<ul class="dropdown-menu pull-right">\n' \
|
||||
'{}</ul></div>'
|
||||
'{}</ul></div>\n'
|
||||
GROUP_LINK = '<li><a href="{}"{}>{}</a></li>\n'
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def custom_links(obj):
|
||||
@register.simple_tag(takes_context=True)
|
||||
def custom_links(context, obj):
|
||||
"""
|
||||
Render all applicable links for the given object.
|
||||
"""
|
||||
@@ -30,8 +30,13 @@ def custom_links(obj):
|
||||
if not custom_links:
|
||||
return ''
|
||||
|
||||
context = {
|
||||
# Pass select context data when rendering the CustomLink
|
||||
link_context = {
|
||||
'obj': obj,
|
||||
'debug': context.get('debug', False), # django.template.context_processors.debug
|
||||
'request': context['request'], # django.template.context_processors.request
|
||||
'user': context['user'], # django.contrib.auth.context_processors.auth
|
||||
'perms': context['perms'], # django.contrib.auth.context_processors.auth
|
||||
}
|
||||
template_code = ''
|
||||
group_names = OrderedDict()
|
||||
@@ -47,9 +52,9 @@ def custom_links(obj):
|
||||
# Add non-grouped links
|
||||
else:
|
||||
try:
|
||||
text_rendered = render_jinja2(cl.text, context)
|
||||
text_rendered = render_jinja2(cl.text, link_context)
|
||||
if text_rendered:
|
||||
link_rendered = render_jinja2(cl.url, context)
|
||||
link_rendered = render_jinja2(cl.url, link_context)
|
||||
link_target = ' target="_blank"' if cl.new_window else ''
|
||||
template_code += LINK_BUTTON.format(
|
||||
link_rendered, link_target, cl.button_class, text_rendered
|
||||
@@ -65,10 +70,10 @@ def custom_links(obj):
|
||||
|
||||
for cl in links:
|
||||
try:
|
||||
text_rendered = render_jinja2(cl.text, context)
|
||||
text_rendered = render_jinja2(cl.text, link_context)
|
||||
if text_rendered:
|
||||
link_target = ' target="_blank"' if cl.new_window else ''
|
||||
link_rendered = render_jinja2(cl.url, context)
|
||||
link_rendered = render_jinja2(cl.url, link_context)
|
||||
links_rendered.append(
|
||||
GROUP_LINK.format(link_rendered, link_target, text_rendered)
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ class SiteContent(PluginTemplateExtension):
|
||||
def full_width_page(self):
|
||||
return "SITE CONTENT - FULL WIDTH PAGE"
|
||||
|
||||
def full_buttons(self):
|
||||
def buttons(self):
|
||||
return "SITE CONTENT - BUTTONS"
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from unittest import skipIf
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import make_aware
|
||||
from django_rq.queues import get_connection
|
||||
from rest_framework import status
|
||||
from rq import Worker
|
||||
@@ -369,8 +369,8 @@ class CreatedUpdatedFilterTest(APITestCase):
|
||||
|
||||
# change the created and last_updated of one
|
||||
Rack.objects.filter(pk=self.rack2.pk).update(
|
||||
last_updated=datetime.datetime(2001, 2, 3, 1, 2, 3, 4, tzinfo=timezone.utc),
|
||||
created=datetime.datetime(2001, 2, 3)
|
||||
last_updated=make_aware(datetime.datetime(2001, 2, 3, 1, 2, 3, 4)),
|
||||
created=make_aware(datetime.datetime(2001, 2, 3))
|
||||
)
|
||||
|
||||
def test_get_rack_created(self):
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.models import Site
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Site, Region
|
||||
from extras.choices import TemplateLanguageChoices
|
||||
from extras.models import Graph, Tag
|
||||
from extras.models import ConfigContext, Graph, Tag
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||
|
||||
|
||||
class GraphTest(TestCase):
|
||||
@@ -53,3 +55,311 @@ class TagTest(TestCase):
|
||||
tag.save()
|
||||
|
||||
self.assertEqual(tag.slug, 'testing-unicode-台灣')
|
||||
|
||||
|
||||
class ConfigContextTest(TestCase):
|
||||
"""
|
||||
These test cases deal with the weighting, ordering, and deep merge logic of config context data.
|
||||
|
||||
It also ensures the various config context querysets are consistent.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
self.devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
|
||||
self.devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
self.region = Region.objects.create(name="Region")
|
||||
self.site = Site.objects.create(name='Site-1', slug='site-1', region=self.region)
|
||||
self.platform = Platform.objects.create(name="Platform")
|
||||
self.tenantgroup = TenantGroup.objects.create(name="Tenant Group")
|
||||
self.tenant = Tenant.objects.create(name="Tenant", group=self.tenantgroup)
|
||||
self.tag = Tag.objects.create(name="Tag", slug="tag")
|
||||
self.tag2 = Tag.objects.create(name="Tag2", slug="tag2")
|
||||
|
||||
self.device = Device.objects.create(
|
||||
name='Device 1',
|
||||
device_type=self.devicetype,
|
||||
device_role=self.devicerole,
|
||||
site=self.site
|
||||
)
|
||||
|
||||
def test_higher_weight_wins(self):
|
||||
|
||||
context1 = ConfigContext(
|
||||
name="context 1",
|
||||
weight=101,
|
||||
data={
|
||||
"a": 123,
|
||||
"b": 456,
|
||||
"c": 777
|
||||
}
|
||||
)
|
||||
context2 = ConfigContext(
|
||||
name="context 2",
|
||||
weight=100,
|
||||
data={
|
||||
"a": 123,
|
||||
"b": 456,
|
||||
"c": 789
|
||||
}
|
||||
)
|
||||
ConfigContext.objects.bulk_create([context1, context2])
|
||||
|
||||
expected_data = {
|
||||
"a": 123,
|
||||
"b": 456,
|
||||
"c": 777
|
||||
}
|
||||
self.assertEqual(self.device.get_config_context(), expected_data)
|
||||
|
||||
def test_name_ordering_after_weight(self):
|
||||
|
||||
context1 = ConfigContext(
|
||||
name="context 1",
|
||||
weight=100,
|
||||
data={
|
||||
"a": 123,
|
||||
"b": 456,
|
||||
"c": 777
|
||||
}
|
||||
)
|
||||
context2 = ConfigContext(
|
||||
name="context 2",
|
||||
weight=100,
|
||||
data={
|
||||
"a": 123,
|
||||
"b": 456,
|
||||
"c": 789
|
||||
}
|
||||
)
|
||||
ConfigContext.objects.bulk_create([context1, context2])
|
||||
|
||||
expected_data = {
|
||||
"a": 123,
|
||||
"b": 456,
|
||||
"c": 789
|
||||
}
|
||||
self.assertEqual(self.device.get_config_context(), expected_data)
|
||||
|
||||
def test_annotation_same_as_get_for_object(self):
|
||||
"""
|
||||
This test incorperates features from all of the above tests cases to ensure
|
||||
the annotate_config_context_data() and get_for_object() queryset methods are the same.
|
||||
"""
|
||||
context1 = ConfigContext(
|
||||
name="context 1",
|
||||
weight=101,
|
||||
data={
|
||||
"a": 123,
|
||||
"b": 456,
|
||||
"c": 777
|
||||
}
|
||||
)
|
||||
context2 = ConfigContext(
|
||||
name="context 2",
|
||||
weight=100,
|
||||
data={
|
||||
"a": 123,
|
||||
"b": 456,
|
||||
"c": 789
|
||||
}
|
||||
)
|
||||
context3 = ConfigContext(
|
||||
name="context 3",
|
||||
weight=99,
|
||||
data={
|
||||
"d": 1
|
||||
}
|
||||
)
|
||||
context4 = ConfigContext(
|
||||
name="context 4",
|
||||
weight=99,
|
||||
data={
|
||||
"d": 2
|
||||
}
|
||||
)
|
||||
ConfigContext.objects.bulk_create([context1, context2, context3, context4])
|
||||
|
||||
annotated_queryset = Device.objects.filter(name=self.device.name).annotate_config_context_data()
|
||||
self.assertEqual(self.device.get_config_context(), annotated_queryset[0].get_config_context())
|
||||
|
||||
def test_annotation_same_as_get_for_object_device_relations(self):
|
||||
|
||||
site_context = ConfigContext.objects.create(
|
||||
name="site",
|
||||
weight=100,
|
||||
data={
|
||||
"site": 1
|
||||
}
|
||||
)
|
||||
site_context.sites.add(self.site)
|
||||
region_context = ConfigContext.objects.create(
|
||||
name="region",
|
||||
weight=100,
|
||||
data={
|
||||
"region": 1
|
||||
}
|
||||
)
|
||||
region_context.regions.add(self.region)
|
||||
platform_context = ConfigContext.objects.create(
|
||||
name="platform",
|
||||
weight=100,
|
||||
data={
|
||||
"platform": 1
|
||||
}
|
||||
)
|
||||
platform_context.platforms.add(self.platform)
|
||||
tenant_group_context = ConfigContext.objects.create(
|
||||
name="tenant group",
|
||||
weight=100,
|
||||
data={
|
||||
"tenant_group": 1
|
||||
}
|
||||
)
|
||||
tenant_group_context.tenant_groups.add(self.tenantgroup)
|
||||
tenant_context = ConfigContext.objects.create(
|
||||
name="tenant",
|
||||
weight=100,
|
||||
data={
|
||||
"tenant": 1
|
||||
}
|
||||
)
|
||||
tenant_context.tenants.add(self.tenant)
|
||||
tag_context = ConfigContext.objects.create(
|
||||
name="tag",
|
||||
weight=100,
|
||||
data={
|
||||
"tag": 1
|
||||
}
|
||||
)
|
||||
tag_context.tags.add(self.tag)
|
||||
|
||||
device = Device.objects.create(
|
||||
name="Device 2",
|
||||
site=self.site,
|
||||
tenant=self.tenant,
|
||||
platform=self.platform,
|
||||
device_role=self.devicerole,
|
||||
device_type=self.devicetype
|
||||
)
|
||||
device.tags.add(self.tag)
|
||||
|
||||
annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
|
||||
self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
|
||||
|
||||
def test_annotation_same_as_get_for_object_virtualmachine_relations(self):
|
||||
|
||||
site_context = ConfigContext.objects.create(
|
||||
name="site",
|
||||
weight=100,
|
||||
data={
|
||||
"site": 1
|
||||
}
|
||||
)
|
||||
site_context.sites.add(self.site)
|
||||
region_context = ConfigContext.objects.create(
|
||||
name="region",
|
||||
weight=100,
|
||||
data={
|
||||
"region": 1
|
||||
}
|
||||
)
|
||||
region_context.regions.add(self.region)
|
||||
platform_context = ConfigContext.objects.create(
|
||||
name="platform",
|
||||
weight=100,
|
||||
data={
|
||||
"platform": 1
|
||||
}
|
||||
)
|
||||
platform_context.platforms.add(self.platform)
|
||||
tenant_group_context = ConfigContext.objects.create(
|
||||
name="tenant group",
|
||||
weight=100,
|
||||
data={
|
||||
"tenant_group": 1
|
||||
}
|
||||
)
|
||||
tenant_group_context.tenant_groups.add(self.tenantgroup)
|
||||
tenant_context = ConfigContext.objects.create(
|
||||
name="tenant",
|
||||
weight=100,
|
||||
data={
|
||||
"tenant": 1
|
||||
}
|
||||
)
|
||||
tenant_context.tenants.add(self.tenant)
|
||||
tag_context = ConfigContext.objects.create(
|
||||
name="tag",
|
||||
weight=100,
|
||||
data={
|
||||
"tag": 1
|
||||
}
|
||||
)
|
||||
tag_context.tags.add(self.tag)
|
||||
cluster_group = ClusterGroup.objects.create(name="Cluster Group")
|
||||
cluster_group_context = ConfigContext.objects.create(
|
||||
name="cluster group",
|
||||
weight=100,
|
||||
data={
|
||||
"cluster_group": 1
|
||||
}
|
||||
)
|
||||
cluster_group_context.cluster_groups.add(cluster_group)
|
||||
cluster_type = ClusterType.objects.create(name="Cluster Type 1")
|
||||
cluster = Cluster.objects.create(name="Cluster", group=cluster_group, type=cluster_type)
|
||||
cluster_context = ConfigContext.objects.create(
|
||||
name="cluster",
|
||||
weight=100,
|
||||
data={
|
||||
"cluster": 1
|
||||
}
|
||||
)
|
||||
cluster_context.clusters.add(cluster)
|
||||
|
||||
virtual_machine = VirtualMachine.objects.create(
|
||||
name="VM 1",
|
||||
cluster=cluster,
|
||||
tenant=self.tenant,
|
||||
platform=self.platform,
|
||||
role=self.devicerole
|
||||
)
|
||||
virtual_machine.tags.add(self.tag)
|
||||
|
||||
annotated_queryset = VirtualMachine.objects.filter(name=virtual_machine.name).annotate_config_context_data()
|
||||
self.assertEqual(virtual_machine.get_config_context(), annotated_queryset[0].get_config_context())
|
||||
|
||||
def test_multiple_tags_return_distinct_objects(self):
|
||||
"""
|
||||
Tagged items use a generic relationship, which results in duplicate rows being returned when queried.
|
||||
This is combatted by by appending distinct() to the config context querysets. This test creates a config
|
||||
context assigned to two tags and ensures objects related by those same two tags result in only a single
|
||||
config context record being returned.
|
||||
|
||||
See https://github.com/netbox-community/netbox/issues/5314
|
||||
"""
|
||||
tag_context = ConfigContext.objects.create(
|
||||
name="tag",
|
||||
weight=100,
|
||||
data={
|
||||
"tag": 1
|
||||
}
|
||||
)
|
||||
tag_context.tags.add(self.tag)
|
||||
tag_context.tags.add(self.tag2)
|
||||
|
||||
device = Device.objects.create(
|
||||
name="Device 3",
|
||||
site=self.site,
|
||||
tenant=self.tenant,
|
||||
platform=self.platform,
|
||||
device_role=self.devicerole,
|
||||
device_type=self.devicetype
|
||||
)
|
||||
device.tags.add(self.tag)
|
||||
device.tags.add(self.tag2)
|
||||
|
||||
annotated_queryset = Device.objects.filter(name=device.name).annotate_config_context_data()
|
||||
self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 1)
|
||||
self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
|
||||
|
||||
@@ -2,11 +2,13 @@ import urllib.parse
|
||||
import uuid
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from dcim.models import Site
|
||||
from extras.choices import ObjectChangeActionChoices
|
||||
from extras.models import ConfigContext, ObjectChange, Tag
|
||||
from extras.models import ConfigContext, CustomLink, ObjectChange, Tag
|
||||
from utilities.testing import ViewTestCases, TestCase
|
||||
|
||||
|
||||
@@ -124,3 +126,24 @@ class ObjectChangeTestCase(TestCase):
|
||||
objectchange = ObjectChange.objects.first()
|
||||
response = self.client.get(objectchange.get_absolute_url())
|
||||
self.assertHttpStatus(response, 200)
|
||||
|
||||
|
||||
class CustomLinkTest(TestCase):
|
||||
user_permissions = ['dcim.view_site']
|
||||
|
||||
def test_view_object_with_custom_link(self):
|
||||
customlink = CustomLink(
|
||||
content_type=ContentType.objects.get_for_model(Site),
|
||||
name='Test',
|
||||
text='FOO {{ obj.name }} BAR',
|
||||
url='http://example.com/?site={{ obj.slug }}',
|
||||
new_window=False
|
||||
)
|
||||
customlink.save()
|
||||
|
||||
site = Site(name='Test Site', slug='test-site')
|
||||
site.save()
|
||||
|
||||
response = self.client.get(site.get_absolute_url(), follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(f'FOO {site.name} BAR', str(response.content))
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from django import template
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Count, Prefetch, Q
|
||||
@@ -13,7 +12,7 @@ from rq import Worker
|
||||
from dcim.models import DeviceRole, Platform, Region, Site
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from utilities.utils import copy_safe_request, shallow_compare_dict
|
||||
from utilities.views import (
|
||||
BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||
@@ -258,7 +257,7 @@ class ObjectChangeLogView(View):
|
||||
# Apply the request context
|
||||
paginate = {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
'per_page': get_paginate_count(request)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(objectchanges_table)
|
||||
|
||||
|
||||
@@ -219,7 +219,8 @@ class IPAddressSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False)
|
||||
assigned_object_type = ContentTypeField(
|
||||
queryset=ContentType.objects.filter(IPADDRESS_ASSIGNMENT_MODELS),
|
||||
required=False
|
||||
required=False,
|
||||
allow_null=True
|
||||
)
|
||||
assigned_object = serializers.SerializerMethodField(read_only=True)
|
||||
nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)
|
||||
|
||||
@@ -253,10 +253,20 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
label='VRF',
|
||||
display_field='display_name'
|
||||
)
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
null_option='None'
|
||||
null_option='None',
|
||||
query_params={
|
||||
'region_id': '$region'
|
||||
}
|
||||
)
|
||||
vlan_group = DynamicModelChoiceField(
|
||||
queryset=VLANGroup.objects.all(),
|
||||
@@ -265,6 +275,9 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
null_option='None',
|
||||
query_params={
|
||||
'site_id': '$site'
|
||||
},
|
||||
initial_params={
|
||||
'vlans': '$vlan'
|
||||
}
|
||||
)
|
||||
vlan = DynamicModelChoiceField(
|
||||
@@ -297,14 +310,6 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
# Initialize helper selectors
|
||||
instance = kwargs.get('instance')
|
||||
initial = kwargs.get('initial', {}).copy()
|
||||
if instance and instance.vlan is not None:
|
||||
initial['vlan_group'] = instance.vlan.group
|
||||
kwargs['initial'] = initial
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields['vrf'].empty_label = 'Global'
|
||||
@@ -374,9 +379,17 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
|
||||
queryset=Prefix.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
)
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
to_field_name='slug'
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'region': '$region'
|
||||
}
|
||||
)
|
||||
vrf = DynamicModelChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
@@ -501,7 +514,10 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
display_field='display_name'
|
||||
display_field='display_name',
|
||||
initial_params={
|
||||
'interfaces': '$interface'
|
||||
}
|
||||
)
|
||||
interface = DynamicModelChoiceField(
|
||||
queryset=Interface.objects.all(),
|
||||
@@ -512,7 +528,10 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
|
||||
)
|
||||
virtual_machine = DynamicModelChoiceField(
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
initial_params={
|
||||
'interfaces': '$vminterface'
|
||||
}
|
||||
)
|
||||
vminterface = DynamicModelChoiceField(
|
||||
queryset=VMInterface.objects.all(),
|
||||
@@ -528,10 +547,21 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
|
||||
label='VRF',
|
||||
display_field='display_name'
|
||||
)
|
||||
nat_region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label='Region',
|
||||
initial_params={
|
||||
'sites': '$nat_site'
|
||||
}
|
||||
)
|
||||
nat_site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
label='Site'
|
||||
label='Site',
|
||||
query_params={
|
||||
'region_id': '$nat_region'
|
||||
}
|
||||
)
|
||||
nat_rack = DynamicModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
@@ -611,10 +641,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
|
||||
initial = kwargs.get('initial', {}).copy()
|
||||
if instance:
|
||||
if type(instance.assigned_object) is Interface:
|
||||
initial['device'] = instance.assigned_object.device
|
||||
initial['interface'] = instance.assigned_object
|
||||
elif type(instance.assigned_object) is VMInterface:
|
||||
initial['virtual_machine'] = instance.assigned_object.virtual_machine
|
||||
initial['vminterface'] = instance.assigned_object
|
||||
if instance.nat_inside:
|
||||
nat_inside_parent = instance.nat_inside.assigned_object
|
||||
@@ -925,16 +953,26 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
|
||||
#
|
||||
|
||||
class VLANGroupForm(BootstrapMixin, forms.ModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region'
|
||||
}
|
||||
)
|
||||
slug = SlugField()
|
||||
|
||||
class Meta:
|
||||
model = VLANGroup
|
||||
fields = [
|
||||
'site', 'name', 'slug', 'description',
|
||||
'region', 'site', 'name', 'slug', 'description',
|
||||
]
|
||||
|
||||
|
||||
@@ -974,10 +1012,20 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form):
|
||||
#
|
||||
|
||||
class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
null_option='None'
|
||||
null_option='None',
|
||||
query_params={
|
||||
'region_id': '$region'
|
||||
}
|
||||
)
|
||||
group = DynamicModelChoiceField(
|
||||
queryset=VLANGroup.objects.all(),
|
||||
@@ -1066,9 +1114,17 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
|
||||
queryset=VLAN.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
)
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
to_field_name='slug'
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'region': '$region'
|
||||
}
|
||||
)
|
||||
group = DynamicModelChoiceField(
|
||||
queryset=VLANGroup.objects.all(),
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django_tables2 import RequestConfig
|
||||
|
||||
from dcim.models import Device, Interface
|
||||
from utilities.paginator import EnhancedPaginator
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from utilities.utils import get_subquery
|
||||
from utilities.views import (
|
||||
BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView,
|
||||
@@ -233,7 +233,7 @@ class AggregateView(ObjectView):
|
||||
|
||||
paginate = {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
'per_page': get_paginate_count(request)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(prefix_table)
|
||||
|
||||
@@ -391,7 +391,7 @@ class PrefixPrefixesView(ObjectView):
|
||||
|
||||
paginate = {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
'per_page': get_paginate_count(request)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(prefix_table)
|
||||
|
||||
@@ -435,7 +435,7 @@ class PrefixIPAddressesView(ObjectView):
|
||||
|
||||
paginate = {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
'per_page': get_paginate_count(request)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(ip_table)
|
||||
|
||||
@@ -527,7 +527,8 @@ class IPAddressView(ObjectView):
|
||||
# Exclude anycast IPs if this IP is anycast
|
||||
if ipaddress.role == IPAddressRoleChoices.ROLE_ANYCAST:
|
||||
duplicate_ips = duplicate_ips.exclude(role=IPAddressRoleChoices.ROLE_ANYCAST)
|
||||
duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False)
|
||||
# Limit to a maximum of 10 duplicates displayed here
|
||||
duplicate_ips_table = tables.IPAddressTable(duplicate_ips[:10], orderable=False)
|
||||
|
||||
# Related IP table
|
||||
related_ips = IPAddress.objects.restrict(request.user, 'view').exclude(
|
||||
@@ -539,7 +540,7 @@ class IPAddressView(ObjectView):
|
||||
|
||||
paginate = {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
'per_page': get_paginate_count(request)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(related_ips_table)
|
||||
|
||||
@@ -547,6 +548,7 @@ class IPAddressView(ObjectView):
|
||||
'ipaddress': ipaddress,
|
||||
'parent_prefixes_table': parent_prefixes_table,
|
||||
'duplicate_ips_table': duplicate_ips_table,
|
||||
'more_duplicate_ips': duplicate_ips.count() > 10,
|
||||
'related_ips_table': related_ips_table,
|
||||
})
|
||||
|
||||
@@ -699,7 +701,7 @@ class VLANGroupVLANsView(ObjectView):
|
||||
|
||||
paginate = {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
'per_page': get_paginate_count(request),
|
||||
}
|
||||
RequestConfig(request, paginate).configure(vlan_table)
|
||||
|
||||
@@ -713,6 +715,7 @@ class VLANGroupVLANsView(ObjectView):
|
||||
return render(request, 'ipam/vlangroup_vlans.html', {
|
||||
'vlan_group': vlan_group,
|
||||
'first_available_vlan': vlan_group.get_next_available_vid(),
|
||||
'bulk_querystring': 'group_id={}'.format(vlan_group.pk),
|
||||
'vlan_table': vlan_table,
|
||||
'permissions': permissions,
|
||||
})
|
||||
@@ -759,7 +762,7 @@ class VLANInterfacesView(ObjectView):
|
||||
|
||||
paginate = {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
'per_page': get_paginate_count(request)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(members_table)
|
||||
|
||||
@@ -780,7 +783,7 @@ class VLANVMInterfacesView(ObjectView):
|
||||
|
||||
paginate = {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
'per_page': get_paginate_count(request)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(members_table)
|
||||
|
||||
|
||||
@@ -137,19 +137,24 @@ class LDAPBackend:
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
try:
|
||||
import ldap
|
||||
from django_auth_ldap.backend import LDAPBackend as LDAPBackend_, LDAPSettings
|
||||
except ImportError:
|
||||
raise ImproperlyConfigured(
|
||||
"LDAP authentication has been configured, but django-auth-ldap is not installed."
|
||||
)
|
||||
import ldap
|
||||
except ModuleNotFoundError as e:
|
||||
if getattr(e, 'name') == 'django_auth_ldap':
|
||||
raise ImproperlyConfigured(
|
||||
"LDAP authentication has been configured, but django-auth-ldap is not installed."
|
||||
)
|
||||
raise e
|
||||
|
||||
try:
|
||||
from netbox import ldap_config
|
||||
except ImportError:
|
||||
raise ImproperlyConfigured(
|
||||
"ldap_config.py does not exist"
|
||||
)
|
||||
except ModuleNotFoundError as e:
|
||||
if getattr(e, 'name') == 'ldap_config':
|
||||
raise ImproperlyConfigured(
|
||||
"LDAP configuration file not found: Check that ldap_config.py has been created alongside "
|
||||
"configuration.py."
|
||||
)
|
||||
raise e
|
||||
|
||||
try:
|
||||
getattr(ldap_config, 'AUTH_LDAP_SERVER_URI')
|
||||
@@ -172,9 +177,4 @@ class LDAPBackend:
|
||||
if getattr(ldap_config, 'LDAP_IGNORE_CERT_ERRORS', False):
|
||||
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
|
||||
|
||||
# Enable logging for django_auth_ldap
|
||||
ldap_logger = logging.getLogger('django_auth_ldap')
|
||||
ldap_logger.addHandler(logging.StreamHandler())
|
||||
ldap_logger.setLevel(logging.DEBUG)
|
||||
|
||||
return obj
|
||||
|
||||
@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '2.9.4'
|
||||
VERSION = '2.9.9'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@@ -38,10 +38,12 @@ if platform.python_version_tuple() < ('3', '6'):
|
||||
# Import configuration parameters
|
||||
try:
|
||||
from netbox import configuration
|
||||
except ImportError:
|
||||
raise ImproperlyConfigured(
|
||||
"Configuration file is not present. Please define netbox/netbox/configuration.py per the documentation."
|
||||
)
|
||||
except ModuleNotFoundError as e:
|
||||
if getattr(e, 'name') == 'configuration':
|
||||
raise ImproperlyConfigured(
|
||||
"Configuration file is not present. Please define netbox/netbox/configuration.py per the documentation."
|
||||
)
|
||||
raise
|
||||
|
||||
# Enforce required configuration parameters
|
||||
for parameter in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY', 'REDIS']:
|
||||
@@ -183,11 +185,13 @@ if STORAGE_BACKEND is not None:
|
||||
|
||||
try:
|
||||
import storages.utils
|
||||
except ImportError:
|
||||
raise ImproperlyConfigured(
|
||||
"STORAGE_BACKEND is set to {} but django-storages is not present. It can be installed by running 'pip "
|
||||
"install django-storages'.".format(STORAGE_BACKEND)
|
||||
)
|
||||
except ModuleNotFoundError as e:
|
||||
if getattr(e, 'name') == 'storages':
|
||||
raise ImproperlyConfigured(
|
||||
f"STORAGE_BACKEND is set to {STORAGE_BACKEND} but django-storages is not present. It can be "
|
||||
f"installed by running 'pip install django-storages'."
|
||||
)
|
||||
raise e
|
||||
|
||||
# Monkey-patch django-storages to fetch settings from STORAGE_CONFIG
|
||||
def _setting(name, default=None):
|
||||
@@ -596,11 +600,13 @@ for plugin_name in PLUGINS:
|
||||
# Import plugin module
|
||||
try:
|
||||
plugin = importlib.import_module(plugin_name)
|
||||
except ImportError:
|
||||
raise ImproperlyConfigured(
|
||||
"Unable to import plugin {}: Module not found. Check that the plugin module has been installed within the "
|
||||
"correct Python environment.".format(plugin_name)
|
||||
)
|
||||
except ModuleNotFoundError as e:
|
||||
if getattr(e, 'name') == plugin_name:
|
||||
raise ImproperlyConfigured(
|
||||
"Unable to import plugin {}: Module not found. Check that the plugin module has been installed within the "
|
||||
"correct Python environment.".format(plugin_name)
|
||||
)
|
||||
raise e
|
||||
|
||||
# Determine plugin config and add to INSTALLED_APPS.
|
||||
try:
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
Python version: {{ python_version }}
|
||||
NetBox version: {{ netbox_version }}</pre>
|
||||
<p>
|
||||
If further assistance is required, please post to the <a href="https://groups.google.com/forum/#!forum/netbox-discuss">NetBox mailing list</a>.
|
||||
If further assistance is required, please post to the <a href="https://groups.google.com/g/netbox-discuss">NetBox mailing list</a>.
|
||||
</p>
|
||||
<div class="text-right">
|
||||
<a href="{% url 'home' %}" class="btn btn-primary">Home Page</a>
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
<p class="form-control-static">{{ form.term_side.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% render_field form.region %}
|
||||
{% render_field form.site %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,12 @@
|
||||
<div class="panel-body">
|
||||
{% if termination_a.device %}
|
||||
{# Device component #}
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label required">Region</label>
|
||||
<div class="col-md-9">
|
||||
<p class="form-control-static">{{ termination_a.device.site.region }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label required">Site</label>
|
||||
<div class="col-md-9">
|
||||
@@ -111,6 +117,9 @@
|
||||
{% if 'termination_b_provider' in form.fields %}
|
||||
{% render_field form.termination_b_provider %}
|
||||
{% endif %}
|
||||
{% if 'termination_b_region' in form.fields %}
|
||||
{% render_field form.termination_b_region %}
|
||||
{% endif %}
|
||||
{% if 'termination_b_site' in form.fields %}
|
||||
{% render_field form.termination_b_site %}
|
||||
{% endif %}
|
||||
|
||||
@@ -48,28 +48,28 @@
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
{% if perms.dcim.add_consoleport %}
|
||||
<li><a href="{% url 'dcim:consoleport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Console Ports</a></li>
|
||||
<li><a href="{% url 'dcim:consoleport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_consoleports">Console Ports</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_consoleserverport %}
|
||||
<li><a href="{% url 'dcim:consoleserverport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Console Server Ports</a></li>
|
||||
<li><a href="{% url 'dcim:consoleserverport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_consoleserverports">Console Server Ports</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_powerport %}
|
||||
<li><a href="{% url 'dcim:powerport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Power Ports</a></li>
|
||||
<li><a href="{% url 'dcim:powerport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_powerports">Power Ports</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_poweroutlet %}
|
||||
<li><a href="{% url 'dcim:poweroutlet_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Power Outlets</a></li>
|
||||
<li><a href="{% url 'dcim:poweroutlet_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_poweroutlets">Power Outlets</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_interface %}
|
||||
<li><a href="{% url 'dcim:interface_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Interfaces</a></li>
|
||||
<li><a href="{% url 'dcim:interface_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_interfaces">Interfaces</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_frontport %}
|
||||
<li><a href="{% url 'dcim:frontport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Front Ports</a></li>
|
||||
<li><a href="{% url 'dcim:frontport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_frontports">Front Ports</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_rearport %}
|
||||
<li><a href="{% url 'dcim:rearport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Rear Ports</a></li>
|
||||
<li><a href="{% url 'dcim:rearport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_rearports">Rear Ports</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_devicebay %}
|
||||
<li><a href="{% url 'dcim:devicebay_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Device Bays</a></li>
|
||||
<li><a href="{% url 'dcim:devicebay_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_devicebays">Device Bays</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_inventoryitem %}
|
||||
<li><a href="{% url 'dcim:inventoryitem_add' %}?device={{ device.pk }}&return_url={% url 'dcim:device_inventory' pk=device.pk %}">Inventory Items</a></li>
|
||||
@@ -326,7 +326,7 @@
|
||||
{% plugin_left_page device %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{% if power_ports and poweroutlets %}
|
||||
{% if powerports and poweroutlets %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Power Utilization</strong>
|
||||
@@ -339,7 +339,7 @@
|
||||
<th>Available</th>
|
||||
<th>Utilization</th>
|
||||
</tr>
|
||||
{% for pp in power_ports %}
|
||||
{% for pp in powerports %}
|
||||
{% with utilization=pp.get_power_draw powerfeed=pp.connected_endpoint %}
|
||||
<tr>
|
||||
<td>{{ pp }}</td>
|
||||
@@ -537,26 +537,26 @@
|
||||
</table>
|
||||
<div class="panel-footer noprint">
|
||||
{% if interfaces and perms.dcim.change_interface %}
|
||||
<button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
|
||||
<button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ device.get_absolute_url }}%23tab_interfaces" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
|
||||
</button>
|
||||
<button type="submit" name="_edit" formaction="{% url 'dcim:interface_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
|
||||
<button type="submit" name="_edit" formaction="{% url 'dcim:interface_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_interfaces" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if interfaces and perms.dcim.change_interface %}
|
||||
<button type="submit" name="_disconnect" formaction="{% url 'dcim:interface_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
|
||||
<button type="submit" name="_disconnect" formaction="{% url 'dcim:interface_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}%23tab_interfaces" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if interfaces and perms.dcim.delete_interface %}
|
||||
<button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
|
||||
<button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' %}?return_url={{ device.get_absolute_url }}%23tab_interfaces" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_interface %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'dcim:interface_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
|
||||
<a href="{% url 'dcim:interface_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_interfaces" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
|
||||
</a>
|
||||
</div>
|
||||
@@ -597,24 +597,24 @@
|
||||
</table>
|
||||
<div class="panel-footer noprint">
|
||||
{% if frontports and perms.dcim.change_frontport %}
|
||||
<button type="submit" name="_rename" formaction="{% url 'dcim:frontport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
|
||||
<button type="submit" name="_rename" formaction="{% url 'dcim:frontport_bulk_rename' %}?return_url={{ device.get_absolute_url }}%23tab_frontports" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
|
||||
</button>
|
||||
<button type="submit" name="_edit" formaction="{% url 'dcim:frontport_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
|
||||
<button type="submit" name="_edit" formaction="{% url 'dcim:frontport_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_frontports" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
|
||||
</button>
|
||||
<button type="submit" name="_disconnect" formaction="{% url 'dcim:frontport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
|
||||
<button type="submit" name="_disconnect" formaction="{% url 'dcim:frontport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}%23tab_frontports" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if frontports and perms.dcim.delete_frontport %}
|
||||
<button type="submit" formaction="{% url 'dcim:frontport_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
|
||||
<button type="submit" formaction="{% url 'dcim:frontport_bulk_delete' %}?return_url={{ device.get_absolute_url }}%23tab_frontports" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_frontport %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'dcim:frontport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
|
||||
<a href="{% url 'dcim:frontport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_frontports" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add front ports
|
||||
</a>
|
||||
</div>
|
||||
@@ -654,24 +654,24 @@
|
||||
</table>
|
||||
<div class="panel-footer noprint">
|
||||
{% if rearports and perms.dcim.change_rearport %}
|
||||
<button type="submit" name="_rename" formaction="{% url 'dcim:rearport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
|
||||
<button type="submit" name="_rename" formaction="{% url 'dcim:rearport_bulk_rename' %}?return_url={{ device.get_absolute_url }}%23tab_rearports" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
|
||||
</button>
|
||||
<button type="submit" name="_edit" formaction="{% url 'dcim:rearport_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
|
||||
<button type="submit" name="_edit" formaction="{% url 'dcim:rearport_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_rearports" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
|
||||
</button>
|
||||
<button type="submit" name="_disconnect" formaction="{% url 'dcim:rearport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
|
||||
<button type="submit" name="_disconnect" formaction="{% url 'dcim:rearport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}%23tab_rearports" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if rearports and perms.dcim.delete_rearport %}
|
||||
<button type="submit" formaction="{% url 'dcim:rearport_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
|
||||
<button type="submit" formaction="{% url 'dcim:rearport_bulk_delete' %}?return_url={{ device.get_absolute_url }}%23tab_rearports" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_rearport %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'dcim:rearport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
|
||||
<a href="{% url 'dcim:rearport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_rearports" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add rear ports
|
||||
</a>
|
||||
</div>
|
||||
@@ -708,24 +708,24 @@
|
||||
</table>
|
||||
<div class="panel-footer noprint">
|
||||
{% if consoleports and perms.dcim.change_consoleport %}
|
||||
<button type="submit" name="_rename" formaction="{% url 'dcim:consoleport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
|
||||
<button type="submit" name="_rename" formaction="{% url 'dcim:consoleport_bulk_rename' %}?return_url={{ device.get_absolute_url }}%23tab_consoleports" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
|
||||
</button>
|
||||
<button type="submit" name="_edit" formaction="{% url 'dcim:consoleport_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
|
||||
<button type="submit" name="_edit" formaction="{% url 'dcim:consoleport_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_consoleports" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
|
||||
</button>
|
||||
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
|
||||
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}%23tab_consoleports" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if consoleports and perms.dcim.delete_consoleport %}
|
||||
<button type="submit" name="_delete" formaction="{% url 'dcim:consoleport_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
|
||||
<button type="submit" name="_delete" formaction="{% url 'dcim:consoleport_bulk_delete' %}?return_url={{ device.get_absolute_url }}%23tab_consoleports" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_consoleport %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'dcim:consoleport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-xs btn-primary">
|
||||
<a href="{% url 'dcim:consoleport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_consoleports" class="btn btn-xs btn-primary">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console port
|
||||
</a>
|
||||
</div>
|
||||
@@ -763,24 +763,24 @@
|
||||
</table>
|
||||
<div class="panel-footer noprint">
|
||||
{% if consoleserverports and perms.dcim.change_consoleport %}
|
||||
<button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
|
||||
<button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={{ device.get_absolute_url }}%23tab_consoleserverports" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
|
||||
</button>
|
||||
<button type="submit" name="_edit" formaction="{% url 'dcim:consoleserverport_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
|
||||
<button type="submit" name="_edit" formaction="{% url 'dcim:consoleserverport_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_consoleserverports" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
|
||||
</button>
|
||||
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
|
||||
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}%23tab_consoleserverports" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if consoleserverports and perms.dcim.delete_consoleserverport %}
|
||||
<button type="submit" formaction="{% url 'dcim:consoleserverport_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
|
||||
<button type="submit" formaction="{% url 'dcim:consoleserverport_bulk_delete' %}?return_url={{ device.get_absolute_url }}%23tab_consoleserverports" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_consoleserverport %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'dcim:consoleserverport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
|
||||
<a href="{% url 'dcim:consoleserverport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_consoleserverports" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
|
||||
</a>
|
||||
</div>
|
||||
@@ -818,24 +818,24 @@
|
||||
</table>
|
||||
<div class="panel-footer noprint">
|
||||
{% if powerports and perms.dcim.change_powerport %}
|
||||
<button type="submit" name="_rename" formaction="{% url 'dcim:powerport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
|
||||
<button type="submit" name="_rename" formaction="{% url 'dcim:powerport_bulk_rename' %}?return_url={{ device.get_absolute_url }}%23tab_powerports" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
|
||||
</button>
|
||||
<button type="submit" name="_edit" formaction="{% url 'dcim:powerport_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
|
||||
<button type="submit" name="_edit" formaction="{% url 'dcim:powerport_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_powerports" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
|
||||
</button>
|
||||
<button type="submit" name="_disconnect" formaction="{% url 'dcim:powerport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
|
||||
<button type="submit" name="_disconnect" formaction="{% url 'dcim:powerport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}%23tab_powerports" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if powerports and perms.dcim.delete_powerport %}
|
||||
<button type="submit" name="_delete" formaction="{% url 'dcim:powerport_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
|
||||
<button type="submit" name="_delete" formaction="{% url 'dcim:powerport_bulk_delete' %}?return_url={{ device.get_absolute_url }}%23tab_powerports" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_powerport %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'dcim:powerport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-xs btn-primary">
|
||||
<a href="{% url 'dcim:powerport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_powerports" class="btn btn-xs btn-primary">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power port
|
||||
</a>
|
||||
</div>
|
||||
@@ -874,24 +874,24 @@
|
||||
</table>
|
||||
<div class="panel-footer noprint">
|
||||
{% if poweroutlets and perms.dcim.change_powerport %}
|
||||
<button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
|
||||
<button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={{ device.get_absolute_url }}%23tab_poweroutlets" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
|
||||
</button>
|
||||
<button type="submit" name="_edit" formaction="{% url 'dcim:poweroutlet_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
|
||||
<button type="submit" name="_edit" formaction="{% url 'dcim:poweroutlet_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_poweroutlets" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
|
||||
</button>
|
||||
<button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
|
||||
<button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}%23tab_poweroutlets" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if poweroutlets and perms.dcim.delete_poweroutlet %}
|
||||
<button type="submit" formaction="{% url 'dcim:poweroutlet_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
|
||||
<button type="submit" formaction="{% url 'dcim:poweroutlet_bulk_delete' %}?return_url={{ device.get_absolute_url }}%23tab_poweroutlets" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_poweroutlet %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'dcim:poweroutlet_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
|
||||
<a href="{% url 'dcim:poweroutlet_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_poweroutlets" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
|
||||
</a>
|
||||
</div>
|
||||
@@ -933,18 +933,18 @@
|
||||
</table>
|
||||
<div class="panel-footer noprint">
|
||||
{% if devicebays and perms.dcim.change_devicebay %}
|
||||
<button type="submit" name="_rename" formaction="{% url 'dcim:devicebay_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
|
||||
<button type="submit" name="_rename" formaction="{% url 'dcim:devicebay_bulk_rename' %}?return_url={{ device.get_absolute_url }}%23tab_devicebays" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if devicebays and perms.dcim.delete_devicebay %}
|
||||
<button type="submit" formaction="{% url 'dcim:devicebay_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
|
||||
<button type="submit" formaction="{% url 'dcim:devicebay_bulk_delete' %}?return_url={{ device.get_absolute_url }}%23tab_devicebays" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_devicebay %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'dcim:devicebay_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
|
||||
<a href="{% url 'dcim:devicebay_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}%23tab_devicebays" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
|
||||
</a>
|
||||
</div>
|
||||
@@ -963,6 +963,16 @@
|
||||
|
||||
{% block javascript %}
|
||||
<script type="text/javascript">
|
||||
// Redirect user to appropriate components tab if specified
|
||||
var hash = document.location.hash;
|
||||
var prefix = "tab_";
|
||||
if (hash) {
|
||||
$('.nav-tabs a[href="'+hash.replace(prefix,"")+'"]').tab('show');
|
||||
}
|
||||
$('.nav-tabs a').on('shown.bs.tab', function (e) {
|
||||
window.location.hash = e.target.hash.replace("#", "#" + prefix);
|
||||
});
|
||||
|
||||
function toggleConnection(elem) {
|
||||
var url = netbox_api_path + "dcim/cables/" + elem.attr('data') + "/";
|
||||
if (elem.hasClass('connected')) {
|
||||
|
||||
@@ -24,14 +24,30 @@
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Components <span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
{% if perms.dcim.add_consoleporttemplate %}<li><a href="{% url 'dcim:consoleporttemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}">Console Ports</a></li>{% endif %}
|
||||
{% if perms.dcim.add_consoleserverporttemplate %}<li><a href="{% url 'dcim:consoleserverporttemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}">Console Server Ports</a></li>{% endif %}
|
||||
{% if perms.dcim.add_powerporttemplate %}<li><a href="{% url 'dcim:powerporttemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}">Power Ports</a></li>{% endif %}
|
||||
{% if perms.dcim.add_poweroutlettemplate %}<li><a href="{% url 'dcim:poweroutlettemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}">Power Outlets</a></li>{% endif %}
|
||||
{% if perms.dcim.add_interfacetemplate %}<li><a href="{% url 'dcim:interfacetemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}">Interfaces</a></li>{% endif %}
|
||||
{% if perms.dcim.add_frontporttemplate %}<li><a href="{% url 'dcim:frontporttemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}">Front Ports</a></li>{% endif %}
|
||||
{% if perms.dcim.add_rearporttemplate %}<li><a href="{% url 'dcim:rearporttemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}">Rear Ports</a></li>{% endif %}
|
||||
{% if perms.dcim.add_devicebaytemplate %}<li><a href="{% url 'dcim:devicebaytemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}">Device Bays</a></li>{% endif %}
|
||||
{% if perms.dcim.add_consoleporttemplate %}
|
||||
<li><a href="{% url 'dcim:consoleporttemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}%23tab_consoleports">Console Ports</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_consoleserverporttemplate %}
|
||||
<li><a href="{% url 'dcim:consoleserverporttemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}%23tab_consoleserverports">Console Server Ports</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_powerporttemplate %}
|
||||
<li><a href="{% url 'dcim:powerporttemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}%23tab_powerports">Power Ports</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_poweroutlettemplate %}
|
||||
<li><a href="{% url 'dcim:poweroutlettemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}%23tab_poweroutlets">Power Outlets</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_interfacetemplate %}
|
||||
<li><a href="{% url 'dcim:interfacetemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}%23tab_interfaces">Interfaces</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_frontporttemplate %}
|
||||
<li><a href="{% url 'dcim:frontporttemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}%23tab_frontports">Front Ports</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_rearporttemplate %}
|
||||
<li><a href="{% url 'dcim:rearporttemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}%23tab_rearports">Rear Ports</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_devicebaytemplate %}
|
||||
<li><a href="{% url 'dcim:devicebaytemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}%23tab_devicebays">Device Bays</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -217,3 +233,17 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block javascript %}
|
||||
<script type="text/javascript">
|
||||
// Redirect user to appropriate components tab if specified
|
||||
var hash = document.location.hash;
|
||||
var prefix = "tab_";
|
||||
if (hash) {
|
||||
$('.nav-tabs a[href="'+hash.replace(prefix,"")+'"]').tab('show');
|
||||
}
|
||||
$('.nav-tabs a').on('shown.bs.tab', function (e) {
|
||||
window.location.hash = e.target.hash.replace("#", "#" + prefix);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{% load helpers %}
|
||||
<div class="rack_header">
|
||||
<strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong>
|
||||
{% if rack.role %}
|
||||
<br /><small class="label" style="color: {{ rack.role.color|fgcolor }}; background-color: #{{ rack.role.color }}">{{ rack.role }}</small>
|
||||
{% endif %}
|
||||
{% if rack.facility_id %}
|
||||
<br /><small class="text-muted">{{ rack.facility_id }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -3,10 +3,16 @@
|
||||
|
||||
{% block form %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Power Feed</strong></div>
|
||||
<div class="panel-heading"><strong>Power Panel</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.region %}
|
||||
{% render_field form.site %}
|
||||
{% render_field form.power_panel %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Power Feed</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.rack %}
|
||||
{% render_field form.name %}
|
||||
{% render_field form.status %}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Rack</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.region %}
|
||||
{% render_field form.site %}
|
||||
{% render_field form.name %}
|
||||
{% render_field form.facility_id %}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-default{% if rack_face == 'rear' %} active{% endif %}">Rear</a>
|
||||
</div>
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request %}" class="btn btn-default{% if not reverse %} active{% endif %}">Normal</a>
|
||||
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request reverse=None %}" class="btn btn-default{% if not reverse %} active{% endif %}">Normal</a>
|
||||
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request reverse='true' %}" class="btn btn-default{% if reverse %} active{% endif %}">Reversed</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -23,10 +23,23 @@
|
||||
<div style="white-space: nowrap; overflow-x: scroll;">
|
||||
{% for rack in page %}
|
||||
<div style="display: inline-block; width: 266px">
|
||||
{% include 'dcim/inc/rack_elevation_header.html' %}
|
||||
<div class="text-center">
|
||||
<strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong>
|
||||
{% if rack.role %}
|
||||
<br /><small class="label" style="color: {{ rack.role.color|fgcolor }}; background-color: #{{ rack.role.color }}">{{ rack.role }}</small>
|
||||
{% endif %}
|
||||
{% if rack.facility_id %}
|
||||
<br /><small class="text-muted">{{ rack.facility_id }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include 'dcim/inc/rack_elevation.html' with face=rack_face %}
|
||||
<div class="clearfix"></div>
|
||||
{% include 'dcim/inc/rack_elevation_header.html' %}
|
||||
<div class="text-center">
|
||||
<strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong>
|
||||
{% if rack.facility_id %}
|
||||
<small class="text-muted">({{ rack.facility_id }})</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Rack Reservation</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.region %}
|
||||
{% render_field form.site %}
|
||||
{% render_field form.rack_group %}
|
||||
{% render_field form.rack %}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Member Devices</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.region %}
|
||||
{% render_field form.site %}
|
||||
{% render_field form.rack %}
|
||||
{% render_field form.members %}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% load custom_links %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load render_table from django_tables2 %}
|
||||
|
||||
{% block header %}
|
||||
<div class="row noprint">
|
||||
@@ -159,7 +160,24 @@
|
||||
<div class="col-md-8">
|
||||
{% include 'panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %}
|
||||
{% if duplicate_ips_table.rows %}
|
||||
{% include 'panel_table.html' with table=duplicate_ips_table heading='Duplicate IP Addresses' panel_class='danger' %}
|
||||
{# Custom version of panel_table.html #}
|
||||
<div class="panel panel-danger">
|
||||
<div class="panel-heading">
|
||||
<strong>Duplicate IP Addresses</strong>
|
||||
{% if more_duplicate_ips %}
|
||||
<div class="pull-right">
|
||||
<a type="button" class="btn btn-primary btn-xs"
|
||||
{% if ipaddress.vrf %}
|
||||
href="{% url 'ipam:ipaddress_list' %}?address={{ ipaddress.address.ip }}&vrf_id={{ ipaddress.vrf.pk }}"
|
||||
{% else %}
|
||||
href="{% url 'ipam:ipaddress_list' %}?address={{ ipaddress.address.ip }}&vrf_id=null"
|
||||
{% endif %}
|
||||
>Show all</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% render_table duplicate_ips_table 'inc/table.html' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include 'utilities/obj_table.html' with table=related_ips_table table_template='panel_table.html' heading='Related IP Addresses' panel_class='default noprint' %}
|
||||
{% plugin_right_page ipaddress %}
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane active" id="by_device">
|
||||
{% render_field form.nat_region %}
|
||||
{% render_field form.nat_site %}
|
||||
{% render_field form.nat_rack %}
|
||||
{% render_field form.nat_device %}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Site/VLAN Assignment</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.region %}
|
||||
{% render_field form.site %}
|
||||
{% render_field form.vlan_group %}
|
||||
{% render_field form.vlan %}
|
||||
|
||||
@@ -8,12 +8,18 @@
|
||||
{% render_field form.vid %}
|
||||
{% render_field form.name %}
|
||||
{% render_field form.status %}
|
||||
{% render_field form.site %}
|
||||
{% render_field form.group %}
|
||||
{% render_field form.role %}
|
||||
{% render_field form.description %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Assignment</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.region %}
|
||||
{% render_field form.site %}
|
||||
{% render_field form.group %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Tenancy</strong></div>
|
||||
<div class="panel-body">
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
{% render_field form.name %}
|
||||
{% render_field form.type %}
|
||||
{% render_field form.group %}
|
||||
{% render_field form.region %}
|
||||
{% render_field form.site %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
|
||||
{% block header %}
|
||||
<div class="row noprint">
|
||||
@@ -12,6 +13,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="pull-right noprint">
|
||||
{% plugin_buttons vminterface %}
|
||||
{% if perms.virtualization.change_vminterface %}
|
||||
<a href="{% url 'virtualization:vminterface_edit' pk=vminterface.pk %}" class="btn btn-warning">
|
||||
<span class="fa fa-pencil" aria-hidden="true"></span> Edit
|
||||
@@ -82,9 +84,11 @@
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% plugin_left_page vminterface %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{% include 'extras/inc/tags_panel.html' with tags=vminterface.tags.all %}
|
||||
{% plugin_right_page vminterface %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
@@ -97,4 +101,9 @@
|
||||
{% include 'panel_table.html' with table=vlan_table heading="VLANs" %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
{% plugin_full_width_page vminterface %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -119,7 +119,10 @@ class TenancyForm(forms.Form):
|
||||
tenant_group = DynamicModelChoiceField(
|
||||
queryset=TenantGroup.objects.all(),
|
||||
required=False,
|
||||
null_option='None'
|
||||
null_option='None',
|
||||
initial_params={
|
||||
'tenants': '$tenant'
|
||||
}
|
||||
)
|
||||
tenant = DynamicModelChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
@@ -129,17 +132,6 @@ class TenancyForm(forms.Form):
|
||||
}
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
# Initialize helper selector
|
||||
instance = kwargs.get('instance')
|
||||
if instance and instance.tenant is not None:
|
||||
initial = kwargs.get('initial', {}).copy()
|
||||
initial['tenant_group'] = instance.tenant.group
|
||||
kwargs['initial'] = initial
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class TenancyFilterForm(forms.Form):
|
||||
tenant_group = DynamicModelMultipleChoiceField(
|
||||
|
||||
@@ -22,7 +22,7 @@ class ObjectPermissionInline(admin.TabularInline):
|
||||
verbose_name_plural = 'Permissions'
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).prefetch_related('objectpermission__object_types')
|
||||
return super().get_queryset(request).prefetch_related('objectpermission__object_types').nocache()
|
||||
|
||||
@staticmethod
|
||||
def object_types(instance):
|
||||
@@ -185,7 +185,7 @@ class ObjectPermissionForm(forms.ModelForm):
|
||||
|
||||
# Validate the specified model constraints by attempting to execute a query. We don't care whether the query
|
||||
# returns anything; we just want to make sure the specified constraints are valid.
|
||||
if constraints:
|
||||
if object_types and constraints:
|
||||
# Normalize the constraints to a list of dicts
|
||||
if type(constraints) is not list:
|
||||
constraints = [constraints]
|
||||
|
||||
@@ -68,11 +68,10 @@ class TreeNodeMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter):
|
||||
"""
|
||||
Filters for a set of Models, including all descendant models within a Tree. Example: [<Region: R1>,<Region: R2>]
|
||||
"""
|
||||
|
||||
def get_filter_predicate(self, v):
|
||||
# null value filtering
|
||||
# Null value filtering
|
||||
if v is None:
|
||||
return {self.field_name.replace('in', 'isnull'): True}
|
||||
return {f"{self.field_name}__isnull": True}
|
||||
return super().get_filter_predicate(v)
|
||||
|
||||
def filter(self, qs, value):
|
||||
|
||||
@@ -248,6 +248,7 @@ class DynamicModelChoiceMixin:
|
||||
"""
|
||||
:param display_field: The name of the attribute of an API response object to display in the selection list
|
||||
:param query_params: A dictionary of additional key/value pairs to attach to the API request
|
||||
:param initial_params: A dictionary of child field references to use for selecting a parent field's initial value
|
||||
:param null_option: The string used to represent a null selection (if any)
|
||||
:param disabled_indicator: The name of the field which, if populated, will disable selection of the
|
||||
choice (optional)
|
||||
@@ -256,10 +257,11 @@ class DynamicModelChoiceMixin:
|
||||
filter = django_filters.ModelChoiceFilter
|
||||
widget = widgets.APISelect
|
||||
|
||||
def __init__(self, display_field='name', query_params=None, null_option=None, disabled_indicator=None,
|
||||
brief_mode=True, *args, **kwargs):
|
||||
def __init__(self, display_field='name', query_params=None, initial_params=None, null_option=None,
|
||||
disabled_indicator=None, brief_mode=True, *args, **kwargs):
|
||||
self.display_field = display_field
|
||||
self.query_params = query_params or {}
|
||||
self.initial_params = initial_params or {}
|
||||
self.null_option = null_option
|
||||
self.disabled_indicator = disabled_indicator
|
||||
self.brief_mode = brief_mode
|
||||
@@ -300,6 +302,16 @@ class DynamicModelChoiceMixin:
|
||||
def get_bound_field(self, form, field_name):
|
||||
bound_field = BoundField(form, self, field_name)
|
||||
|
||||
# Set initial value based on prescribed child fields (if not already set)
|
||||
if not self.initial and self.initial_params:
|
||||
filter_kwargs = {}
|
||||
for kwarg, child_field in self.initial_params.items():
|
||||
value = form.initial.get(child_field.lstrip('$'))
|
||||
if value:
|
||||
filter_kwargs[kwarg] = value
|
||||
if filter_kwargs:
|
||||
self.initial = self.queryset.filter(**filter_kwargs).first()
|
||||
|
||||
# Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
|
||||
# will be populated on-demand via the APISelect widget.
|
||||
data = bound_field.value()
|
||||
|
||||
@@ -22,7 +22,7 @@ class Command(_Command):
|
||||
"This command is available for development purposes only. It will\n"
|
||||
"NOT resolve any issues with missing or unapplied migrations. For assistance,\n"
|
||||
"please post to the NetBox mailing list:\n"
|
||||
" https://groups.google.com/forum/#!forum/netbox-discuss"
|
||||
" https://groups.google.com/g/netbox-discuss"
|
||||
)
|
||||
|
||||
super().handle(*args, **kwargs)
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.http import Http404, HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
|
||||
from .api import is_api_request
|
||||
from .views import server_error
|
||||
from .views import server_error, rest_api_server_error
|
||||
|
||||
|
||||
class LoginRequiredMiddleware(object):
|
||||
@@ -86,6 +86,10 @@ class ExceptionHandlingMiddleware(object):
|
||||
if isinstance(exception, Http404):
|
||||
return
|
||||
|
||||
# Handle exceptions that occur from REST API requests
|
||||
if is_api_request(request):
|
||||
return rest_api_server_error(request)
|
||||
|
||||
# Determine the type of exception. If it's a common issue, return a custom error page with instructions.
|
||||
custom_template = None
|
||||
if isinstance(exception, ProgrammingError):
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from django.contrib.postgres.aggregates import JSONBAgg
|
||||
from django.contrib.postgres.aggregates.mixins import OrderableAggMixin
|
||||
from django.db.models import F, Func
|
||||
|
||||
|
||||
@@ -7,3 +9,21 @@ class CollateAsChar(Func):
|
||||
"""
|
||||
function = 'C'
|
||||
template = '(%(expressions)s) COLLATE "%(function)s"'
|
||||
|
||||
|
||||
class OrderableJSONBAgg(OrderableAggMixin, JSONBAgg):
|
||||
"""
|
||||
TODO in Django 3.2 ordering is supported natively on JSONBAgg so this is no longer needed.
|
||||
"""
|
||||
template = '%(function)s(%(distinct)s%(expressions)s %(ordering)s)'
|
||||
|
||||
|
||||
class EmptyGroupByJSONBAgg(OrderableJSONBAgg):
|
||||
"""
|
||||
JSONBAgg is a builtin aggregation function which means it includes the use of a GROUP BY clause.
|
||||
When used as an annotation for collecting config context data objects, the GROUP BY is
|
||||
incorrect. This subclass overrides the Django ORM aggregation control to remove the GROUP BY.
|
||||
|
||||
TODO in Django 3.2 ordering is supported natively on JSONBAgg so we only need to inherit from JSONBAgg.
|
||||
"""
|
||||
contains_aggregate = False
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import django_tables2 as tables
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.core.exceptions import FieldDoesNotExist
|
||||
from django.db.models.fields.related import RelatedField
|
||||
from django.utils.safestring import mark_safe
|
||||
@@ -63,7 +64,7 @@ class BaseTable(tables.Table):
|
||||
field_path = column.accessor.split('.')
|
||||
try:
|
||||
model_field = model._meta.get_field(field_path[0])
|
||||
if isinstance(model_field, RelatedField):
|
||||
if isinstance(model_field, (RelatedField, GenericForeignKey)):
|
||||
prefetch_fields.append('__'.join(field_path))
|
||||
except FieldDoesNotExist:
|
||||
pass
|
||||
@@ -129,6 +130,7 @@ class ButtonsColumn(tables.TemplateColumn):
|
||||
|
||||
:param model: Model class to use for calculating URL view names
|
||||
:param prepend_content: Additional template content to render in the column (optional)
|
||||
:param return_url_extra: String to append to the return URL (e.g. for specifying a tab) (optional)
|
||||
"""
|
||||
buttons = ('changelog', 'edit', 'delete')
|
||||
attrs = {'td': {'class': 'text-right text-nowrap noprint'}}
|
||||
@@ -140,18 +142,19 @@ class ButtonsColumn(tables.TemplateColumn):
|
||||
</a>
|
||||
{{% endif %}}
|
||||
{{% if "edit" in buttons and perms.{app_label}.change_{model_name} %}}
|
||||
<a href="{{% url '{app_label}:{model_name}_edit' {pk_field}=record.{pk_field} %}}?return_url={{{{ request.path }}}}" class="btn btn-xs btn-warning" title="Edit">
|
||||
<a href="{{% url '{app_label}:{model_name}_edit' {pk_field}=record.{pk_field} %}}?return_url={{{{ request.path }}}}{{{{ return_url_extra }}}}" class="btn btn-xs btn-warning" title="Edit">
|
||||
<i class="fa fa-pencil"></i>
|
||||
</a>
|
||||
{{% endif %}}
|
||||
{{% if "delete" in buttons and perms.{app_label}.delete_{model_name} %}}
|
||||
<a href="{{% url '{app_label}:{model_name}_delete' {pk_field}=record.{pk_field} %}}?return_url={{{{ request.path }}}}" class="btn btn-xs btn-danger" title="Delete">
|
||||
<a href="{{% url '{app_label}:{model_name}_delete' {pk_field}=record.{pk_field} %}}?return_url={{{{ request.path }}}}{{{{ return_url_extra }}}}" class="btn btn-xs btn-danger" title="Delete">
|
||||
<i class="fa fa-trash"></i>
|
||||
</a>
|
||||
{{% endif %}}
|
||||
"""
|
||||
|
||||
def __init__(self, model, *args, pk_field='pk', buttons=None, prepend_template=None, **kwargs):
|
||||
def __init__(self, model, *args, pk_field='pk', buttons=None, prepend_template=None, return_url_extra='',
|
||||
**kwargs):
|
||||
if prepend_template:
|
||||
prepend_template = prepend_template.replace('{', '{{')
|
||||
prepend_template = prepend_template.replace('}', '}}')
|
||||
@@ -168,6 +171,7 @@ class ButtonsColumn(tables.TemplateColumn):
|
||||
|
||||
self.extra_context.update({
|
||||
'buttons': buttons or self.buttons,
|
||||
'return_url_extra': return_url_extra,
|
||||
})
|
||||
|
||||
def header(self):
|
||||
|
||||
@@ -23,7 +23,8 @@ class TreeNodeMultipleChoiceFilterTest(TestCase):
|
||||
class SiteFilterSet(django_filters.FilterSet):
|
||||
region = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='region__in',
|
||||
field_name='region',
|
||||
lookup_expr='in',
|
||||
to_field_name='slug',
|
||||
)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured, Obje
|
||||
from django.db import transaction, IntegrityError
|
||||
from django.db.models import ManyToManyField, ProtectedError
|
||||
from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
|
||||
from django.http import HttpResponse, HttpResponseServerError
|
||||
from django.http import HttpResponse, HttpResponseServerError, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.template import loader
|
||||
from django.template.exceptions import TemplateDoesNotExist
|
||||
@@ -27,6 +27,7 @@ from django.views.decorators.csrf import requires_csrf_token
|
||||
from django.views.defaults import ERROR_500_TEMPLATE_NAME
|
||||
from django.views.generic import View
|
||||
from django_tables2 import RequestConfig
|
||||
from rest_framework import status
|
||||
|
||||
from extras.models import CustomField, CustomFieldValue, ExportTemplate
|
||||
from extras.querysets import CustomFieldQueryset
|
||||
@@ -1352,7 +1353,7 @@ class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin,
|
||||
for obj in data['pk']:
|
||||
|
||||
names = data['name_pattern']
|
||||
labels = data['label_pattern']
|
||||
labels = data['label_pattern'] if 'label_pattern' in data else None
|
||||
for i, name in enumerate(names):
|
||||
label = labels[i] if labels else None
|
||||
|
||||
@@ -1423,8 +1424,22 @@ def server_error(request, template_name=ERROR_500_TEMPLATE_NAME):
|
||||
type_, error, traceback = sys.exc_info()
|
||||
|
||||
return HttpResponseServerError(template.render({
|
||||
'python_version': platform.python_version(),
|
||||
'netbox_version': settings.VERSION,
|
||||
'exception': str(type_),
|
||||
'error': error,
|
||||
'exception': str(type_),
|
||||
'netbox_version': settings.VERSION,
|
||||
'python_version': platform.python_version(),
|
||||
}))
|
||||
|
||||
|
||||
def rest_api_server_error(request, *args, **kwargs):
|
||||
"""
|
||||
Handle exceptions and return a useful error message for REST API requests.
|
||||
"""
|
||||
type_, error, traceback = sys.exc_info()
|
||||
data = {
|
||||
'error': str(error),
|
||||
'exception': type_.__name__,
|
||||
'netbox_version': settings.VERSION,
|
||||
'python_version': platform.python_version(),
|
||||
}
|
||||
return JsonResponse(data, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
@@ -6,7 +6,7 @@ from rest_framework.routers import APIRootView
|
||||
|
||||
from dcim.models import Device
|
||||
from extras.api.serializers import RenderedGraphSerializer
|
||||
from extras.api.views import CustomFieldModelViewSet
|
||||
from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet
|
||||
from extras.models import Graph
|
||||
from utilities.api import ModelViewSet
|
||||
from utilities.utils import get_subquery
|
||||
@@ -58,7 +58,7 @@ class ClusterViewSet(CustomFieldModelViewSet):
|
||||
# Virtual machines
|
||||
#
|
||||
|
||||
class VirtualMachineViewSet(CustomFieldModelViewSet):
|
||||
class VirtualMachineViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin):
|
||||
queryset = VirtualMachine.objects.prefetch_related(
|
||||
'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags'
|
||||
)
|
||||
|
||||
@@ -186,6 +186,10 @@ class VirtualMachineFilterSet(
|
||||
field_name='interfaces__mac_address',
|
||||
label='MAC address',
|
||||
)
|
||||
has_primary_ip = django_filters.BooleanFilter(
|
||||
method='_has_primary_ip',
|
||||
label='Has a primary IP',
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
@@ -200,6 +204,12 @@ class VirtualMachineFilterSet(
|
||||
Q(comments__icontains=value)
|
||||
)
|
||||
|
||||
def _has_primary_ip(self, queryset, name, value):
|
||||
params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False)
|
||||
if value:
|
||||
return queryset.filter(params)
|
||||
return queryset.exclude(params)
|
||||
|
||||
|
||||
class VMInterfaceFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
|
||||
@@ -79,9 +79,19 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
queryset=ClusterGroup.objects.all(),
|
||||
required=False
|
||||
)
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region'
|
||||
}
|
||||
)
|
||||
comments = CommentField()
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
@@ -92,7 +102,7 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
class Meta:
|
||||
model = Cluster
|
||||
fields = (
|
||||
'name', 'type', 'group', 'tenant', 'site', 'comments', 'tags',
|
||||
'name', 'type', 'group', 'tenant', 'region', 'site', 'comments', 'tags',
|
||||
)
|
||||
|
||||
|
||||
@@ -143,9 +153,17 @@ class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False
|
||||
)
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
to_field_name='slug'
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'region': '$region'
|
||||
}
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=SmallTextarea,
|
||||
@@ -266,7 +284,10 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
cluster_group = DynamicModelChoiceField(
|
||||
queryset=ClusterGroup.objects.all(),
|
||||
required=False,
|
||||
null_option='None'
|
||||
null_option='None',
|
||||
initial_params={
|
||||
'clusters': '$cluster'
|
||||
}
|
||||
)
|
||||
cluster = DynamicModelChoiceField(
|
||||
queryset=Cluster.objects.all(),
|
||||
@@ -311,14 +332,6 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
# Initialize helper selector
|
||||
instance = kwargs.get('instance')
|
||||
if instance.pk and instance.cluster is not None:
|
||||
initial = kwargs.get('initial', {}).copy()
|
||||
initial['cluster_group'] = instance.cluster.group
|
||||
kwargs['initial'] = initial
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if self.instance.pk:
|
||||
@@ -516,6 +529,13 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
|
||||
required=False,
|
||||
label='MAC address'
|
||||
)
|
||||
has_primary_ip = forms.NullBooleanField(
|
||||
required=False,
|
||||
label='Has a primary IP',
|
||||
widget=StaticSelect2(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from taggit.managers import TaggableManager
|
||||
from dcim.choices import InterfaceModeChoices
|
||||
from dcim.models import BaseInterface, Device
|
||||
from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
|
||||
from extras.querysets import ConfigContextModelQuerySet
|
||||
from extras.utils import extras_features
|
||||
from utilities.fields import NaturalOrderingField
|
||||
from utilities.ordering import naturalize_interface
|
||||
@@ -282,7 +283,7 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
objects = ConfigContextModelQuerySet.as_manager()
|
||||
|
||||
csv_headers = [
|
||||
'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.models import DeviceRole, Platform, Region, Site
|
||||
from ipam.models import IPAddress
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from virtualization.choices import *
|
||||
from virtualization.filters import *
|
||||
@@ -266,6 +267,15 @@ class VirtualMachineTestCase(TestCase):
|
||||
)
|
||||
VMInterface.objects.bulk_create(interfaces)
|
||||
|
||||
# Assign primary IPs for filtering
|
||||
ipaddresses = (
|
||||
IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]),
|
||||
IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]),
|
||||
)
|
||||
IPAddress.objects.bulk_create(ipaddresses)
|
||||
VirtualMachine.objects.filter(pk=vms[0].pk).update(primary_ip4=ipaddresses[0])
|
||||
VirtualMachine.objects.filter(pk=vms[1].pk).update(primary_ip4=ipaddresses[1])
|
||||
|
||||
def test_id(self):
|
||||
params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@@ -344,6 +354,12 @@ class VirtualMachineTestCase(TestCase):
|
||||
params = {'mac_address': ['00-00-00-00-00-01', '00-00-00-00-00-02']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_has_primary_ip(self):
|
||||
params = {'has_primary_ip': 'true'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'has_primary_ip': 'false'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_local_context_data(self):
|
||||
params = {'local_context_data': 'true'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
@@ -261,7 +261,7 @@ class VirtualMachineView(ObjectView):
|
||||
|
||||
|
||||
class VirtualMachineConfigContextView(ObjectConfigContextView):
|
||||
queryset = VirtualMachine.objects.all()
|
||||
queryset = VirtualMachine.objects.annotate_config_context_data()
|
||||
base_template = 'virtualization/virtualmachine.html'
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Django==3.1
|
||||
django-cacheops==5.0.1
|
||||
django-cacheops==5.1.0
|
||||
django-cors-headers==3.4.0
|
||||
django-debug-toolbar==2.2
|
||||
django-filter==2.3.0
|
||||
|
||||
@@ -13,6 +13,15 @@ EXIT=0
|
||||
RED='\033[0;31m'
|
||||
NOCOLOR='\033[0m'
|
||||
|
||||
if [ -d ./venv/ ]; then
|
||||
VENV="$PWD/venv"
|
||||
if [ -e $VENV/bin/python ]; then
|
||||
PATH=$VENV/bin:$PATH
|
||||
elif [ -e $VENV/Scripts/python.exe ]; then
|
||||
PATH=$VENV/Scripts:$PATH
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Validating PEP8 compliance..."
|
||||
pycodestyle --ignore=W504,E501 netbox/
|
||||
if [ $? != 0 ]; then
|
||||
|
||||
Reference in New Issue
Block a user