diff --git a/docs/installation/6-ldap.md b/docs/installation/6-ldap.md index bb1300c08..b7d525413 100644 --- a/docs/installation/6-ldap.md +++ b/docs/installation/6-ldap.md @@ -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. diff --git a/docs/installation/migrating-to-systemd.md b/docs/installation/migrating-to-systemd.md index 65f42a6bc..51508392f 100644 --- a/docs/installation/migrating-to-systemd.md +++ b/docs/installation/migrating-to-systemd.md @@ -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/... ... ``` diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 34274342c..028fcb2a4 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -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 diff --git a/docs/plugins/development.md b/docs/plugins/development.md index 3dadddb9c..d65e7d830 100644 --- a/docs/plugins/development.md +++ b/docs/plugins/development.md @@ -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 %} -

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

-{% endwith %} + {% with config=settings.PLUGINS_CONFIG.netbox_animal_sounds %} +

+ {% 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 %} +

+ {% endwith %} {% endblock %} ``` diff --git a/docs/plugins/index.md b/docs/plugins/index.md index 1f5587539..202e0a96b 100644 --- a/docs/plugins/index.md +++ b/docs/plugins/index.md @@ -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: diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index 4e758ead7..dc6a10dae 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -1,5 +1,22 @@ # 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) ### Bug Fixes diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index f5b37021d..427aecd5f 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -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', diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index e96becadf..6fe2cea2f 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -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', diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 371eff9db..3106ed2a1 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -432,7 +432,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 +445,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 +458,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 +471,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 +487,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 +503,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 +516,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 +529,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): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index c6c0ced97..886f3e702 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -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' diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index a63dbe44d..74c9ea889 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -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 diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index e57caf091..fe4531b51 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -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: diff --git a/netbox/extras/plugins/views.py b/netbox/extras/plugins/views.py index f30e0f539..7484b9632 100644 --- a/netbox/extras/plugins/views.py +++ b/netbox/extras/plugins/views.py @@ -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", diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index 9d9b55778..9bfa5da83 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -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), @@ -57,3 +61,72 @@ class ConfigContextQuerySet(RestrictedQuerySet): Q(tags__slug__in=obj.tags.slugs()) | Q(tags=None), is_active=True, ).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 diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py index 22e6e1f8f..280da75b6 100644 --- a/netbox/extras/tests/test_models.py +++ b/netbox/extras/tests/test_models.py @@ -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,276 @@ 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.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()) diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index 6328c40d7..21fb3e229 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -172,9 +172,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 diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index e27939408..2dfbf04d4 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '2.9.7' +VERSION = '2.9.8' # Hostname HOSTNAME = platform.node() diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 477953a97..c42ef828e 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -48,28 +48,28 @@