]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Enhancement: support custom field values on post document (#10859)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Wed, 17 Sep 2025 22:42:06 +0000 (15:42 -0700)
committerGitHub <noreply@github.com>
Wed, 17 Sep 2025 22:42:06 +0000 (22:42 +0000)
docs/api.md
src/documents/serialisers.py
src/documents/tests/test_api_documents.py
src/documents/views.py

index cd3e462dae5b6cea16fa09eef85a1f16b903ebf6..f7e12bf67a1274534564c6ee426a5bc934dff63c 100644 (file)
@@ -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
index 0b01e221bfdf6f486769333ba93add97aeca90da..1608a0e4e12383fe07b4f06541b529c12d9cdb3f 100644 (file)
@@ -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):
index 2a001ded3e2018c345284ca185a99c5a6b5a6cdb..927744c378f434820075c48133863fb44c52cd97 100644 (file)
@@ -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
index 4bd3707ceb3311d59d73a6ca48cb5abb9fb2df54..86eab92e384c205b8cf428a74f35be1c2307f054 100644 (file)
@@ -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(