mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-04-02 13:45:10 -05:00
Enhancement: History (audit log) for bulk edit operations (#8196)
This commit is contained in:
parent
5a74a92b74
commit
dbccd13915
@ -1,7 +1,9 @@
|
|||||||
import json
|
import json
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
from auditlog.models import LogEntry
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.test import override_settings
|
||||||
from guardian.shortcuts import assign_perm
|
from guardian.shortcuts import assign_perm
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
@ -51,8 +53,12 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
self.doc3.tags.add(self.t2)
|
self.doc3.tags.add(self.t2)
|
||||||
self.doc4.tags.add(self.t1, self.t2)
|
self.doc4.tags.add(self.t1, self.t2)
|
||||||
self.sp1 = StoragePath.objects.create(name="sp1", path="Something/{checksum}")
|
self.sp1 = StoragePath.objects.create(name="sp1", path="Something/{checksum}")
|
||||||
self.cf1 = CustomField.objects.create(name="cf1", data_type="text")
|
self.cf1 = CustomField.objects.create(name="cf1", data_type="string")
|
||||||
self.cf2 = CustomField.objects.create(name="cf2", data_type="text")
|
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")
|
@mock.patch("documents.bulk_edit.bulk_update_documents.delay")
|
||||||
def test_api_set_correspondent(self, bulk_update_task_mock):
|
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")
|
@mock.patch("documents.serialisers.bulk_edit.modify_tags")
|
||||||
def test_api_modify_tags(self, m):
|
def test_api_modify_tags(self, m):
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "modify_tags")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@ -211,7 +217,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
- API returns HTTP 400
|
- API returns HTTP 400
|
||||||
- modify_tags is not called
|
- modify_tags is not called
|
||||||
"""
|
"""
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "modify_tags")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@ -230,7 +236,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
|
@mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
|
||||||
def test_api_modify_custom_fields(self, m):
|
def test_api_modify_custom_fields(self, m):
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "modify_custom_fields")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@ -263,8 +269,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
- API returns HTTP 400
|
- API returns HTTP 400
|
||||||
- modify_custom_fields is not called
|
- modify_custom_fields is not called
|
||||||
"""
|
"""
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "modify_custom_fields")
|
||||||
|
|
||||||
# Missing add_custom_fields
|
# Missing add_custom_fields
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
@ -359,7 +364,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.delete")
|
@mock.patch("documents.serialisers.bulk_edit.delete")
|
||||||
def test_api_delete(self, m):
|
def test_api_delete(self, m):
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "delete")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@ -383,8 +388,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- set_storage_path is called with correct document IDs and storage_path ID
|
- 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(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@ -414,8 +418,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- set_storage_path is called with correct document IDs and None storage_path
|
- 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(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@ -728,7 +731,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
||||||
def test_set_permissions(self, m):
|
def test_set_permissions(self, m):
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "set_permissions")
|
||||||
user1 = User.objects.create(username="user1")
|
user1 = User.objects.create(username="user1")
|
||||||
user2 = User.objects.create(username="user2")
|
user2 = User.objects.create(username="user2")
|
||||||
permissions = {
|
permissions = {
|
||||||
@ -763,7 +766,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
@mock.patch("documents.serialisers.bulk_edit.set_permissions")
|
||||||
def test_set_permissions_merge(self, m):
|
def test_set_permissions_merge(self, m):
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "set_permissions")
|
||||||
user1 = User.objects.create(username="user1")
|
user1 = User.objects.create(username="user1")
|
||||||
user2 = User.objects.create(username="user2")
|
user2 = User.objects.create(username="user2")
|
||||||
permissions = {
|
permissions = {
|
||||||
@ -823,7 +826,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- User is not able to change permissions
|
- 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.owner = User.objects.get(username="temp_admin")
|
||||||
self.doc1.save()
|
self.doc1.save()
|
||||||
user1 = User.objects.create(username="user1")
|
user1 = User.objects.create(username="user1")
|
||||||
@ -875,7 +878,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- set_storage_path only called if user can edit all docs
|
- 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.owner = User.objects.get(username="temp_admin")
|
||||||
self.doc1.save()
|
self.doc1.save()
|
||||||
user1 = User.objects.create(username="user1")
|
user1 = User.objects.create(username="user1")
|
||||||
@ -919,8 +922,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.rotate")
|
@mock.patch("documents.serialisers.bulk_edit.rotate")
|
||||||
def test_rotate(self, m):
|
def test_rotate(self, m):
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "rotate")
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@ -974,8 +976,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.merge")
|
@mock.patch("documents.serialisers.bulk_edit.merge")
|
||||||
def test_merge(self, m):
|
def test_merge(self, m):
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "merge")
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@ -1003,8 +1004,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
user1 = User.objects.create(username="user1")
|
user1 = User.objects.create(username="user1")
|
||||||
self.client.force_authenticate(user=user1)
|
self.client.force_authenticate(user=user1)
|
||||||
|
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "merge")
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@ -1053,8 +1053,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
THEN:
|
THEN:
|
||||||
- The API fails with a correct error code
|
- The API fails with a correct error code
|
||||||
"""
|
"""
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "merge")
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@ -1074,8 +1073,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.split")
|
@mock.patch("documents.serialisers.bulk_edit.split")
|
||||||
def test_split(self, m):
|
def test_split(self, m):
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "split")
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@ -1165,8 +1163,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.delete_pages")
|
@mock.patch("documents.serialisers.bulk_edit.delete_pages")
|
||||||
def test_delete_pages(self, m):
|
def test_delete_pages(self, m):
|
||||||
m.return_value = "OK"
|
self.setup_mock(m, "delete_pages")
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/documents/bulk_edit/",
|
"/api/documents/bulk_edit/",
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@ -1254,3 +1251,87 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn(b"pages must be a list of integers", response.content)
|
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)
|
||||||
|
@ -26,11 +26,13 @@ from django.db.models import Case
|
|||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
from django.db.models import IntegerField
|
from django.db.models import IntegerField
|
||||||
from django.db.models import Max
|
from django.db.models import Max
|
||||||
|
from django.db.models import Model
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.db.models import Sum
|
from django.db.models import Sum
|
||||||
from django.db.models import When
|
from django.db.models import When
|
||||||
from django.db.models.functions import Length
|
from django.db.models.functions import Length
|
||||||
from django.db.models.functions import Lower
|
from django.db.models.functions import Lower
|
||||||
|
from django.db.models.manager import Manager
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.http import HttpResponseBadRequest
|
from django.http import HttpResponseBadRequest
|
||||||
@ -961,6 +963,22 @@ class SavedViewViewSet(ModelViewSet, PassUserMixin):
|
|||||||
|
|
||||||
|
|
||||||
class BulkEditView(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,)
|
permission_classes = (IsAuthenticated,)
|
||||||
serializer_class = BulkEditSerializer
|
serializer_class = BulkEditSerializer
|
||||||
parser_classes = (parsers.JSONParser,)
|
parser_classes = (parsers.JSONParser,)
|
||||||
@ -1013,8 +1031,53 @@ class BulkEditView(PassUserMixin):
|
|||||||
return HttpResponseForbidden("Insufficient permissions")
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
|
|
||||||
try:
|
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
|
# TODO: parameter validation
|
||||||
result = method(documents, **parameters)
|
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})
|
return Response({"result": result})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"An error occurred performing bulk edit: {e!s}")
|
logger.warning(f"An error occurred performing bulk edit: {e!s}")
|
||||||
|
Loading…
x
Reference in New Issue
Block a user