Просмотр исходного кода

Merge pull request #5266 from netbox-community/4559-config-context-rendering

4559 config context rendering
Jeremy Stretch 5 лет назад
Родитель
Сommit
04d763d814

+ 2 - 2
netbox/dcim/api/views.py

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

+ 2 - 1
netbox/dcim/models/devices.py

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

+ 1 - 1
netbox/dcim/views.py

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

+ 23 - 0
netbox/extras/api/views.py

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

+ 10 - 2
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
         # 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:

+ 76 - 3
netbox/extras/querysets.py

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

+ 277 - 2
netbox/extras/tests/test_models.py

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

+ 20 - 0
netbox/utilities/query_functions.py

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

+ 2 - 2
netbox/virtualization/api/views.py

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

+ 2 - 1
netbox/virtualization/models.py

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

+ 1 - 1
netbox/virtualization/views.py

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