Skip to content

Commit fb93735

Browse files
committed
🐛(backend): prevent restoring a document when an ancestor is deleted
Add a validation in restore() to block restoration if any ancestor is soft-deleted. This avoids a 500 error when attempting to restore a child whose parent is deleted. Signed-off-by: dakshesh14 <65905942+dakshesh14@users.noreply.github.com>
1 parent 7f9869f commit fb93735

9 files changed

Lines changed: 66 additions & 37 deletions

File tree

src/backend/core/api/serializers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ class Meta:
210210
"updated_at",
211211
"user_role",
212212
"websocket",
213+
"has_deleted_ancestor",
213214
]
214215
read_only_fields = [
215216
"id",
@@ -231,6 +232,7 @@ class Meta:
231232
"path",
232233
"updated_at",
233234
"user_role",
235+
"has_deleted_ancestor",
234236
]
235237

236238
def get_fields(self):

src/backend/core/models.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1233,6 +1233,11 @@ def computed_link_role(self):
12331233
"""Actual link role on the document."""
12341234
return self.computed_link_definition["link_role"]
12351235

1236+
@property
1237+
def has_deleted_ancestor(self):
1238+
"""Check whether any ancestor document is deleted."""
1239+
return self.get_ancestors().filter(deleted_at__isnull=False).exists()
1240+
12361241
def get_abilities(self, user):
12371242
"""
12381243
Compute and return abilities for a given user on the document.
@@ -1432,6 +1437,11 @@ def soft_delete(self):
14321437
@transaction.atomic
14331438
def restore(self):
14341439
"""Cancelling a soft delete with checks."""
1440+
if self.has_deleted_ancestor:
1441+
raise ValidationError(
1442+
"Cannot restore this document because one of its ancestors is deleted."
1443+
)
1444+
14351445
# This should not happen
14361446
if self._meta.model.objects.filter(
14371447
pk=self.pk, deleted_at__isnull=True

src/backend/core/tests/documents/test_api_documents_favorite_list.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ def test_api_document_favorite_list_authenticated_with_favorite():
8080
"title": document.title,
8181
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
8282
"user_role": "reader",
83+
"has_deleted_ancestor": False,
8384
}
8485
],
8586
}

src/backend/core/tests/documents/test_api_documents_restore.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,7 @@ def test_api_documents_restore_authenticated_owner_success():
7878

7979
def test_api_documents_restore_authenticated_owner_ancestor_deleted():
8080
"""
81-
The restored document should still be marked as deleted if one of its
82-
ancestors is soft deleted as well.
81+
Restore should fail if one of the ancestors is soft deleted.
8382
"""
8483
user = factories.UserFactory()
8584
client = APIClient()
@@ -100,14 +99,16 @@ def test_api_documents_restore_authenticated_owner_ancestor_deleted():
10099

101100
response = client.post(f"/api/v1.0/documents/{document.id!s}/restore/")
102101

103-
assert response.status_code == 200
104-
assert response.json() == {"detail": "Document has been successfully restored."}
102+
# Restore should be rejected
103+
assert response.status_code == 400
104+
assert response.json()[0] == (
105+
"Cannot restore this document because one of its ancestors is deleted."
106+
)
105107

108+
# Ensure state is unchanged
106109
document.refresh_from_db()
107-
assert document.deleted_at is None
108-
# document is still marked as deleted
109-
assert document.ancestors_deleted_at == grand_parent_deleted_at
110-
assert grand_parent_deleted_at > document_deleted_at
110+
assert document.deleted_at == document_deleted_at
111+
assert document.ancestors_deleted_at == document_deleted_at
111112

112113

113114
def test_api_documents_restore_authenticated_owner_expired():

src/backend/core/tests/documents/test_api_documents_retrieve.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
8686
"title": document.title,
8787
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
8888
"user_role": None,
89+
"has_deleted_ancestor": False,
8990
}
9091

9192

@@ -164,6 +165,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
164165
"title": document.title,
165166
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
166167
"user_role": None,
168+
"has_deleted_ancestor": False,
167169
}
168170

169171

@@ -275,6 +277,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
275277
"title": document.title,
276278
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
277279
"user_role": None,
280+
"has_deleted_ancestor": False,
278281
}
279282
assert (
280283
models.LinkTrace.objects.filter(document=document, user=user).exists() is True
@@ -360,6 +363,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
360363
"title": document.title,
361364
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
362365
"user_role": None,
366+
"has_deleted_ancestor": False,
363367
}
364368

365369

@@ -475,6 +479,7 @@ def test_api_documents_retrieve_authenticated_related_direct():
475479
"title": document.title,
476480
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
477481
"user_role": access.role,
482+
"has_deleted_ancestor": False,
478483
}
479484

480485

@@ -560,6 +565,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
560565
"title": document.title,
561566
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
562567
"user_role": access.role,
568+
"has_deleted_ancestor": False,
563569
}
564570

565571

@@ -717,6 +723,7 @@ def test_api_documents_retrieve_authenticated_related_team_members(
717723
"title": document.title,
718724
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
719725
"user_role": role,
726+
"has_deleted_ancestor": False,
720727
}
721728

722729

@@ -784,6 +791,7 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
784791
"title": document.title,
785792
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
786793
"user_role": role,
794+
"has_deleted_ancestor": False,
787795
}
788796

789797

@@ -851,6 +859,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
851859
"title": document.title,
852860
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
853861
"user_role": role,
862+
"has_deleted_ancestor": False,
854863
}
855864

856865

src/backend/core/tests/test_models_documents.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from django.utils import timezone
1818

1919
import pytest
20+
from rest_framework.exceptions import ValidationError as DRFValidationError
2021

2122
from core import factories, models
2223

@@ -1431,8 +1432,8 @@ def test_models_documents_restore(django_assert_num_queries):
14311432
assert document.ancestors_deleted_at == document.deleted_at
14321433

14331434

1434-
def test_models_documents_restore_complex(django_assert_num_queries):
1435-
"""The restore method should restore a soft-deleted document and its ancestors."""
1435+
def test_models_documents_restore_complex():
1436+
"""Restore should fail if a soft-deleted document has a deleted ancestor."""
14361437
grand_parent = factories.DocumentFactory()
14371438
parent = factories.DocumentFactory(parent=grand_parent)
14381439
document = factories.DocumentFactory(parent=parent)
@@ -1466,18 +1467,19 @@ def test_models_documents_restore_complex(django_assert_num_queries):
14661467
assert child1.ancestors_deleted_at == document.deleted_at
14671468
assert child2.ancestors_deleted_at == document.deleted_at
14681469

1469-
# Restore the item
1470-
with django_assert_num_queries(14):
1470+
# Restore should fail because ancestor is deleted
1471+
with pytest.raises(DRFValidationError):
14711472
document.restore()
14721473
document.refresh_from_db()
14731474
child1.refresh_from_db()
14741475
child2.refresh_from_db()
14751476
grand_parent.refresh_from_db()
1476-
assert document.deleted_at is None
1477-
assert document.ancestors_deleted_at == grand_parent.deleted_at
1478-
# child 1 and child 2 should now have the same ancestors_deleted_at as the grand parent
1479-
assert child1.ancestors_deleted_at == grand_parent.deleted_at
1480-
assert child2.ancestors_deleted_at == grand_parent.deleted_at
1477+
1478+
# State remains unchanged
1479+
assert document.deleted_at is not None
1480+
assert document.ancestors_deleted_at == document.deleted_at
1481+
assert child1.ancestors_deleted_at == document.deleted_at
1482+
assert child2.ancestors_deleted_at == document.deleted_at
14811483

14821484

14831485
def test_models_documents_restore_complex_bis(django_assert_num_queries):

src/frontend/apps/impress/src/features/docs/doc-header/components/AlertRestore.tsx

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -73,26 +73,28 @@ export const AlertRestore = ({ doc }: { doc: Doc }) => {
7373
/>
7474
{t('Document deleted')}
7575
</Box>
76-
<Button
77-
onClick={() =>
78-
restoreDoc({
79-
docId: doc.id,
80-
})
81-
}
82-
color="error"
83-
variant="tertiary"
84-
size="nano"
85-
icon={
86-
<Icon
87-
iconName="undo"
88-
$withThemeInherited
89-
$size="18px"
90-
variant="symbols-outlined"
91-
/>
92-
}
93-
>
94-
Restore
95-
</Button>
76+
{!doc.has_deleted_ancestor && (
77+
<Button
78+
onClick={() =>
79+
restoreDoc({
80+
docId: doc.id,
81+
})
82+
}
83+
color="error"
84+
variant="tertiary"
85+
size="nano"
86+
icon={
87+
<Icon
88+
iconName="undo"
89+
$withThemeInherited
90+
$size="18px"
91+
variant="symbols-outlined"
92+
/>
93+
}
94+
>
95+
Restore
96+
</Button>
97+
)}
9698
</Card>
9799
);
98100
};

src/frontend/apps/impress/src/features/docs/doc-management/types.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export interface Doc {
7171
numchild: number;
7272
updated_at: string;
7373
user_role: Role;
74+
has_deleted_ancestor: boolean;
7475
abilities: {
7576
accesses_manage: boolean;
7677
accesses_view: boolean;

src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ export class ApiPlugin implements WorkboxPlugin {
218218
computed_link_role: LinkRole.READER,
219219
ancestors_link_reach: LinkReach.RESTRICTED,
220220
ancestors_link_role: undefined,
221+
has_deleted_ancestor: false,
221222
};
222223

223224
await DocsDB.cacheResponse(

0 commit comments

Comments
 (0)