diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index bf38c91e6a..d918dc2b06 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,13 +1,17 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 320 +INVENTREE_API_VERSION = 321 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v321 - 2025-03-06 : https://github.com/inventree/InvenTree/pull/9236 + - Adds conditionally-returned fields to the schema to match API behavior + - Removes required flag for nullable read-only fields to match API behavior + v320 - 2025-03-05 : https://github.com/inventree/InvenTree/pull/9243 - Link fields are now up to 2000 chars long diff --git a/src/backend/InvenTree/InvenTree/ready.py b/src/backend/InvenTree/InvenTree/ready.py index 53a456cf50..9d35445cd4 100644 --- a/src/backend/InvenTree/InvenTree/ready.py +++ b/src/backend/InvenTree/InvenTree/ready.py @@ -1,5 +1,6 @@ """Functions to check if certain parts of InvenTree are ready.""" +import inspect import os import sys @@ -44,6 +45,14 @@ def isRunningBackup(): ) +def isGeneratingSchema(): + """Return true if schema generation is being executed.""" + if 'schema' in sys.argv: + return True + + return any('drf_spectacular' in frame.filename for frame in inspect.stack()) + + def isInWorkerThread(): """Returns True if the current thread is a background worker thread.""" return 'qcluster' in sys.argv diff --git a/src/backend/InvenTree/InvenTree/schema.py b/src/backend/InvenTree/InvenTree/schema.py new file mode 100644 index 0000000000..2f8976f4c6 --- /dev/null +++ b/src/backend/InvenTree/InvenTree/schema.py @@ -0,0 +1,23 @@ +"""Schema processing functions for cleaning up generated schema.""" + + +def postprocess_required_nullable(result, generator, request, public): + """Un-require nullable fields. + + Read-only values are all marked as required by spectacular, but InvenTree doesn't always include them in the response. This removes them from the required list to allow responses lacking read-only nullable fields to validate against the schema. + """ + # Process schema section + schemas = result.get('components', {}).get('schemas', {}) + for schema in schemas.values(): + required_fields = schema.get('required', []) + properties = schema.get('properties', {}) + + # copy list to allow removing from it while iterating + for field in list(required_fields): + field_dict = properties.get(field, {}) + if field_dict.get('readOnly') and field_dict.get('nullable'): + required_fields.remove(field) + if 'required' in schema and len(required_fields) == 0: + schema.pop('required') + + return result diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py index d4d2b6f0e4..4d6c84f082 100644 --- a/src/backend/InvenTree/InvenTree/serializers.py +++ b/src/backend/InvenTree/InvenTree/serializers.py @@ -22,6 +22,7 @@ from rest_framework.utils import model_meta from taggit.serializers import TaggitSerializer import common.models as common_models +import InvenTree.ready from common.currency import currency_code_default, currency_code_mappings from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField @@ -468,7 +469,10 @@ class NotesFieldMixin: if hasattr(self, 'context'): if view := self.context.get('view', None): - if issubclass(view.__class__, ListModelMixin): + if ( + issubclass(view.__class__, ListModelMixin) + and not InvenTree.ready.isGeneratingSchema() + ): self.fields.pop('notes', None) diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index cd1675c795..7c98d8c5b7 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -1423,6 +1423,10 @@ SPECTACULAR_SETTINGS = { 'VERSION': str(inventreeApiVersion()), 'SERVE_INCLUDE_SCHEMA': False, 'SCHEMA_PATH_PREFIX': '/api/', + 'POSTPROCESSING_HOOKS': [ + 'drf_spectacular.hooks.postprocess_schema_enums', + 'InvenTree.schema.postprocess_required_nullable', + ], } if SITE_URL and not TESTING: diff --git a/src/backend/InvenTree/InvenTree/tests.py b/src/backend/InvenTree/InvenTree/tests.py index bb06fad50f..a428647161 100644 --- a/src/backend/InvenTree/InvenTree/tests.py +++ b/src/backend/InvenTree/InvenTree/tests.py @@ -37,7 +37,7 @@ from InvenTree.unit_test import InvenTreeTestCase, in_env_context from part.models import Part, PartCategory from stock.models import StockItem, StockLocation -from . import config, helpers, ready, status, version +from . import config, helpers, ready, schema, status, version from .tasks import offload_task from .validators import validate_overage @@ -1116,6 +1116,10 @@ class TestStatus(TestCase): """Test isImportingData check.""" self.assertEqual(ready.isImportingData(), False) + def test_GeneratingSchema(self): + """Test isGeneratingSchema check.""" + self.assertEqual(ready.isGeneratingSchema(), False) + class TestSettings(InvenTreeTestCase): """Unit tests for settings.""" @@ -1631,3 +1635,62 @@ class ClassProviderMixinTest(TestCase): def test_get_is_builtin(self): """Test the get_is_builtin function.""" self.assertTrue(self.TestClass.get_is_builtin()) + + +class SchemaPostprocessingTest(TestCase): + """Tests for schema postprocessing functions.""" + + def create_result_structure(self): + """Create a schema dict structure representative of the spectacular-generated on.""" + return { + 'openapi': {}, + 'info': {}, + 'paths': {}, + 'components': { + 'examples': {}, + 'parameters': {}, + 'requestBodies': {}, + 'responses': {}, + 'schemas': {}, + 'securitySchemes': {}, + }, + 'servers': {}, + 'externalDocs': {}, + } + + def test_postprocess_required_nullable(self): + """Verify that only selected elements are removed from required list.""" + result_in = self.create_result_structure() + schemas_in = result_in.get('components').get('schemas') + + schemas_in['SalesOrder'] = { + 'properties': { + 'pk': {'type': 'integer', 'readOnly': True, 'title': 'ID'}, + 'customer_detail': { + 'allOf': [{'$ref': '#/components/schemas/CompanyBrief'}], + 'readOnly': True, + 'nullable': True, + }, + }, + 'required': ['customer_detail', 'pk'], + } + + schemas_in['SalesOrderShipment'] = { + 'properties': { + 'order_detail': { + 'allOf': [{'$ref': '#/components/schemas/SalesOrder'}], + 'readOnly': True, + 'nullable': True, + } + }, + 'required': ['order_detail'], + } + + result_out = schema.postprocess_required_nullable(result_in, {}, {}, {}) + schemas_out = result_out.get('components').get('schemas') + + # only intended elements removed (read-only, required, and object type) + self.assertIn('pk', schemas_out.get('SalesOrder')['required']) + self.assertNotIn('customer_detail', schemas_out.get('SalesOrder')['required']) + # required key removed when empty + self.assertNotIn('required', schemas_out.get('SalesOrderShipment')) diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 33ee775153..afc21240b4 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -32,6 +32,7 @@ from common.serializers import ProjectCodeSerializer from common.settings import get_global_setting from generic.states.fields import InvenTreeCustomStatusSerializerMixin from importer.mixins import DataImportExportSerializerMixin +from InvenTree.ready import isGeneratingSchema from InvenTree.serializers import ( InvenTreeDecimalField, InvenTreeModelSerializer, @@ -126,7 +127,9 @@ class BuildSerializer( issued_by_detail = UserSerializer(source='issued_by', read_only=True) - responsible_detail = OwnerSerializer(source='responsible', read_only=True) + responsible_detail = OwnerSerializer( + source='responsible', read_only=True, allow_null=True + ) barcode_hash = serializers.CharField(read_only=True) @@ -177,6 +180,9 @@ class BuildSerializer( super().__init__(*args, **kwargs) + if isGeneratingSchema(): + return + if not create: self.fields.pop('create_child_builds', None) @@ -1204,6 +1210,9 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali super().__init__(*args, **kwargs) + if isGeneratingSchema(): + return + if not part_detail: self.fields.pop('part_detail', None) @@ -1246,11 +1255,12 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali source='stock_item.part', many=False, read_only=True, + allow_null=True, pricing=False, ) stock_item_detail = StockItemSerializerBrief( - source='stock_item', read_only=True, label=_('Stock Item') + source='stock_item', read_only=True, allow_null=True, label=_('Stock Item') ) location = serializers.PrimaryKeyRelatedField( @@ -1258,11 +1268,18 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali ) location_detail = LocationBriefSerializer( - label=_('Location'), source='stock_item.location', read_only=True + label=_('Location'), + source='stock_item.location', + read_only=True, + allow_null=True, ) build_detail = BuildSerializer( - label=_('Build'), source='build_line.build', many=False, read_only=True + label=_('Build'), + source='build_line.build', + many=False, + read_only=True, + allow_null=True, ) supplier_part_detail = company.serializers.SupplierPartSerializer( @@ -1270,6 +1287,7 @@ class BuildItemSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali source='stock_item.supplier_part', many=False, read_only=True, + allow_null=True, brief=True, ) @@ -1339,6 +1357,9 @@ class BuildLineSerializer(DataImportExportSerializerMixin, InvenTreeModelSeriali super().__init__(*args, **kwargs) + if isGeneratingSchema(): + return + if not part_detail: self.fields.pop('part_detail', None) diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index 48b11e1816..5410437abd 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -333,7 +333,9 @@ class ProjectCodeSerializer(DataImportExportSerializerMixin, InvenTreeModelSeria model = common_models.ProjectCode fields = ['pk', 'code', 'description', 'responsible', 'responsible_detail'] - responsible_detail = OwnerSerializer(source='responsible', read_only=True) + responsible_detail = OwnerSerializer( + source='responsible', read_only=True, allow_null=True + ) @register_importer() diff --git a/src/backend/InvenTree/company/serializers.py b/src/backend/InvenTree/company/serializers.py index edc6c12a13..dd8ecc673e 100644 --- a/src/backend/InvenTree/company/serializers.py +++ b/src/backend/InvenTree/company/serializers.py @@ -14,6 +14,7 @@ import part.filters import part.serializers as part_serializers from importer.mixins import DataImportExportSerializerMixin from importer.registry import register_importer +from InvenTree.ready import isGeneratingSchema from InvenTree.serializers import ( InvenTreeCurrencySerializer, InvenTreeDecimalField, @@ -256,6 +257,9 @@ class ManufacturerPartSerializer( super().__init__(*args, **kwargs) + if isGeneratingSchema(): + return + if part_detail is not True: self.fields.pop('part_detail', None) @@ -266,14 +270,14 @@ class ManufacturerPartSerializer( self.fields.pop('pretty_name', None) part_detail = part_serializers.PartBriefSerializer( - source='part', many=False, read_only=True + source='part', many=False, read_only=True, allow_null=True ) manufacturer_detail = CompanyBriefSerializer( - source='manufacturer', many=False, read_only=True + source='manufacturer', many=False, read_only=True, allow_null=True ) - pretty_name = serializers.CharField(read_only=True) + pretty_name = serializers.CharField(read_only=True, allow_null=True) manufacturer = serializers.PrimaryKeyRelatedField( queryset=Company.objects.filter(is_manufacturer=True) @@ -306,11 +310,11 @@ class ManufacturerPartParameterSerializer( super().__init__(*args, **kwargs) - if not man_detail: + if not man_detail and not isGeneratingSchema(): self.fields.pop('manufacturer_part_detail', None) manufacturer_part_detail = ManufacturerPartSerializer( - source='manufacturer_part', many=False, read_only=True + source='manufacturer_part', many=False, read_only=True, allow_null=True ) @@ -389,6 +393,9 @@ class SupplierPartSerializer( super().__init__(*args, **kwargs) + if isGeneratingSchema(): + return + if part_detail is not True: self.fields.pop('part_detail', None) @@ -409,20 +416,28 @@ class SupplierPartSerializer( self.fields.pop('availability_updated') # Annotated field showing total in-stock quantity - in_stock = serializers.FloatField(read_only=True, label=_('In Stock')) + in_stock = serializers.FloatField( + read_only=True, allow_null=True, label=_('In Stock') + ) - on_order = serializers.FloatField(read_only=True, label=_('On Order')) + on_order = serializers.FloatField( + read_only=True, allow_null=True, label=_('On Order') + ) available = serializers.FloatField(required=False, label=_('Available')) pack_quantity_native = serializers.FloatField(read_only=True) part_detail = part_serializers.PartBriefSerializer( - label=_('Part'), source='part', many=False, read_only=True + label=_('Part'), source='part', many=False, read_only=True, allow_null=True ) supplier_detail = CompanyBriefSerializer( - label=_('Supplier'), source='supplier', many=False, read_only=True + label=_('Supplier'), + source='supplier', + many=False, + read_only=True, + allow_null=True, ) manufacturer_detail = CompanyBriefSerializer( @@ -430,9 +445,10 @@ class SupplierPartSerializer( source='manufacturer_part.manufacturer', many=False, read_only=True, + allow_null=True, ) - pretty_name = serializers.CharField(read_only=True) + pretty_name = serializers.CharField(read_only=True, allow_null=True) supplier = serializers.PrimaryKeyRelatedField( label=_('Supplier'), queryset=Company.objects.filter(is_supplier=True) @@ -443,6 +459,7 @@ class SupplierPartSerializer( source='manufacturer_part', part_detail=False, read_only=True, + allow_null=True, ) MPN = serializers.CharField( @@ -529,6 +546,9 @@ class SupplierPriceBreakSerializer( super().__init__(*args, **kwargs) + if isGeneratingSchema(): + return + if not supplier_detail: self.fields.pop('supplier_detail', None) @@ -553,10 +573,10 @@ class SupplierPriceBreakSerializer( ) supplier_detail = CompanyBriefSerializer( - source='part.supplier', many=False, read_only=True + source='part.supplier', many=False, read_only=True, allow_null=True ) # Detail serializer for SupplierPart part_detail = SupplierPartSerializer( - source='part', brief=True, many=False, read_only=True + source='part', brief=True, many=False, read_only=True, allow_null=True ) diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index 33128d1ae9..5e105f4f5a 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -44,6 +44,7 @@ from InvenTree.helpers import ( normalize, str2bool, ) +from InvenTree.ready import isGeneratingSchema from InvenTree.serializers import ( InvenTreeCurrencySerializer, InvenTreeDecimalField, @@ -128,11 +129,13 @@ class AbstractOrderSerializer(DataImportExportSerializerMixin, serializers.Seria reference = serializers.CharField(required=True) # Detail for point-of-contact field - contact_detail = ContactSerializer(source='contact', many=False, read_only=True) + contact_detail = ContactSerializer( + source='contact', many=False, read_only=True, allow_null=True + ) # Detail for responsible field responsible_detail = OwnerSerializer( - source='responsible', read_only=True, many=False + source='responsible', read_only=True, allow_null=True, many=False ) project_code_label = serializers.CharField( @@ -149,7 +152,7 @@ class AbstractOrderSerializer(DataImportExportSerializerMixin, serializers.Seria # Detail for address field address_detail = AddressBriefSerializer( - source='address', many=False, read_only=True + source='address', many=False, read_only=True, allow_null=True ) # Boolean field indicating if this order is overdue (Note: must be annotated) @@ -275,7 +278,7 @@ class AbstractExtraLineSerializer( super().__init__(*args, **kwargs) - if order_detail is not True: + if order_detail is not True and not isGeneratingSchema(): self.fields.pop('order_detail', None) quantity = serializers.FloatField() @@ -343,7 +346,7 @@ class PurchaseOrderSerializer( super().__init__(*args, **kwargs) - if supplier_detail is not True: + if supplier_detail is not True and not isGeneratingSchema(): self.fields.pop('supplier_detail', None) def skip_create_fields(self): @@ -384,7 +387,7 @@ class PurchaseOrderSerializer( ) supplier_detail = CompanyBriefSerializer( - source='supplier', many=False, read_only=True + source='supplier', many=False, read_only=True, allow_null=True ) @@ -518,6 +521,9 @@ class PurchaseOrderLineItemSerializer( super().__init__(*args, **kwargs) + if isGeneratingSchema(): + return + if part_detail is not True: self.fields.pop('part_detail', None) self.fields.pop('supplier_part_detail', None) @@ -608,11 +614,11 @@ class PurchaseOrderLineItemSerializer( total_price = serializers.FloatField(read_only=True) part_detail = PartBriefSerializer( - source='get_base_part', many=False, read_only=True + source='get_base_part', many=False, read_only=True, allow_null=True ) supplier_part_detail = SupplierPartSerializer( - source='part', brief=True, many=False, read_only=True + source='part', brief=True, many=False, read_only=True, allow_null=True ) purchase_price = InvenTreeMoneySerializer(allow_null=True) @@ -633,7 +639,9 @@ class PurchaseOrderLineItemSerializer( help_text=_('Purchase price currency') ) - order_detail = PurchaseOrderSerializer(source='order', read_only=True, many=False) + order_detail = PurchaseOrderSerializer( + source='order', read_only=True, allow_null=True, many=False + ) merge_items = serializers.BooleanField( label=_('Merge Items'), @@ -699,7 +707,9 @@ class PurchaseOrderExtraLineSerializer( ): """Serializer for a PurchaseOrderExtraLine object.""" - order_detail = PurchaseOrderSerializer(source='order', many=False, read_only=True) + order_detail = PurchaseOrderSerializer( + source='order', many=False, read_only=True, allow_null=True + ) class Meta(AbstractExtraLineMeta): """Metaclass options.""" @@ -1025,7 +1035,7 @@ class SalesOrderSerializer( super().__init__(*args, **kwargs) - if customer_detail is not True: + if customer_detail is not True and not isGeneratingSchema(): self.fields.pop('customer_detail', None) def skip_create_fields(self): @@ -1069,7 +1079,7 @@ class SalesOrderSerializer( return queryset customer_detail = CompanyBriefSerializer( - source='customer', many=False, read_only=True + source='customer', many=False, read_only=True, allow_null=True ) shipments_count = serializers.IntegerField(read_only=True, label=_('Shipments')) @@ -1135,6 +1145,9 @@ class SalesOrderLineItemSerializer( super().__init__(*args, **kwargs) + if isGeneratingSchema(): + return + if part_detail is not True: self.fields.pop('part_detail', None) @@ -1233,10 +1246,14 @@ class SalesOrderLineItemSerializer( return queryset - order_detail = SalesOrderSerializer(source='order', many=False, read_only=True) - part_detail = PartBriefSerializer(source='part', many=False, read_only=True) + order_detail = SalesOrderSerializer( + source='order', many=False, read_only=True, allow_null=True + ) + part_detail = PartBriefSerializer( + source='part', many=False, read_only=True, allow_null=True + ) customer_detail = CompanyBriefSerializer( - source='order.customer', many=False, read_only=True + source='order.customer', many=False, read_only=True, allow_null=True ) # Annotated fields @@ -1289,7 +1306,7 @@ class SalesOrderShipmentSerializer(NotesFieldMixin, InvenTreeModelSerializer): super().__init__(*args, **kwargs) - if not order_detail: + if not order_detail and not isGeneratingSchema(): self.fields.pop('order_detail', None) @staticmethod @@ -1306,7 +1323,9 @@ class SalesOrderShipmentSerializer(NotesFieldMixin, InvenTreeModelSerializer): read_only=True, label=_('Allocated Items') ) - order_detail = SalesOrderSerializer(source='order', read_only=True, many=False) + order_detail = SalesOrderSerializer( + source='order', read_only=True, allow_null=True, many=False + ) class SalesOrderAllocationSerializer(InvenTreeModelSerializer): @@ -1352,6 +1371,9 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer): super().__init__(*args, **kwargs) + if isGeneratingSchema(): + return + if not order_detail: self.fields.pop('order_detail', None) @@ -1378,16 +1400,20 @@ class SalesOrderAllocationSerializer(InvenTreeModelSerializer): ) # Extra detail fields - order_detail = SalesOrderSerializer(source='line.order', many=False, read_only=True) - part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True) + order_detail = SalesOrderSerializer( + source='line.order', many=False, read_only=True, allow_null=True + ) + part_detail = PartBriefSerializer( + source='item.part', many=False, read_only=True, allow_null=True + ) item_detail = stock.serializers.StockItemSerializerBrief( - source='item', many=False, read_only=True + source='item', many=False, read_only=True, allow_null=True ) location_detail = stock.serializers.LocationBriefSerializer( - source='item.location', many=False, read_only=True + source='item.location', many=False, read_only=True, allow_null=True ) customer_detail = CompanyBriefSerializer( - source='line.order.customer', many=False, read_only=True + source='line.order.customer', many=False, read_only=True, allow_null=True ) shipment_detail = SalesOrderShipmentSerializer( @@ -1833,7 +1859,9 @@ class SalesOrderExtraLineSerializer( model = order.models.SalesOrderExtraLine - order_detail = SalesOrderSerializer(source='order', many=False, read_only=True) + order_detail = SalesOrderSerializer( + source='order', many=False, read_only=True, allow_null=True + ) @register_importer() @@ -1869,7 +1897,7 @@ class ReturnOrderSerializer( super().__init__(*args, **kwargs) - if customer_detail is not True: + if customer_detail is not True and not isGeneratingSchema(): self.fields.pop('customer_detail', None) def skip_create_fields(self): @@ -1902,7 +1930,7 @@ class ReturnOrderSerializer( return queryset customer_detail = CompanyBriefSerializer( - source='customer', many=False, read_only=True + source='customer', many=False, read_only=True, allow_null=True ) @@ -2081,6 +2109,9 @@ class ReturnOrderLineItemSerializer( super().__init__(*args, **kwargs) + if isGeneratingSchema(): + return + if not order_detail: self.fields.pop('order_detail', None) @@ -2090,17 +2121,21 @@ class ReturnOrderLineItemSerializer( if not part_detail: self.fields.pop('part_detail', None) - order_detail = ReturnOrderSerializer(source='order', many=False, read_only=True) + order_detail = ReturnOrderSerializer( + source='order', many=False, read_only=True, allow_null=True + ) quantity = serializers.FloatField( label=_('Quantity'), help_text=_('Quantity to return') ) item_detail = stock.serializers.StockItemSerializer( - source='item', many=False, read_only=True + source='item', many=False, read_only=True, allow_null=True ) - part_detail = PartBriefSerializer(source='item.part', many=False, read_only=True) + part_detail = PartBriefSerializer( + source='item.part', many=False, read_only=True, allow_null=True + ) price = InvenTreeMoneySerializer(allow_null=True) price_currency = InvenTreeCurrencySerializer(help_text=_('Line price currency')) @@ -2117,4 +2152,6 @@ class ReturnOrderExtraLineSerializer( model = order.models.ReturnOrderExtraLine - order_detail = ReturnOrderSerializer(source='order', many=False, read_only=True) + order_detail = ReturnOrderSerializer( + source='order', many=False, read_only=True, allow_null=True + ) diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 6c8640abad..08d77c2e64 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -35,6 +35,7 @@ import users.models from build.status_codes import BuildStatusGroups from importer.mixins import DataImportExportSerializerMixin from importer.registry import register_importer +from InvenTree.ready import isGeneratingSchema from InvenTree.tasks import offload_task from users.serializers import UserSerializer @@ -94,7 +95,7 @@ class CategorySerializer( super().__init__(*args, **kwargs) - if not path_detail: + if not path_detail and not isGeneratingSchema(): self.fields.pop('path', None) def get_starred(self, category) -> bool: @@ -133,7 +134,10 @@ class CategorySerializer( starred = serializers.SerializerMethodField() path = serializers.ListField( - child=serializers.DictField(), source='get_path', read_only=True + child=serializers.DictField(), + source='get_path', + read_only=True, + allow_null=True, ) icon = serializers.CharField( @@ -383,7 +387,7 @@ class PartBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer): super().__init__(*args, **kwargs) - if not pricing: + if not pricing and not isGeneratingSchema(): self.fields.pop('pricing_min', None) self.fields.pop('pricing_max', None) @@ -444,15 +448,20 @@ class PartParameterSerializer( super().__init__(*args, **kwargs) + if isGeneratingSchema(): + return + if not part_detail: self.fields.pop('part_detail', None) if not template_detail: self.fields.pop('template_detail', None) - part_detail = PartBriefSerializer(source='part', many=False, read_only=True) + part_detail = PartBriefSerializer( + source='part', many=False, read_only=True, allow_null=True + ) template_detail = PartParameterTemplateSerializer( - source='template', many=False, read_only=True + source='template', many=False, read_only=True, allow_null=True ) @@ -782,6 +791,9 @@ class PartSerializer( super().__init__(*args, **kwargs) + if isGeneratingSchema(): + return + if not category_detail: self.fields.pop('category_detail', None) @@ -923,14 +935,19 @@ class PartSerializer( return part in self.starred_parts # Extra detail for the category - category_detail = CategorySerializer(source='category', many=False, read_only=True) + category_detail = CategorySerializer( + source='category', many=False, read_only=True, allow_null=True + ) category_path = serializers.ListField( - child=serializers.DictField(), source='category.get_path', read_only=True + child=serializers.DictField(), + source='category.get_path', + read_only=True, + allow_null=True, ) default_location_detail = DefaultLocationSerializer( - source='default_location', many=False, read_only=True + source='default_location', many=False, read_only=True, allow_null=True ) category_name = serializers.CharField( @@ -1003,7 +1020,7 @@ class PartSerializer( source='pricing_data.updated', allow_null=True, read_only=True ) - parameters = PartParameterSerializer(many=True, read_only=True) + parameters = PartParameterSerializer(many=True, read_only=True, allow_null=True) # Extra fields used only for creation of a new Part instance duplicate = DuplicatePartSerializer( @@ -1617,6 +1634,9 @@ class BomItemSerializer( super().__init__(*args, **kwargs) + if isGeneratingSchema(): + return + if not part_detail: self.fields.pop('part_detail', None) @@ -1648,10 +1668,12 @@ class BomItemSerializer( help_text=_('Select the parent assembly'), ) - substitutes = BomItemSubstituteSerializer(many=True, read_only=True) + substitutes = BomItemSubstituteSerializer( + many=True, read_only=True, allow_null=True + ) part_detail = PartBriefSerializer( - source='part', label=_('Assembly'), many=False, read_only=True + source='part', label=_('Assembly'), many=False, read_only=True, allow_null=True ) sub_part = serializers.PrimaryKeyRelatedField( @@ -1661,7 +1683,11 @@ class BomItemSerializer( ) sub_part_detail = PartBriefSerializer( - source='sub_part', label=_('Component'), many=False, read_only=True + source='sub_part', + label=_('Component'), + many=False, + read_only=True, + allow_null=True, ) on_order = serializers.FloatField(label=_('On Order'), read_only=True) @@ -1867,7 +1893,9 @@ class CategoryParameterTemplateSerializer( source='parameter_template', many=False, read_only=True ) - category_detail = CategorySerializer(source='category', many=False, read_only=True) + category_detail = CategorySerializer( + source='category', many=False, read_only=True, allow_null=True + ) class PartCopyBOMSerializer(serializers.Serializer): diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index 7e6c75d053..da7a941139 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -30,6 +30,7 @@ from common.settings import get_global_setting from generic.states.fields import InvenTreeCustomStatusSerializerMixin from importer.mixins import DataImportExportSerializerMixin from importer.registry import register_importer +from InvenTree.ready import isGeneratingSchema from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField from users.serializers import UserSerializer @@ -218,13 +219,16 @@ class StockItemTestResultSerializer( super().__init__(*args, **kwargs) + if isGeneratingSchema(): + return + if user_detail is not True: self.fields.pop('user_detail', None) if template_detail is not True: self.fields.pop('template_detail', None) - user_detail = UserSerializer(source='user', read_only=True) + user_detail = UserSerializer(source='user', read_only=True, allow_null=True) template = serializers.PrimaryKeyRelatedField( queryset=part_models.PartTestTemplate.objects.all(), @@ -236,7 +240,7 @@ class StockItemTestResultSerializer( ) template_detail = part_serializers.PartTestTemplateSerializer( - source='template', read_only=True + source='template', read_only=True, allow_null=True ) attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField( @@ -442,6 +446,9 @@ class StockItemSerializer( super().__init__(*args, **kwargs) + if isGeneratingSchema(): + return + if not part_detail: self.fields.pop('part_detail', None) @@ -473,7 +480,10 @@ class StockItemSerializer( ) location_path = serializers.ListField( - child=serializers.DictField(), source='location.get_path', read_only=True + child=serializers.DictField(), + source='location.get_path', + read_only=True, + allow_null=True, ) in_stock = serializers.BooleanField(read_only=True, label=_('In Stock')) @@ -617,18 +627,23 @@ class StockItemSerializer( part_detail=False, many=False, read_only=True, + allow_null=True, ) part_detail = part_serializers.PartBriefSerializer( - label=_('Part'), source='part', many=False, read_only=True + label=_('Part'), source='part', many=False, read_only=True, allow_null=True ) location_detail = LocationBriefSerializer( - label=_('Location'), source='location', many=False, read_only=True + label=_('Location'), + source='location', + many=False, + read_only=True, + allow_null=True, ) tests = StockItemTestResultSerializer( - source='test_results', many=True, read_only=True + source='test_results', many=True, read_only=True, allow_null=True ) quantity = InvenTreeDecimalField() @@ -1184,7 +1199,7 @@ class LocationSerializer( super().__init__(*args, **kwargs) - if not path_detail: + if not path_detail and not isGeneratingSchema(): self.fields.pop('path', None) @staticmethod @@ -1219,7 +1234,10 @@ class LocationSerializer( tags = TagListSerializerField(required=False) path = serializers.ListField( - child=serializers.DictField(), source='get_path', read_only=True + child=serializers.DictField(), + source='get_path', + read_only=True, + allow_null=True, ) # explicitly set this field, so it gets included for AutoSchema @@ -1263,6 +1281,9 @@ class StockTrackingSerializer( super().__init__(*args, **kwargs) + if isGeneratingSchema(): + return + if item_detail is not True: self.fields.pop('item_detail', None) @@ -1271,9 +1292,13 @@ class StockTrackingSerializer( label = serializers.CharField(read_only=True) - item_detail = StockItemSerializerBrief(source='item', many=False, read_only=True) + item_detail = StockItemSerializerBrief( + source='item', many=False, read_only=True, allow_null=True + ) - user_detail = UserSerializer(source='user', many=False, read_only=True) + user_detail = UserSerializer( + source='user', many=False, read_only=True, allow_null=True + ) deltas = serializers.JSONField(read_only=True) diff --git a/src/backend/InvenTree/users/serializers.py b/src/backend/InvenTree/users/serializers.py index 61c720a099..c31e24deeb 100644 --- a/src/backend/InvenTree/users/serializers.py +++ b/src/backend/InvenTree/users/serializers.py @@ -8,6 +8,7 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from rest_framework.exceptions import PermissionDenied +from InvenTree.ready import isGeneratingSchema from InvenTree.serializers import InvenTreeModelSerializer from .models import ApiToken, Owner, RuleSet, UserProfile, check_user_role @@ -44,12 +45,12 @@ class GroupSerializer(InvenTreeModelSerializer): super().__init__(*args, **kwargs) try: - if not permission_detail: + if not permission_detail and not isGeneratingSchema(): self.fields.pop('permissions', None) except AppRegistryNotReady: pass - permissions = serializers.SerializerMethodField() + permissions = serializers.SerializerMethodField(allow_null=True) def get_permissions(self, group: Group): """Return a list of permissions associated with the group.""" @@ -74,7 +75,7 @@ class RoleSerializer(InvenTreeModelSerializer): user = serializers.IntegerField(source='pk') roles = serializers.SerializerMethodField() - permissions = serializers.SerializerMethodField() + permissions = serializers.SerializerMethodField(allow_null=True) def get_roles(self, user: User) -> dict: """Roles associated with the user."""