Compare commits

..

59 Commits

Author SHA1 Message Date
Jeremy Stretch
8ba50d0cf2 Merge pull request #5332 from netbox-community/develop
Release v2.9.9
2020-11-09 20:55:28 -05:00
Jeremy Stretch
a6f0154803 Release v2.9.9 2020-11-09 20:51:35 -05:00
Jeremy Stretch
9b2dc89c6c Closes #5327: Be more strict when capturing anticipated ImportError exceptions 2020-11-09 16:49:05 -05:00
Jeremy Stretch
dc7da4f0f6 Fixes #5316: Dry running scripts should not trigger webhooks 2020-11-09 16:22:45 -05:00
Jeremy Stretch
ab33b3aa84 Fixes #5328: Fix CreatedUpdatedFilterTest when running in non-UTC timezone 2020-11-09 16:04:09 -05:00
Jeremy Stretch
bf836d4d46 Fixes #5331: Fix filtering of sites by null region 2020-11-09 15:58:59 -05:00
Jeremy Stretch
c3f2cc7648 Correct buttons() method in dummy plugin template content 2020-11-09 14:31:53 -05:00
Jeremy Stretch
d2c4c7fb10 Closes #5324: Add missing template extension tags for plugins for VM interface view 2020-11-09 14:19:22 -05:00
Jeremy Stretch
39f4dea051 Merge pull request #5326 from 991jo/patch-1
Fixed a UI Bug where there is no whitespace after grouped custom links.
2020-11-09 12:54:08 -05:00
991jo
7cf4095983 Fixed a UI Bug where there is no whitespace after grouped custom links.
See #5325 for details
2020-11-09 07:56:26 +01:00
John Anderson
0d27abc6fc fixes #5314 - Fix config context rendering when multiple tags are assgined to an object 2020-11-06 16:47:07 -05:00
Jeremy Stretch
b39019cec9 Clarify cache invalidation instructions 2020-11-05 14:41:06 -05:00
Jeremy Stretch
f2cba78549 Closes #5310: Link to rack groups within rack list table 2020-11-05 14:24:22 -05:00
Jeremy Stretch
f845eeb117 Closes #5304: Return server error messages as JSON when handling REST API requests 2020-11-04 17:11:57 -05:00
Jeremy Stretch
c53990c739 Closes #5307: Add a region selector for every site form field 2020-11-04 15:27:41 -05:00
Jeremy Stretch
7d52b4d9a5 Changelog for #5271 2020-11-04 13:23:56 -05:00
Jeremy Stretch
a69d61c124 Merge pull request #5308 from netbox-community/5271-DynamicModelChoiceMixin-initial
Fixes #5271: Extend DynamicModelChoiceMixin to accept initial field values implied by child fields
2020-11-04 13:20:30 -05:00
Jeremy Stretch
798220cf9a Fix filter reference 2020-11-04 13:05:24 -05:00
Jeremy Stretch
7598b891c1 Add initial_params to all forms 2020-11-04 11:09:13 -05:00
Jeremy Stretch
1f0b1dd10e Add initial_params to DynamicModelChoiceMixin 2020-11-04 10:15:15 -05:00
Jeremy Stretch
f0bac741cc Move to lock-threads app 2020-11-04 08:58:20 -05:00
Jeremy Stretch
2424f684b9 Post-release version bump 2020-10-30 10:41:05 -04:00
Jeremy Stretch
6dcb2de28f Merge pull request #5293 from netbox-community/develop
Release v2.9.8
2020-10-30 10:38:17 -04:00
Jeremy Stretch
cbd155406b Release v2.9.8 2020-10-30 10:11:13 -04:00
Jeremy Stretch
70cc368225 Changelog for #4559, #5282, #5285 2020-10-30 09:14:52 -04:00
Jeremy Stretch
6d28eae5dc Merge pull request #5290 from nniehoff/5282-ldap-logging
Fixing #5282 - moving logging configuration from to configuration.py
2020-10-30 09:11:12 -04:00
Jeremy Stretch
ca932ecadc Merge pull request #5286 from netbox-community/5285-plugins-api-app_name
Fixes #5285: Include plugin API endpoints in root view without needing app_name set
2020-10-30 09:10:12 -04:00
Jeremy Stretch
04d763d814 Merge pull request #5266 from netbox-community/4559-config-context-rendering
4559 config context rendering
2020-10-30 09:09:40 -04:00
Nick Niehoff
97ea15e978 Removing logfile variable 2020-10-30 06:32:12 -06:00
John Anderson
28c17f33ab move get_queryset() to common mixin 2020-10-30 02:56:43 -04:00
John Anderson
db87a69488 convert region fields to f-string 2020-10-30 02:56:26 -04:00
Nick Niehoff
2b154dcfdf Fixing #5282 - moving logging configuration from authentication.py to configuration.py 2020-10-29 15:14:52 -06:00
Jeremy Stretch
1000039c74 Merge pull request #5277 from systeembeheerder/develop
Update upgrading.md
2020-10-29 17:11:04 -04:00
Jeremy Stretch
2ccc543542 Document the available template blocks within base.html 2020-10-29 17:10:19 -04:00
systeembeheerder
8ac2ee7b49 Update upgrading.md
make destination path for cp media absolute
2020-10-29 22:05:19 +01:00
Jeremy Stretch
db3fd27da2 Merge pull request #5283 from markkuleinio/develop
Fix #5265: Change systemctl status outputs to venv era
2020-10-29 16:49:34 -04:00
Jeremy Stretch
b871d2a8c4 Fixes #5285: Include plugin API endpoints in root view without needing app_name set 2020-10-29 12:30:10 -04:00
Markku Leinio
02f348b15f Fix typo in weekday 2020-10-28 21:06:20 +02:00
Markku Leinio
4672f16fda Fix #5265: Change systemctl status outputs to venv era 2020-10-28 21:03:35 +02:00
systeembeheerder
6404657f99 Update upgrading.md
confirm 5275; use absolute paths
2020-10-27 16:07:38 +01:00
Jeremy Stretch
2f3557c18f Fixes #5273: Fix exception when validating a new permission with no models selected 2020-10-27 10:01:49 -04:00
John Anderson
047f03a58c clean up imports 2020-10-25 19:00:56 -04:00
John Anderson
606b015314 Merge branch 'develop' into 4559-config-context-rendering 2020-10-25 18:47:40 -04:00
John Anderson
478d82196a rebase develop 2020-10-25 18:20:21 -04:00
John Anderson
034ff2fb3f #3672 changelog 2020-10-25 18:19:20 -04:00
John Anderson
9bfb89fffd fixes #3672 - update django-cacheops to 5.1.0 2020-10-25 18:19:20 -04:00
John Anderson
26ff33c41a pep8 2020-10-25 17:56:42 -04:00
John Anderson
9e84e3b83b added tests 2020-10-25 16:49:18 -04:00
John Anderson
82f5d0070e account for null value annotations 2020-10-23 10:56:02 -04:00
John Anderson
22d2289ed2 add support for regions and vms 2020-10-23 01:18:04 -04:00
John Anderson
3ba18633de initial work on config context performance improvements 2020-10-20 01:07:22 -04:00
Jeremy Stretch
dde52309d2 Fixes #5243: Redirect user to appropriate tab after modifying device components 2020-10-15 16:55:20 -04:00
John Anderson
87c13a876b fixes #5113 - incorrect caching of permission object assignments to user groups in the admin panel 2020-10-14 20:10:21 -04:00
Jeremy Stretch
0d01a3fce8 Add schema migrations to plugins installation doc 2020-10-14 10:42:31 -04:00
Jeremy Stretch
d4b96a5a27 Post-release version bump 2020-10-12 09:51:11 -04:00
Jeremy Stretch
1bbfc6da25 Merge pull request #5236 from netbox-community/develop
Release v2.9.7
2020-10-12 09:49:51 -04:00
Jeremy Stretch
1aabb2ed5d Release v2.9.7 2020-10-12 09:39:39 -04:00
Jeremy Stretch
f53810ebb2 Fixes #5231: Fix KeyError exception when viewing object with custom link and debugging is disabled 2020-10-09 15:53:45 -04:00
Jeremy Stretch
7545599493 Post-release version bump 2020-10-09 13:31:28 -04:00
56 changed files with 1204 additions and 345 deletions

