Closes #4897: Allow filtering by content type identified as <app>.<model> string

This commit is contained in:
Jeremy Stretch 2020-09-22 16:06:38 -04:00
parent 5ba4252388
commit 0c3fafdfef
5 changed files with 132 additions and 7 deletions

View File

@ -35,6 +35,7 @@ http://netbox/api/dcim/sites/ \
* [#1503](https://github.com/netbox-community/netbox/issues/1503) - Allow assigment of secrets to virtual machines * [#1503](https://github.com/netbox-community/netbox/issues/1503) - Allow assigment of secrets to virtual machines
* [#1692](https://github.com/netbox-community/netbox/issues/1692) - Allow assigment of inventory items to parent items in web UI * [#1692](https://github.com/netbox-community/netbox/issues/1692) - Allow assigment of inventory items to parent items in web UI
* [#2179](https://github.com/netbox-community/netbox/issues/2179) - Support the assignment of multiple port numbers for services * [#2179](https://github.com/netbox-community/netbox/issues/2179) - Support the assignment of multiple port numbers for services
* [#4897](https://github.com/netbox-community/netbox/issues/4897) - Allow filtering by content type identified as `<app>.<model>` string
* [#4956](https://github.com/netbox-community/netbox/issues/4956) - Include inventory items on primary device view * [#4956](https://github.com/netbox-community/netbox/issues/4956) - Include inventory items on primary device view
* [#5003](https://github.com/netbox-community/netbox/issues/5003) - CSV import now accepts slug values for choice fields * [#5003](https://github.com/netbox-community/netbox/issues/5003) - CSV import now accepts slug values for choice fields
* [#5146](https://github.com/netbox-community/netbox/issues/5146) - Add custom fields support for cables, power panels, rack reservations, and virtual chassis * [#5146](https://github.com/netbox-community/netbox/issues/5146) - Add custom fields support for cables, power panels, rack reservations, and virtual chassis
@ -57,5 +58,7 @@ http://netbox/api/dcim/sites/ \
* dcim.VirtualChassis: Added `custom_fields` * dcim.VirtualChassis: Added `custom_fields`
* extras.ExportTemplate: The `template_language` field has been removed * extras.ExportTemplate: The `template_language` field has been removed
* extras.Graph: This API endpoint has been removed (see #4349) * extras.Graph: This API endpoint has been removed (see #4349)
* extras.ImageAttachment: Filtering by `content_type` now takes a string in the form `<app>.<model>`
* extras.ObjectChange: Filtering by `changed_object_type` now takes a string in the form `<app>.<model>`
* ipam.Service: Renamed `port` to `ports`; now holds a list of one or more port numbers * ipam.Service: Renamed `port` to `ports`; now holds a list of one or more port numbers
* secrets.Secret: Removed `device` field; replaced with `assigned_object` generic foreign key. This may represent either a device or a virtual machine. Assign an object by setting `assigned_object_type` and `assigned_object_id`. * secrets.Secret: Removed `device` field; replaced with `assigned_object` generic foreign key. This may represent either a device or a virtual machine. Assign an object by setting `assigned_object_type` and `assigned_object_id`.

View File

@ -4,7 +4,7 @@ from django.db.models import Q
from dcim.models import DeviceRole, Platform, Region, Site from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.filters import BaseFilterSet from utilities.filters import BaseFilterSet, ContentTypeFilter
from virtualization.models import Cluster, ClusterGroup from virtualization.models import Cluster, ClusterGroup
from .choices import * from .choices import *
from .models import ConfigContext, CustomField, ExportTemplate, ImageAttachment, JobResult, ObjectChange, Tag from .models import ConfigContext, CustomField, ExportTemplate, ImageAttachment, JobResult, ObjectChange, Tag
@ -81,10 +81,11 @@ class ExportTemplateFilterSet(BaseFilterSet):
class ImageAttachmentFilterSet(BaseFilterSet): class ImageAttachmentFilterSet(BaseFilterSet):
content_type = ContentTypeFilter()
class Meta: class Meta:
model = ImageAttachment model = ImageAttachment
fields = ['id', 'content_type', 'object_id', 'name'] fields = ['id', 'content_type_id', 'object_id', 'name']
class TagFilterSet(BaseFilterSet): class TagFilterSet(BaseFilterSet):
@ -234,11 +235,12 @@ class ObjectChangeFilterSet(BaseFilterSet):
label='Search', label='Search',
) )
time = django_filters.DateTimeFromToRangeFilter() time = django_filters.DateTimeFromToRangeFilter()
changed_object_type = ContentTypeFilter()
class Meta: class Meta:
model = ObjectChange model = ObjectChange
fields = [ fields = [
'id', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id', 'id', 'user', 'user_name', 'request_id', 'action', 'changed_object_type_id', 'changed_object_id',
'object_repr', 'object_repr',
] ]

View File

@ -361,8 +361,8 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
api_url='/api/users/users/', api_url='/api/users/users/',
) )
) )
changed_object_type = forms.ModelChoiceField( changed_object_type_id = forms.ModelChoiceField(
queryset=ContentType.objects.order_by('model'), queryset=ContentType.objects.order_by('app_label', 'model'),
required=False, required=False,
widget=ContentTypeSelect(), widget=ContentTypeSelect(),
label='Object Type' label='Object Type'

View File

@ -1,9 +1,14 @@
import uuid
from django.contrib.auth.models import User
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 DeviceRole, Platform, Rack, Region, Site from dcim.models import DeviceRole, Platform, Rack, Region, Site
from extras.choices import ObjectChangeActionChoices
from extras.filters import * from extras.filters import *
from extras.models import ConfigContext, ExportTemplate, ImageAttachment, Tag from extras.models import ConfigContext, ExportTemplate, ImageAttachment, ObjectChange, Tag
from ipam.models import IPAddress
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from virtualization.models import Cluster, ClusterGroup, ClusterType from virtualization.models import Cluster, ClusterGroup, ClusterType
@ -298,4 +303,98 @@ class TagTestCase(TestCase):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
# TODO: ObjectChangeFilter test class ObjectChangeTestCase(TestCase):
queryset = ObjectChange.objects.all()
filterset = ObjectChangeFilterSet
@classmethod
def setUpTestData(cls):
users = (
User(username='user1'),
User(username='user2'),
User(username='user3'),
)
User.objects.bulk_create(users)
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
ipaddress = IPAddress.objects.create(address='192.0.2.1/24')
object_changes = (
ObjectChange(
user=users[0],
user_name=users[0].username,
request_id=uuid.uuid4(),
action=ObjectChangeActionChoices.ACTION_CREATE,
changed_object=site,
object_repr=str(site),
object_data={'name': site.name, 'slug': site.slug}
),
ObjectChange(
user=users[0],
user_name=users[0].username,
request_id=uuid.uuid4(),
action=ObjectChangeActionChoices.ACTION_UPDATE,
changed_object=site,
object_repr=str(site),
object_data={'name': site.name, 'slug': site.slug}
),
ObjectChange(
user=users[1],
user_name=users[1].username,
request_id=uuid.uuid4(),
action=ObjectChangeActionChoices.ACTION_DELETE,
changed_object=site,
object_repr=str(site),
object_data={'name': site.name, 'slug': site.slug}
),
ObjectChange(
user=users[1],
user_name=users[1].username,
request_id=uuid.uuid4(),
action=ObjectChangeActionChoices.ACTION_CREATE,
changed_object=ipaddress,
object_repr=str(ipaddress),
object_data={'address': ipaddress.address, 'status': ipaddress.status}
),
ObjectChange(
user=users[2],
user_name=users[2].username,
request_id=uuid.uuid4(),
action=ObjectChangeActionChoices.ACTION_UPDATE,
changed_object=ipaddress,
object_repr=str(ipaddress),
object_data={'address': ipaddress.address, 'status': ipaddress.status}
),
ObjectChange(
user=users[2],
user_name=users[2].username,
request_id=uuid.uuid4(),
action=ObjectChangeActionChoices.ACTION_DELETE,
changed_object=ipaddress,
object_repr=str(ipaddress),
object_data={'address': ipaddress.address, 'status': ipaddress.status}
),
)
ObjectChange.objects.bulk_create(object_changes)
def test_id(self):
params = {'id': self.queryset.values_list('pk', flat=True)[:3]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
# def test_user(self):
# params = {'user_id': User.objects.filter(username__in=['user1', 'user2']).values_list('pk', flat=True)}
# self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
# params = {'user': ['user1', 'user2']}
# self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_user_name(self):
params = {'user_name': ['user1', 'user2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_changed_object_type(self):
params = {'changed_object_type': 'dcim.site'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_changed_object_type_id(self):
params = {'changed_object_type_id': ContentType.objects.get(app_label='dcim', model='site').pk}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)

View File

@ -1,4 +1,5 @@
import django_filters import django_filters
from django_filters.constants import EMPTY_VALUES
from copy import deepcopy from copy import deepcopy
from dcim.forms import MACAddressField from dcim.forms import MACAddressField
from django import forms from django import forms
@ -115,6 +116,26 @@ class NumericArrayFilter(django_filters.NumberFilter):
return super().filter(qs, value) return super().filter(qs, value)
class ContentTypeFilter(django_filters.CharFilter):
"""
Allow specifying a ContentType by <app_label>.<model> (e.g. "dcim.site").
"""
def filter(self, qs, value):
if value in EMPTY_VALUES:
return qs
try:
app_label, model = value.lower().split('.')
except ValueError:
return qs.none()
return qs.filter(
**{
f'{self.field_name}__app_label': app_label,
f'{self.field_name}__model': model
}
)
# #
# FilterSets # FilterSets
# #