2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-02-06 13:25:53 +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:
Oliver
2025-12-17 07:14:56 +11:00
committed by GitHub
parent 2eccf13c93
commit 140c65b26c
16 changed files with 61 additions and 97 deletions

View File

@@ -1,11 +1,15 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 v433 -> 2025-12-16 : https://github.com/inventree/InvenTree/pull/11023
- "substitutes" field on the BomItem API endpoint is now excluded by default - "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) - Add "?substitutes=true" query parameter to include substitute parts in BomItem API endpoint(s)

View File

@@ -23,7 +23,7 @@ from rest_framework.fields import empty
from rest_framework.mixins import ListModelMixin from rest_framework.mixins import ListModelMixin
from rest_framework.serializers import DecimalField from rest_framework.serializers import DecimalField
from rest_framework.utils import model_meta 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 common.models as common_models
import InvenTree.ready import InvenTree.ready
@@ -211,6 +211,12 @@ class FilterableSerializerMixin:
if getattr(self, '_exporting_data', False): if getattr(self, '_exporting_data', False):
return 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) # Throw out fields which are not requested (either by default or explicitly)
for k, v in self.filter_target_values.items(): for k, v in self.filter_target_values.items():
# See `enable_filter` where` is_filterable and is_filterable_vals are set # 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.""" """Custom IntegerField which allows filtering."""
class FilterableTagListField(FilterableSerializerField, TagListSerializerField):
"""Custom TagListSerializerField which allows filtering."""
class Meta:
"""Empty Meta class."""
# endregion # endregion

View File

@@ -1238,7 +1238,6 @@ class BuildItemSerializer(
filter_name='stock_detail', filter_name='stock_detail',
prefetch_fields=[ prefetch_fields=[
'stock_item', 'stock_item',
'stock_item__tags',
'stock_item__part', 'stock_item__part',
'stock_item__supplier_part', 'stock_item__supplier_part',
'stock_item__supplier_part__manufacturer_part', 'stock_item__supplier_part__manufacturer_part',
@@ -1257,7 +1256,7 @@ class BuildItemSerializer(
allow_null=True, allow_null=True,
), ),
True, True,
prefetch_fields=['stock_item__location', 'stock_item__location__tags'], prefetch_fields=['stock_item__location'],
) )
build_detail = enable_filter( build_detail = enable_filter(
@@ -1389,7 +1388,6 @@ class BuildLineSerializer(
'allocations__stock_item__supplier_part', 'allocations__stock_item__supplier_part',
'allocations__stock_item__supplier_part__manufacturer_part', 'allocations__stock_item__supplier_part__manufacturer_part',
'allocations__stock_item__location', 'allocations__stock_item__location',
'allocations__stock_item__tags',
], ],
) )

View File

@@ -380,3 +380,21 @@ def enable_parameters_filter():
'parameters_list__template', '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'],
)

View File

@@ -12,8 +12,8 @@ from error_report.models import Error
from flags.state import flag_state from flags.state import flag_state
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from taggit.serializers import TagListSerializerField
import common.filters
import common.models as common_models import common.models as common_models
import common.validators import common.validators
import generic.states.custom import generic.states.custom
@@ -612,7 +612,7 @@ class FailedTaskSerializer(InvenTreeModelSerializer):
result = serializers.CharField() result = serializers.CharField()
class AttachmentSerializer(InvenTreeModelSerializer): class AttachmentSerializer(FilterableSerializerMixin, InvenTreeModelSerializer):
"""Serializer class for the Attachment model.""" """Serializer class for the Attachment model."""
class Meta: class Meta:
@@ -645,7 +645,7 @@ class AttachmentSerializer(InvenTreeModelSerializer):
'model_type' 'model_type'
].choices = common.validators.attachment_model_options() ].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) user_detail = UserSerializer(source='upload_user', read_only=True, many=False)

View File

@@ -178,7 +178,7 @@ class ManufacturerPartMixin(SerializerContextMixin):
"""Return annotated queryset for the ManufacturerPart list endpoint.""" """Return annotated queryset for the ManufacturerPart list endpoint."""
queryset = super().get_queryset(*args, **kwargs) queryset = super().get_queryset(*args, **kwargs)
queryset = queryset.prefetch_related('supplier_parts', 'tags') queryset = queryset.prefetch_related('supplier_parts')
return queryset return queryset
@@ -323,7 +323,7 @@ class SupplierPartOutputOptions(OutputConfiguration):
class SupplierPartMixin: class SupplierPartMixin:
"""Mixin class for SupplierPart API endpoints.""" """Mixin class for SupplierPart API endpoints."""
queryset = SupplierPart.objects.all().prefetch_related('tags') queryset = SupplierPart.objects.all()
serializer_class = SupplierPartSerializer serializer_class = SupplierPartSerializer
def get_queryset(self, *args, **kwargs): def get_queryset(self, *args, **kwargs):
@@ -331,9 +331,7 @@ class SupplierPartMixin:
queryset = super().get_queryset(*args, **kwargs) queryset = super().get_queryset(*args, **kwargs)
queryset = SupplierPartSerializer.annotate_queryset(queryset) queryset = SupplierPartSerializer.annotate_queryset(queryset)
queryset = queryset.prefetch_related( queryset = queryset.prefetch_related('part', 'part__pricing_data')
'part', 'part__pricing_data', 'manufacturer_part__tags'
)
return queryset return queryset

