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

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

Jeremy Stretch 5 лет назад
Родитель
Сommit
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
 * [#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
+* [#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
 * [#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
@@ -57,5 +58,7 @@ http://netbox/api/dcim/sites/ \
 * dcim.VirtualChassis: Added `custom_fields`
 * extras.ExportTemplate: The `template_language` field has been removed
 * 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
 * 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 tenancy.models import Tenant, TenantGroup
-from utilities.filters import BaseFilterSet
+from utilities.filters import BaseFilterSet, ContentTypeFilter
 from virtualization.models import Cluster, ClusterGroup
 from .choices import *
 from .models import ConfigContext, CustomField, ExportTemplate, ImageAttachment, JobResult, ObjectChange, Tag
@@ -81,10 +81,11 @@ class ExportTemplateFilterSet(BaseFilterSet):
 
 
 class ImageAttachmentFilterSet(BaseFilterSet):
+    content_type = ContentTypeFilter()
 
     class Meta:
         model = ImageAttachment
-        fields = ['id', 'content_type', 'object_id', 'name']
+        fields = ['id', 'content_type_id', 'object_id', 'name']
 
 
 class TagFilterSet(BaseFilterSet):
@@ -234,11 +235,12 @@ class ObjectChangeFilterSet(BaseFilterSet):
         label='Search',
     )
     time = django_filters.DateTimeFromToRangeFilter()
+    changed_object_type = ContentTypeFilter()
 
     class Meta:
         model = ObjectChange
         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',
         ]
 

+ 2 - 2
netbox/extras/forms.py

@@ -361,8 +361,8 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
             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,
         widget=ContentTypeSelect(),
         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.test import TestCase
 
 from dcim.models import DeviceRole, Platform, Rack, Region, Site
+from extras.choices import ObjectChangeActionChoices
 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 virtualization.models import Cluster, ClusterGroup, ClusterType
 
@@ -298,4 +303,98 @@ class TagTestCase(TestCase):
         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
+from django_filters.constants import EMPTY_VALUES
 from copy import deepcopy
 from dcim.forms import MACAddressField
 from django import forms
@@ -115,6 +116,26 @@ class NumericArrayFilter(django_filters.NumberFilter):
         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
 #