]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Enhancement: symmetric document links (#4907)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Tue, 19 Dec 2023 21:43:50 +0000 (13:43 -0800)
committerGitHub <noreply@github.com>
Tue, 19 Dec 2023 21:43:50 +0000 (13:43 -0800)
docs/usage.md
src/documents/serialisers.py
src/documents/tests/test_api_custom_fields.py

index 2412e3cbeb111975f63f697401f2d964102da6d6..42701728d3ffb8a7745256b20720a9b2e872ade1 100644 (file)
@@ -345,7 +345,7 @@ The following custom field types are supported:
 - `Integer`: integer number e.g. 12
 - `Number`: float number e.g. 12.3456
 - `Monetary`: float number with exactly two decimals, e.g. 12.30
-- `Document Link`: reference(s) to other document(s), displayed as links
+- `Document Link`: reference(s) to other document(s) displayed as links, automatically creates a symmetrical link in reverse
 
 ## Share Links
 
index 39b811e143ff5006ef031c566fd314b403c9c88a..b1cc1d7f0c2c9527993b58c03bc95274b82407ad 100644 (file)
@@ -471,6 +471,10 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
         # This key must exist, as it is validated
         data_store_name = type_to_data_store_name_map[custom_field.data_type]
 
+        if custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
+            # prior to update so we can look for any docs that are going to be removed
+            self.reflect_doclinks(document, custom_field, validated_data["value"])
+
         # Actually update or create the instance, providing the value
         # to fill in the correct attribute based on the type
         instance, _ = CustomFieldInstance.objects.update_or_create(
@@ -494,6 +498,77 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
             URLValidator()(data["value"])
         return data
 
+    def reflect_doclinks(
+        self,
+        document: Document,
+        field: CustomField,
+        target_doc_ids: list[int],
+    ):
+        """
+        Add or remove 'symmetrical' links to `document` on all `target_doc_ids`
+        """
+        # Check if any documents are going to be removed from the current list of links and remove the symmetrical links
+        current_field_instance = CustomFieldInstance.objects.filter(
+            field=field,
+            document=document,
+        ).first()
+        if current_field_instance is not None:
+            for doc_id in current_field_instance.value:
+                if doc_id not in target_doc_ids:
+                    self.remove_doclink(document, field, doc_id)
+
+        # Create an instance if target doc doesnt have this field or append it to an existing one
+        existing_custom_field_instances = {
+            custom_field.document_id: custom_field
+            for custom_field in CustomFieldInstance.objects.filter(
+                field=field,
+                document_id__in=target_doc_ids,
+            )
+        }
+        custom_field_instances_to_create = []
+        custom_field_instances_to_update = []
+        for target_doc_id in target_doc_ids:
+            target_doc_field_instance = existing_custom_field_instances.get(
+                target_doc_id,
+            )
+            if target_doc_field_instance is None:
+                custom_field_instances_to_create.append(
+                    CustomFieldInstance(
+                        document_id=target_doc_id,
+                        field=field,
+                        value_document_ids=[document.id],
+                    ),
+                )
+            elif document.id not in target_doc_field_instance.value:
+                target_doc_field_instance.value_document_ids.append(document.id)
+                custom_field_instances_to_update.append(target_doc_field_instance)
+
+        CustomFieldInstance.objects.bulk_create(custom_field_instances_to_create)
+        CustomFieldInstance.objects.bulk_update(
+            custom_field_instances_to_update,
+            ["value_document_ids"],
+        )
+
+    @staticmethod
+    def remove_doclink(
+        document: Document,
+        field: CustomField,
+        target_doc_id: int,
+    ):
+        """
+        Removes a 'symmetrical' link to `document` from the target document's existing custom field instance
+        """
+        target_doc_field_instance = CustomFieldInstance.objects.filter(
+            document_id=target_doc_id,
+            field=field,
+        ).first()
+        if (
+            target_doc_field_instance is not None
+            and document.id in target_doc_field_instance.value
+        ):
+            target_doc_field_instance.value.remove(document.id)
+            target_doc_field_instance.save()
+
     class Meta:
         model = CustomFieldInstance
         fields = [
@@ -549,6 +624,21 @@ class DocumentSerializer(
             instance.save()
         if "created_date" in validated_data:
             validated_data.pop("created_date")
+        if instance.custom_fields.count() > 0 and "custom_fields" in validated_data:
+            incoming_custom_fields = [
+                field["field"] for field in validated_data["custom_fields"]
+            ]
+            for custom_field_instance in instance.custom_fields.filter(
+                field__data_type=CustomField.FieldDataType.DOCUMENTLINK,
+            ):
+                if custom_field_instance.field not in incoming_custom_fields:
+                    # Doc link field is being removed entirely
+                    for doc_id in custom_field_instance.value:
+                        CustomFieldInstanceSerializer.remove_doclink(
+                            instance,
+                            custom_field_instance.field,
+                            doc_id,
+                        )
         super().update(instance, validated_data)
         return instance
 
index cde5f302ce1d4bb31e2ccd6600ccc42a887c65f3..2eb46e3882e60a7f27dc5f601af9bd7c7f41c212 100644 (file)
@@ -70,6 +70,12 @@ class TestCustomField(DirectoriesMixin, APITestCase):
             checksum="123",
             mime_type="application/pdf",
         )
+        doc2 = Document.objects.create(
+            title="WOW2",
+            content="the content2",
+            checksum="1234",
+            mime_type="application/pdf",
+        )
         custom_field_string = CustomField.objects.create(
             name="Test Custom Field String",
             data_type=CustomField.FieldDataType.STRING,
@@ -139,7 +145,7 @@ class TestCustomField(DirectoriesMixin, APITestCase):
                     },
                     {
                         "field": custom_field_documentlink.id,
-                        "value": [1, 2, 3],
+                        "value": [doc2.id],
                     },
                 ],
             },
@@ -160,7 +166,7 @@ class TestCustomField(DirectoriesMixin, APITestCase):
                 {"field": custom_field_url.id, "value": "https://example.com"},
                 {"field": custom_field_float.id, "value": 12.3456},
                 {"field": custom_field_monetary.id, "value": 11.10},
-                {"field": custom_field_documentlink.id, "value": [1, 2, 3]},
+                {"field": custom_field_documentlink.id, "value": [doc2.id]},
             ],
         )
 
