From 6dbd32759d60ad4bdb6bffbfe1fd680c81e60900 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Wed, 17 Sep 2025 15:42:06 -0700 Subject: [PATCH] Enhancement: support custom field values on post document (#10859) --- docs/api.md | 4 +- src/documents/serialisers.py | 60 +++++++++++++++-- src/documents/tests/test_api_documents.py | 81 +++++++++++++++++++++++ src/documents/views.py | 12 ++-- 4 files changed, 144 insertions(+), 13 deletions(-) diff --git a/docs/api.md b/docs/api.md index cd3e462da..f7e12bf67 100644 --- a/docs/api.md +++ b/docs/api.md @@ -192,8 +192,8 @@ The endpoint supports the following optional form fields: - `tags`: Similar to correspondent. Specify this multiple times to have multiple tags added to the document. - `archive_serial_number`: An optional archive serial number to set. -- `custom_fields`: An array of custom field ids to assign (with an empty - value) to the document. +- `custom_fields`: Either an array of custom field ids to assign (with an empty + value) to the document or an object mapping field id -> value. The endpoint will immediately return HTTP 200 if the document consumption process was started successfully, with the UUID of the consumption task diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 0b01e221b..1608a0e4e 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -1749,9 +1749,8 @@ class PostDocumentSerializer(serializers.Serializer): max_value=Document.ARCHIVE_SERIAL_NUMBER_MAX, ) - custom_fields = serializers.PrimaryKeyRelatedField( - many=True, - queryset=CustomField.objects.all(), + # Accept either a list of custom field ids or a dict mapping id -> value + custom_fields = serializers.JSONField( label="Custom fields", write_only=True, required=False, @@ -1808,11 +1807,60 @@ class PostDocumentSerializer(serializers.Serializer): return None def validate_custom_fields(self, custom_fields): - if custom_fields: - return [custom_field.id for custom_field in custom_fields] - else: + if not custom_fields: return None + # Normalize single values to a list + if isinstance(custom_fields, int): + custom_fields = [custom_fields] + if isinstance(custom_fields, dict): + custom_field_serializer = CustomFieldInstanceSerializer() + normalized = {} + for field_id, value in custom_fields.items(): + try: + field_id_int = int(field_id) + except (TypeError, ValueError): + raise serializers.ValidationError( + _("Custom field id must be an integer: %(id)s") + % {"id": field_id}, + ) + try: + field = CustomField.objects.get(id=field_id_int) + except CustomField.DoesNotExist: + raise serializers.ValidationError( + _("Custom field with id %(id)s does not exist") + % {"id": field_id_int}, + ) + custom_field_serializer.validate( + { + "field": field, + "value": value, + }, + ) + normalized[field_id_int] = value + return normalized + elif isinstance(custom_fields, list): + try: + ids = [int(i) for i in custom_fields] + except (TypeError, ValueError): + raise serializers.ValidationError( + _( + "Custom fields must be a list of integers or an object mapping ids to values.", + ), + ) + if CustomField.objects.filter(id__in=ids).count() != len(set(ids)): + raise serializers.ValidationError( + _("Some custom fields don't exist or were specified twice."), + ) + return ids + raise serializers.ValidationError( + _( + "Custom fields must be a list of integers or an object mapping ids to values.", + ), + ) + + # custom_fields_w_values handled via validate_custom_fields + def validate_created(self, created): # support datetime format for created for backwards compatibility if isinstance(created, datetime): diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py index 2a001ded3..927744c37 100644 --- a/src/documents/tests/test_api_documents.py +++ b/src/documents/tests/test_api_documents.py @@ -1,4 +1,5 @@ import datetime +import json import shutil import tempfile import uuid @@ -1537,6 +1538,86 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): overrides.update(new_overrides) self.assertEqual(overrides.custom_fields, {cf.id: None, cf2.id: 123}) + def test_upload_with_custom_field_values(self): + """ + GIVEN: A document with a source file + WHEN: Upload the document with custom fields and values + THEN: Metadata is set correctly + """ + self.consume_file_mock.return_value = celery.result.AsyncResult( + id=str(uuid.uuid4()), + ) + + cf_string = CustomField.objects.create( + name="stringfield", + data_type=CustomField.FieldDataType.STRING, + ) + cf_int = CustomField.objects.create( + name="intfield", + data_type=CustomField.FieldDataType.INT, + ) + + with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f: + response = self.client.post( + "/api/documents/post_document/", + { + "document": f, + "custom_fields": json.dumps( + { + str(cf_string.id): "a string", + str(cf_int.id): 123, + }, + ), + }, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.consume_file_mock.assert_called_once() + + input_doc, overrides = self.get_last_consume_delay_call_args() + + self.assertEqual(input_doc.original_file.name, "simple.pdf") + self.assertEqual(overrides.filename, "simple.pdf") + self.assertEqual( + overrides.custom_fields, + {cf_string.id: "a string", cf_int.id: 123}, + ) + + def test_upload_with_custom_fields_errors(self): + """ + GIVEN: A document with a source file + WHEN: Upload the document with invalid custom fields payloads + THEN: The upload is rejected + """ + self.consume_file_mock.return_value = celery.result.AsyncResult( + id=str(uuid.uuid4()), + ) + + error_payloads = [ + # Non-integer key in mapping + {"custom_fields": json.dumps({"abc": "a string"})}, + # List with non-integer entry + {"custom_fields": json.dumps(["abc"])}, + # Nonexistent id in mapping + {"custom_fields": json.dumps({99999999: "a string"})}, + # Nonexistent id in list + {"custom_fields": json.dumps([99999999])}, + # Invalid type (JSON string, not list/dict/int) + {"custom_fields": json.dumps("not-a-supported-structure")}, + ] + + for payload in error_payloads: + with (Path(__file__).parent / "samples" / "simple.pdf").open("rb") as f: + data = {"document": f, **payload} + response = self.client.post( + "/api/documents/post_document/", + data, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + self.consume_file_mock.assert_not_called() + def test_upload_with_webui_source(self): """ GIVEN: A document with a source file diff --git a/src/documents/views.py b/src/documents/views.py index 4bd3707ce..86eab92e3 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -1505,7 +1505,7 @@ class PostDocumentView(GenericAPIView): title = serializer.validated_data.get("title") created = serializer.validated_data.get("created") archive_serial_number = serializer.validated_data.get("archive_serial_number") - custom_field_ids = serializer.validated_data.get("custom_fields") + cf = serializer.validated_data.get("custom_fields") from_webui = serializer.validated_data.get("from_webui") t = int(mktime(datetime.now().timetuple())) @@ -1524,6 +1524,11 @@ class PostDocumentView(GenericAPIView): source=DocumentSource.WebUI if from_webui else DocumentSource.ApiUpload, original_file=temp_file_path, ) + custom_fields = None + if isinstance(cf, dict) and cf: + custom_fields = cf + elif isinstance(cf, list) and cf: + custom_fields = dict.fromkeys(cf, None) input_doc_overrides = DocumentMetadataOverrides( filename=doc_name, title=title, @@ -1534,10 +1539,7 @@ class PostDocumentView(GenericAPIView): created=created, asn=archive_serial_number, owner_id=request.user.id, - # TODO: set values - custom_fields={cf_id: None for cf_id in custom_field_ids} - if custom_field_ids - else None, + custom_fields=custom_fields, ) async_task = consume_file.delay(