Feature: document history (audit log UI) (#6388)

This commit is contained in:
shamoon
2024-04-23 08:16:28 -07:00
committed by GitHub
parent d65fcf70f3
commit 05b1ff9738
29 changed files with 773 additions and 158 deletions

View File

@@ -882,7 +882,12 @@ class CustomFieldInstance(models.Model):
if settings.AUDIT_LOG_ENABLED:
auditlog.register(Document, m2m_fields={"tags"})
auditlog.register(
Document,
m2m_fields={"tags"},
mask_fields=["content"],
exclude_fields=["modified"],
)
auditlog.register(Correspondent)
auditlog.register(Tag)
auditlog.register(DocumentType)

View File

@@ -5,6 +5,7 @@ import zoneinfo
from decimal import Decimal
import magic
from auditlog.context import set_actor
from celery import states
from django.conf import settings
from django.contrib.auth.models import Group
@@ -746,7 +747,11 @@ class DocumentSerializer(
for tag in instance.tags.all()
if tag not in inbox_tags_not_being_added
]
super().update(instance, validated_data)
if settings.AUDIT_LOG_ENABLED:
with set_actor(self.user):
super().update(instance, validated_data)
else:
super().update(instance, validated_data)
return instance
def __init__(self, *args, **kwargs):

View File

@@ -316,6 +316,133 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
response = self.client.get(f"/api/documents/{doc.pk}/thumb/")
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_document_history_action(self):
"""
GIVEN:
- Document
WHEN:
- Document is updated
THEN:
- Audit log contains changes
"""
doc = Document.objects.create(
title="First title",
checksum="123",
mime_type="application/pdf",
)
self.client.force_login(user=self.user)
self.client.patch(
f"/api/documents/{doc.pk}/",
{"title": "New title"},
format="json",
)
response = self.client.get(f"/api/documents/{doc.pk}/history/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 2)
self.assertEqual(response.data[0]["actor"]["id"], self.user.id)
self.assertEqual(response.data[0]["action"], "update")
self.assertEqual(
response.data[0]["changes"],
{"title": ["First title", "New title"]},
)
def test_document_history_action_w_custom_fields(self):
"""
GIVEN:
- Document with custom fields
WHEN:
- Document is updated
THEN:
- Audit log contains custom field changes
"""
doc = Document.objects.create(
title="First title",
checksum="123",
mime_type="application/pdf",
)
custom_field = CustomField.objects.create(
name="custom field str",
data_type=CustomField.FieldDataType.STRING,
)
self.client.force_login(user=self.user)
self.client.patch(
f"/api/documents/{doc.pk}/",
data={
"custom_fields": [
{
"field": custom_field.pk,
"value": "custom value",
},
],
},
format="json",
)
response = self.client.get(f"/api/documents/{doc.pk}/history/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data[1]["actor"]["id"], self.user.id)
self.assertEqual(response.data[1]["action"], "create")
self.assertEqual(
response.data[1]["changes"],
{
"custom_fields": {
"type": "custom_field",
"field": "custom field str",
"value": "custom value",
},
},
)
@override_settings(AUDIT_LOG_ENABLED=False)
def test_document_history_action_disabled(self):
"""
GIVEN:
- Audit log is disabled
WHEN:
- Document is updated
- Audit log is requested
THEN:
- Audit log returns HTTP 400 Bad Request
"""
doc = Document.objects.create(
title="First title",
checksum="123",
mime_type="application/pdf",
)
self.client.force_login(user=self.user)
self.client.patch(
f"/api/documents/{doc.pk}/",
{"title": "New title"},
format="json",
)
response = self.client.get(f"/api/documents/{doc.pk}/history/")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_document_history_insufficient_perms(self):
"""
GIVEN:
- Audit log is disabled
WHEN:
- Document is updated
- Audit log is requested
THEN:
- Audit log returns HTTP 400 Bad Request
"""
user = User.objects.create_user(username="test")
user.user_permissions.add(*Permission.objects.filter(codename="view_document"))
self.client.force_login(user=user)
doc = Document.objects.create(
title="First title",
checksum="123",
mime_type="application/pdf",
owner=user,
)
response = self.client.get(f"/api/documents/{doc.pk}/history/")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_document_filters(self):
doc1 = Document.objects.create(
title="none1",

View File

@@ -39,6 +39,7 @@ class TestApiUiSettings(DirectoriesMixin, APITestCase):
{
"app_title": None,
"app_logo": None,
"auditlog_enabled": True,
"update_checking": {
"backend_setting": "default",
},

View File

@@ -4,6 +4,7 @@ import tempfile
from pathlib import Path
from unittest import mock
from auditlog.context import disable_auditlog
from django.conf import settings
from django.contrib.auth.models import User
from django.db import DatabaseError
@@ -143,7 +144,9 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
# Set a correspondent and save the document
document.correspondent = Correspondent.objects.get_or_create(name="test")[0]
with mock.patch("documents.signals.handlers.Document.objects.filter") as m:
with mock.patch(
"documents.signals.handlers.Document.objects.filter",
) as m, disable_auditlog():
m.side_effect = DatabaseError()
document.save()
@@ -557,20 +560,21 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
@override_settings(FILENAME_FORMAT="{title}")
@mock.patch("documents.signals.handlers.Document.objects.filter")
def test_no_update_without_change(self, m):
doc = Document.objects.create(
title="document",
filename="document.pdf",
archive_filename="document.pdf",
checksum="A",
archive_checksum="B",
mime_type="application/pdf",
)
Path(doc.source_path).touch()
Path(doc.archive_path).touch()
with disable_auditlog():
doc = Document.objects.create(
title="document",
filename="document.pdf",
archive_filename="document.pdf",
checksum="A",
archive_checksum="B",
mime_type="application/pdf",
)
Path(doc.source_path).touch()
Path(doc.archive_path).touch()
doc.save()
doc.save()
m.assert_not_called()
m.assert_not_called()
class TestFileHandlingWithArchive(DirectoriesMixin, FileSystemAssertsMixin, TestCase):

View File

@@ -18,6 +18,7 @@ import pathvalidate
from django.apps import apps
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.db import connections
from django.db.migrations.loader import MigrationLoader
from django.db.migrations.recorder import MigrationRecorder
@@ -105,6 +106,7 @@ from documents.matching import match_storage_paths
from documents.matching import match_tags
from documents.models import Correspondent
from documents.models import CustomField
from documents.models import CustomFieldInstance
from documents.models import Document
from documents.models import DocumentType
from documents.models import Note
@@ -729,6 +731,66 @@ class DocumentViewSet(
]
return Response(links)
@action(methods=["get"], detail=True, name="Audit Trail")
def history(self, request, pk=None):
if not settings.AUDIT_LOG_ENABLED:
return HttpResponseBadRequest("Audit log is disabled")
try:
doc = Document.objects.get(pk=pk)
if not request.user.has_perm("auditlog.view_logentry") or (
doc.owner is not None and doc.owner != request.user
):
return HttpResponseForbidden(
"Insufficient permissions",
)
except Document.DoesNotExist: # pragma: no cover
raise Http404
# documents
entries = [
{
"id": entry.id,
"timestamp": entry.timestamp,
"action": entry.get_action_display(),
"changes": entry.changes,
"actor": (
{"id": entry.actor.id, "username": entry.actor.username}
if entry.actor
else None
),
}
for entry in LogEntry.objects.filter(object_pk=doc.pk).select_related(
"actor",
)
]
# custom fields
for entry in LogEntry.objects.filter(
object_pk__in=doc.custom_fields.values_list("id", flat=True),
content_type=ContentType.objects.get_for_model(CustomFieldInstance),
).select_related("actor"):
entries.append(
{
"id": entry.id,
"timestamp": entry.timestamp,
"action": entry.get_action_display(),
"changes": {
"custom_fields": {
"type": "custom_field",
"field": str(entry.object_repr).split(":")[0].strip(),
"value": str(entry.object_repr).split(":")[1].strip(),
},
},
"actor": (
{"id": entry.actor.id, "username": entry.actor.username}
if entry.actor
else None
),
},
)
return Response(sorted(entries, key=lambda x: x["timestamp"], reverse=True))
class SearchResultSerializer(DocumentSerializer, PassUserMixin):
def to_representation(self, instance):
@@ -1267,6 +1329,8 @@ class UiSettingsView(GenericAPIView):
if general_config.app_logo is not None and len(general_config.app_logo) > 0:
ui_settings["app_logo"] = general_config.app_logo
ui_settings["auditlog_enabled"] = settings.AUDIT_LOG_ENABLED
user_resp = {
"id": user.id,
"username": user.username,