From 638d9970fd468d8c02c91d19bd28f8b0796bdcb1 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 19 Dec 2023 13:43:50 -0800 Subject: [PATCH] Enhancement: symmetric document links (#4907) --- docs/usage.md | 2 +- src/documents/serialisers.py | 90 +++++++++++++ src/documents/tests/test_api_custom_fields.py | 118 +++++++++++++++++- 3 files changed, 207 insertions(+), 3 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 2412e3cbe..42701728d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -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 diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 39b811e14..b1cc1d7f0 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -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 diff --git a/src/documents/tests/test_api_custom_fields.py b/src/documents/tests/test_api_custom_fields.py index cde5f302c..2eb46e388 100644 --- a/src/documents/tests/test_api_custom_fields.py +++ b/src/documents/tests/test_api_custom_fields.py @@ -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])