Merge pull request #5293 from netbox-community/develop

Release v2.9.8
This commit is contained in:
Jeremy Stretch 2020-10-30 10:38:17 -04:00 committed by GitHub
commit 6dcb2de28f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 621 additions and 132 deletions

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`. `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 ```python
import logging, logging.handlers LOGGING = {
logfile = "/opt/netbox/logs/django-ldap-debug.log" 'version': 1,
my_logger = logging.getLogger('django_auth_ldap') 'disable_existing_loggers': False,
my_logger.setLevel(logging.DEBUG) 'handlers': {
handler = logging.handlers.RotatingFileHandler( 'netbox_auth_log': {
logfile, maxBytes=1024 * 500, backupCount=5 'level': 'DEBUG',
) 'class': 'logging.handlers.RotatingFileHandler',
my_logger.addHandler(handler) '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. 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 # systemctl status netbox.service
● netbox.service - NetBox WSGI Service ● netbox.service - NetBox WSGI Service
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled) 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/ Docs: https://netbox.readthedocs.io/en/stable/
Main PID: 11993 (gunicorn) Main PID: 11993 (gunicorn)
Tasks: 6 (limit: 2362) Tasks: 6 (limit: 2362)
CGroup: /system.slice/netbox.service CGroup: /system.slice/netbox.service
├─11993 /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 /usr/bin/python3 /usr/local/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 /usr/bin/python3 /usr/local/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 # wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz
# tar -xzf vX.Y.Z.tar.gz -C /opt # tar -xzf vX.Y.Z.tar.gz -C /opt
# cd /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: Copy the 'configuration.py' you created when first installing to the new version:
```no-highlight ```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: Copy your local requirements file if used:
```no-highlight ```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: Also copy the LDAP configuration if using LDAP:
```no-highlight ```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.) 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 ```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.) 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 ```no-highlight
# cp -r /opt/netbox-X.Y.Z/netbox/scripts /opt/netbox/netbox/scripts/ # cp -r /opt/netbox-X.Y.Z/netbox/scripts /opt/netbox/netbox/
# cp -r /opt/netbox-X.Y.Z/netbox/reports /opt/netbox/netbox/reports/ # 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: If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well:
```no-highlight ```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 ### 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 ```jinja2
{% extends 'base.html' %} {% extends 'base.html' %}
{% block content %} {% block content %}
{% with config=settings.PLUGINS_CONFIG.netbox_animal_sounds %} {% with config=settings.PLUGINS_CONFIG.netbox_animal_sounds %}
<h2 class="text-center" style="margin-top: 200px"> <h2 class="text-center" style="margin-top: 200px">
{% if animal %} {% if animal %}
The {{ animal.name|lower }} says The {{ animal.name|lower }} says
{% if config.loud %} {% if config.loud %}
{{ animal.sound|upper }}! {{ animal.sound|upper }}!
{% else %} {% else %}
{{ animal.sound }} {{ animal.sound }}
{% endif %} {% endif %}
{% else %} {% else %}
No animals have been created yet! No animals have been created yet!
{% endif %} {% endif %}
</h2> </h2>
{% endwith %} {% endwith %}
{% endblock %} {% 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 ### 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: 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,22 @@
# NetBox v2.9 # NetBox v2.9
## 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) ## v2.9.7 (2020-10-12)
### Bug Fixes ### Bug Fixes

View File

@ -24,7 +24,7 @@ from dcim.models import (
VirtualChassis, VirtualChassis,
) )
from extras.api.serializers import RenderedGraphSerializer 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 extras.models import Graph
from ipam.models import Prefix, VLAN from ipam.models import Prefix, VLAN
from utilities.api import ( from utilities.api import (
@ -336,7 +336,7 @@ class PlatformViewSet(ModelViewSet):
# Devices # Devices
# #
class DeviceViewSet(CustomFieldModelViewSet): class DeviceViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin):
queryset = Device.objects.prefetch_related( queryset = Device.objects.prefetch_related(
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay', 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay',
'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags', 'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',

View File

@ -15,6 +15,7 @@ from taggit.managers import TaggableManager
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, TaggedItem from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, TaggedItem
from extras.querysets import ConfigContextModelQuerySet
from extras.utils import extras_features from extras.utils import extras_features
from utilities.choices import ColorChoices from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField from utilities.fields import ColorField, NaturalOrderingField
@ -594,7 +595,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager() objects = ConfigContextModelQuerySet.as_manager()
csv_headers = [ csv_headers = [
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',

View File

@ -432,7 +432,8 @@ class ComponentTemplateTable(BaseTable):
class ConsolePortTemplateTable(ComponentTemplateTable): class ConsolePortTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn( actions = ButtonsColumn(
model=ConsolePortTemplate, model=ConsolePortTemplate,
buttons=('edit', 'delete') buttons=('edit', 'delete'),
return_url_extra='%23tab_consoleports'
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
@ -444,7 +445,8 @@ class ConsolePortTemplateTable(ComponentTemplateTable):
class ConsoleServerPortTemplateTable(ComponentTemplateTable): class ConsoleServerPortTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn( actions = ButtonsColumn(
model=ConsoleServerPortTemplate, model=ConsoleServerPortTemplate,
buttons=('edit', 'delete') buttons=('edit', 'delete'),
return_url_extra='%23tab_consoleserverports'
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
@ -456,7 +458,8 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable):
class PowerPortTemplateTable(ComponentTemplateTable): class PowerPortTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn( actions = ButtonsColumn(
model=PowerPortTemplate, model=PowerPortTemplate,
buttons=('edit', 'delete') buttons=('edit', 'delete'),
return_url_extra='%23tab_powerports'
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
@ -468,7 +471,8 @@ class PowerPortTemplateTable(ComponentTemplateTable):
class PowerOutletTemplateTable(ComponentTemplateTable): class PowerOutletTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn( actions = ButtonsColumn(
model=PowerOutletTemplate, model=PowerOutletTemplate,
buttons=('edit', 'delete') buttons=('edit', 'delete'),
return_url_extra='%23tab_poweroutlets'
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
@ -483,7 +487,8 @@ class InterfaceTemplateTable(ComponentTemplateTable):
) )
actions = ButtonsColumn( actions = ButtonsColumn(
model=InterfaceTemplate, model=InterfaceTemplate,
buttons=('edit', 'delete') buttons=('edit', 'delete'),
return_url_extra='%23tab_interfaces'
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
@ -498,7 +503,8 @@ class FrontPortTemplateTable(ComponentTemplateTable):
) )
actions = ButtonsColumn( actions = ButtonsColumn(
model=FrontPortTemplate, model=FrontPortTemplate,
buttons=('edit', 'delete') buttons=('edit', 'delete'),
return_url_extra='%23tab_frontports'
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
@ -510,7 +516,8 @@ class FrontPortTemplateTable(ComponentTemplateTable):
class RearPortTemplateTable(ComponentTemplateTable): class RearPortTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn( actions = ButtonsColumn(
model=RearPortTemplate, model=RearPortTemplate,
buttons=('edit', 'delete') buttons=('edit', 'delete'),
return_url_extra='%23tab_rearports'
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
@ -522,7 +529,8 @@ class RearPortTemplateTable(ComponentTemplateTable):
class DeviceBayTemplateTable(ComponentTemplateTable): class DeviceBayTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn( actions = ButtonsColumn(
model=DeviceBayTemplate, model=DeviceBayTemplate,
buttons=('edit', 'delete') buttons=('edit', 'delete'),
return_url_extra='%23tab_devicebays'
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):

View File

@ -1163,7 +1163,7 @@ class DeviceConfigView(ObjectView):
class DeviceConfigContextView(ObjectConfigContextView): class DeviceConfigContextView(ObjectConfigContextView):
queryset = Device.objects.all() queryset = Device.objects.annotate_config_context_data()
base_template = 'dcim/device.html' base_template = 'dcim/device.html'

View File

@ -26,6 +26,29 @@ from utilities.utils import copy_safe_request
from . import serializers 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): class ExtrasRootView(APIRootView):
""" """
Extras API root view 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 # Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs
data = OrderedDict() 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 the object has local config context data defined, merge it last
if self.local_context_data: 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.reverse import reverse
from rest_framework.views import APIView from rest_framework.views import APIView
from extras.plugins.utils import import_object
class InstalledPluginsAdminView(View): class InstalledPluginsAdminView(View):
""" """
@ -62,11 +60,7 @@ class PluginsAPIRootView(APIView):
@staticmethod @staticmethod
def _get_plugin_entry(plugin, app_config, request, format): def _get_plugin_entry(plugin, app_config, request, format):
# Check if the plugin specifies any API URLs # Check if the plugin specifies any API URLs
api_app_name = import_object(f"{plugin}.api.urls.app_name") api_app_name = f'{app_config.name}-api'
if api_app_name is None:
# Plugin does not expose an API
return None
try: try:
entry = (getattr(app_config, 'base_url', app_config.label), reverse( entry = (getattr(app_config, 'base_url', app_config.label), reverse(
f"plugins-api:{api_app_name}:api-root", f"plugins-api:{api_app_name}:api-root",

View File

@ -1,7 +1,8 @@
from collections import OrderedDict 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 from utilities.querysets import RestrictedQuerySet
@ -23,9 +24,12 @@ class CustomFieldQueryset:
class ConfigContextQuerySet(RestrictedQuerySet): 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. 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 # `device_role` for Device; `role` for VirtualMachine
@ -45,7 +49,7 @@ class ConfigContextQuerySet(RestrictedQuerySet):
else: else:
regions = [] regions = []
return self.filter( queryset = self.filter(
Q(regions__in=regions) | Q(regions=None), Q(regions__in=regions) | Q(regions=None),
Q(sites=obj.site) | Q(sites=None), Q(sites=obj.site) | Q(sites=None),
Q(roles=role) | Q(roles=None), Q(roles=role) | Q(roles=None),
@ -57,3 +61,72 @@ class ConfigContextQuerySet(RestrictedQuerySet):
Q(tags__slug__in=obj.tags.slugs()) | Q(tags=None), Q(tags__slug__in=obj.tags.slugs()) | Q(tags=None),
is_active=True, is_active=True,
).order_by('weight', 'name') ).order_by('weight', 'name')
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")
)
)
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

@ -1,9 +1,11 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.test import TestCase 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.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): class GraphTest(TestCase):
@ -53,3 +55,276 @@ class TagTest(TestCase):
tag.save() tag.save()
self.assertEqual(tag.slug, 'testing-unicode-台灣') 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.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())

View File

@ -172,9 +172,4 @@ class LDAPBackend:
if getattr(ldap_config, 'LDAP_IGNORE_CERT_ERRORS', False): if getattr(ldap_config, 'LDAP_IGNORE_CERT_ERRORS', False):
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER) 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 return obj

View File

@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup # Environment setup
# #
VERSION = '2.9.7' VERSION = '2.9.8'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()

View File

@ -48,28 +48,28 @@
</button> </button>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
{% if perms.dcim.add_consoleport %} {% 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 %} {% endif %}
{% if perms.dcim.add_consoleserverport %} {% 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 %} {% endif %}
{% if perms.dcim.add_powerport %} {% 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 %} {% endif %}
{% if perms.dcim.add_poweroutlet %} {% 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 %} {% endif %}
{% if perms.dcim.add_interface %} {% 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 %} {% endif %}
{% if perms.dcim.add_frontport %} {% 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 %} {% endif %}
{% if perms.dcim.add_rearport %} {% 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 %} {% endif %}
{% if perms.dcim.add_devicebay %} {% 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 %} {% endif %}
{% if perms.dcim.add_inventoryitem %} {% 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> <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> </table>
<div class="panel-footer noprint"> <div class="panel-footer noprint">
{% if interfaces and perms.dcim.change_interface %} {% 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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button> </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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button> </button>
{% endif %} {% endif %}
{% if interfaces and perms.dcim.change_interface %} {% 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 <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button> </button>
{% endif %} {% endif %}
{% if interfaces and perms.dcim.delete_interface %} {% 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 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button> </button>
{% endif %} {% endif %}
{% if perms.dcim.add_interface %} {% if perms.dcim.add_interface %}
<div class="pull-right"> <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 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
</a> </a>
</div> </div>
@ -597,24 +597,24 @@
</table> </table>
<div class="panel-footer noprint"> <div class="panel-footer noprint">
{% if frontports and perms.dcim.change_frontport %} {% 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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button> </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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button> </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 <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button> </button>
{% endif %} {% endif %}
{% if frontports and perms.dcim.delete_frontport %} {% 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 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button> </button>
{% endif %} {% endif %}
{% if perms.dcim.add_frontport %} {% if perms.dcim.add_frontport %}
<div class="pull-right"> <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 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add front ports
</a> </a>
</div> </div>
@ -654,24 +654,24 @@
</table> </table>
<div class="panel-footer noprint"> <div class="panel-footer noprint">
{% if rearports and perms.dcim.change_rearport %} {% 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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button> </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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button> </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 <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button> </button>
{% endif %} {% endif %}
{% if rearports and perms.dcim.delete_rearport %} {% 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 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button> </button>
{% endif %} {% endif %}
{% if perms.dcim.add_rearport %} {% if perms.dcim.add_rearport %}
<div class="pull-right"> <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 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add rear ports
</a> </a>
</div> </div>
@ -708,24 +708,24 @@
</table> </table>
<div class="panel-footer noprint"> <div class="panel-footer noprint">
{% if consoleports and perms.dcim.change_consoleport %} {% 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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button> </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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button> </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 <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button> </button>
{% endif %} {% endif %}
{% if consoleports and perms.dcim.delete_consoleport %} {% 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 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button> </button>
{% endif %} {% endif %}
{% if perms.dcim.add_consoleport %} {% if perms.dcim.add_consoleport %}
<div class="pull-right"> <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 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console port
</a> </a>
</div> </div>
@ -763,24 +763,24 @@
</table> </table>
<div class="panel-footer noprint"> <div class="panel-footer noprint">
{% if consoleserverports and perms.dcim.change_consoleport %} {% 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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button> </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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button> </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 <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button> </button>
{% endif %} {% endif %}
{% if consoleserverports and perms.dcim.delete_consoleserverport %} {% 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 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button> </button>
{% endif %} {% endif %}
{% if perms.dcim.add_consoleserverport %} {% if perms.dcim.add_consoleserverport %}
<div class="pull-right"> <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 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
</a> </a>
</div> </div>
@ -818,24 +818,24 @@
</table> </table>
<div class="panel-footer noprint"> <div class="panel-footer noprint">
{% if powerports and perms.dcim.change_powerport %} {% 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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button> </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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button> </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 <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button> </button>
{% endif %} {% endif %}
{% if powerports and perms.dcim.delete_powerport %} {% 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 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button> </button>
{% endif %} {% endif %}
{% if perms.dcim.add_powerport %} {% if perms.dcim.add_powerport %}
<div class="pull-right"> <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 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power port
</a> </a>
</div> </div>
@ -874,24 +874,24 @@
</table> </table>
<div class="panel-footer noprint"> <div class="panel-footer noprint">
{% if poweroutlets and perms.dcim.change_powerport %} {% 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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button> </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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button> </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 <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button> </button>
{% endif %} {% endif %}
{% if poweroutlets and perms.dcim.delete_poweroutlet %} {% 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 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button> </button>
{% endif %} {% endif %}
{% if perms.dcim.add_poweroutlet %} {% if perms.dcim.add_poweroutlet %}
<div class="pull-right"> <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 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
</a> </a>
</div> </div>
@ -933,18 +933,18 @@
</table> </table>
<div class="panel-footer noprint"> <div class="panel-footer noprint">
{% if devicebays and perms.dcim.change_devicebay %} {% 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 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button> </button>
{% endif %} {% endif %}
{% if devicebays and perms.dcim.delete_devicebay %} {% 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 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
</button> </button>
{% endif %} {% endif %}
{% if perms.dcim.add_devicebay %} {% if perms.dcim.add_devicebay %}
<div class="pull-right"> <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 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
</a> </a>
</div> </div>
@ -963,6 +963,16 @@
{% block javascript %} {% block javascript %}
<script type="text/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) { function toggleConnection(elem) {
var url = netbox_api_path + "dcim/cables/" + elem.attr('data') + "/"; var url = netbox_api_path + "dcim/cables/" + elem.attr('data') + "/";
if (elem.hasClass('connected')) { 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> <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Components <span class="caret"></span>
</button> </button>
<ul class="dropdown-menu"> <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_consoleporttemplate %}
{% 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 %} <li><a href="{% url 'dcim:consoleporttemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}%23tab_consoleports">Console Ports</a></li>
{% 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 %} {% 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_consoleserverporttemplate %}
{% 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 %} <li><a href="{% url 'dcim:consoleserverporttemplate_add' %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}%23tab_consoleserverports">Console Server Ports</a></li>
{% 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 %} {% 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_powerporttemplate %}
{% 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 %} <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> </ul>
</div> </div>
{% endif %} {% endif %}
@ -217,3 +233,17 @@
</div> </div>
</div> </div>
{% endblock %} {% 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

@ -22,7 +22,7 @@ class ObjectPermissionInline(admin.TabularInline):
verbose_name_plural = 'Permissions' verbose_name_plural = 'Permissions'
def get_queryset(self, request): 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 @staticmethod
def object_types(instance): 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 # 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. # 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 # Normalize the constraints to a list of dicts
if type(constraints) is not list: if type(constraints) is not list:
constraints = [constraints] constraints = [constraints]

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 from django.db.models import F, Func
@ -7,3 +9,21 @@ class CollateAsChar(Func):
""" """
function = 'C' function = 'C'
template = '(%(expressions)s) COLLATE "%(function)s"' 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 model: Model class to use for calculating URL view names
:param prepend_content: Additional template content to render in the column (optional) :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') buttons = ('changelog', 'edit', 'delete')
attrs = {'td': {'class': 'text-right text-nowrap noprint'}} attrs = {'td': {'class': 'text-right text-nowrap noprint'}}
@ -141,18 +142,19 @@ class ButtonsColumn(tables.TemplateColumn):
</a> </a>
{{% endif %}} {{% endif %}}
{{% if "edit" in buttons and perms.{app_label}.change_{model_name} %}} {{% 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> <i class="fa fa-pencil"></i>
</a> </a>
{{% endif %}} {{% endif %}}
{{% if "delete" in buttons and perms.{app_label}.delete_{model_name} %}} {{% 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> <i class="fa fa-trash"></i>
</a> </a>
{{% endif %}} {{% 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: if prepend_template:
prepend_template = prepend_template.replace('{', '{{') prepend_template = prepend_template.replace('{', '{{')
prepend_template = prepend_template.replace('}', '}}') prepend_template = prepend_template.replace('}', '}}')
@ -169,6 +171,7 @@ class ButtonsColumn(tables.TemplateColumn):
self.extra_context.update({ self.extra_context.update({
'buttons': buttons or self.buttons, 'buttons': buttons or self.buttons,
'return_url_extra': return_url_extra,
}) })
def header(self): def header(self):

View File

@ -6,7 +6,7 @@ from rest_framework.routers import APIRootView
from dcim.models import Device from dcim.models import Device
from extras.api.serializers import RenderedGraphSerializer 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 extras.models import Graph
from utilities.api import ModelViewSet from utilities.api import ModelViewSet
from utilities.utils import get_subquery from utilities.utils import get_subquery
@ -58,7 +58,7 @@ class ClusterViewSet(CustomFieldModelViewSet):
# Virtual machines # Virtual machines
# #
class VirtualMachineViewSet(CustomFieldModelViewSet): class VirtualMachineViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin):
queryset = VirtualMachine.objects.prefetch_related( queryset = VirtualMachine.objects.prefetch_related(
'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags' 'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags'
) )

View File

@ -8,6 +8,7 @@ from taggit.managers import TaggableManager
from dcim.choices import InterfaceModeChoices from dcim.choices import InterfaceModeChoices
from dcim.models import BaseInterface, Device from dcim.models import BaseInterface, Device
from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
from extras.querysets import ConfigContextModelQuerySet
from extras.utils import extras_features from extras.utils import extras_features
from utilities.fields import NaturalOrderingField from utilities.fields import NaturalOrderingField
from utilities.ordering import naturalize_interface from utilities.ordering import naturalize_interface
@ -282,7 +283,7 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager() objects = ConfigContextModelQuerySet.as_manager()
csv_headers = [ csv_headers = [
'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', 'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',

View File

@ -261,7 +261,7 @@ class VirtualMachineView(ObjectView):
class VirtualMachineConfigContextView(ObjectConfigContextView): class VirtualMachineConfigContextView(ObjectConfigContextView):
queryset = VirtualMachine.objects.all() queryset = VirtualMachine.objects.annotate_config_context_data()
base_template = 'virtualization/virtualmachine.html' base_template = 'virtualization/virtualmachine.html'

View File

@ -1,5 +1,5 @@
Django==3.1 Django==3.1
django-cacheops==5.0.1 django-cacheops==5.1.0
django-cors-headers==3.4.0 django-cors-headers==3.4.0
django-debug-toolbar==2.2 django-debug-toolbar==2.2
django-filter==2.3.0 django-filter==2.3.0