diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index 67665ab38..bd54006fb 100644 --- a/docs/release-notes/version-2.10.md +++ b/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 `.` 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 `.` +* extras.ObjectChange: Filtering by `changed_object_type` now takes a string in the form `.` * 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`. diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 8741c7f13..da2097f61 100644 --- a/netbox/extras/filters.py +++ b/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', ] diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 2cdd5d2ed..d7cbede69 100644 --- a/netbox/extras/forms.py +++ b/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' diff --git a/netbox/extras/tests/test_filters.py b/netbox/extras/tests/test_filters.py index e96293b20..d3be69557 100644 --- a/netbox/extras/tests/test_filters.py +++ b/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) diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 69ddbf704..20cb77bdc 100644 --- a/netbox/utilities/filters.py +++ b/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 . (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 #