diff --git a/src/documents/tests/test_api_bulk_edit.py b/src/documents/tests/test_api_bulk_edit.py index ba0e1139f..075bbfd6a 100644 --- a/src/documents/tests/test_api_bulk_edit.py +++ b/src/documents/tests/test_api_bulk_edit.py @@ -1,7 +1,9 @@ import json from unittest import mock +from auditlog.models import LogEntry from django.contrib.auth.models import User +from django.test import override_settings from guardian.shortcuts import assign_perm from rest_framework import status from rest_framework.test import APITestCase @@ -51,8 +53,12 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.doc3.tags.add(self.t2) self.doc4.tags.add(self.t1, self.t2) self.sp1 = StoragePath.objects.create(name="sp1", path="Something/{checksum}") - self.cf1 = CustomField.objects.create(name="cf1", data_type="text") - self.cf2 = CustomField.objects.create(name="cf2", data_type="text") + self.cf1 = CustomField.objects.create(name="cf1", data_type="string") + self.cf2 = CustomField.objects.create(name="cf2", data_type="string") + + def setup_mock(self, m, method_name, return_value="OK"): + m.return_value = return_value + m.__name__ = method_name @mock.patch("documents.bulk_edit.bulk_update_documents.delay") def test_api_set_correspondent(self, bulk_update_task_mock): @@ -178,7 +184,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): @mock.patch("documents.serialisers.bulk_edit.modify_tags") def test_api_modify_tags(self, m): - m.return_value = "OK" + self.setup_mock(m, "modify_tags") response = self.client.post( "/api/documents/bulk_edit/", json.dumps( @@ -211,7 +217,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): - API returns HTTP 400 - modify_tags is not called """ - m.return_value = "OK" + self.setup_mock(m, "modify_tags") response = self.client.post( "/api/documents/bulk_edit/", json.dumps( @@ -230,7 +236,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): @mock.patch("documents.serialisers.bulk_edit.modify_custom_fields") def test_api_modify_custom_fields(self, m): - m.return_value = "OK" + self.setup_mock(m, "modify_custom_fields") response = self.client.post( "/api/documents/bulk_edit/", json.dumps( @@ -263,8 +269,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): - API returns HTTP 400 - modify_custom_fields is not called """ - m.return_value = "OK" - + self.setup_mock(m, "modify_custom_fields") # Missing add_custom_fields response = self.client.post( "/api/documents/bulk_edit/", @@ -359,7 +364,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): @mock.patch("documents.serialisers.bulk_edit.delete") def test_api_delete(self, m): - m.return_value = "OK" + self.setup_mock(m, "delete") response = self.client.post( "/api/documents/bulk_edit/", json.dumps( @@ -383,8 +388,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): THEN: - set_storage_path is called with correct document IDs and storage_path ID """ - m.return_value = "OK" - + self.setup_mock(m, "set_storage_path") response = self.client.post( "/api/documents/bulk_edit/", json.dumps( @@ -414,8 +418,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): THEN: - set_storage_path is called with correct document IDs and None storage_path """ - m.return_value = "OK" - + self.setup_mock(m, "set_storage_path") response = self.client.post( "/api/documents/bulk_edit/", json.dumps( @@ -728,7 +731,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): @mock.patch("documents.serialisers.bulk_edit.set_permissions") def test_set_permissions(self, m): - m.return_value = "OK" + self.setup_mock(m, "set_permissions") user1 = User.objects.create(username="user1") user2 = User.objects.create(username="user2") permissions = { @@ -763,7 +766,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): @mock.patch("documents.serialisers.bulk_edit.set_permissions") def test_set_permissions_merge(self, m): - m.return_value = "OK" + self.setup_mock(m, "set_permissions") user1 = User.objects.create(username="user1") user2 = User.objects.create(username="user2") permissions = { @@ -823,7 +826,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): THEN: - User is not able to change permissions """ - m.return_value = "OK" + self.setup_mock(m, "set_permissions") self.doc1.owner = User.objects.get(username="temp_admin") self.doc1.save() user1 = User.objects.create(username="user1") @@ -875,7 +878,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): THEN: - set_storage_path only called if user can edit all docs """ - m.return_value = "OK" + self.setup_mock(m, "set_storage_path") self.doc1.owner = User.objects.get(username="temp_admin") self.doc1.save() user1 = User.objects.create(username="user1") @@ -919,8 +922,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): @mock.patch("documents.serialisers.bulk_edit.rotate") def test_rotate(self, m): - m.return_value = "OK" - + self.setup_mock(m, "rotate") response = self.client.post( "/api/documents/bulk_edit/", json.dumps( @@ -974,8 +976,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): @mock.patch("documents.serialisers.bulk_edit.merge") def test_merge(self, m): - m.return_value = "OK" - + self.setup_mock(m, "merge") response = self.client.post( "/api/documents/bulk_edit/", json.dumps( @@ -1003,8 +1004,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): user1 = User.objects.create(username="user1") self.client.force_authenticate(user=user1) - m.return_value = "OK" - + self.setup_mock(m, "merge") response = self.client.post( "/api/documents/bulk_edit/", json.dumps( @@ -1053,8 +1053,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): THEN: - The API fails with a correct error code """ - m.return_value = "OK" - + self.setup_mock(m, "merge") response = self.client.post( "/api/documents/bulk_edit/", json.dumps( @@ -1074,8 +1073,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): @mock.patch("documents.serialisers.bulk_edit.split") def test_split(self, m): - m.return_value = "OK" - + self.setup_mock(m, "split") response = self.client.post( "/api/documents/bulk_edit/", json.dumps( @@ -1165,8 +1163,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): @mock.patch("documents.serialisers.bulk_edit.delete_pages") def test_delete_pages(self, m): - m.return_value = "OK" - + self.setup_mock(m, "delete_pages") response = self.client.post( "/api/documents/bulk_edit/", json.dumps( @@ -1254,3 +1251,87 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn(b"pages must be a list of integers", response.content) + + @override_settings(AUDIT_LOG_ENABLED=True) + def test_bulk_edit_audit_log_enabled_simple_field(self): + """ + GIVEN: + - Audit log is enabled + WHEN: + - API to bulk edit documents is called + THEN: + - Audit log is created + """ + LogEntry.objects.all().delete() + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [self.doc1.id], + "method": "set_correspondent", + "parameters": {"correspondent": self.c2.id}, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(LogEntry.objects.filter(object_pk=self.doc1.id).count(), 1) + + @override_settings(AUDIT_LOG_ENABLED=True) + def test_bulk_edit_audit_log_enabled_tags(self): + """ + GIVEN: + - Audit log is enabled + WHEN: + - API to bulk edit tags is called + THEN: + - Audit log is created + """ + LogEntry.objects.all().delete() + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [self.doc1.id], + "method": "modify_tags", + "parameters": { + "add_tags": [self.t1.id], + "remove_tags": [self.t2.id], + }, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(LogEntry.objects.filter(object_pk=self.doc1.id).count(), 1) + + @override_settings(AUDIT_LOG_ENABLED=True) + def test_bulk_edit_audit_log_enabled_custom_fields(self): + """ + GIVEN: + - Audit log is enabled + WHEN: + - API to bulk edit custom fields is called + THEN: + - Audit log is created + """ + LogEntry.objects.all().delete() + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [self.doc1.id], + "method": "modify_custom_fields", + "parameters": { + "add_custom_fields": [self.cf1.id], + "remove_custom_fields": [], + }, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(LogEntry.objects.filter(object_pk=self.doc1.id).count(), 2) diff --git a/src/documents/views.py b/src/documents/views.py index 367559c6d..722ae7440 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -26,11 +26,13 @@ from django.db.models import Case from django.db.models import Count from django.db.models import IntegerField from django.db.models import Max +from django.db.models import Model from django.db.models import Q from django.db.models import Sum from django.db.models import When from django.db.models.functions import Length from django.db.models.functions import Lower +from django.db.models.manager import Manager from django.http import Http404 from django.http import HttpResponse from django.http import HttpResponseBadRequest @@ -961,6 +963,22 @@ class SavedViewViewSet(ModelViewSet, PassUserMixin): class BulkEditView(PassUserMixin): + MODIFIED_FIELD_BY_METHOD = { + "set_correspondent": "correspondent", + "set_document_type": "document_type", + "set_storage_path": "storage_path", + "add_tag": "tags", + "remove_tag": "tags", + "modify_tags": "tags", + "modify_custom_fields": "custom_fields", + "set_permissions": None, + "delete": "deleted_at", + "rotate": "checksum", + "delete_pages": "checksum", + "split": None, + "merge": None, + } + permission_classes = (IsAuthenticated,) serializer_class = BulkEditSerializer parser_classes = (parsers.JSONParser,) @@ -1013,8 +1031,53 @@ class BulkEditView(PassUserMixin): return HttpResponseForbidden("Insufficient permissions") try: + modified_field = self.MODIFIED_FIELD_BY_METHOD[method.__name__] + if settings.AUDIT_LOG_ENABLED and modified_field: + old_documents = { + obj["pk"]: obj + for obj in Document.objects.filter(pk__in=documents).values( + "pk", + "correspondent", + "document_type", + "storage_path", + "tags", + "custom_fields", + "deleted_at", + "checksum", + ) + } + # TODO: parameter validation result = method(documents, **parameters) + + if settings.AUDIT_LOG_ENABLED and modified_field: + new_documents = Document.objects.filter(pk__in=documents) + for doc in new_documents: + old_value = old_documents[doc.pk][modified_field] + new_value = getattr(doc, modified_field) + + if isinstance(new_value, Model): + # correspondent, document type, etc. + new_value = new_value.pk + elif isinstance(new_value, Manager): + # tags, custom fields + new_value = list(new_value.values_list("pk", flat=True)) + + LogEntry.objects.log_create( + instance=doc, + changes={ + modified_field: [ + old_value, + new_value, + ], + }, + action=LogEntry.Action.UPDATE, + actor=user, + additional_data={ + "reason": f"Bulk edit: {method.__name__}", + }, + ) + return Response({"result": result}) except Exception as e: logger.warning(f"An error occurred performing bulk edit: {e!s}")