@@ -393,3 +399,111 @@ class TestCustomField(DirectoriesMixin, APITestCase):
 
         self.assertEqual(CustomFieldInstance.objects.count(), 0)
         self.assertEqual(len(doc.custom_fields.all()), 0)
+
+    def test_bidirectional_doclink_fields(self):
+        """
+        GIVEN:
+            - Existing document
+        WHEN:
+            - Doc links are added or removed
+        THEN:
+            - Symmetrical link is created or removed as expected
+        """
+        doc1 = Document.objects.create(
+            title="WOW1",
+            content="1",
+            checksum="1",
+            mime_type="application/pdf",
+        )
+        doc2 = Document.objects.create(
+            title="WOW2",
+            content="the content2",
+            checksum="2",
+            mime_type="application/pdf",
+        )
+        doc3 = Document.objects.create(
+            title="WOW3",
+            content="the content3",
+            checksum="3",
+            mime_type="application/pdf",
+        )
+        doc4 = Document.objects.create(
+            title="WOW4",
+            content="the content4",
+            checksum="4",
+            mime_type="application/pdf",
+        )
+        custom_field_doclink = CustomField.objects.create(
+            name="Test Custom Field Doc Link",
+            data_type=CustomField.FieldDataType.DOCUMENTLINK,
+        )
+
+        # Add links, creates bi-directional
+        resp = self.client.patch(
+            f"/api/documents/{doc1.id}/",
+            data={
+                "custom_fields": [
+                    {
+                        "field": custom_field_doclink.id,
+                        "value": [2, 3, 4],
+                    },
+                ],
+            },
+            format="json",
+        )
+
+        self.assertEqual(resp.status_code, status.HTTP_200_OK)
+        self.assertEqual(CustomFieldInstance.objects.count(), 4)
+        self.assertEqual(doc2.custom_fields.first().value, [1])
+        self.assertEqual(doc3.custom_fields.first().value, [1])
+        self.assertEqual(doc4.custom_fields.first().value, [1])
+
+        # Add links appends if necessary
+        resp = self.client.patch(
+            f"/api/documents/{doc3.id}/",
+            data={
+                "custom_fields": [
+                    {
+                        "field": custom_field_doclink.id,
+                        "value": [1, 4],
+                    },
+                ],
+            },
+            format="json",
+        )
+
+        self.assertEqual(resp.status_code, status.HTTP_200_OK)
+        self.assertEqual(doc4.custom_fields.first().value, [1, 3])
+
+        # Remove one of the links, removed on other doc
+        resp = self.client.patch(
+            f"/api/documents/{doc1.id}/",
+            data={
+                "custom_fields": [
+                    {
+                        "field": custom_field_doclink.id,
+                        "value": [2, 3],
+                    },
+                ],
+            },
+            format="json",
+        )
+
+        self.assertEqual(resp.status_code, status.HTTP_200_OK)
+        self.assertEqual(doc2.custom_fields.first().value, [1])
+        self.assertEqual(doc3.custom_fields.first().value, [1, 4])
+        self.assertEqual(doc4.custom_fields.first().value, [3])
+
+        # Removes the field entirely
+        resp = self.client.patch(
+            f"/api/documents/{doc1.id}/",
+            data={
+                "custom_fields": [],
+            },
+            format="json",
+        )
+
+        self.assertEqual(resp.status_code, status.HTTP_200_OK)
+        self.assertEqual(doc2.custom_fields.first().value, [])
+        self.assertEqual(doc3.custom_fields.first().value, [4])
+        self.assertEqual(doc4.custom_fields.first().value, [3])