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

Closes #9582: Enable assigning config contexts based on device location

jeremystretch 3 лет назад
Родитель
Сommit
379880cd84

+ 2 - 0
docs/models/extras/configcontext.md

@@ -5,9 +5,11 @@ Sometimes it is desirable to associate additional data with a group of devices o
 * Region
 * Site group
 * Site
+* Location (devices only)
 * Device type (devices only)
 * Role
 * Platform
+* Cluster type (VMs only)
 * Cluster group (VMs only)
 * Cluster (VMs only)
 * Tenant group

+ 3 - 0
docs/release-notes/version-3.3.md

@@ -25,6 +25,7 @@
 * [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping
 * [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results
 * [#9166](https://github.com/netbox-community/netbox/issues/9166) - Add UI visibility toggle for custom fields
+* [#9582](https://github.com/netbox-community/netbox/issues/9582) - Enable assigning config contexts based on device location
 
 ### Other Changes
 
@@ -45,6 +46,8 @@
     * Added required `status` field (default value: `active`)
 * dcim.Rack
     * The `elevation` endpoint now includes half-height rack units, and utilizes decimal values for the ID and name of each unit
+* extras.ConfigContext
+    * Added the `locations` many-to-many field to track the assignment of ConfigContexts to Locations
 * extras.CustomField
     * Added `group_name` and `ui_visibility` fields
 * ipam.IPAddress

+ 11 - 5
netbox/extras/api/serializers.py

@@ -5,10 +5,10 @@ from drf_yasg.utils import swagger_serializer_method
 from rest_framework import serializers
 
 from dcim.api.nested_serializers import (
-    NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedPlatformSerializer, NestedRegionSerializer,
-    NestedSiteSerializer, NestedSiteGroupSerializer,
+    NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer,
+    NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
 )
-from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
+from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 from extras.choices import *
 from extras.models import *
 from extras.utils import FeatureQuery
@@ -272,6 +272,12 @@ class ConfigContextSerializer(ValidatedModelSerializer):
         required=False,
         many=True
     )
+    locations = SerializedPKRelatedField(
+        queryset=Location.objects.all(),
+        serializer=NestedLocationSerializer,
+        required=False,
+        many=True
+    )
     device_types = SerializedPKRelatedField(
         queryset=DeviceType.objects.all(),
         serializer=NestedDeviceTypeSerializer,
@@ -331,8 +337,8 @@ class ConfigContextSerializer(ValidatedModelSerializer):
         model = ConfigContext
         fields = [
             'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites',
-            'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
-            'tenants', 'tags', 'data', 'created', 'last_updated',
+            'locations', 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters',
+            'tenant_groups', 'tenants', 'tags', 'data', 'created', 'last_updated',
         ]
 
 

+ 1 - 1
netbox/extras/api/views.py

@@ -138,7 +138,7 @@ class JournalEntryViewSet(NetBoxModelViewSet):
 
 class ConfigContextViewSet(NetBoxModelViewSet):
     queryset = ConfigContext.objects.prefetch_related(
-        'regions', 'site_groups', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants',
+        'regions', 'site_groups', 'sites', 'locations', 'roles', 'platforms', 'tenant_groups', 'tenants',
     )
     serializer_class = serializers.ConfigContextSerializer
     filterset_class = filtersets.ConfigContextFilterSet

+ 12 - 1
netbox/extras/filtersets.py

@@ -3,7 +3,7 @@ from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import Q
 
-from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
+from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
 from tenancy.models import Tenant, TenantGroup
 from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
@@ -255,6 +255,17 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
         to_field_name='slug',
         label='Site (slug)',
     )
+    location_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='locations',
+        queryset=Location.objects.all(),
+        label='Location',
+    )
+    location = django_filters.ModelMultipleChoiceFilter(
+        field_name='locations__slug',
+        queryset=Location.objects.all(),
+        to_field_name='slug',
+        label='Location (slug)',
+    )
     device_type_id = django_filters.ModelMultipleChoiceFilter(
         field_name='device_types',
         queryset=DeviceType.objects.all(),

+ 7 - 2
netbox/extras/forms/filtersets.py

@@ -3,7 +3,7 @@ from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext as _
 
-from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
+from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 from extras.choices import *
 from extras.models import *
 from extras.utils import FeatureQuery
@@ -170,7 +170,7 @@ class TagFilterForm(FilterForm):
 class ConfigContextFilterForm(FilterForm):
     fieldsets = (
         (None, ('q', 'tag_id')),
-        ('Location', ('region_id', 'site_group_id', 'site_id')),
+        ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
         ('Device', ('device_type_id', 'platform_id', 'role_id')),
         ('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id'))
@@ -190,6 +190,11 @@ class ConfigContextFilterForm(FilterForm):
         required=False,
         label=_('Sites')
     )
+    location_id = DynamicModelMultipleChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        label=_('Locations')
+    )
     device_type_id = DynamicModelMultipleChoiceField(
         queryset=DeviceType.objects.all(),
         required=False,

+ 16 - 5
netbox/extras/forms/models.py

@@ -1,7 +1,7 @@
 from django import forms
 from django.contrib.contenttypes.models import ContentType
 
-from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
+from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
 from extras.choices import *
 from extras.models import *
 from extras.utils import FeatureQuery
@@ -166,6 +166,10 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
         queryset=Site.objects.all(),
         required=False
     )
+    locations = DynamicModelMultipleChoiceField(
+        queryset=Location.objects.all(),
+        required=False
+    )
     device_types = DynamicModelMultipleChoiceField(
         queryset=DeviceType.objects.all(),
         required=False
@@ -202,15 +206,22 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
         queryset=Tag.objects.all(),
         required=False
     )
-    data = JSONField(
-        label=''
+    data = JSONField()
+
+    fieldsets = (
+        ('Config Context', ('name', 'weight', 'description', 'data', 'is_active')),
+        ('Assignment', (
+            'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types',
+            'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags',
+        )),
     )
 
     class Meta:
         model = ConfigContext
         fields = (
-            'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'roles', 'device_types',
-            'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
+            'name', 'weight', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites', 'locations',
+            'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
+            'tenants', 'tags',
         )
 
 

+ 19 - 0
netbox/extras/migrations/0076_configcontext_locations.py

@@ -0,0 +1,19 @@
+# Generated by Django 4.0.5 on 2022-06-22 19:13
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0156_location_status'),
+        ('extras', '0075_customfield_ui_visibility'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='configcontext',
+            name='locations',
+            field=models.ManyToManyField(blank=True, related_name='+', to='dcim.location'),
+        ),
+    ]

+ 7 - 5
netbox/extras/models/configcontexts.py

@@ -1,5 +1,3 @@
-from collections import OrderedDict
-
 from django.core.validators import ValidationError
 from django.db import models
 from django.urls import reverse
@@ -55,6 +53,11 @@ class ConfigContext(WebhooksMixin, ChangeLoggedModel):
         related_name='+',
         blank=True
     )
