mirror of
https://github.com/inventree/InvenTree.git
synced 2025-12-17 09:48:30 +00:00
[API] Tags filters (#11021)
* Add optional "tags" field * Refactor "tags" field - Off by default - Only prefetch when requested (expensive) - Ref: https://github.com/inventree/InvenTree/pull/11012 - Ref: https://github.com/inventree/InvenTree/issues/11002 - Closes https://github.com/inventree/InvenTree/issues/10996 * Bump API version * Tweak unit tests * Ensure all fields are available when writing data * Handle case where request has *no* method
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 433
|
||||
INVENTREE_API_VERSION = 434
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v434 -> 2025-12-16 : https://github.com/inventree/InvenTree/pull/11021
|
||||
- The "tags" fields (on various API endpoints) is now optional, and disabled by default
|
||||
- To request tags information, add "tags=true" to the API request query parameters
|
||||
|
||||
v433 -> 2025-12-16 : https://github.com/inventree/InvenTree/pull/11023
|
||||
- "substitutes" field on the BomItem API endpoint is now excluded by default
|
||||
- Add "?substitutes=true" query parameter to include substitute parts in BomItem API endpoint(s)
|
||||
|
||||
@@ -23,7 +23,7 @@ from rest_framework.fields import empty
|
||||
from rest_framework.mixins import ListModelMixin
|
||||
from rest_framework.serializers import DecimalField
|
||||
from rest_framework.utils import model_meta
|
||||
from taggit.serializers import TaggitSerializer
|
||||
from taggit.serializers import TaggitSerializer, TagListSerializerField
|
||||
|
||||
import common.models as common_models
|
||||
import InvenTree.ready
|
||||
@@ -211,6 +211,12 @@ class FilterableSerializerMixin:
|
||||
if getattr(self, '_exporting_data', False):
|
||||
return
|
||||
|
||||
# Skip filtering for a write requests - all fields should be present for data creation
|
||||
if request := self.context.get('request', None):
|
||||
if method := getattr(request, 'method', None):
|
||||
if str(method).lower() in ['post', 'put', 'patch']:
|
||||
return
|
||||
|
||||
# Throw out fields which are not requested (either by default or explicitly)
|
||||
for k, v in self.filter_target_values.items():
|
||||
# See `enable_filter` where` is_filterable and is_filterable_vals are set
|
||||
@@ -253,6 +259,13 @@ class FilterableIntegerField(FilterableSerializerField, serializers.IntegerField
|
||||
"""Custom IntegerField which allows filtering."""
|
||||
|
||||
|
||||
class FilterableTagListField(FilterableSerializerField, TagListSerializerField):
|
||||
"""Custom TagListSerializerField which allows filtering."""
|
||||
|
||||
class Meta:
|
||||
"""Empty Meta class."""
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
|
||||
@@ -1238,7 +1238,6 @@ class BuildItemSerializer(
|
||||
filter_name='stock_detail',
|
||||
prefetch_fields=[
|
||||
'stock_item',
|
||||
'stock_item__tags',
|
||||
'stock_item__part',
|
||||
'stock_item__supplier_part',
|
||||
'stock_item__supplier_part__manufacturer_part',
|
||||
@@ -1257,7 +1256,7 @@ class BuildItemSerializer(
|
||||
allow_null=True,
|
||||
),
|
||||
True,
|
||||
prefetch_fields=['stock_item__location', 'stock_item__location__tags'],
|
||||
prefetch_fields=['stock_item__location'],
|
||||
)
|
||||
|
||||
build_detail = enable_filter(
|
||||
@@ -1389,7 +1388,6 @@ class BuildLineSerializer(
|
||||
'allocations__stock_item__supplier_part',
|
||||
'allocations__stock_item__supplier_part__manufacturer_part',
|
||||
'allocations__stock_item__location',
|
||||
'allocations__stock_item__tags',
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -380,3 +380,21 @@ def enable_parameters_filter():
|
||||
'parameters_list__template',
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def enable_tags_filter(default: bool = False):
|
||||
"""Add an optional 'tags' field to an API serializer.
|
||||
|
||||
Arguments:
|
||||
default: If True, enable the filter by default.
|
||||
|
||||
If applied, this field will automatically prefetch the 'tags' relationship.
|
||||
"""
|
||||
from InvenTree.serializers import FilterableTagListField
|
||||
|
||||
return InvenTree.serializers.enable_filter(
|
||||
FilterableTagListField(required=False),
|
||||
default,
|
||||
filter_name='tags',
|
||||
prefetch_fields=['tags', 'tagged_items', 'tagged_items__tag'],
|
||||
)
|
||||
|
||||
@@ -12,8 +12,8 @@ from error_report.models import Error
|
||||
from flags.state import flag_state
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from taggit.serializers import TagListSerializerField
|
||||
|
||||
import common.filters
|
||||
import common.models as common_models
|
||||
import common.validators
|
||||
import generic.states.custom
|
||||
@@ -612,7 +612,7 @@ class FailedTaskSerializer(InvenTreeModelSerializer):
|
||||
result = serializers.CharField()
|
||||
|
||||
|
||||
class AttachmentSerializer(InvenTreeModelSerializer):
|
||||
class AttachmentSerializer(FilterableSerializerMixin, InvenTreeModelSerializer):
|
||||
"""Serializer class for the Attachment model."""
|
||||
|
||||
class Meta:
|
||||
@@ -645,7 +645,7 @@ class AttachmentSerializer(InvenTreeModelSerializer):
|
||||
'model_type'
|
||||
].choices = common.validators.attachment_model_options()
|
||||
|
||||
tags = TagListSerializerField(required=False)
|
||||
tags = common.filters.enable_tags_filter()
|
||||
|
||||
user_detail = UserSerializer(source='upload_user', read_only=True, many=False)
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@ class ManufacturerPartMixin(SerializerContextMixin):
|
||||
"""Return annotated queryset for the ManufacturerPart list endpoint."""
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
|
||||
queryset = queryset.prefetch_related('supplier_parts', 'tags')
|
||||
queryset = queryset.prefetch_related('supplier_parts')
|
||||
|
||||
return queryset
|
||||
|
||||
@@ -323,7 +323,7 @@ class SupplierPartOutputOptions(OutputConfiguration):
|
||||
class SupplierPartMixin:
|
||||
"""Mixin class for SupplierPart API endpoints."""
|
||||
|
||||
queryset = SupplierPart.objects.all().prefetch_related('tags')
|
||||
queryset = SupplierPart.objects.all()
|
||||
serializer_class = SupplierPartSerializer
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
@@ -331,9 +331,7 @@ class SupplierPartMixin:
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
queryset = SupplierPartSerializer.annotate_queryset(queryset)
|
||||
|
||||
queryset = queryset.prefetch_related(
|
||||
'part', 'part__pricing_data', 'manufacturer_part__tags'
|
||||
)
|
||||
queryset = queryset.prefetch_related('part', 'part__pricing_data')
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
@@ -591,23 +591,6 @@ class ManufacturerPart(
|
||||
return s
|
||||
|
||||
|
||||
class SupplierPartManager(models.Manager):
|
||||
"""Define custom SupplierPart objects manager.
|
||||
|
||||
The main purpose of this manager is to improve database hit as the
|
||||
SupplierPart model involves A LOT of foreign keys lookups
|
||||
"""
|
||||
|
||||
def get_queryset(self):
|
||||
"""Prefetch related fields when querying against the SupplierPart model."""
|
||||
# Always prefetch related models
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.prefetch_related('part', 'supplier', 'manufacturer_part__manufacturer')
|
||||
)
|
||||
|
||||
|
||||
class SupplierPart(
|
||||
InvenTree.models.InvenTreeAttachmentMixin,
|
||||
InvenTree.models.InvenTreeParameterMixin,
|
||||
@@ -647,8 +630,6 @@ class SupplierPart(
|
||||
# This model was moved from the 'Part' app
|
||||
db_table = 'part_supplierpart'
|
||||
|
||||
objects = SupplierPartManager()
|
||||
|
||||
tags = TaggableManager(blank=True)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -8,7 +8,6 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework import serializers
|
||||
from sql_util.utils import SubqueryCount
|
||||
from taggit.serializers import TagListSerializerField
|
||||
|
||||
import common.filters
|
||||
import company.filters
|
||||
@@ -260,7 +259,7 @@ class ManufacturerPartSerializer(
|
||||
'parameters',
|
||||
]
|
||||
|
||||
tags = TagListSerializerField(required=False)
|
||||
tags = common.filters.enable_tags_filter()
|
||||
|
||||
parameters = common.filters.enable_parameters_filter()
|
||||
|
||||
@@ -383,7 +382,7 @@ class SupplierPartSerializer(
|
||||
'pack_quantity_native',
|
||||
]
|
||||
|
||||
tags = TagListSerializerField(required=False)
|
||||
tags = common.filters.enable_tags_filter()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize this serializer with extra detail fields as required."""
|
||||
@@ -398,10 +397,9 @@ class SupplierPartSerializer(
|
||||
return
|
||||
|
||||
if brief:
|
||||
self.fields.pop('tags')
|
||||
self.fields.pop('available')
|
||||
self.fields.pop('on_order')
|
||||
self.fields.pop('availability_updated')
|
||||
self.fields.pop('available', None)
|
||||
self.fields.pop('on_order', None)
|
||||
self.fields.pop('availability_updated', None)
|
||||
|
||||
# Annotated field showing total in-stock quantity
|
||||
in_stock = serializers.FloatField(
|
||||
|
||||
@@ -563,7 +563,6 @@ class PurchaseOrderLineItemSerializer(
|
||||
'part__part',
|
||||
'part__part__pricing_data',
|
||||
'part__part__default_location',
|
||||
'part__tags',
|
||||
'part__supplier',
|
||||
'part__manufacturer_part',
|
||||
'part__manufacturer_part__manufacturer',
|
||||
|
||||
@@ -1060,6 +1060,7 @@ class PartOutputOptions(OutputConfiguration):
|
||||
InvenTreeOutputOption('location_detail'),
|
||||
InvenTreeOutputOption('path_detail'),
|
||||
InvenTreeOutputOption('price_breaks'),
|
||||
InvenTreeOutputOption('tags'),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -347,29 +347,6 @@ def rename_part_image(instance, filename):
|
||||
return os.path.join(base, fname)
|
||||
|
||||
|
||||
class PartManager(TreeManager):
|
||||
"""Defines a custom object manager for the Part model.
|
||||
|
||||
The main purpose of this manager is to reduce the number of database hits,
|
||||
as the Part model has a large number of ForeignKey fields!
|
||||
"""
|
||||
|
||||
def get_queryset(self):
|
||||
"""Perform default prefetch operations when accessing Part model from the database."""
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.prefetch_related(
|
||||
'category',
|
||||
'pricing_data',
|
||||
'category__parent',
|
||||
'stock_items',
|
||||
'builds',
|
||||
'tags',
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class PartCategoryParameterTemplate(InvenTree.models.InvenTreeMetadataModel):
|
||||
"""A PartCategoryParameterTemplate creates a unique relationship between a PartCategory and a ParameterTemplate.
|
||||
|
||||
@@ -540,7 +517,7 @@ class Part(
|
||||
NODE_PARENT_KEY = 'variant_of'
|
||||
IMAGE_RENAME = rename_part_image
|
||||
|
||||
objects = PartManager()
|
||||
objects = TreeManager()
|
||||
|
||||
tags = TaggableManager(blank=True)
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ from djmoney.contrib.exchange.models import convert_money
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
from sql_util.utils import SubqueryCount
|
||||
from taggit.serializers import TagListSerializerField
|
||||
|
||||
import common.currency
|
||||
import common.filters
|
||||
@@ -633,8 +632,6 @@ class PartSerializer(
|
||||
]
|
||||
read_only_fields = ['barcode_hash', 'creation_date', 'creation_user']
|
||||
|
||||
tags = TagListSerializerField(required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Custom initialization method for PartSerializer.
|
||||
|
||||
@@ -910,6 +907,8 @@ class PartSerializer(
|
||||
|
||||
parameters = common.filters.enable_parameters_filter()
|
||||
|
||||
tags = common.filters.enable_tags_filter()
|
||||
|
||||
price_breaks = enable_filter(
|
||||
PartSalePriceSerializer(
|
||||
source='salepricebreaks', many=True, read_only=True, allow_null=True
|
||||
|
||||
@@ -1734,8 +1734,6 @@ class PartDetailTests(PartImageTestMixin, PartAPITestBase):
|
||||
# Now, try to set the name to the *same* value
|
||||
# 2021-06-22 this test is to check that the "duplicate part" checks don't do strange things
|
||||
response = self.patch(url, {'name': 'a new better name'})
|
||||
|
||||
# Try to remove a tag
|
||||
response = self.patch(url, {'tags': ['tag1']})
|
||||
self.assertEqual(response.data['tags'], ['tag1'])
|
||||
|
||||
@@ -2051,8 +2049,7 @@ class PartListTests(PartAPITestBase):
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
self.get(url, query, expected_code=200)
|
||||
|
||||
# No more than 25 database queries
|
||||
self.assertLess(len(ctx), 25)
|
||||
self.assertLess(len(ctx), 30)
|
||||
|
||||
# Test 'category_detail' annotation
|
||||
for b in [False, True]:
|
||||
@@ -2065,8 +2062,7 @@ class PartListTests(PartAPITestBase):
|
||||
if b and result['category'] is not None:
|
||||
self.assertIn('category_detail', result)
|
||||
|
||||
# No more than 25 DB queries
|
||||
self.assertLessEqual(len(ctx), 25)
|
||||
self.assertLessEqual(len(ctx), 30)
|
||||
|
||||
def test_price_breaks(self):
|
||||
"""Test that price_breaks parameter works correctly and efficiently."""
|
||||
|
||||
@@ -157,7 +157,7 @@ class CategoryTest(TestCase):
|
||||
def test_parameters(self):
|
||||
"""Test that the Category parameters are correctly fetched."""
|
||||
# Check number of SQL queries to iterate other parameters
|
||||
with self.assertNumQueries(9):
|
||||
with self.assertNumQueries(3):
|
||||
# Prefetch: 3 queries (parts, parameters and parameters_template)
|
||||
fasteners = self.fasteners.prefetch_parts_parameters()
|
||||
# Iterate through all parts and parameters
|
||||
|
||||
@@ -100,21 +100,6 @@ class StockLocationType(InvenTree.models.MetadataMixin, models.Model):
|
||||
)
|
||||
|
||||
|
||||
class StockLocationManager(TreeManager):
|
||||
"""Custom database manager for the StockLocation class.
|
||||
|
||||
StockLocation querysets will automatically select related fields for performance.
|
||||
"""
|
||||
|
||||
def get_queryset(self):
|
||||
"""Prefetch queryset to optimize db hits.
|
||||
|
||||
- Joins the StockLocationType by default for speedier icon access
|
||||
"""
|
||||
# return super().get_queryset().select_related("location_type")
|
||||
return super().get_queryset()
|
||||
|
||||
|
||||
class StockLocationReportContext(report.mixins.BaseReportContext):
|
||||
"""Report context for the StockLocation model.
|
||||
|
||||
@@ -151,7 +136,7 @@ class StockLocation(
|
||||
|
||||
EXTRA_PATH_FIELDS = ['icon']
|
||||
|
||||
objects = StockLocationManager()
|
||||
objects = TreeManager()
|
||||
|
||||
class Meta:
|
||||
"""Metaclass defines extra model properties."""
|
||||
|
||||
@@ -14,9 +14,9 @@ from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
from rest_framework.serializers import ValidationError
|
||||
from sql_util.utils import SubqueryCount, SubquerySum
|
||||
from taggit.serializers import TagListSerializerField
|
||||
|
||||
import build.models
|
||||
import common.filters
|
||||
import company.models
|
||||
import company.serializers as company_serializers
|
||||
import InvenTree.helpers
|
||||
@@ -496,7 +496,6 @@ class StockItemSerializer(
|
||||
'belongs_to',
|
||||
'sales_order',
|
||||
'consumed_by',
|
||||
'tags',
|
||||
).select_related('part')
|
||||
|
||||
# Annotate the queryset with the total allocated to sales orders
|
||||
@@ -579,10 +578,8 @@ class StockItemSerializer(
|
||||
False,
|
||||
prefetch_fields=[
|
||||
'supplier_part__supplier',
|
||||
'supplier_part__manufacturer_part__manufacturer',
|
||||
'supplier_part__manufacturer_part__tags',
|
||||
'supplier_part__purchase_order_line_items',
|
||||
'supplier_part__tags',
|
||||
'supplier_part__manufacturer_part__manufacturer',
|
||||
],
|
||||
)
|
||||
|
||||
@@ -655,7 +652,7 @@ class StockItemSerializer(
|
||||
source='sales_order.reference', read_only=True, allow_null=True
|
||||
)
|
||||
|
||||
tags = TagListSerializerField(required=False)
|
||||
tags = common.filters.enable_tags_filter()
|
||||
|
||||
|
||||
class SerializeStockItemSerializer(serializers.Serializer):
|
||||
@@ -1196,7 +1193,7 @@ class LocationSerializer(
|
||||
|
||||
level = serializers.IntegerField(read_only=True)
|
||||
|
||||
tags = TagListSerializerField(required=False)
|
||||
tags = common.filters.enable_tags_filter()
|
||||
|
||||
path = enable_filter(
|
||||
FilterableListField(
|
||||
|
||||
Reference in New Issue
Block a user