View File

@@ -591,23 +591,6 @@ class ManufacturerPart(
return s 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( class SupplierPart(
InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeAttachmentMixin,
InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeParameterMixin,
@@ -647,8 +630,6 @@ class SupplierPart(
# This model was moved from the 'Part' app # This model was moved from the 'Part' app
db_table = 'part_supplierpart' db_table = 'part_supplierpart'
objects = SupplierPartManager()
tags = TaggableManager(blank=True) tags = TaggableManager(blank=True)
@staticmethod @staticmethod

View File

@@ -8,7 +8,6 @@ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from sql_util.utils import SubqueryCount from sql_util.utils import SubqueryCount
from taggit.serializers import TagListSerializerField
import common.filters import common.filters
import company.filters import company.filters
@@ -260,7 +259,7 @@ class ManufacturerPartSerializer(
'parameters', 'parameters',
] ]
tags = TagListSerializerField(required=False) tags = common.filters.enable_tags_filter()
parameters = common.filters.enable_parameters_filter() parameters = common.filters.enable_parameters_filter()
@@ -383,7 +382,7 @@ class SupplierPartSerializer(
'pack_quantity_native', 'pack_quantity_native',
] ]
tags = TagListSerializerField(required=False) tags = common.filters.enable_tags_filter()
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Initialize this serializer with extra detail fields as required.""" """Initialize this serializer with extra detail fields as required."""
@@ -398,10 +397,9 @@ class SupplierPartSerializer(
return return
if brief: if brief:
self.fields.pop('tags') self.fields.pop('available', None)
self.fields.pop('available') self.fields.pop('on_order', None)
self.fields.pop('on_order') self.fields.pop('availability_updated', None)
self.fields.pop('availability_updated')
# Annotated field showing total in-stock quantity # Annotated field showing total in-stock quantity
in_stock = serializers.FloatField( in_stock = serializers.FloatField(

View File

@@ -563,7 +563,6 @@ class PurchaseOrderLineItemSerializer(
'part__part', 'part__part',
'part__part__pricing_data', 'part__part__pricing_data',
'part__part__default_location', 'part__part__default_location',
'part__tags',
'part__supplier', 'part__supplier',
'part__manufacturer_part', 'part__manufacturer_part',
'part__manufacturer_part__manufacturer', 'part__manufacturer_part__manufacturer',

View File

@@ -1060,6 +1060,7 @@ class PartOutputOptions(OutputConfiguration):
InvenTreeOutputOption('location_detail'), InvenTreeOutputOption('location_detail'),
InvenTreeOutputOption('path_detail'), InvenTreeOutputOption('path_detail'),
InvenTreeOutputOption('price_breaks'), InvenTreeOutputOption('price_breaks'),
InvenTreeOutputOption('tags'),
] ]

View File

@@ -347,29 +347,6 @@ def rename_part_image(instance, filename):
return os.path.join(base, fname) 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): class PartCategoryParameterTemplate(InvenTree.models.InvenTreeMetadataModel):
"""A PartCategoryParameterTemplate creates a unique relationship between a PartCategory and a ParameterTemplate. """A PartCategoryParameterTemplate creates a unique relationship between a PartCategory and a ParameterTemplate.
@@ -540,7 +517,7 @@ class Part(
NODE_PARENT_KEY = 'variant_of' NODE_PARENT_KEY = 'variant_of'
IMAGE_RENAME = rename_part_image IMAGE_RENAME = rename_part_image
objects = PartManager() objects = TreeManager()
tags = TaggableManager(blank=True) tags = TaggableManager(blank=True)

View File

@@ -19,7 +19,6 @@ from djmoney.contrib.exchange.models import convert_money
from drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from sql_util.utils import SubqueryCount from sql_util.utils import SubqueryCount
from taggit.serializers import TagListSerializerField
import common.currency import common.currency
import common.filters import common.filters
@@ -633,8 +632,6 @@ class PartSerializer(
] ]
read_only_fields = ['barcode_hash', 'creation_date', 'creation_user'] read_only_fields = ['barcode_hash', 'creation_date', 'creation_user']
tags = TagListSerializerField(required=False)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Custom initialization method for PartSerializer. """Custom initialization method for PartSerializer.
@@ -910,6 +907,8 @@ class PartSerializer(
parameters = common.filters.enable_parameters_filter() parameters = common.filters.enable_parameters_filter()
tags = common.filters.enable_tags_filter()
price_breaks = enable_filter( price_breaks = enable_filter(
PartSalePriceSerializer( PartSalePriceSerializer(
source='salepricebreaks', many=True, read_only=True, allow_null=True source='salepricebreaks', many=True, read_only=True, allow_null=True

View File

@@ -1734,8 +1734,6 @@ class PartDetailTests(PartImageTestMixin, PartAPITestBase):
# Now, try to set the name to the *same* value # 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 # 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'}) response = self.patch(url, {'name': 'a new better name'})
# Try to remove a tag
response = self.patch(url, {'tags': ['tag1']}) response = self.patch(url, {'tags': ['tag1']})
self.assertEqual(response.data['tags'], ['tag1']) self.assertEqual(response.data['tags'], ['tag1'])
@@ -2051,8 +2049,7 @@ class PartListTests(PartAPITestBase):
with CaptureQueriesContext(connection) as ctx: with CaptureQueriesContext(connection) as ctx:
self.get(url, query, expected_code=200) self.get(url, query, expected_code=200)
# No more than 25 database queries self.assertLess(len(ctx), 30)
self.assertLess(len(ctx), 25)
# Test 'category_detail' annotation # Test 'category_detail' annotation
for b in [False, True]: for b in [False, True]:
@@ -2065,8 +2062,7 @@ class PartListTests(PartAPITestBase):
if b and result['category'] is not None: if b and result['category'] is not None:
self.assertIn('category_detail', result) self.assertIn('category_detail', result)
# No more than 25 DB queries self.assertLessEqual(len(ctx), 30)
self.assertLessEqual(len(ctx), 25)
def test_price_breaks(self): def test_price_breaks(self):
"""Test that price_breaks parameter works correctly and efficiently.""" """Test that price_breaks parameter works correctly and efficiently."""

View File

@@ -157,7 +157,7 @@ class CategoryTest(TestCase):
def test_parameters(self): def test_parameters(self):
"""Test that the Category parameters are correctly fetched.""" """Test that the Category parameters are correctly fetched."""
# Check number of SQL queries to iterate other parameters # 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) # Prefetch: 3 queries (parts, parameters and parameters_template)
fasteners = self.fasteners.prefetch_parts_parameters() fasteners = self.fasteners.prefetch_parts_parameters()
# Iterate through all parts and parameters # Iterate through all parts and parameters

View File

@@ -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): class StockLocationReportContext(report.mixins.BaseReportContext):
"""Report context for the StockLocation model. """Report context for the StockLocation model.
@@ -151,7 +136,7 @@ class StockLocation(
EXTRA_PATH_FIELDS = ['icon'] EXTRA_PATH_FIELDS = ['icon']
objects = StockLocationManager() objects = TreeManager()
class Meta: class Meta:
"""Metaclass defines extra model properties.""" """Metaclass defines extra model properties."""

View File

@@ -14,9 +14,9 @@ from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
from sql_util.utils import SubqueryCount, SubquerySum from sql_util.utils import SubqueryCount, SubquerySum
from taggit.serializers import TagListSerializerField
import build.models import build.models
import common.filters
import company.models import company.models
import company.serializers as company_serializers import company.serializers as company_serializers
import InvenTree.helpers import InvenTree.helpers
@@ -496,7 +496,6 @@ class StockItemSerializer(
'belongs_to', 'belongs_to',
'sales_order', 'sales_order',
'consumed_by', 'consumed_by',
'tags',
).select_related('part') ).select_related('part')
# Annotate the queryset with the total allocated to sales orders # Annotate the queryset with the total allocated to sales orders
@@ -579,10 +578,8 @@ class StockItemSerializer(
False, False,
prefetch_fields=[ prefetch_fields=[
'supplier_part__supplier', 'supplier_part__supplier',
'supplier_part__manufacturer_part__manufacturer',
'supplier_part__manufacturer_part__tags',
'supplier_part__purchase_order_line_items', '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 source='sales_order.reference', read_only=True, allow_null=True
) )
tags = TagListSerializerField(required=False) tags = common.filters.enable_tags_filter()
class SerializeStockItemSerializer(serializers.Serializer): class SerializeStockItemSerializer(serializers.Serializer):
@@ -1196,7 +1193,7 @@ class LocationSerializer(
level = serializers.IntegerField(read_only=True) level = serializers.IntegerField(read_only=True)
tags = TagListSerializerField(required=False) tags = common.filters.enable_tags_filter()
path = enable_filter( path = enable_filter(
FilterableListField( FilterableListField(