+    locations = models.ManyToManyField(
+        to='dcim.Location',
+        related_name='+',
+        blank=True
+    )
     device_types = models.ManyToManyField(
         to='dcim.DeviceType',
         related_name='+',
@@ -138,11 +141,10 @@ class ConfigContextModel(models.Model):
 
     def get_config_context(self):
         """
+        Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs.
         Return the rendered configuration context for a device or VM.
         """
-
-        # Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs
-        data = OrderedDict()
+        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

+ 4 - 1
netbox/extras/querysets.py

@@ -19,8 +19,9 @@ class ConfigContextQuerySet(RestrictedQuerySet):
         # `device_role` for Device; `role` for VirtualMachine
         role = getattr(obj, 'device_role', None) or obj.role
 
-        # Device type assignment is relevant only for Devices
+        # Device type and location assignment is relevant only for Devices
         device_type = getattr(obj, 'device_type', None)
+        location = getattr(obj, 'location', None)
 
         # Get assigned cluster, group, and type (if any)
         cluster = getattr(obj, 'cluster', None)
@@ -42,6 +43,7 @@ class ConfigContextQuerySet(RestrictedQuerySet):
             Q(regions__in=regions) | Q(regions=None),
             Q(site_groups__in=sitegroups) | Q(site_groups=None),
             Q(sites=obj.site) | Q(sites=None),
+            Q(locations=location) | Q(locations=None),
             Q(device_types=device_type) | Q(device_types=None),
             Q(roles=role) | Q(roles=None),
             Q(platforms=obj.platform) | Q(platforms=None),
@@ -114,6 +116,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
         )
 
         if self.model._meta.model_name == 'device':
