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

This commit is contained in:
jeremystretch 2022-06-22 16:10:48 -04:00
parent 341615668b
commit 379880cd84
15 changed files with 138 additions and 86 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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