2
0
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:
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
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)

View File

@@ -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

View File

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

View File

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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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(

View File

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

View File

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

View File

@@ -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)

View File

@@ -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

View File

@@ -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."""

View File

@@ -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

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):
"""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."""

View File

@@ -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(