From 376428b80b333a345e2c881fa4f2f67aa70c92a7 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 8 Jul 2021 17:02:45 +1000
Subject: [PATCH 1/8] Add regex IPN filter for Part API

---
 InvenTree/part/api.py                         | 132 ++++++++++++------
 .../migrations/0070_alter_part_variant_of.py  |  19 +++
 InvenTree/part/models.py                      |   1 -
 3 files changed, 109 insertions(+), 43 deletions(-)
 create mode 100644 InvenTree/part/migrations/0070_alter_part_variant_of.py

diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index 337201c95f..3199e96d63 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -5,9 +5,10 @@ Provides a JSON API for the Part app
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
-from django_filters.rest_framework import DjangoFilterBackend
+from django.conf.urls import url, include
+from django.urls import reverse
 from django.http import JsonResponse
-from django.db.models import Q, F, Count, Min, Max, Avg
+from django.db.models import Q, F, Count, Min, Max, Avg, query
 from django.utils.translation import ugettext_lazy as _
 
 from rest_framework import status
@@ -15,12 +16,13 @@ from rest_framework.response import Response
 from rest_framework import filters, serializers
 from rest_framework import generics
 
+from django_filters.rest_framework import DjangoFilterBackend
+from django_filters import rest_framework as rest_filters
+
 from djmoney.money import Money
 from djmoney.contrib.exchange.models import convert_money
 from djmoney.contrib.exchange.exceptions import MissingRate
 
-from django.conf.urls import url, include
-from django.urls import reverse
 
 from .models import Part, PartCategory, BomItem
 from .models import PartParameter, PartParameterTemplate
@@ -405,6 +407,74 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
         return response
 
 
+class PartFilter(rest_filters.FilterSet):
+    """
+    Custom filters for the PartList endpoint.
+    Uses the django_filters extension framework
+    """
+
+    # Exact match for IPN
+    ipn = rest_filters.CharFilter(
+        label='Filter by exact IPN (internal part number)',
+        field_name='IPN',
+        lookup_expr="iexact"
+    )
+
+    # Regex match for IPN
+    ipn_regex = rest_filters.CharFilter(
+        field_name='IPN', lookup_expr='iregex'
+    )
+
+    # low_stock filter
+    low_stock = rest_filters.BooleanFilter(method='filter_low_stock')
+
+    def filter_low_stock(self, queryset, name, value):
+        """
+        Filter by "low stock" status
+        """
+
+        value = str2bool(value)
+
+        if value:
+            # Ignore any parts which do not have a specified 'minimum_stock' level
+            queryset = queryset.exclude(minimum_stock=0)
+            # Filter items which have an 'in_stock' level lower than 'minimum_stock'
+            queryset = queryset.filter(Q(in_stock__lt=F('minimum_stock')))
+        else:
+            # Filter items which have an 'in_stock' level higher than 'minimum_stock'
+            queryset = queryset.filter(Q(in_stock__gte=F('minimum_stock')))
+
+        return queryset
+
+    # has_stock filter
+    has_stock = rest_filters.BooleanFilter(method='filter_has_stock')
+
+    def filter_has_stock(self, queryset, name, value):
+
+        value = str2bool(value)
+
+        if value:
+            queryset = queryset.filter(Q(in_stock__gt=0))
+        else:
+            queryset = queryset.filter(Q(in_stock__lte=0))
+
+        return queryset
+
+    is_template = rest_filters.CharFilter()
+
+    assembly = rest_filters.BooleanFilter()
+
+    component = rest_filters.BooleanFilter()
+
+    trackable = rest_filters.BooleanFilter()
+
+    purchaseable = rest_filters.BooleanFilter()
+
+    salable = rest_filters.BooleanFilter()
+
+    active = rest_filters.BooleanFilter()
+
+
 class PartList(generics.ListCreateAPIView):
     """ API endpoint for accessing a list of Part objects
 
@@ -427,8 +497,8 @@ class PartList(generics.ListCreateAPIView):
     """
 
     serializer_class = part_serializers.PartSerializer
-
     queryset = Part.objects.all()
