mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-11-04 03:27:12 -05:00 
			
		
		
		
	Enhancement: symmetric document links (#4907)
This commit is contained in:
		
							parent
							
								
									5e8de4c1da
								
							
						
					
					
						commit
						638d9970fd
					
				@ -345,7 +345,7 @@ The following custom field types are supported:
 | 
				
			|||||||
- `Integer`: integer number e.g. 12
 | 
					- `Integer`: integer number e.g. 12
 | 
				
			||||||
- `Number`: float number e.g. 12.3456
 | 
					- `Number`: float number e.g. 12.3456
 | 
				
			||||||
- `Monetary`: float number with exactly two decimals, e.g. 12.30
 | 
					- `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
 | 
					## Share Links
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -471,6 +471,10 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
 | 
				
			|||||||
        # This key must exist, as it is validated
 | 
					        # This key must exist, as it is validated
 | 
				
			||||||
        data_store_name = type_to_data_store_name_map[custom_field.data_type]
 | 
					        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
 | 
					        # Actually update or create the instance, providing the value
 | 
				
			||||||
        # to fill in the correct attribute based on the type
 | 
					        # to fill in the correct attribute based on the type
 | 
				
			||||||
        instance, _ = CustomFieldInstance.objects.update_or_create(
 | 
					        instance, _ = CustomFieldInstance.objects.update_or_create(
 | 
				
			||||||
@ -494,6 +498,77 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
 | 
				
			|||||||
            URLValidator()(data["value"])
 | 
					            URLValidator()(data["value"])
 | 
				
			||||||
        return data
 | 
					        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:
 | 
					    class Meta:
 | 
				
			||||||
        model = CustomFieldInstance
 | 
					        model = CustomFieldInstance
 | 
				
			||||||
        fields = [
 | 
					        fields = [
 | 
				
			||||||
@ -549,6 +624,21 @@ class DocumentSerializer(
 | 
				
			|||||||
            instance.save()
 | 
					            instance.save()
 | 
				
			||||||
        if "created_date" in validated_data:
 | 
					        if "created_date" in validated_data:
 | 
				
			||||||
            validated_data.pop("created_date")
 | 
					            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)
 | 
					        super().update(instance, validated_data)
 | 
				
			||||||
        return instance
 | 
					        return instance
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -70,6 +70,12 @@ class TestCustomField(DirectoriesMixin, APITestCase):
 | 
				
			|||||||
            checksum="123",
 | 
					            checksum="123",
 | 
				
			||||||
            mime_type="application/pdf",
 | 
					            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(
 | 
					        custom_field_string = CustomField.objects.create(
 | 
				
			||||||
            name="Test Custom Field String",
 | 
					            name="Test Custom Field String",
 | 
				
			||||||
            data_type=CustomField.FieldDataType.STRING,
 | 
					            data_type=CustomField.FieldDataType.STRING,
 | 
				
			||||||
@ -139,7 +145,7 @@ class TestCustomField(DirectoriesMixin, APITestCase):
 | 
				
			|||||||
                    },
 | 
					                    },
 | 
				
			||||||
                    {
 | 
					                    {
 | 
				
			||||||
                        "field": custom_field_documentlink.id,
 | 
					                        "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_url.id, "value": "https://example.com"},
 | 
				
			||||||
                {"field": custom_field_float.id, "value": 12.3456},
 | 
					                {"field": custom_field_float.id, "value": 12.3456},
 | 
				
			||||||
                {"field": custom_field_monetary.id, "value": 11.10},
 | 
					                {"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(CustomFieldInstance.objects.count(), 0)
 | 
				
			||||||
        self.assertEqual(len(doc.custom_fields.all()), 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])
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user