diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b2b7ee83..19225f7d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -186,6 +186,7 @@ functionality provided by the front end UI. * [#2791](https://github.com/digitalocean/netbox/issues/2791) - Add `comments` field for tags * [#2920](https://github.com/digitalocean/netbox/issues/2920) - Rename Interface `form_factor` to `type` (backward-compatible until v2.7) * [#2926](https://github.com/digitalocean/netbox/issues/2926) - Add change logging to the Tag model +* [#3038](https://github.com/digitalocean/netbox/issues/3038) - OR logic now used when multiple values of a query filter are passed ## API Changes @@ -193,6 +194,7 @@ functionality provided by the front end UI. * New API endpoint for custom field choices: `/api/extras/_custom_field_choices/` * ForeignKey fields now accept either the related object PK or a dictionary of attributes describing the related object. * Organizational objects now include child object counts. For example, the Role serializer includes `prefix_count` and `vlan_count`. +* The `id__in` filter is now deprecated and will be removed in v2.7. (Begin using the `?id=1&id=2` format instead.) * Added a `description` field for all device components. * dcim.Device: The devices list endpoint now includes rendered context data. * dcim.DeviceType: `instance_count` has been renamed to `device_count`. diff --git a/docs/api/overview.md b/docs/api/overview.md index 6b9a1a429..e74a12371 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -274,12 +274,31 @@ A list of objects retrieved via the API can be filtered by passing one or more q GET /api/ipam/prefixes/?status=1 ``` -Certain filters can be included multiple times within a single request. These will effect a logical OR and return objects matching any of the given values. For example, the following will return all active and reserved prefixes: +The choices available for fixed choice fields such as `status` are exposed in the API under a special `_choices` endpoint for each NetBox app. For example, the available choices for `Prefix.status` are listed at `/api/ipam/_choices/` under the key `prefix:status`: ``` -GET /api/ipam/prefixes/?status=1&status=2 +"prefix:status": [ + { + "label": "Container", + "value": 0 + }, + { + "label": "Active", + "value": 1 + }, + { + "label": "Reserved", + "value": 2 + }, + { + "label": "Deprecated", + "value": 3 + } +], ``` +For most fields, when a filter is passed multiple times, objects matching _any_ of the provided values will be returned. For example, `GET /api/dcim/sites/?name=Foo&name=Bar` will return all sites named "Foo" _or_ "Bar". The exception to this rule is ManyToManyFields which may have multiple values assigned. Tags are the most common example of a ManyToManyField. For example, `GET /api/dcim/sites/?tag=foo&tag=bar` will return only sites tagged with both "foo" _and_ "bar". + ## Custom Fields To filter on a custom field, prepend `cf_` to the field name. For example, the following query will return only sites where a custom field named `foo` is equal to 123: diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index 12955eeca..02e95019a 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -9,7 +9,7 @@ from .constants import CIRCUIT_STATUS_CHOICES from .models import Provider, Circuit, CircuitTermination, CircuitType -class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet): +class ProviderFilter(CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -51,10 +51,10 @@ class CircuitTypeFilter(NameSlugSearchFilterSet): class Meta: model = CircuitType - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug'] -class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): +class CircuitFilter(CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 53163e7bd..48c38dd30 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -1,6 +1,5 @@ import django_filters from django.contrib.auth.models import User -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.db.models import Q from netaddr import EUI @@ -9,9 +8,7 @@ from netaddr.core import AddrFormatError from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant from utilities.constants import COLOR_CHOICES -from utilities.filters import ( - NameSlugSearchFilterSet, NullableCharFieldFilter, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter -) +from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter from virtualization.models import Cluster from .constants import * from .models import ( @@ -37,7 +34,7 @@ class RegionFilter(NameSlugSearchFilterSet): class Meta: model = Region - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug'] class SiteFilter(CustomFieldFilterSet): @@ -78,7 +75,10 @@ class SiteFilter(CustomFieldFilterSet): class Meta: model = Site - fields = ['q', 'name', 'slug', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email'] + fields = [ + 'id', 'name', 'slug', 'facility', 'asn', 'latitude', 'longitude', 'contact_name', 'contact_phone', + 'contact_email', + ] def search(self, queryset, name, value): if not value.strip(): @@ -115,14 +115,14 @@ class RackGroupFilter(NameSlugSearchFilterSet): class Meta: model = RackGroup - fields = ['site_id', 'name', 'slug'] + fields = ['id', 'name', 'slug'] class RackRoleFilter(NameSlugSearchFilterSet): class Meta: model = RackRole - fields = ['name', 'slug', 'color'] + fields = ['id', 'name', 'slug', 'color'] class RackFilter(CustomFieldFilterSet): @@ -134,7 +134,6 @@ class RackFilter(CustomFieldFilterSet): method='search', label='Search', ) - facility_id = NullableCharFieldFilter() site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', @@ -179,14 +178,13 @@ class RackFilter(CustomFieldFilterSet): to_field_name='slug', label='Role (slug)', ) - asset_tag = NullableCharFieldFilter() tag = TagFilter() class Meta: model = Rack fields = [ - 'name', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', - 'outer_unit', + 'id', 'name', 'facility_id', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', + 'outer_width', 'outer_depth', 'outer_unit', ] def search(self, queryset, name, value): @@ -276,7 +274,7 @@ class ManufacturerFilter(NameSlugSearchFilterSet): class Meta: model = Manufacturer - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug'] class DeviceTypeFilter(CustomFieldFilterSet): @@ -374,63 +372,63 @@ class ConsolePortTemplateFilter(DeviceTypeComponentFilterSet): class Meta: model = ConsolePortTemplate - fields = ['name'] + fields = ['id', 'name'] class ConsoleServerPortTemplateFilter(DeviceTypeComponentFilterSet): class Meta: model = ConsoleServerPortTemplate - fields = ['name'] + fields = ['id', 'name'] class PowerPortTemplateFilter(DeviceTypeComponentFilterSet): class Meta: model = PowerPortTemplate - fields = ['name'] + fields = ['id', 'name', 'maximum_draw', 'allocated_draw'] class PowerOutletTemplateFilter(DeviceTypeComponentFilterSet): class Meta: model = PowerOutletTemplate - fields = ['name'] + fields = ['id', 'name', 'feed_leg'] class InterfaceTemplateFilter(DeviceTypeComponentFilterSet): class Meta: model = InterfaceTemplate - fields = ['name', 'type', 'mgmt_only'] + fields = ['id', 'name', 'type', 'mgmt_only'] class FrontPortTemplateFilter(DeviceTypeComponentFilterSet): class Meta: model = FrontPortTemplate - fields = ['name', 'type'] + fields = ['id', 'name', 'type'] class RearPortTemplateFilter(DeviceTypeComponentFilterSet): class Meta: model = RearPortTemplate - fields = ['name', 'type'] + fields = ['id', 'name', 'type', 'positions'] class DeviceBayTemplateFilter(DeviceTypeComponentFilterSet): class Meta: model = DeviceBayTemplate - fields = ['name'] + fields = ['id', 'name'] class DeviceRoleFilter(NameSlugSearchFilterSet): class Meta: model = DeviceRole - fields = ['name', 'slug', 'color', 'vm_role'] + fields = ['id', 'name', 'slug', 'color', 'vm_role'] class PlatformFilter(NameSlugSearchFilterSet): @@ -448,7 +446,7 @@ class PlatformFilter(NameSlugSearchFilterSet): class Meta: model = Platform - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug', 'napalm_driver'] class DeviceFilter(CustomFieldFilterSet): @@ -506,8 +504,6 @@ class DeviceFilter(CustomFieldFilterSet): to_field_name='slug', label='Platform (slug)', ) - name = NullableCharFieldFilter() - asset_tag = NullableCharFieldFilter() region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region__in', @@ -539,10 +535,6 @@ class DeviceFilter(CustomFieldFilterSet): queryset=Rack.objects.all(), label='Rack (ID)', ) - position = django_filters.ChoiceFilter( - choices=DEVICE_POSITION_CHOICES, - null_label='Non-racked' - ) cluster_id = django_filters.ModelMultipleChoiceFilter( queryset=Cluster.objects.all(), label='VM cluster (ID)', @@ -602,7 +594,7 @@ class DeviceFilter(CustomFieldFilterSet): class Meta: model = Device - fields = ['serial', 'face'] + fields = ['id', 'name', 'serial', 'asset_tag', 'face', 'position', 'vc_position', 'vc_priority'] def search(self, queryset, name, value): if not value.strip(): @@ -693,7 +685,7 @@ class ConsolePortFilter(DeviceComponentFilterSet): class Meta: model = ConsolePort - fields = ['name', 'description', 'connection_status'] + fields = ['id', 'name', 'description', 'connection_status'] class ConsoleServerPortFilter(DeviceComponentFilterSet): @@ -705,7 +697,7 @@ class ConsoleServerPortFilter(DeviceComponentFilterSet): class Meta: model = ConsoleServerPort - fields = ['name', 'description', 'connection_status'] + fields = ['id', 'name', 'description', 'connection_status'] class PowerPortFilter(DeviceComponentFilterSet): @@ -717,7 +709,7 @@ class PowerPortFilter(DeviceComponentFilterSet): class Meta: model = PowerPort - fields = ['name', 'description', 'connection_status'] + fields = ['id', 'name', 'maximum_draw', 'allocated_draw', 'description', 'connection_status'] class PowerOutletFilter(DeviceComponentFilterSet): @@ -729,7 +721,7 @@ class PowerOutletFilter(DeviceComponentFilterSet): class Meta: model = PowerOutlet - fields = ['name', 'description', 'connection_status'] + fields = ['id', 'name', 'feed_leg', 'description', 'connection_status'] class InterfaceFilter(django_filters.FilterSet): @@ -784,7 +776,7 @@ class InterfaceFilter(django_filters.FilterSet): class Meta: model = Interface - fields = ['name', 'connection_status', 'type', 'enabled', 'mtu', 'mgmt_only', 'description'] + fields = ['id', 'name', 'connection_status', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description'] def search(self, queryset, name, value): if not value.strip(): @@ -848,7 +840,7 @@ class FrontPortFilter(DeviceComponentFilterSet): class Meta: model = FrontPort - fields = ['name', 'type', 'description'] + fields = ['id', 'name', 'type', 'description'] class RearPortFilter(DeviceComponentFilterSet): @@ -860,14 +852,14 @@ class RearPortFilter(DeviceComponentFilterSet): class Meta: model = RearPort - fields = ['name', 'type', 'description'] + fields = ['id', 'name', 'type', 'positions', 'description'] class DeviceBayFilter(DeviceComponentFilterSet): class Meta: model = DeviceBay - fields = ['name', 'description'] + fields = ['id', 'name', 'description'] class InventoryItemFilter(DeviceComponentFilterSet): @@ -898,11 +890,10 @@ class InventoryItemFilter(DeviceComponentFilterSet): to_field_name='slug', label='Manufacturer (slug)', ) - asset_tag = NullableCharFieldFilter() class Meta: model = InventoryItem - fields = ['name', 'part_id', 'serial', 'asset_tag', 'discovered'] + fields = ['id', 'name', 'part_id', 'serial', 'asset_tag', 'discovered'] def search(self, queryset, name, value): if not value.strip(): @@ -948,7 +939,7 @@ class VirtualChassisFilter(django_filters.FilterSet): class Meta: model = VirtualChassis - fields = ['domain'] + fields = ['id', 'domain'] def search(self, queryset, name, value): if not value.strip(): @@ -968,6 +959,9 @@ class CableFilter(django_filters.FilterSet): type = django_filters.MultipleChoiceFilter( choices=CABLE_TYPE_CHOICES ) + status = django_filters.MultipleChoiceFilter( + choices=CONNECTION_STATUS_CHOICES + ) color = django_filters.MultipleChoiceFilter( choices=COLOR_CHOICES ) @@ -982,7 +976,7 @@ class CableFilter(django_filters.FilterSet): class Meta: model = Cable - fields = ['type', 'status', 'color', 'length', 'length_unit'] + fields = ['id', 'label', 'length', 'length_unit'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 2df680240..5558094eb 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -13,7 +13,7 @@ from .constants import IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_ from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF -class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): +class VRFFilter(CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -59,7 +59,7 @@ class RIRFilter(NameSlugSearchFilterSet): fields = ['name', 'slug', 'is_private'] -class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): +class AggregateFilter(CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -68,6 +68,10 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): method='search', label='Search', ) + prefix = django_filters.CharFilter( + method='filter_prefix', + label='Prefix', + ) rir_id = django_filters.ModelMultipleChoiceFilter( queryset=RIR.objects.all(), label='RIR (ID)', @@ -95,6 +99,15 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): pass return queryset.filter(qs_filter) + def filter_prefix(self, queryset, name, value): + if not value.strip(): + return queryset + try: + query = str(netaddr.IPNetwork(value).cidr) + return queryset.filter(prefix=query) + except ValidationError: + return queryset.none() + class RoleFilter(NameSlugSearchFilterSet): q = django_filters.CharFilter( @@ -104,10 +117,10 @@ class RoleFilter(NameSlugSearchFilterSet): class Meta: model = Role - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug'] -class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): +class PrefixFilter(CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -254,7 +267,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): return queryset.filter(prefix__net_mask_length=value) -class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): +class IPAddressFilter(CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -392,10 +405,10 @@ class VLANGroupFilter(NameSlugSearchFilterSet): class Meta: model = VLANGroup - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug'] -class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): +class VLANFilter(CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -494,7 +507,7 @@ class ServiceFilter(django_filters.FilterSet): class Meta: model = Service - fields = ['name', 'protocol', 'port'] + fields = ['id', 'name', 'protocol', 'port'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/secrets/filters.py b/netbox/secrets/filters.py index 6548708b5..628d716db 100644 --- a/netbox/secrets/filters.py +++ b/netbox/secrets/filters.py @@ -11,10 +11,10 @@ class SecretRoleFilter(NameSlugSearchFilterSet): class Meta: model = SecretRole - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug'] -class SecretFilter(CustomFieldFilterSet, django_filters.FilterSet): +class SecretFilter(CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py index 2610b3ec0..acb0fa0cc 100644 --- a/netbox/tenancy/filters.py +++ b/netbox/tenancy/filters.py @@ -10,10 +10,10 @@ class TenantGroupFilter(NameSlugSearchFilterSet): class Meta: model = TenantGroup - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug'] -class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet): +class TenantFilter(CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 6e2116751..614c09902 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -1,10 +1,51 @@ import django_filters +from django import forms from django.conf import settings -from django.db.models import Q +from django.db import models from extras.models import Tag +def multivalue_field_factory(field_class): + """ + Given a form field class, return a subclass capable of accepting multiple values. This allows us to OR on multiple + filter values while maintaining the field's built-in vlaidation. Example: GET /api/dcim/devices/?name=foo&name=bar + """ + class NewField(field_class): + widget = forms.SelectMultiple + + def to_python(self, value): + if not value: + return [] + return [super(field_class, self).to_python(v) for v in value] + + return type('MultiValue{}'.format(field_class.__name__), (NewField,), dict()) + + +# +# Filters +# + +class MultiValueCharFilter(django_filters.MultipleChoiceFilter): + field_class = multivalue_field_factory(forms.CharField) + + +class MultiValueDateFilter(django_filters.MultipleChoiceFilter): + field_class = multivalue_field_factory(forms.DateField) + + +class MultiValueDateTimeFilter(django_filters.MultipleChoiceFilter): + field_class = multivalue_field_factory(forms.DateTimeField) + + +class MultiValueNumberFilter(django_filters.MultipleChoiceFilter): + field_class = multivalue_field_factory(forms.IntegerField) + + +class MultiValueTimeFilter(django_filters.MultipleChoiceFilter): + field_class = multivalue_field_factory(forms.TimeField) + + class TreeNodeMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter): """ Filters for a set of Models, including all descendant models within a Tree. Example: [,] @@ -48,6 +89,10 @@ class TagFilter(django_filters.ModelMultipleChoiceFilter): super().__init__(*args, **kwargs) +# +# FilterSets +# + class NameSlugSearchFilterSet(django_filters.FilterSet): """ A base class for adding the search method to models which only expose the `name` and `slug` fields @@ -61,6 +106,57 @@ class NameSlugSearchFilterSet(django_filters.FilterSet): if not value.strip(): return queryset return queryset.filter( - Q(name__icontains=value) | - Q(slug__icontains=value) + models.Q(name__icontains=value) | + models.Q(slug__icontains=value) ) + + +# +# Update default filters +# + +FILTER_DEFAULTS = django_filters.filterset.FILTER_FOR_DBFIELD_DEFAULTS +FILTER_DEFAULTS.update({ + models.AutoField: { + 'filter_class': MultiValueNumberFilter + }, + models.CharField: { + 'filter_class': MultiValueCharFilter + }, + models.DateField: { + 'filter_class': MultiValueDateFilter + }, + models.DateTimeField: { + 'filter_class': MultiValueDateTimeFilter + }, + models.DecimalField: { + 'filter_class': MultiValueNumberFilter + }, + models.EmailField: { + 'filter_class': MultiValueCharFilter + }, + models.FloatField: { + 'filter_class': MultiValueNumberFilter + }, + models.IntegerField: { + 'filter_class': MultiValueNumberFilter + }, + models.PositiveIntegerField: { + 'filter_class': MultiValueNumberFilter + }, + models.PositiveSmallIntegerField: { + 'filter_class': MultiValueNumberFilter + }, + models.SlugField: { + 'filter_class': MultiValueCharFilter + }, + models.SmallIntegerField: { + 'filter_class': MultiValueNumberFilter + }, + models.TimeField: { + 'filter_class': MultiValueTimeFilter + }, + models.URLField: { + 'filter_class': MultiValueCharFilter + }, +}) diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index 0e5ff6cd2..ec6487704 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -1,5 +1,4 @@ import django_filters -from django.core.exceptions import ObjectDoesNotExist from django.db.models import Q from netaddr import EUI from netaddr.core import AddrFormatError @@ -16,14 +15,14 @@ class ClusterTypeFilter(NameSlugSearchFilterSet): class Meta: model = ClusterType - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug'] class ClusterGroupFilter(NameSlugSearchFilterSet): class Meta: model = ClusterGroup - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug'] class ClusterFilter(CustomFieldFilterSet): @@ -175,7 +174,7 @@ class VirtualMachineFilter(CustomFieldFilterSet): class Meta: model = VirtualMachine - fields = ['name', 'cluster'] + fields = ['id', 'name', 'cluster', 'vcpus', 'memory', 'disk'] def search(self, queryset, name, value): if not value.strip(): @@ -209,7 +208,7 @@ class InterfaceFilter(django_filters.FilterSet): class Meta: model = Interface - fields = ['name', 'enabled', 'mtu'] + fields = ['id', 'name', 'enabled', 'mtu'] def _mac_address(self, queryset, name, value): value = value.strip() diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 814e05854..f1e372dd4 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -464,7 +464,7 @@ class VirtualMachineTest(APITestCase): def test_config_context_included_by_default_in_list_view(self): url = reverse('virtualization-api:virtualmachine-list') - url = '{}?id__in={}'.format(url, self.virtualmachine_with_context_data.pk) + url = '{}?id={}'.format(url, self.virtualmachine_with_context_data.pk) response = self.client.get(url, **self.header) self.assertEqual(response.data['results'][0].get('config_context', {}).get('A'), 1)