Merge pull request #1773 from SchrodingersGat/ipn-filtering

API Filtering improvements
This commit is contained in:
Oliver 2021-07-08 20:47:07 +10:00 committed by GitHub
commit 0599fbaf26
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 222 additions and 229 deletions

View File

@ -5,7 +5,8 @@ Provides a JSON API for the Part app
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals 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.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
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@ -15,12 +16,13 @@ from rest_framework.response import Response
from rest_framework import filters, serializers from rest_framework import filters, serializers
from rest_framework import generics 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.money import Money
from djmoney.contrib.exchange.models import convert_money from djmoney.contrib.exchange.models import convert_money
from djmoney.contrib.exchange.exceptions import MissingRate 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 Part, PartCategory, BomItem
from .models import PartParameter, PartParameterTemplate from .models import PartParameter, PartParameterTemplate
@ -405,6 +407,87 @@ class PartDetail(generics.RetrieveUpdateDestroyAPIView):
return response return response
class PartFilter(rest_filters.FilterSet):
"""
Custom filters for the PartList endpoint.
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)',
field_name='IPN',
lookup_expr="iexact"
)
# 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(label='Low stock', 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(label='Has stock', 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.BooleanFilter()
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): class PartList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of Part objects """ API endpoint for accessing a list of Part objects
@ -427,8 +510,8 @@ class PartList(generics.ListCreateAPIView):
""" """
serializer_class = part_serializers.PartSerializer serializer_class = part_serializers.PartSerializer
queryset = Part.objects.all() queryset = Part.objects.all()
filterset_class = PartFilter
starred_parts = None starred_parts = None
@ -469,7 +552,7 @@ class PartList(generics.ListCreateAPIView):
# Do we wish to include PartCategory detail? # Do we wish to include PartCategory detail?
if str2bool(request.query_params.get('category_detail', False)): 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() category_ids = set()
for part in data: for part in data:
@ -541,6 +624,10 @@ class PartList(generics.ListCreateAPIView):
params = self.request.query_params 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) queryset = super().filter_queryset(queryset)
# Filter by "uses" query - Limit to parts which use the provided part # Filter by "uses" query - Limit to parts which use the provided part
@ -567,17 +654,6 @@ class PartList(generics.ListCreateAPIView):
except (ValueError, Part.DoesNotExist): except (ValueError, Part.DoesNotExist):
pass 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 whether the BOM has been validated (or not) # Filter by whether the BOM has been validated (or not)
bom_valid = params.get('bom_valid', None) bom_valid = params.get('bom_valid', None)
@ -643,36 +719,6 @@ class PartList(generics.ListCreateAPIView):
except (ValueError, PartCategory.DoesNotExist): except (ValueError, PartCategory.DoesNotExist):
pass 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 # Filer by 'depleted_stock' status -> has no stock and stock items
depleted_stock = params.get('depleted_stock', None) depleted_stock = params.get('depleted_stock', None)
@ -722,14 +768,7 @@ class PartList(generics.ListCreateAPIView):
] ]
filter_fields = [ filter_fields = [
'is_template',
'variant_of', 'variant_of',
'assembly',
'component',
'trackable',
'purchaseable',
'salable',
'active',
] ]
ordering_fields = [ ordering_fields = [

View File

@ -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'),
),
]

View File

@ -692,7 +692,6 @@ class Part(MPTTModel):
null=True, blank=True, null=True, blank=True,
limit_choices_to={ limit_choices_to={
'is_template': True, 'is_template': True,
'active': True,
}, },
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
help_text=_('Is this part a variant of another part?'), help_text=_('Is this part a variant of another part?'),

View File

@ -14,8 +14,8 @@ from rest_framework.views import APIView
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import generics, filters, permissions from rest_framework import generics, filters, permissions
from django_filters.rest_framework import FilterSet, DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from django_filters import NumberFilter from django_filters import rest_framework as rest_filters
from .models import StockLocation, StockItem from .models import StockLocation, StockItem
from .models import StockItemTracking from .models import StockItemTracking
@ -110,20 +110,6 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
return super().update(request, *args, **kwargs) 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): class StockAdjust(APIView):
""" """
A generic class for handling stocktake actions. A generic class for handling stocktake actions.
@ -356,6 +342,113 @@ class StockLocationList(generics.ListCreateAPIView):
] ]
class StockFilter(rest_filters.FilterSet):
"""
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_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
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):
if str2bool(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):
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): class StockList(generics.ListCreateAPIView):
""" API endpoint for list view of Stock objects """ API endpoint for list view of Stock objects
@ -372,6 +465,7 @@ class StockList(generics.ListCreateAPIView):
serializer_class = StockItemSerializer serializer_class = StockItemSerializer
queryset = StockItem.objects.all() queryset = StockItem.objects.all()
filterset_class = StockFilter
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
""" """
@ -542,24 +636,11 @@ class StockList(generics.ListCreateAPIView):
if belongs_to: if belongs_to:
queryset = queryset.filter(belongs_to=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) build = params.get('build', None)
if build: if build:
queryset = queryset.filter(build=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) sales_order = params.get('sales_order', None)
if sales_order: if sales_order:
@ -577,19 +658,6 @@ class StockList(generics.ListCreateAPIView):
# Note: The "installed_in" field is called "belongs_to" # Note: The "installed_in" field is called "belongs_to"
queryset = queryset.filter(belongs_to=installed_in) 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(): if common.settings.stock_expiry_enabled():
# Filter by 'expired' status # Filter by 'expired' status
@ -628,61 +696,7 @@ class StockList(generics.ListCreateAPIView):
if customer: if customer:
queryset = queryset.filter(customer=customer) queryset = queryset.filter(customer=customer)
# Filter if items have been sent to a customer (any customer) # Filter by 'allocated' parts?
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 "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?
allocated = params.get('allocated', None) allocated = params.get('allocated', None)
if allocated is not None: if allocated is not None:
@ -695,37 +709,6 @@ class StockList(generics.ListCreateAPIView):
# Filter StockItem without build allocations or sales order allocations # Filter StockItem without build allocations or sales order allocations
queryset = queryset.filter(Q(sales_order_allocations__isnull=True) & Q(allocations__isnull=True)) 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)
if depleted is not None:
depleted = str2bool(depleted)
if depleted:
queryset = queryset.filter(quantity__lte=0)
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? # Does the client wish to filter by the Part ID?
part_id = params.get('part', None) part_id = params.get('part', None)
@ -824,50 +807,6 @@ class StockList(generics.ListCreateAPIView):
if manufacturer is not None: if manufacturer is not None:
queryset = queryset.filter(supplier_part__manufacturer_part__manufacturer=manufacturer) 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 # Optionally, limit the maximum number of returned results
max_results = params.get('max_results', None) max_results = params.get('max_results', None)
@ -895,9 +834,6 @@ class StockList(generics.ListCreateAPIView):
filters.OrderingFilter, filters.OrderingFilter,
] ]
filter_fields = [
]
ordering_fields = [ ordering_fields = [
'part__name', 'part__name',
'part__IPN', 'part__IPN',