+            base_query.add((Q(locations=OuterRef('location')) | Q(locations=None)), Q.AND)
             base_query.add((Q(device_types=OuterRef('device_type')) | Q(device_types=None)), Q.AND)
             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)

+ 3 - 2
netbox/extras/tables/tables.py

@@ -167,8 +167,9 @@ class ConfigContextTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = ConfigContext
         fields = (
-            'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles', 'platforms',
-            'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created', 'last_updated',
+            'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'locations', 'roles',
+            'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created',
+            'last_updated',
         )
         default_columns = ('pk', 'name', 'weight', 'is_active', 'description')
 

+ 23 - 7
netbox/extras/tests/test_filtersets.py

@@ -6,7 +6,7 @@ from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
 
 from circuits.models import Provider
-from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup
+from dcim.models import DeviceRole, DeviceType, Location, Manufacturer, Platform, Rack, Region, Site, SiteGroup
 from extras.choices import JournalEntryKindChoices, ObjectChangeActionChoices
 from extras.filtersets import *
 from extras.models import *
@@ -368,9 +368,9 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
     def setUpTestData(cls):
 
         regions = (
-            Region(name='Test Region 1', slug='test-region-1'),
-            Region(name='Test Region 2', slug='test-region-2'),
-            Region(name='Test Region 3', slug='test-region-3'),
+            Region(name='Region 1', slug='region-1'),
+            Region(name='Region 2', slug='region-2'),
+            Region(name='Region 3', slug='region-3'),
         )
         for r in regions:
             r.save()
@@ -384,12 +384,20 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
             site_group.save()
 
         sites = (
-            Site(name='Test Site 1', slug='test-site-1'),
-            Site(name='Test Site 2', slug='test-site-2'),
-            Site(name='Test Site 3', slug='test-site-3'),
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+            Site(name='Site 3', slug='site-3'),
         )
         Site.objects.bulk_create(sites)
 