+    filterset_class = PartFilter
 
     starred_parts = None
 
@@ -541,6 +611,10 @@ class PartList(generics.ListCreateAPIView):
 
         params = self.request.query_params
 
+        # Annotate calculated data to the queryset
+        # (This will be used for further filtering)
+        queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
+
         queryset = super().filter_queryset(queryset)
 
         # Filter by "uses" query - Limit to parts which use the provided part
@@ -578,6 +652,17 @@ class PartList(generics.ListCreateAPIView):
             else:
                 queryset = queryset.filter(IPN='')
 
+        # Filter by IPN
+        """
+        ipn = params.get('ipn', None)
+
+        if ipn is not None:
+
+            queryset = queryset.filter(IPN=ipn)
+
+        """
+        # Filter by IPN (regex support)
+
         # Filter by whether the BOM has been validated (or not)
         bom_valid = params.get('bom_valid', None)
 
@@ -643,36 +728,6 @@ class PartList(generics.ListCreateAPIView):
                 except (ValueError, PartCategory.DoesNotExist):
                     pass
 
-        # Annotate calculated data to the queryset
-        # (This will be used for further filtering)
-        queryset = part_serializers.PartSerializer.annotate_queryset(queryset)
-
-        # Filter by whether the part has stock
-        has_stock = params.get("has_stock", None)
-
-        if has_stock is not None:
-            has_stock = str2bool(has_stock)
-
-            if has_stock:
-                queryset = queryset.filter(Q(in_stock__gt=0))
-            else:
-                queryset = queryset.filter(Q(in_stock__lte=0))
-
-        # If we are filtering by 'low_stock' status
-        low_stock = params.get('low_stock', None)
-
-        if low_stock is not None:
-            low_stock = str2bool(low_stock)
-
-            if low_stock:
-                # Ignore any parts which do not have a specified 'minimum_stock' level
-                queryset = queryset.exclude(minimum_stock=0)
-                # Filter items which have an 'in_stock' level lower than 'minimum_stock'
-                queryset = queryset.filter(Q(in_stock__lt=F('minimum_stock')))
-            else:
-                # Filter items which have an 'in_stock' level higher than 'minimum_stock'
-                queryset = queryset.filter(Q(in_stock__gte=F('minimum_stock')))
-
         # Filer by 'depleted_stock' status -> has no stock and stock items
         depleted_stock = params.get('depleted_stock', None)
 
