mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Enhancement: symmetric document links (#4907)
This commit is contained in:
		| @@ -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 | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
| @@ -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]) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 shamoon
					shamoon