From 67565ca191a1976b6a4938b2bbfde578997b6264 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Mon, 24 Feb 2020 15:03:07 -0500 Subject: [PATCH] added docs and more tests --- docs/api/filtering.md | 71 ++++++++++ docs/api/overview.md | 2 + mkdocs.yml | 1 + netbox/utilities/constants.py | 13 -- netbox/utilities/filters.py | 3 +- netbox/utilities/tests/test_filters.py | 186 +++++++++++++++++++++---- 6 files changed, 231 insertions(+), 45 deletions(-) create mode 100644 docs/api/filtering.md diff --git a/docs/api/filtering.md b/docs/api/filtering.md new file mode 100644 index 000000000..e7b51d303 --- /dev/null +++ b/docs/api/filtering.md @@ -0,0 +1,71 @@ +# API Filtering + +The NetBox API supports robust filtering of results based on the fields of each model. +Generally speaking you are able to filter based on the attributes (fields) present in +the response body. Please note however that certain read-only or metadata fields are not +filterable. + +Filtering is achieved by passing HTTP query parameters and the parameter name is the +name of the field you wish to filter on and the value is the field value. + +E.g. filtering based on a device's name: +``` +/api/dcim/devices/?name=DC-SPINE-1 +``` + +## Multi Value Logic + +While you are able to filter based on an arbitrary number of fields, you are also able to +pass multiple values for the same field. In most cases filtering on multiple values is +implemented as a logical OR operation. A notible exception is the `tag` filter which +is a logical AND. Passing multiple values for one field, can be combined with other fields. + +For example, filtering for devices with either the name of DC-SPINE-1 _or_ DC-LEAF-4: +``` +/api/dcim/devices/?name=DC-SPINE-1&name=DC-LEAF-4 +``` + +Filtering for devices with tag `router` and `customer-a` will return only devices with +_both_ of those tags applied: +``` +/api/dcim/devices/?tag=router&tag=customer-a +``` + +## Lookup Expressions + +Certain model fields also support filtering using additonal lookup expressions. This allows +for negation and other context specific filtering. + +These lookup expressions can be applied by adding a suffix to the desired field's name. +E.g. `mac_address__n`. In this case, the filter expression is for negation and it is seperated +by two underscores. Below are the lookup expressions that are supported across different field +types. + +### Numeric Fields + +Numeric based fields (ASN, VLAN ID, etc) support these lookup expressions: + +- `n` - not equal (negation) +- `lt` - less than +- `lte` - less than or equal +- `gt` - greater than +- `gte` - greater than or equal + +### String Fields + +String based (char) fields (Name, Address, etc) support these lookup expressions: + +- `n` - not equal (negation) +- `ic` - case insensitive contains +- `nic` - negated case insensitive contains +- `isw` - case insensitive starts with +- `nisw` - negated case insensitive starts with +- `iew` - case insensitive ends with +- `niew` - negated case insensitive ends with +- `ie` - case sensitive exact match +- `nie` - negated case sensitive exact match + +### Foreign Keys & Other Fields + +Certain other fields, namely foreign key relationships support just the negation +expression: `n`. diff --git a/docs/api/overview.md b/docs/api/overview.md index 3841e8bbf..daa4f7c63 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -62,6 +62,8 @@ Lists of objects can be filtered using a set of query parameters. For example, t GET /api/dcim/interfaces/?device_id=123 ``` +See [filtering](filtering.md) for more details. + # Serialization The NetBox API employs three types of serializers to represent model data: diff --git a/mkdocs.yml b/mkdocs.yml index 4ba91dfe5..9dc8b8578 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -50,6 +50,7 @@ pages: - Authentication: 'api/authentication.md' - Working with Secrets: 'api/working-with-secrets.md' - Examples: 'api/examples.md' + - Filtering: 'api/filtering.md' - Development: - Introduction: 'development/index.md' - Style Guide: 'development/style-guide.md' diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py index 1665b45c2..bf2cba592 100644 --- a/netbox/utilities/constants.py +++ b/netbox/utilities/constants.py @@ -57,19 +57,6 @@ FILTER_NEGATION_LOOKUP_MAP = dict( n='exact' ) -FILTER_LOOKUP_HELP_TEXT_MAP = dict( - icontains='case insensitive contains', - iendswith='case insensitive ends with', - istartswith='case insensitive starts with', - iexact='case insensitive exact', - exact='case sensitive exact', - lt='less than', - lte='less than or equal', - gt='greater than', - gte='greater than or equal', - n='negated' -) - # Keys for PostgreSQL advisory locks. These are arbitrary bigints used by # the advisory_lock contextmanager. When a lock is acquired, diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 5e9e1f4c1..2a9f04316 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -8,8 +8,7 @@ from django_filters.utils import get_model_field, resolve_field from extras.models import Tag from utilities.constants import ( - FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_LOOKUP_HELP_TEXT_MAP, FILTER_NEGATION_LOOKUP_MAP, - FILTER_NUMERIC_BASED_LOOKUP_MAP + FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_NUMERIC_BASED_LOOKUP_MAP ) diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py index a1cb771a1..ca1c10faf 100644 --- a/netbox/utilities/tests/test_filters.py +++ b/netbox/utilities/tests/test_filters.py @@ -2,8 +2,12 @@ from django.conf import settings from django.test import TestCase import django_filters -from dcim.filters import SiteFilterSet -from dcim.models import Region, Site +from dcim.filters import DeviceFilterSet, SiteFilterSet +from dcim.choices import * +from dcim.models import ( + Device, DeviceRole, DeviceType, Interface, Manufacturer, Platform, Rack, Region, Site +) +from ipam.models import IPAddress from utilities.filters import BaseFilterSet, TreeNodeMultipleChoiceFilter @@ -68,67 +72,189 @@ class DynamicFilterLookupExpressionTest(TestCase): These tests ensure of the utilities.filters.BaseFilterSet.get_filters() method correctly generates dynamic filter expressions """ + device_queryset = Device.objects.all() + device_filterset = DeviceFilterSet + site_queryset = Site.objects.all() + site_filterset = SiteFilterSet - def setUp(self): + @classmethod + def setUpTestData(cls): - super().setUp() + manufacturers = ( + Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), + Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), + Manufacturer(name='Manufacturer 3', slug='manufacturer-3'), + ) + Manufacturer.objects.bulk_create(manufacturers) - self.region1 = Region.objects.create(name='Test Region 1', slug='test-region-1') - self.region2 = Region.objects.create(name='Test Region 2', slug='test-region-2') - self.site1 = Site.objects.create(region=self.region1, name='Test Site 1', slug='ABC-test-site1-ABC', asn=65001) - self.site2 = Site.objects.create(region=self.region2, name='Test Site 2', slug='def-test-site2-def', asn=65101) - self.site3 = Site.objects.create(region=None, name='Test Site 3', slug='ghi-test-site3-ghi', asn=65201) + device_types = ( + DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', is_full_depth=True), + DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', is_full_depth=True), + DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', is_full_depth=False), + ) + DeviceType.objects.bulk_create(device_types) - self.queryset = Site.objects.all() + device_roles = ( + DeviceRole(name='Device Role 1', slug='device-role-1'), + DeviceRole(name='Device Role 2', slug='device-role-2'), + DeviceRole(name='Device Role 3', slug='device-role-3'), + ) + DeviceRole.objects.bulk_create(device_roles) + + platforms = ( + Platform(name='Platform 1', slug='platform-1'), + Platform(name='Platform 2', slug='platform-2'), + Platform(name='Platform 3', slug='platform-3'), + ) + Platform.objects.bulk_create(platforms) + + regions = ( + Region(name='Region 1', slug='region-1'), + Region(name='Region 2', slug='region-2'), + Region(name='Region 3', slug='region-3'), + ) + for region in regions: + region.save() + + sites = ( + Site(name='Site 1', slug='abc-site-1', region=regions[0], asn=65001), + Site(name='Site 2', slug='def-site-2', region=regions[1], asn=65101), + Site(name='Site 3', slug='ghi-site-3', region=regions[2], asn=65201), + ) + Site.objects.bulk_create(sites) + + racks = ( + Rack(name='Rack 1', site=sites[0]), + Rack(name='Rack 2', site=sites[1]), + Rack(name='Rack 3', site=sites[2]), + ) + Rack.objects.bulk_create(racks) + + devices = ( + Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], serial='ABC', asset_tag='1001', site=sites[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_ACTIVE, local_context_data={"foo": 123}), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], serial='DEF', asset_tag='1002', site=sites[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_STAGED), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], serial='GHI', asset_tag='1003', site=sites[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, status=DeviceStatusChoices.STATUS_FAILED), + ) + Device.objects.bulk_create(devices) + + interfaces = ( + Interface(device=devices[0], name='Interface 1', mac_address='00-00-00-00-00-01'), + Interface(device=devices[0], name='Interface 2', mac_address='aa-00-00-00-00-01'), + Interface(device=devices[1], name='Interface 3', mac_address='00-00-00-00-00-02'), + Interface(device=devices[1], name='Interface 4', mac_address='bb-00-00-00-00-02'), + Interface(device=devices[2], name='Interface 5', mac_address='00-00-00-00-00-03'), + Interface(device=devices[2], name='Interface 6', mac_address='cc-00-00-00-00-03'), + ) + Interface.objects.bulk_create(interfaces) def test_site_name_negation(self): - params = {'name__n': ['Test Site 1']} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + params = {'name__n': ['Site 1']} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) def test_site_slug_icontains(self): - params = {'slug__ic': ['abc']} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 1) + params = {'slug__ic': ['-1']} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) def test_site_slug_icontains_negation(self): - params = {'slug__nic': ['abc']} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + params = {'slug__nic': ['-1']} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) def test_site_slug_startswith(self): params = {'slug__isw': ['abc']} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 1) + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) def test_site_slug_startswith_negation(self): params = {'slug__nisw': ['abc']} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) def test_site_slug_endswith(self): - params = {'slug__iew': ['abc']} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 1) + params = {'slug__iew': ['-1']} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) def test_site_slug_endswith_negation(self): - params = {'slug__niew': ['abc']} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + params = {'slug__niew': ['-1']} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) def test_site_asn_lt(self): params = {'asn__lt': [65101]} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 1) + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) def test_site_asn_lte(self): params = {'asn__lte': [65101]} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) def test_site_asn_gt(self): params = {'asn__lt': [65101]} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 1) + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) def test_site_asn_gte(self): params = {'asn__gte': [65101]} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) def test_site_region_negation(self): - params = {'region__n': ['test-region-1']} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + params = {'region__n': ['region-1']} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) def test_site_region_id_negation(self): - params = {'region_id__n': [self.region1.pk]} - self.assertEqual(SiteFilterSet(params, self.queryset).qs.count(), 2) + params = {'region_id__n': [Region.objects.first().pk]} + self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) + + def test_device_name_eq(self): + params = {'name': ['Device 1']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1) + + def test_device_name_negation(self): + params = {'name__n': ['Device 1']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + + def test_device_name_startswith(self): + params = {'name__isw': ['Device']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 3) + + def test_device_name_startswith_negation(self): + params = {'name__nisw': ['Device 1']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + + def test_device_name_endswith(self): + params = {'name__iew': [' 1']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1) + + def test_device_name_endswith_negation(self): + params = {'name__niew': [' 1']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + + def test_device_name_icontains(self): + params = {'name__ic': [' 2']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1) + + def test_device_name_icontains_negation(self): + params = {'name__nic': [' ']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 0) + + def test_device_mac_address_negation(self): + params = {'mac_address__n': ['00-00-00-00-00-01', 'aa-00-00-00-00-01']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + + def test_device_mac_address_startswith(self): + params = {'mac_address__isw': ['aa:']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1) + + def test_device_mac_address_startswith_negation(self): + params = {'mac_address__nisw': ['aa:']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + + def test_device_mac_address_endswith(self): + params = {'mac_address__iew': [':02']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1) + + def test_device_mac_address_endswith_negation(self): + params = {'mac_address__niew': [':02']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + + def test_device_mac_address_icontains(self): + params = {'mac_address__ic': ['aa:', 'bb']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + + def test_device_mac_address_icontains_negation(self): + params = {'mac_address__nic': ['aa:', 'bb']} + self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1)