+        locations = (
+            Location(name='Location 1', slug='location-1', site=sites[0]),
+            Location(name='Location 2', slug='location-2', site=sites[1]),
+            Location(name='Location 3', slug='location-3', site=sites[2]),
+        )
+        for location in locations:
+            location.save()
+
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         device_types = (
             DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
@@ -460,6 +468,7 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
             c.regions.set([regions[i]])
             c.site_groups.set([site_groups[i]])
             c.sites.set([sites[i]])
+            c.locations.set([locations[i]])
             c.device_types.set([device_types[i]])
             c.roles.set([device_roles[i]])
             c.platforms.set([platforms[i]])
@@ -501,6 +510,13 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'site': [sites[0].slug, sites[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_location(self):
+        locations = Location.objects.all()[:2]
+        params = {'location_id': [locations[0].pk, locations[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'location': [locations[0].slug, locations[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_device_type(self):
         device_types = DeviceType.objects.all()[:2]
         params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}

+ 29 - 19
netbox/extras/tests/test_models.py

@@ -1,6 +1,6 @@
 from django.test import TestCase
 
-from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site, SiteGroup
+from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
 from extras.models import ConfigContext, Tag
 from tenancy.models import Tenant, TenantGroup
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -29,7 +29,8 @@ class ConfigContextTest(TestCase):
         self.devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
         self.region = Region.objects.create(name="Region")
         self.sitegroup = SiteGroup.objects.create(name="Site Group")
-        self.site = Site.objects.create(name='Site-1', slug='site-1', region=self.region, group=self.sitegroup)
+        self.site = Site.objects.create(name='Site 1', slug='site-1', region=self.region, group=self.sitegroup)
+        self.location = Location.objects.create(name='Location 1', slug='location-1', site=self.site)
         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)
@@ -40,7 +41,8 @@ class ConfigContextTest(TestCase):
             name='Device 1',
             device_type=self.devicetype,
             device_role=self.devicerole,
-            site=self.site
+            site=self.site,
+            location=self.location
         )
 
     def test_higher_weight_wins(self):
@@ -144,15 +146,6 @@ class ConfigContextTest(TestCase):
         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,
@@ -169,6 +162,22 @@ class ConfigContextTest(TestCase):
             }
         )
         sitegroup_context.site_groups.add(self.sitegroup)
+        site_context = ConfigContext.objects.create(
+            name="site",
+            weight=100,
+            data={
+                "site": 1
+            }
+        )
+        site_context.sites.add(self.site)
+        location_context = ConfigContext.objects.create(
+            name="location",
+            weight=100,
+            data={
+                "location": 1
+            }
+        )
+        location_context.locations.add(self.location)
         platform_context = ConfigContext.objects.create(
             name="platform",
             weight=100,
@@ -205,6 +214,7 @@ class ConfigContextTest(TestCase):
         device = Device.objects.create(
             name="Device 2",
             site=self.site,
+            location=self.location,
             tenant=self.tenant,
             platform=self.platform,
             device_role=self.devicerole,
@@ -220,13 +230,6 @@ class ConfigContextTest(TestCase):
         cluster_group = ClusterGroup.objects.create(name="Cluster Group")
         cluster = Cluster.objects.create(name="Cluster", group=cluster_group, type=cluster_type)
 
-        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,
@@ -241,6 +244,13 @@ class ConfigContextTest(TestCase):
         )
         sitegroup_context.site_groups.add(self.sitegroup)
 
+        site_context = ConfigContext.objects.create(
+            name="site",
+            weight=100,
+            data={"site": 1}
+        )
+        site_context.sites.add(self.site)
+
         platform_context = ConfigContext.objects.create(
             name="platform",
             weight=100,

+ 1 - 1
netbox/extras/views.py

@@ -281,6 +281,7 @@ class ConfigContextView(generic.ObjectView):
             ('Regions', instance.regions.all),
             ('Site Groups', instance.site_groups.all),
             ('Sites', instance.sites.all),
+            ('Locations', instance.locations.all),
             ('Device Types', instance.device_types.all),
             ('Roles', instance.roles.all),
             ('Platforms', instance.platforms.all),
@@ -311,7 +312,6 @@ class ConfigContextView(generic.ObjectView):
 class ConfigContextEditView(generic.ObjectEditView):
     queryset = ConfigContext.objects.all()
     form = forms.ConfigContextForm
-    template_name = 'extras/configcontext_edit.html'
 
 
 class ConfigContextBulkEditView(generic.BulkEditView):

+ 0 - 37
netbox/templates/extras/configcontext_edit.html

@@ -1,37 +0,0 @@
-{% extends 'generic/object_edit.html' %}
-{% load form_helpers %}
-
-{% block form %}
-    <div class="card">
-        <h5 class="card-header">Config Context</h5>
-        <div class="card-body">
-            {% render_field form.name %}
-            {% render_field form.weight %}
-            {% render_field form.description %}
-            {% render_field form.is_active %}
-        </div>
-    </div>
-    <div class="card">
-        <h5 class="card-header">Assignment</h5>
-        <div class="card-body">
-            {% render_field form.regions %}
-            {% render_field form.site_groups %}
-            {% render_field form.sites %}
-            {% render_field form.device_types %}
-            {% render_field form.roles %}
-            {% render_field form.platforms %}
-            {% render_field form.cluster_types %}
-            {% render_field form.cluster_groups %}
-            {% render_field form.clusters %}
-            {% render_field form.tenant_groups %}
-            {% render_field form.tenants %}
-            {% render_field form.tags %}
-        </div>
-    </div>
-    <div class="card">
-        <h5 class="card-header">Data</h5>
-        <div class="card-body">
-            {% render_field form.data %}
-        </div>
-    </div>
-{% endblock %}