@@ -722,14 +777,7 @@ class PartList(generics.ListCreateAPIView):
     ]
 
     filter_fields = [
-        'is_template',
         'variant_of',
-        'assembly',
-        'component',
-        'trackable',
-        'purchaseable',
-        'salable',
-        'active',
     ]
 
     ordering_fields = [
diff --git a/InvenTree/part/migrations/0070_alter_part_variant_of.py b/InvenTree/part/migrations/0070_alter_part_variant_of.py
new file mode 100644
index 0000000000..a2b2f7ec18
--- /dev/null
+++ b/InvenTree/part/migrations/0070_alter_part_variant_of.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.4 on 2021-07-08 07:02
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('part', '0069_auto_20210701_0509'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='part',
+            name='variant_of',
+            field=models.ForeignKey(blank=True, help_text='Is this part a variant of another part?', limit_choices_to={'is_template': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='variants', to='part.part', verbose_name='Variant Of'),
+        ),
+    ]
diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index b69177c05b..cc533177d9 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -692,7 +692,6 @@ class Part(MPTTModel):
         null=True, blank=True,
         limit_choices_to={
             'is_template': True,
-            'active': True,
         },
         on_delete=models.SET_NULL,
         help_text=_('Is this part a variant of another part?'),

From ba0a13443fd1a812cdcc69941be72c819e3aff12 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 8 Jul 2021 17:02:55 +1000
Subject: [PATCH 2/8] PEP fixes

---
 InvenTree/part/api.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index 3199e96d63..2ee82bf20b 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -8,7 +8,7 @@ from __future__ import unicode_literals
 from django.conf.urls import url, include
 from django.urls import reverse
 from django.http import JsonResponse
-from django.db.models import Q, F, Count, Min, Max, Avg, query
+from django.db.models import Q, F, Count, Min, Max, Avg
 from django.utils.translation import ugettext_lazy as _
 
 from rest_framework import status

From a8a21f7c9d14fc246a09d36ac560ef415f8a48d8 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 8 Jul 2021 17:16:02 +1000
Subject: [PATCH 3/8] Transition "has IPN" filter to django-filters approach

---
 InvenTree/part/api.py | 45 +++++++++++++++++--------------------------
 1 file changed, 18 insertions(+), 27 deletions(-)

diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index 2ee82bf20b..2fd5babe0a 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -8,7 +8,7 @@ from __future__ import unicode_literals
 from django.conf.urls import url, include
 from django.urls import reverse
 from django.http import JsonResponse
-from django.db.models import Q, F, Count, Min, Max, Avg
+from django.db.models import Q, F, Count, Min, Max, Avg, query
 from django.utils.translation import ugettext_lazy as _
 
 from rest_framework import status
@@ -413,6 +413,18 @@ class PartFilter(rest_filters.FilterSet):
     Uses the django_filters extension framework
     """
 
+    # Filter by parts which have (or not) an IPN value
+    has_ipn = rest_filters.BooleanFilter(label='Has IPN', method='filter_has_ipn')
+
+    def filter_has_ipn(self, queryset, name, value):
+
+        value = str2bool(value)
+
+        if value:
+            queryset = queryset.exclude(IPN='')
+        else:
+            queryset = queryset.filter(IPN='')
+
     # Exact match for IPN
     ipn = rest_filters.CharFilter(
         label='Filter by exact IPN (internal part number)',
@@ -422,11 +434,12 @@ class PartFilter(rest_filters.FilterSet):
 
     # Regex match for IPN
     ipn_regex = rest_filters.CharFilter(
+        label='Filter by regex on IPN (internal part number) field',
         field_name='IPN', lookup_expr='iregex'
     )
 
     # low_stock filter
-    low_stock = rest_filters.BooleanFilter(method='filter_low_stock')
+    low_stock = rest_filters.BooleanFilter(label='Low stock', method='filter_low_stock')
 
     def filter_low_stock(self, queryset, name, value):
         """
@@ -447,7 +460,7 @@ class PartFilter(rest_filters.FilterSet):
         return queryset
 
     # has_stock filter
-    has_stock = rest_filters.BooleanFilter(method='filter_has_stock')
+    has_stock = rest_filters.BooleanFilter(label='Has stock', method='filter_has_stock')
 
     def filter_has_stock(self, queryset, name, value):
 
@@ -460,7 +473,7 @@ class PartFilter(rest_filters.FilterSet):
 
         return queryset
 
-    is_template = rest_filters.CharFilter()
+    is_template = rest_filters.BooleanFilter()
 
     assembly = rest_filters.BooleanFilter()
 
@@ -539,7 +552,7 @@ class PartList(generics.ListCreateAPIView):
         # Do we wish to include PartCategory detail?
         if str2bool(request.query_params.get('category_detail', False)):
 
-            # Work out which part categorie we need to query
+            # Work out which part categories we need to query
             category_ids = set()
 
             for part in data:
@@ -641,28 +654,6 @@ class PartList(generics.ListCreateAPIView):
             except (ValueError, Part.DoesNotExist):
                 pass
 
-        # Filter by whether the part has an IPN (internal part number) defined
-        has_ipn = params.get('has_ipn', None)
-
-        if has_ipn is not None:
-            has_ipn = str2bool(has_ipn)
-
-            if has_ipn:
-                queryset = queryset.exclude(IPN='')
-            else:
-                queryset = queryset.filter(IPN='')
-
-        # Filter by IPN
-        """
-        ipn = params.get('ipn', None)
-
-        if ipn is not None:
-
-            queryset = queryset.filter(IPN=ipn)
-
-        """
-        # Filter by IPN (regex support)
-
         # Filter by whether the BOM has been validated (or not)
         bom_valid = params.get('bom_valid', None)
 

From 81010994e75e55bf6f663c9a678a356fe682b299 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 8 Jul 2021 17:26:55 +1000
Subject: [PATCH 4/8] Adds regex filtering for "batch" code on StockItem

---
 InvenTree/stock/api.py | 48 +++++++++++++++++-------------------------
 1 file changed, 19 insertions(+), 29 deletions(-)

diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py
index cf13811c8c..15dd6f6994 100644
--- a/InvenTree/stock/api.py
+++ b/InvenTree/stock/api.py
@@ -14,8 +14,8 @@ from rest_framework.views import APIView
 from rest_framework.response import Response
 from rest_framework import generics, filters, permissions
 
-from django_filters.rest_framework import FilterSet, DjangoFilterBackend
-from django_filters import NumberFilter
+from django_filters.rest_framework import DjangoFilterBackend
+from django_filters import rest_framework as rest_filters
 
 from .models import StockLocation, StockItem
 from .models import StockItemTracking
@@ -110,20 +110,6 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
         return super().update(request, *args, **kwargs)
 
 
-class StockFilter(FilterSet):
-    """ FilterSet for advanced stock filtering.
-
-    Allows greater-than / less-than filtering for stock quantity
-    """
-
-    min_stock = NumberFilter(name='quantity', lookup_expr='gte')
-    max_stock = NumberFilter(name='quantity', lookup_expr='lte')
-
-    class Meta:
-        model = StockItem
-        fields = ['quantity', 'part', 'location']
-
-
 class StockAdjust(APIView):
     """
     A generic class for handling stocktake actions.
@@ -356,6 +342,22 @@ class StockLocationList(generics.ListCreateAPIView):
     ]
 
 
+class StockFilter(rest_filters.FilterSet):
+    """ FilterSet for advanced stock filtering.
+
+    Allows greater-than / less-than filtering for stock quantity
+    """
+
+    min_stock = rest_filters.NumberFilter(label='Minimum stock', field_name='quantity', lookup_expr='gte')
+    max_stock = rest_filters.NumberFilter(label='Maximum stock', field_name='quantity', lookup_expr='lte')
+
+    batch = rest_filters.CharFilter(label="Batch code filter (case insensitive)", lookup_expr='iexact')
+
+    batch_regex = rest_filters.CharFilter(label="Batch code filter (regex)", field_name='batch', lookup_expr='iregex')
+
+    is_building = rest_filters.BooleanFilter(label="In production")
+
+
 class StockList(generics.ListCreateAPIView):
     """ API endpoint for list view of Stock objects
 
@@ -372,6 +374,7 @@ class StockList(generics.ListCreateAPIView):
 
     serializer_class = StockItemSerializer
     queryset = StockItem.objects.all()
+    filterset_class = StockFilter
 
     def create(self, request, *args, **kwargs):
         """
@@ -542,24 +545,11 @@ class StockList(generics.ListCreateAPIView):
         if belongs_to:
             queryset = queryset.filter(belongs_to=belongs_to)
 
-        # Filter by batch code
-        batch = params.get('batch', None)
-
-        if batch is not None:
-            queryset = queryset.filter(batch=batch)
-
         build = params.get('build', None)
 
         if build:
             queryset = queryset.filter(build=build)
 
-        # Filter by 'is building' status
-        is_building = params.get('is_building', None)
-
-        if is_building:
-            is_building = str2bool(is_building)
-            queryset = queryset.filter(is_building=is_building)
-
         sales_order = params.get('sales_order', None)
 
         if sales_order:

From f0e7826fdc53e9798175e4514291720b34c45131 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 8 Jul 2021 17:44:52 +1000
Subject: [PATCH 5/8] Adds some more API filters for the StockItem endpoint

---
 InvenTree/part/api.py  |   2 +-
 InvenTree/stock/api.py | 113 +++++++++++++++++------------------------
 2 files changed, 47 insertions(+), 68 deletions(-)

diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index 2fd5babe0a..7303f06787 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -8,7 +8,7 @@ from __future__ import unicode_literals
 from django.conf.urls import url, include
 from django.urls import reverse
 from django.http import JsonResponse
-from django.db.models import Q, F, Count, Min, Max, Avg, query
+from django.db.models import Q, F, Count, Min, Max, Avg
 from django.utils.translation import ugettext_lazy as _
 
 from rest_framework import status
diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py
index 15dd6f6994..fc18ee7731 100644
--- a/InvenTree/stock/api.py
+++ b/InvenTree/stock/api.py
@@ -343,20 +343,62 @@ class StockLocationList(generics.ListCreateAPIView):
 
 
 class StockFilter(rest_filters.FilterSet):
-    """ FilterSet for advanced stock filtering.
-
-    Allows greater-than / less-than filtering for stock quantity
     """
+    FilterSet for StockItem LIST API
+    """
+
+    # Part name filters
+    name = rest_filters.CharFilter(label='Part name (case insensitive)', field_name='part__name', lookup_expr='iexact')
+    name_regex = rest_filters.CharFilter(label='Part name (regex)', field_name='part__name', lookup_expr='iregex')
+
+    # Part IPN filters
+    ipn = rest_filters.CharFilter(label='Part IPN (case insensitive)', field_name='part__IPN', lookup_expr='iexact')
+    ipn_regex = rest_filters.CharFilter(label='Part IPN (regex)', field_name='part__IPN', lookup_expr='iregex')
+
+    # Part attribute filters
+    assembly = rest_filters.BooleanFilter(label="Assembly", field_name='part__assembly')
+    active = rest_filters.BooleanFilter(label="Active", field_name='part__active')
 
     min_stock = rest_filters.NumberFilter(label='Minimum stock', field_name='quantity', lookup_expr='gte')
     max_stock = rest_filters.NumberFilter(label='Maximum stock', field_name='quantity', lookup_expr='lte')
 
+    in_stock = rest_filters.BooleanFilter(label='In Stock', method='filter_in_stock')
+
+    def filter_in_stock(self, queryset, name, value):
+
+        value = str2bool(value)
+
+        if value:
+            queryset = queryset.filter(StockItem.IN_STOCK_FILTER)
+        else:
+            queryset = queryset.exclude(StockItem.IN_STOCK_FILTER)
+
+        return queryset
+
     batch = rest_filters.CharFilter(label="Batch code filter (case insensitive)", lookup_expr='iexact')
 
     batch_regex = rest_filters.CharFilter(label="Batch code filter (regex)", field_name='batch', lookup_expr='iregex')
 
     is_building = rest_filters.BooleanFilter(label="In production")
 
+    # Serial number filtering
+    serial_gte = rest_filters.NumberFilter(label='Serial number GTE', field_name='serial', lookup_expr='gte')
+    serial_lte = rest_filters.NumberFilter(label='Serial number LTE', field_name='serial', lookup_expr='lte')
+    serial = rest_filters.NumberFilter(label='Serial number', field_name='serial', lookup_expr='exact')
+
+    serialized = rest_filters.BooleanFilter(label='Has serial number', method='filter_serialized')
+
+    def filter_serialized(self, queryset, name, value):
+
+        value = str2bool(value)
+
+        if value:
+            queryset = queryset.exclude(serial=None)
+        else:
+            queryset = queryset.filter(serial=None)
+
+        return queryset
+
 
 class StockList(generics.ListCreateAPIView):
     """ API endpoint for list view of Stock objects
@@ -629,50 +671,7 @@ class StockList(generics.ListCreateAPIView):
             else:
                 queryset = queryset.filter(customer=None)
 
-        # Filter by "serialized" status?
-        serialized = params.get('serialized', None)
-
-        if serialized is not None:
-            serialized = str2bool(serialized)
-
-            if serialized:
-                queryset = queryset.exclude(serial=None)
-            else:
-                queryset = queryset.filter(serial=None)
-
-        # Filter by serial number?
-        serial_number = params.get('serial', None)
-
-        if serial_number is not None:
-            queryset = queryset.filter(serial=serial_number)
-
-        # Filter by range of serial numbers?
-        serial_number_gte = params.get('serial_gte', None)
-        serial_number_lte = params.get('serial_lte', None)
-
-        if serial_number_gte is not None or serial_number_lte is not None:
-            queryset = queryset.exclude(serial=None)
-
-        if serial_number_gte is not None:
-            queryset = queryset.filter(serial__gte=serial_number_gte)
-
-        if serial_number_lte is not None:
-            queryset = queryset.filter(serial__lte=serial_number_lte)
-
-        # Filter by "in_stock" status
-        in_stock = params.get('in_stock', None)
-
-        if in_stock is not None:
-            in_stock = str2bool(in_stock)
-
-            if in_stock:
-                # Filter out parts which are not actually "in stock"
-                queryset = queryset.filter(StockItem.IN_STOCK_FILTER)
-            else:
-                # Only show parts which are not in stock
-                queryset = queryset.exclude(StockItem.IN_STOCK_FILTER)
-
-        # Filter by 'allocated' patrs?
+        # Filter by 'allocated' parts?
         allocated = params.get('allocated', None)
 
         if allocated is not None:
@@ -685,20 +684,6 @@ class StockList(generics.ListCreateAPIView):
                 # Filter StockItem without build allocations or sales order allocations
                 queryset = queryset.filter(Q(sales_order_allocations__isnull=True) & Q(allocations__isnull=True))
 
-        # Do we wish to filter by "active parts"
-        active = params.get('active', None)
-
-        if active is not None:
-            active = str2bool(active)
-            queryset = queryset.filter(part__active=active)
-
-        # Do we wish to filter by "assembly parts"
-        assembly = params.get('assembly', None)
-
-        if assembly is not None:
-            assembly = str2bool(assembly)
-            queryset = queryset.filter(part__assembly=assembly)
-
         # Filter by 'depleted' status
         depleted = params.get('depleted', None)
 
@@ -710,12 +695,6 @@ class StockList(generics.ListCreateAPIView):
             else:
                 queryset = queryset.exclude(quantity__lte=0)
 
-        # Filter by internal part number
-        ipn = params.get('IPN', None)
-
-        if ipn is not None:
-            queryset = queryset.filter(part__IPN=ipn)
-
         # Does the client wish to filter by the Part ID?
         part_id = params.get('part', None)
 

From 79d90b1c4a375848b78d1a7667ebca4b3e318581 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 8 Jul 2021 17:46:57 +1000
Subject: [PATCH 6/8] Additional filtering options for name and IPN fields

---
 InvenTree/stock/api.py | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py
index fc18ee7731..9fb3e356a5 100644
--- a/InvenTree/stock/api.py
+++ b/InvenTree/stock/api.py
@@ -349,10 +349,12 @@ class StockFilter(rest_filters.FilterSet):
 
     # Part name filters
     name = rest_filters.CharFilter(label='Part name (case insensitive)', field_name='part__name', lookup_expr='iexact')
+    name_contains = rest_filters.CharFilter(label='Part name contains (case insensitive)', field_name='part__name', lookup_expr='icontains')
     name_regex = rest_filters.CharFilter(label='Part name (regex)', field_name='part__name', lookup_expr='iregex')
 
     # Part IPN filters
     ipn = rest_filters.CharFilter(label='Part IPN (case insensitive)', field_name='part__IPN', lookup_expr='iexact')
+    ipn_contains = rest_filters.CharFilter(label='Part IPN contains (case insensitive)', field_name='part__IPN', lookup_expr='icontains')
     ipn_regex = rest_filters.CharFilter(label='Part IPN (regex)', field_name='part__IPN', lookup_expr='iregex')
 
     # Part attribute filters
@@ -864,9 +866,6 @@ class StockList(generics.ListCreateAPIView):
         filters.OrderingFilter,
     ]
 
-    filter_fields = [
-    ]
-
     ordering_fields = [
         'part__name',
         'part__IPN',

From c7f79a5a08d9d567765ad092276a9443e702cf94 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 8 Jul 2021 19:23:01 +1000
Subject: [PATCH 7/8] Fixes

---
 InvenTree/stock/api.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py
index 9fb3e356a5..20b4a2e36a 100644
--- a/InvenTree/stock/api.py
+++ b/InvenTree/stock/api.py
@@ -353,9 +353,9 @@ class StockFilter(rest_filters.FilterSet):
     name_regex = rest_filters.CharFilter(label='Part name (regex)', field_name='part__name', lookup_expr='iregex')
 
     # Part IPN filters
-    ipn = rest_filters.CharFilter(label='Part IPN (case insensitive)', field_name='part__IPN', lookup_expr='iexact')
-    ipn_contains = rest_filters.CharFilter(label='Part IPN contains (case insensitive)', field_name='part__IPN', lookup_expr='icontains')
-    ipn_regex = rest_filters.CharFilter(label='Part IPN (regex)', field_name='part__IPN', lookup_expr='iregex')
+    IPN = rest_filters.CharFilter(label='Part IPN (case insensitive)', field_name='part__IPN', lookup_expr='iexact')
+    IPN_contains = rest_filters.CharFilter(label='Part IPN contains (case insensitive)', field_name='part__IPN', lookup_expr='icontains')
+    IPN_regex = rest_filters.CharFilter(label='Part IPN (regex)', field_name='part__IPN', lookup_expr='iregex')
 
     # Part attribute filters
     assembly = rest_filters.BooleanFilter(label="Assembly", field_name='part__assembly')

From a985e11aa8c88337fbf212f7addaba4592f317a9 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Thu, 8 Jul 2021 20:10:22 +1000
Subject: [PATCH 8/8] Simplify and add filters for StockList API endpoint

---
 InvenTree/stock/api.py | 138 ++++++++++++++++-------------------------
 1 file changed, 53 insertions(+), 85 deletions(-)

diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py
index 20b4a2e36a..4a6e7111e8 100644
--- a/InvenTree/stock/api.py
+++ b/InvenTree/stock/api.py
@@ -368,9 +368,7 @@ class StockFilter(rest_filters.FilterSet):
 
     def filter_in_stock(self, queryset, name, value):
 
-        value = str2bool(value)
-
-        if value:
+        if str2bool(value):
             queryset = queryset.filter(StockItem.IN_STOCK_FILTER)
         else:
             queryset = queryset.exclude(StockItem.IN_STOCK_FILTER)
@@ -392,15 +390,64 @@ class StockFilter(rest_filters.FilterSet):
 
     def filter_serialized(self, queryset, name, value):
 
-        value = str2bool(value)
-
-        if value:
+        if str2bool(value):
             queryset = queryset.exclude(serial=None)
         else:
             queryset = queryset.filter(serial=None)
 
         return queryset
 
+    installed = rest_filters.BooleanFilter(label='Installed in other stock item', method='filter_installed')
+
+    def filter_installed(self, queryset, name, value):
+        """
+        Filter stock items by "belongs_to" field being empty
+        """
+
+        if str2bool(value):
+            queryset = queryset.exclude(belongs_to=None)
+        else:
+            queryset = queryset.filter(belongs_to=None)
+
+        return queryset
+
+    sent_to_customer = rest_filters.BooleanFilter(label='Sent to customer', method='filter_sent_to_customer')
+
+    def filter_sent_to_customer(self, queryset, name, value):
+
+        if str2bool(value):
+            queryset = queryset.exclude(customer=None)
+        else:
+            queryset = queryset.filter(customer=None)
+            
+        return queryset
+
+    depleted = rest_filters.BooleanFilter(label='Depleted', method='filter_depleted')
+
+    def filter_depleted(self, queryset, name, value):
+
+        if str2bool(value):
+            queryset = queryset.filter(quantity__lte=0)
+        else:
+            queryset = queryset.exclude(quantity__lte=0)
+
+        return queryset
+
+    has_purchase_price = rest_filters.BooleanFilter(label='Has purchase price', method='filter_has_purchase_price')
+
+    def filter_has_purchase_price(self, queryset, name, value):
+
+        if str2bool(value):
+            queryset = queryset.exclude(purcahse_price=None)
+        else:
+            queryset = queryset.filter(purchase_price=None)
+
+        return queryset
+
+    # Update date filters
+    updated_before = rest_filters.DateFilter(label='Updated before', field_name='updated', lookup_expr='lte')
+    updated_after = rest_filters.DateFilter(label='Updated after', field_name='updated', lookup_expr='gte')
+
 
 class StockList(generics.ListCreateAPIView):
     """ API endpoint for list view of Stock objects
@@ -611,19 +658,6 @@ class StockList(generics.ListCreateAPIView):
             # Note: The "installed_in" field is called "belongs_to"
             queryset = queryset.filter(belongs_to=installed_in)
 
-        # Filter stock items which are installed in another stock item
-        installed = params.get('installed', None)
-
-        if installed is not None:
-            installed = str2bool(installed)
-
-            if installed:
-                # Exclude items which are *not* installed in another item
-                queryset = queryset.exclude(belongs_to=None)
-            else:
-                # Exclude items which are instaled in another item
-                queryset = queryset.filter(belongs_to=None)
-
         if common.settings.stock_expiry_enabled():
 
             # Filter by 'expired' status
@@ -662,17 +696,6 @@ class StockList(generics.ListCreateAPIView):
         if customer:
             queryset = queryset.filter(customer=customer)
 
-        # Filter if items have been sent to a customer (any customer)
-        sent_to_customer = params.get('sent_to_customer', None)
-
-        if sent_to_customer is not None:
-            sent_to_customer = str2bool(sent_to_customer)
-
-            if sent_to_customer:
-                queryset = queryset.exclude(customer=None)
-            else:
-                queryset = queryset.filter(customer=None)
-
         # Filter by 'allocated' parts?
         allocated = params.get('allocated', None)
 
@@ -686,17 +709,6 @@ class StockList(generics.ListCreateAPIView):
                 # Filter StockItem without build allocations or sales order allocations
                 queryset = queryset.filter(Q(sales_order_allocations__isnull=True) & Q(allocations__isnull=True))
 
-        # Filter by 'depleted' status
-        depleted = params.get('depleted', None)
-
-        if depleted is not None:
-            depleted = str2bool(depleted)
-
-            if depleted:
-                queryset = queryset.filter(quantity__lte=0)
-            else:
-                queryset = queryset.exclude(quantity__lte=0)
-
         # Does the client wish to filter by the Part ID?
         part_id = params.get('part', None)
 
@@ -795,50 +807,6 @@ class StockList(generics.ListCreateAPIView):
         if manufacturer is not None:
             queryset = queryset.filter(supplier_part__manufacturer_part__manufacturer=manufacturer)
 
-        """
-        Filter by the 'last updated' date of the stock item(s):
-
-        - updated_before=? : Filter stock items which were last updated *before* the provided date
-        - updated_after=? : Filter stock items which were last updated *after* the provided date
-        """
-
-        date_fmt = '%Y-%m-%d'  # ISO format date string
-
-        updated_before = params.get('updated_before', None)
-        updated_after = params.get('updated_after', None)
-
-        if updated_before:
-            try:
-                updated_before = datetime.strptime(str(updated_before), date_fmt).date()
-                queryset = queryset.filter(updated__lte=updated_before)
-
-                print("Before:", updated_before.isoformat())
-            except (ValueError, TypeError):
-                # Account for improperly formatted date string
-                print("After before:", str(updated_before))
-                pass
-
-        if updated_after:
-            try:
-                updated_after = datetime.strptime(str(updated_after), date_fmt).date()
-                queryset = queryset.filter(updated__gte=updated_after)
-                print("After:", updated_after.isoformat())
-            except (ValueError, TypeError):
-                # Account for improperly formatted date string
-                print("After error:", str(updated_after))
-                pass
-
-        # Filter stock items which have a purchase price set
-        has_purchase_price = params.get('has_purchase_price', None)
-
-        if has_purchase_price is not None:
-            has_purchase_price = str2bool(has_purchase_price)
-
-            if has_purchase_price:
-                queryset = queryset.exclude(purchase_price=None)
-            else:
-                queryset = queryset.filter(purchase_price=None)
-
         # Optionally, limit the maximum number of returned results
         max_results = params.get('max_results', None)