浏览代码

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

Jeremy Stretch 5 年之前
父节点
当前提交
0c3fafdfef

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

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

+ 5 - 3
netbox/extras/filters.py

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

+ 2 - 2
netbox/extras/forms.py

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

+ 101 - 2
netbox/extras/tests/test_filters.py

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

+ 21 - 0
netbox/utilities/filters.py

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