23
.github/lock.yml vendored
View File

@@ -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
View 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'

View File

@@ -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
```

View File

@@ -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.

View 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/...
...
```

View File

@@ -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

View File

@@ -201,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 %}
```

View File

@@ -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:

View File

@@ -1,5 +1,50 @@
# 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

View File

@@ -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",

View File

@@ -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:

View File

@@ -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:
@@ -3426,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(),
@@ -3455,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,
@@ -3553,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(),
@@ -3580,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):
@@ -3590,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(),
@@ -3836,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(),
@@ -3888,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(),
@@ -3904,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(),
@@ -3920,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(),
@@ -3947,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(),
@@ -3982,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):
@@ -4079,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(),
@@ -4180,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(),
@@ -4198,7 +4316,7 @@ class PowerPanelForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = PowerPanel
fields = [
'site', 'rack_group', 'name', 'tags',
'region', 'site', 'rack_group', 'name', 'tags',
]
@@ -4233,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(),
@@ -4287,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(),
@@ -4314,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 = {
@@ -4324,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(

View File

@@ -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',

View File

@@ -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):

View File

@@ -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):

View File

@@ -1163,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'
@@ -2043,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)

View File

@@ -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

View File

@@ -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:

View File

@@ -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",

View File

@@ -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

View File

@@ -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,

View File

@@ -16,7 +16,7 @@ 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'
@@ -33,7 +33,7 @@ def custom_links(context, obj):
# Pass select context data when rendering the CustomLink
link_context = {
'obj': obj,
'debug': context['debug'], # django.template.context_processors.debug
'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

View File

@@ -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"

View File

@@ -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):

View File

@@ -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())

View File

@@ -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))

View File

@@ -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(),

View File

@@ -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.INFO)
return obj

View File

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup
#
VERSION = '2.9.6'
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:

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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>
@@ -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')) {

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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">

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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(

View File

@@ -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]

View File

@@ -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):

View File

@@ -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()

View File

@@ -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):

View File

@@ -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

View File

@@ -130,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'}}
@@ -141,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('}', '}}')
@@ -169,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):

View File

@@ -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',
)

View File

@@ -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
@@ -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)

View File

@@ -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'
)

View File

@@ -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:

View File

@@ -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',

View File

@@ -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'

View File

@@ -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