Skip to content

Commit 4583e32

Browse files
committed
✨(backend) add management command to reset a Document
We need a management command to reset a Document to an initial state and deletes everything related to it. This command can be usefull to reset a demo for example.
1 parent 607bae0 commit 4583e32

File tree

3 files changed

+329
-1
lines changed

3 files changed

+329
-1
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ and this project adheres to
88

99
### Added
1010

11-
✨(frontend) Can print a doc #1832
11+
- ✨(frontend) Can print a doc #1832
12+
- ✨(backend) add management command to reset a Document
1213

1314
### Changed
1415

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""Clean a document by resetting it (keeping its title) and deleting all descendants."""
2+
3+
import logging
4+
5+
from django.conf import settings
6+
from django.core.files.storage import default_storage
7+
from django.core.management.base import BaseCommand, CommandError
8+
from django.db import transaction
9+
10+
from botocore.exceptions import ClientError
11+
12+
from core.choices import LinkReachChoices, LinkRoleChoices
13+
from core.models import Document, DocumentAccess, Invitation
14+
15+
logger = logging.getLogger("impress.commands.clean_document")
16+
17+
18+
class Command(BaseCommand):
19+
"""Reset a document (keeping its title) and delete all its descendants."""
20+
21+
help = __doc__
22+
23+
def add_arguments(self, parser):
24+
"""Define command arguments."""
25+
parser.add_argument(
26+
"document_id",
27+
type=str,
28+
help="UUID of the document to clean",
29+
)
30+
parser.add_argument(
31+
"-f",
32+
"--force",
33+
action="store_true",
34+
default=False,
35+
help="Force command execution despite DEBUG is set to False",
36+
)
37+
parser.add_argument(
38+
"-t",
39+
"--title",
40+
type=str,
41+
default=None,
42+
help="Update the document title to this value",
43+
)
44+
45+
def handle(self, *args, **options):
46+
"""Execute the clean_document command."""
47+
if not settings.DEBUG and not options["force"]:
48+
raise CommandError(
49+
"This command is not meant to be used in production environment "
50+
"except you know what you are doing, if so use --force parameter"
51+
)
52+
53+
document_id = options["document_id"]
54+
55+
try:
56+
document = Document.objects.get(pk=document_id)
57+
except (Document.DoesNotExist, ValueError) as err:
58+
raise CommandError(f"Document {document_id} does not exist.") from err
59+
60+
descendants = list(document.get_descendants())
61+
descendant_ids = [doc.id for doc in descendants]
62+
all_documents = [document, *descendants]
63+
64+
# Collect all attachment keys before the transaction clears them
65+
all_attachment_keys = []
66+
for doc in all_documents:
67+
all_attachment_keys.extend(doc.attachments)
68+
69+
self.stdout.write(
70+
f"Cleaning document {document_id} and deleting "
71+
f"{len(descendants)} descendant(s)..."
72+
)
73+
74+
with transaction.atomic():
75+
# Clean accesses and invitations on the root document
76+
access_count, _ = DocumentAccess.objects.filter(
77+
document_id=document.id
78+
).delete()
79+
self.stdout.write(f"Deleted {access_count} access(es) on root document.")
80+
81+
invitation_count, _ = Invitation.objects.filter(
82+
document_id=document.id
83+
).delete()
84+
self.stdout.write(
85+
f"Deleted {invitation_count} invitation(s) on root document."
86+
)
87+
88+
# Reset root document fields
89+
update_fields = {
90+
"excerpt": None,
91+
"link_reach": LinkReachChoices.RESTRICTED,
92+
"link_role": LinkRoleChoices.READER,
93+
"attachments": [],
94+
}
95+
if options["title"] is not None:
96+
update_fields["title"] = options["title"]
97+
Document.objects.filter(id=document.id).update(**update_fields)
98+
99+
if options["title"] is not None:
100+
self.stdout.write(
101+
f'Reset fields on root document (title set to "{options["title"]}").'
102+
)
103+
else:
104+
self.stdout.write("Reset fields on root document (title kept).")
105+
106+
# Delete all descendants (cascades accesses and invitations)
107+
if descendants:
108+
deleted_count, _ = Document.objects.filter(
109+
id__in=descendant_ids
110+
).delete()
111+
self.stdout.write(f"Deleted {deleted_count} descendant(s).")
112+
113+
# Delete S3 content outside the transaction (S3 is not transactional)
114+
s3_client = default_storage.connection.meta.client
115+
bucket = default_storage.bucket_name
116+
117+
for doc in all_documents:
118+
try:
119+
s3_client.delete_object(Bucket=bucket, Key=doc.file_key)
120+
except ClientError:
121+
logger.warning("Failed to delete S3 file for document %s", doc.id)
122+
123+
self.stdout.write(f"Deleted S3 content for {len(all_documents)} document(s).")
124+
125+
for key in all_attachment_keys:
126+
try:
127+
s3_client.delete_object(Bucket=bucket, Key=key)
128+
except ClientError:
129+
logger.warning("Failed to delete S3 attachment %s", key)
130+
131+
self.stdout.write(f"Deleted {len(all_attachment_keys)} attachment(s) from S3.")
132+
self.stdout.write("Done.")
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
"""Unit tests for the `clean_document` management command."""
2+
3+
from unittest import mock
4+
from uuid import uuid4
5+
6+
from django.core.management import CommandError, call_command
7+
8+
import pytest
9+
from botocore.exceptions import ClientError
10+
11+
from core import factories, models
12+
from core.choices import LinkReachChoices, LinkRoleChoices
13+
14+
15+
@pytest.mark.django_db
16+
def test_clean_document_with_descendants(settings):
17+
"""The command should reset the root (keeping title) and delete descendants."""
18+
settings.DEBUG = True
19+
20+
# Create a root document with subdocuments
21+
root = factories.DocumentFactory(
22+
title="Root",
23+
link_reach=LinkReachChoices.PUBLIC,
24+
link_role=LinkRoleChoices.EDITOR,
25+
)
26+
child = factories.DocumentFactory(
27+
parent=root,
28+
title="Child",
29+
link_reach=LinkReachChoices.AUTHENTICATED,
30+
link_role=LinkRoleChoices.EDITOR,
31+
)
32+
grandchild = factories.DocumentFactory(
33+
parent=child,
34+
title="Grandchild",
35+
)
36+
37+
# Create accesses and invitations
38+
factories.UserDocumentAccessFactory(document=root)
39+
factories.UserDocumentAccessFactory(document=child)
40+
factories.InvitationFactory(document=root)
41+
factories.InvitationFactory(document=child)
42+
43+
with mock.patch(
44+
"core.management.commands.clean_document.default_storage"
45+
) as mock_storage:
46+
call_command("clean_document", str(root.id), "--force")
47+
48+
# Root document should still exist with title kept and other fields reset
49+
root.refresh_from_db()
50+
assert root.title == "Root"
51+
assert root.excerpt is None
52+
assert root.link_reach == LinkReachChoices.RESTRICTED
53+
assert root.link_role == LinkRoleChoices.READER
54+
assert root.attachments == []
55+
56+
# Accesses and invitations on root should be deleted
57+
assert not models.DocumentAccess.objects.filter(document=root).exists()
58+
assert not models.Invitation.objects.filter(document=root).exists()
59+
60+
# Descendants should be deleted entirely
61+
assert not models.Document.objects.filter(id__in=[child.id, grandchild.id]).exists()
62+
63+
# Root should have no descendants
64+
root.refresh_from_db()
65+
assert root.get_descendants().count() == 0
66+
67+
# S3 delete should have been called for document files + attachments
68+
delete_calls = mock_storage.connection.meta.client.delete_object.call_args_list
69+
assert len(delete_calls) == 3
70+
71+
72+
@pytest.mark.django_db
73+
def test_clean_document_invalid_uuid(settings):
74+
"""The command should raise an error for a non-existent document."""
75+
settings.DEBUG = True
76+
77+
fake_id = str(uuid4())
78+
with pytest.raises(CommandError, match=f"Document {fake_id} does not exist."):
79+
call_command("clean_document", fake_id, "--force")
80+
81+
82+
@pytest.mark.django_db
83+
def test_clean_document_no_force_in_production(settings):
84+
"""The command should require --force when DEBUG is False."""
85+
settings.DEBUG = False
86+
87+
doc = factories.DocumentFactory()
88+
with pytest.raises(CommandError, match="not meant to be used in production"):
89+
call_command("clean_document", str(doc.id))
90+
91+
92+
@pytest.mark.django_db
93+
def test_clean_document_single_document(settings):
94+
"""The command should work on a single document without children."""
95+
settings.DEBUG = True
96+
97+
doc = factories.DocumentFactory(
98+
title="Single",
99+
link_reach=LinkReachChoices.PUBLIC,
100+
link_role=LinkRoleChoices.EDITOR,
101+
)
102+
access = factories.UserDocumentAccessFactory(document=doc)
103+
invitation = factories.InvitationFactory(document=doc)
104+
105+
with mock.patch(
106+
"core.management.commands.clean_document.default_storage"
107+
) as mock_storage:
108+
call_command("clean_document", str(doc.id), "--force")
109+
110+
assert not models.DocumentAccess.objects.filter(id=access.id).exists()
111+
assert not models.Invitation.objects.filter(id=invitation.id).exists()
112+
113+
doc.refresh_from_db()
114+
assert doc.title == "Single"
115+
assert doc.excerpt is None
116+
assert doc.link_reach == LinkReachChoices.RESTRICTED
117+
assert doc.link_role == LinkRoleChoices.READER
118+
assert doc.attachments == []
119+
120+
mock_storage.connection.meta.client.delete_object.assert_called_once()
121+
122+
123+
@pytest.mark.django_db
124+
def test_clean_document_with_title_option(settings):
125+
"""The --title option should update the document title."""
126+
settings.DEBUG = True
127+
128+
doc = factories.DocumentFactory(
129+
title="Old Title",
130+
link_reach=LinkReachChoices.PUBLIC,
131+
link_role=LinkRoleChoices.EDITOR,
132+
)
133+
134+
with mock.patch("core.management.commands.clean_document.default_storage"):
135+
call_command("clean_document", str(doc.id), "--force", "--title", "New Title")
136+
137+
doc.refresh_from_db()
138+
assert doc.title == "New Title"
139+
assert doc.excerpt is None
140+
assert doc.link_reach == LinkReachChoices.RESTRICTED
141+
assert doc.link_role == LinkRoleChoices.READER
142+
assert doc.attachments == []
143+
144+
145+
@pytest.mark.django_db
146+
def test_clean_document_deletes_attachments_from_s3(settings):
147+
"""The command should delete attachment files from S3."""
148+
settings.DEBUG = True
149+
150+
root = factories.DocumentFactory(
151+
attachments=["root-id/attachments/file1.png", "root-id/attachments/file2.pdf"],
152+
)
153+
child = factories.DocumentFactory(
154+
parent=root,
155+
attachments=["child-id/attachments/file3.png"],
156+
)
157+
158+
with mock.patch(
159+
"core.management.commands.clean_document.default_storage"
160+
) as mock_storage:
161+
call_command("clean_document", str(root.id), "--force")
162+
163+
delete_calls = mock_storage.connection.meta.client.delete_object.call_args_list
164+
deleted_keys = [call.kwargs["Key"] for call in delete_calls]
165+
166+
# Document files (root + child)
167+
assert root.file_key in deleted_keys
168+
assert child.file_key in deleted_keys
169+
170+
# Attachment files
171+
assert "root-id/attachments/file1.png" in deleted_keys
172+
assert "root-id/attachments/file2.pdf" in deleted_keys
173+
assert "child-id/attachments/file3.png" in deleted_keys
174+
175+
assert len(delete_calls) == 5
176+
177+
178+
@pytest.mark.django_db
179+
def test_clean_document_s3_errors_do_not_stop_command(settings):
180+
"""S3 deletion errors should be logged but not stop the command."""
181+
settings.DEBUG = True
182+
183+
doc = factories.DocumentFactory(
184+
attachments=["doc-id/attachments/file1.png"],
185+
)
186+
187+
with mock.patch(
188+
"core.management.commands.clean_document.default_storage"
189+
) as mock_storage:
190+
mock_storage.connection.meta.client.delete_object.side_effect = ClientError(
191+
{"Error": {"Code": "500", "Message": "Internal Error"}},
192+
"DeleteObject",
193+
)
194+
# Command should complete without raising
195+
call_command("clean_document", str(doc.id), "--force")

0 commit comments

Comments
 (0)