Compare commits

..
Author SHA1 Message Date
shamoon cb1335d152 Update test_document_model.py 2026-09-07 19:17:29 -07:00
shamoon 21fb88e3f0 Fix: prevent orphaned versions from trashing 2026-09-07 19:11:20 -07:00
100 changed files with 34729 additions and 40466 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import logging import logging
import tempfile import tempfile
import uuid
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import Literal from typing import Literal
@@ -379,7 +380,7 @@ def delete(doc_ids: list[int]) -> Literal["OK"]:
) )
delete_ids = list({*doc_ids, *version_ids}) delete_ids = list({*doc_ids, *version_ids})
Document.objects.filter(id__in=delete_ids).delete() Document.objects.filter(id__in=delete_ids).delete(transaction_id=uuid.uuid4())
from documents.search import get_backend from documents.search import get_backend
+10 -2
View File
@@ -1,4 +1,5 @@
import datetime import datetime
import uuid
from pathlib import Path from pathlib import Path
from typing import Final from typing import Final
@@ -514,13 +515,20 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
def delete( def delete(
self, self,
*args, *args,
transaction_id=None,
**kwargs, **kwargs,
): ):
# If deleting a root document, move all its versions to trash as well. # Versions must share the root's transaction ID so they are restored
# together by django-softdelete.
if transaction_id is None:
transaction_id = uuid.uuid4()
if self.root_document_id is None: if self.root_document_id is None:
Document.objects.filter(root_document=self).delete() Document.objects.filter(root_document=self).delete(
transaction_id=transaction_id,
)
return super().delete( return super().delete(
*args, *args,
transaction_id=transaction_id,
**kwargs, **kwargs,
) )
+62
View File
@@ -207,3 +207,65 @@ class TestTrashAPI(DirectoriesMixin, APITestCase):
) )
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("have not yet been deleted", resp.data["documents"][0]) self.assertIn("have not yet been deleted", resp.data["documents"][0])
def _make_versioned_document(self) -> tuple[Document, list[Document]]:
root = Document.objects.create(
title="root",
content="root-content",
checksum="root",
mime_type="application/pdf",
)
versions = [
Document.objects.create(
title=f"v{index}",
content=f"v{index}-content",
checksum=f"v{index}",
mime_type="application/pdf",
root_document=root,
version_index=index,
)
for index in range(1, 3)
]
return root, versions
def test_api_trash_restore_document_restores_its_versions(self) -> None:
"""
GIVEN:
- Existing document with two versions
WHEN:
- API request to delete the document
- API request to restore it from the trash
THEN:
- Only the document itself is listed in the trash
- A version cannot be restored without its root
- The document is restored together with all of its versions
"""
root, versions = self._make_versioned_document()
self.client.force_login(user=self.user)
self.client.delete(f"/api/documents/{root.pk}/")
self.assertEqual(Document.deleted_objects.count(), 3)
resp = self.client.get("/api/trash/")
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(resp.data["count"], 1)
self.assertEqual(resp.data["results"][0]["id"], root.pk)
# A version cannot be restored while its root remains in the trash.
resp = self.client.post(
"/api/trash/",
{"action": "restore", "documents": [versions[0].pk]},
)
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("Restore the root document", resp.data["documents"][0])
resp = self.client.post(
"/api/trash/",
{"action": "restore", "documents": [root.pk]},
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
self.assertEqual(Document.deleted_objects.count(), 0)
self.assertCountEqual(
Document.objects.filter(root_document=root).values_list("id", flat=True),
[version.pk for version in versions],
)
+5
View File
@@ -392,6 +392,11 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
self.assertFalse(Document.objects.filter(id=self.doc1.id).exists()) self.assertFalse(Document.objects.filter(id=self.doc1.id).exists())
self.assertFalse(Document.objects.filter(id=version.id).exists()) self.assertFalse(Document.objects.filter(id=version.id).exists())
Document.deleted_objects.get(id=self.doc1.id).restore(strict=False)
self.assertTrue(Document.objects.filter(id=self.doc1.id).exists())
self.assertTrue(Document.objects.filter(id=version.id).exists())
def test_delete_version_document_keeps_root(self) -> None: def test_delete_version_document_keeps_root(self) -> None:
version = Document.objects.create( version = Document.objects.create(
checksum="A-v1", checksum="A-v1",
+5 -1
View File
@@ -110,7 +110,7 @@ class TestDocument(TestCase):
checksum="checksum", checksum="checksum",
mime_type="application/pdf", mime_type="application/pdf",
) )
Document.objects.create( version = Document.objects.create(
root_document=root, root_document=root,
correspondent=root.correspondent, correspondent=root.correspondent,
title="Version", title="Version",
@@ -124,6 +124,10 @@ class TestDocument(TestCase):
self.assertEqual(Document.objects.count(), 0) self.assertEqual(Document.objects.count(), 0)
self.assertEqual(Document.deleted_objects.count(), 2) self.assertEqual(Document.deleted_objects.count(), 2)
root.restore(strict=False)
self.assertTrue(Document.objects.filter(pk=version.pk).exists())
def test_file_name(self) -> None: def test_file_name(self) -> None:
doc = Document( doc = Document(
mime_type="application/pdf", mime_type="application/pdf",
+13 -2
View File
@@ -5431,7 +5431,10 @@ class TrashView(ListModelMixin, PassUserMixin):
model = Document model = Document
queryset = Document.deleted_objects.all() # A version is listed separately only when its root is not in the trash.
queryset = Document.deleted_objects.exclude(
root_document_id__in=Document.deleted_objects.values("id"),
)
def get(self, request: Request, format: str | None = None) -> Response: def get(self, request: Request, format: str | None = None) -> Response:
self.serializer_class = DocumentSerializer self.serializer_class = DocumentSerializer
@@ -5462,7 +5465,15 @@ class TrashView(ListModelMixin, PassUserMixin):
return HttpResponseForbidden("Insufficient permissions") return HttpResponseForbidden("Insufficient permissions")
action = serializer.validated_data.get("action") action = serializer.validated_data.get("action")
if action == "restore": if action == "restore":
restored = list(Document.deleted_objects.filter(id__in=doc_ids)) restored = list(self.get_queryset().filter(id__in=doc_ids))
if len(restored) != len(doc_ids):
raise ValidationError(
{
"documents": [
"Restore the root document instead of one of its versions.",
],
},
)
for doc in restored: for doc in restored:
doc.restore(strict=False) doc.restore(strict=False)
if restored: if restored:
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff