diff --git a/docs/models/extras/configcontext.md b/docs/models/extras/configcontext.md index af81cfbf9..bb4a22e0d 100644 --- a/docs/models/extras/configcontext.md +++ b/docs/models/extras/configcontext.md @@ -3,11 +3,13 @@ Sometimes it is desirable to associate additional data with a group of devices or virtual machines to aid in automated configuration. For example, you might want to associate a set of syslog servers for all devices within a particular region. Context data enables the association of extra user-defined data with devices and virtual machines grouped by one or more of the following assignments: * Region +* Site group * Site +* Device type (devices only) * Role * Platform -* Cluster group -* Cluster +* Cluster group (VMs only) +* Cluster (VMs only) * Tenant group * Tenant * Tag diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 6500e36e3..39c38ced2 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -84,6 +84,7 @@ A new Cloud model has been introduced to represent the boundary of a network tha ### Enhancements +* [#4833](https://github.com/netbox-community/netbox/issues/4833) - Allow assigning config contexts by device type * [#5370](https://github.com/netbox-community/netbox/issues/5370) - Extend custom field support to organizational models * [#5375](https://github.com/netbox-community/netbox/issues/5375) - Add `speed` attribute to console port models * [#5401](https://github.com/netbox-community/netbox/issues/5401) - Extend custom field support to device component models diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index d1eea15ee..4a1b154d3 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -4,10 +4,10 @@ from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers from dcim.api.nested_serializers import ( - NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedRackSerializer, - NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer, + NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedPlatformSerializer, + NestedRackSerializer, NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer, ) -from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, DeviceType, Platform, Rack, Region, Site, SiteGroup from extras.choices import * from extras.models import * from extras.utils import FeatureQuery @@ -251,6 +251,12 @@ class ConfigContextSerializer(ValidatedModelSerializer): required=False, many=True ) + device_types = SerializedPKRelatedField( + queryset=DeviceType.objects.all(), + serializer=NestedDeviceTypeSerializer, + required=False, + many=True + ) roles = SerializedPKRelatedField( queryset=DeviceRole.objects.all(), serializer=NestedDeviceRoleSerializer, @@ -298,8 +304,8 @@ class ConfigContextSerializer(ValidatedModelSerializer): model = ConfigContext fields = [ 'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', - 'roles', 'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', 'created', - 'last_updated', + 'device_types', 'roles', 'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', + 'data', 'created', 'last_updated', ] diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 3ac25eec4..495e03797 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.forms import DateField, IntegerField, NullBooleanField -from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup +from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup from tenancy.models import Tenant, TenantGroup from utilities.filters import BaseFilterSet, ContentTypeFilter from virtualization.models import Cluster, ClusterGroup @@ -206,6 +206,11 @@ class ConfigContextFilterSet(BaseFilterSet): to_field_name='slug', label='Site (slug)', ) + device_type_id = django_filters.ModelMultipleChoiceFilter( + field_name='device_types', + queryset=DeviceType.objects.all(), + label='Device type', + ) role_id = django_filters.ModelMultipleChoiceFilter( field_name='roles', queryset=DeviceRole.objects.all(), diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index b12ecc11d..4cf5023a3 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -4,11 +4,11 @@ from django.contrib.contenttypes.models import ContentType from django.utils.safestring import mark_safe from django.utils.translation import gettext as _ -from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup +from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup from tenancy.models import Tenant, TenantGroup from utilities.forms import ( add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect, - CommentField, CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect2, + CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup @@ -218,6 +218,10 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): queryset=Site.objects.all(), required=False ) + device_types = DynamicModelMultipleChoiceField( + queryset=DeviceType.objects.all(), + required=False + ) roles = DynamicModelMultipleChoiceField( queryset=DeviceRole.objects.all(), required=False @@ -253,8 +257,8 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConfigContext fields = ( - 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'roles', 'platforms', - 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', + 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'roles', 'device_types', + 'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', ) @@ -306,6 +310,11 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form): required=False, label=_('Sites') ) + device_type_id = DynamicModelMultipleChoiceField( + queryset=DeviceType.objects.all(), + required=False, + label=_('Device types') + ) role_id = DynamicModelMultipleChoiceField( queryset=DeviceRole.objects.all(), required=False, diff --git a/netbox/extras/migrations/0056_sitegroup.py b/netbox/extras/migrations/0056_extend_configcontext.py similarity index 65% rename from netbox/extras/migrations/0056_sitegroup.py rename to netbox/extras/migrations/0056_extend_configcontext.py index b81cdb8a1..9c7e2d700 100644 --- a/netbox/extras/migrations/0056_sitegroup.py +++ b/netbox/extras/migrations/0056_extend_configcontext.py @@ -14,4 +14,9 @@ class Migration(migrations.Migration): name='site_groups', field=models.ManyToManyField(blank=True, related_name='_extras_configcontext_site_groups_+', to='dcim.SiteGroup'), ), + migrations.AddField( + model_name='configcontext', + name='device_types', + field=models.ManyToManyField(blank=True, related_name='_extras_configcontext_device_types_+', to='dcim.DeviceType'), + ), ] diff --git a/netbox/extras/migrations/0057_customlink_rename_fields.py b/netbox/extras/migrations/0057_customlink_rename_fields.py index 4ed5c7bc7..6aba35d9f 100644 --- a/netbox/extras/migrations/0057_customlink_rename_fields.py +++ b/netbox/extras/migrations/0057_customlink_rename_fields.py @@ -6,7 +6,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('extras', '0056_sitegroup'), + ('extras', '0056_extend_configcontext'), ] operations = [ diff --git a/netbox/extras/models/configcontexts.py b/netbox/extras/models/configcontexts.py index b7da6852c..8c142de8b 100644 --- a/netbox/extras/models/configcontexts.py +++ b/netbox/extras/models/configcontexts.py @@ -56,6 +56,11 @@ class ConfigContext(ChangeLoggedModel): related_name='+', blank=True ) + device_types = models.ManyToManyField( + to='dcim.DeviceType', + related_name='+', + blank=True + ) roles = models.ManyToManyField( to='dcim.DeviceRole', related_name='+', diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index 3710bec46..f0f073e37 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -19,7 +19,10 @@ class ConfigContextQuerySet(RestrictedQuerySet): # `device_role` for Device; `role` for VirtualMachine role = getattr(obj, 'device_role', None) or obj.role - # Virtualization cluster for VirtualMachine + # Device type assignment is relevant only for Devices + device_type = getattr(obj, 'device_type', None) + + # Cluster assignment is relevant only for VirtualMachines cluster = getattr(obj, 'cluster', None) cluster_group = getattr(cluster, 'group', None) @@ -36,6 +39,7 @@ class ConfigContextQuerySet(RestrictedQuerySet): queryset = self.filter( Q(regions__in=regions) | Q(regions=None), Q(sites=obj.site) | Q(sites=None), + Q(device_types=device_type) | Q(device_types=None), Q(roles=role) | Q(roles=None), Q(platforms=obj.platform) | Q(platforms=None), Q(cluster_groups=cluster_group) | Q(cluster_groups=None), @@ -108,6 +112,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet): ) if self.model._meta.model_name == 'device': + 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) region_field = 'site__region' diff --git a/netbox/extras/tests/test_filters.py b/netbox/extras/tests/test_filters.py index 5e1b0401d..33aaa7df2 100644 --- a/netbox/extras/tests/test_filters.py +++ b/netbox/extras/tests/test_filters.py @@ -4,7 +4,7 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.test import TestCase -from dcim.models import DeviceRole, Platform, Rack, Region, Site, SiteGroup +from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup from extras.choices import JournalEntryKindChoices, ObjectChangeActionChoices from extras.filters import * from extras.models import * @@ -379,6 +379,14 @@ class ConfigContextTestCase(TestCase): ) Site.objects.bulk_create(sites) + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + device_types = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-3'), + DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-4'), + ) + DeviceType.objects.bulk_create(device_types) + device_roles = ( DeviceRole(name='Device Role 1', slug='device-role-1'), DeviceRole(name='Device Role 2', slug='device-role-2'), @@ -433,6 +441,7 @@ class ConfigContextTestCase(TestCase): c.regions.set([regions[i]]) c.site_groups.set([site_groups[i]]) c.sites.set([sites[i]]) + c.roles.set([device_types[i]]) c.roles.set([device_roles[i]]) c.platforms.set([platforms[i]]) c.cluster_groups.set([cluster_groups[i]]) @@ -475,6 +484,11 @@ class ConfigContextTestCase(TestCase): params = {'site': [sites[0].slug, sites[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]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_role(self): device_roles = DeviceRole.objects.all()[:2] params = {'role_id': [device_roles[0].pk, device_roles[1].pk]} diff --git a/netbox/templates/extras/configcontext.html b/netbox/templates/extras/configcontext.html index 7ece11b2e..626002a68 100644 --- a/netbox/templates/extras/configcontext.html +++ b/netbox/templates/extras/configcontext.html @@ -94,13 +94,27 @@ {% endif %} +