Skip to content

Commit 5d5ac0c

Browse files
committed
✨(backend) allow the duplication of subpages
Adds a new with_descendants parameter to the doc duplication API. The logic of the duplicate() method has been moved to a new internal _duplicate_document() method to allow for recursion. Adds unit tests for the new feature.
1 parent d0b7565 commit 5d5ac0c

File tree

4 files changed

+543
-49
lines changed

4 files changed

+543
-49
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to
1414
- ✨(frontend) Can print a doc #1832
1515
- ✨(backend) manage reconciliation requests for user accounts #1878
1616
- 👷(CI) add GHCR workflow for forked repo testing #1851
17+
- ✨(backend) allow the duplication of subpages #1893
1718

1819
### Changed
1920

src/backend/core/api/serializers.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -591,10 +591,13 @@ def validate(self, attrs):
591591
class DocumentDuplicationSerializer(serializers.Serializer):
592592
"""
593593
Serializer for duplicating a document.
594-
Allows specifying whether to keep access permissions.
594+
Allows specifying whether to keep access permissions,
595+
and whether to duplicate descendant documents as well
596+
(deep copy) or not (shallow copy).
595597
"""
596598

597599
with_accesses = serializers.BooleanField(default=False)
600+
with_descendants = serializers.BooleanField(default=False)
598601

599602
def create(self, validated_data):
600603
"""

src/backend/core/api/viewsets.py

Lines changed: 117 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1208,11 +1208,7 @@ def tree(self, request, pk, *args, **kwargs):
12081208
@transaction.atomic
12091209
def duplicate(self, request, *args, **kwargs):
12101210
"""
1211-
Duplicate a document and store the links to attached files in the duplicated
1212-
document to allow cross-access.
1213-
1214-
Optionally duplicates accesses if `with_accesses` is set to true
1215-
in the payload.
1211+
Duplicate a document, alongside its descendants if requested.
12161212
"""
12171213
# Get document while checking permissions
12181214
document_to_duplicate = self.get_object()
@@ -1221,8 +1217,43 @@ def duplicate(self, request, *args, **kwargs):
12211217
data=request.data, partial=True
12221218
)
12231219
serializer.is_valid(raise_exception=True)
1220+
user = request.user
1221+
1222+
duplicated_document = self._duplicate_document(
1223+
document_to_duplicate=document_to_duplicate,
1224+
serializer=serializer,
1225+
user=user,
1226+
)
1227+
1228+
return drf_response.Response(
1229+
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
1230+
)
1231+
1232+
def _duplicate_document(
1233+
self,
1234+
document_to_duplicate,
1235+
serializer,
1236+
user,
1237+
new_parent=None,
1238+
):
1239+
"""
1240+
Duplicate a document and store the links to attached files in the duplicated
1241+
document to allow cross-access.
1242+
1243+
Optionally duplicates accesses if `with_accesses` is set to true
1244+
in the payload.
1245+
1246+
Optionally duplicates sub-documents if `with_descendants` is set to true in
1247+
the payload. In this case, the whole subtree of the document will be duplicated,
1248+
and the links to attached files will be stored in all duplicated documents.
1249+
1250+
The `with_accesses` option will also be applied to all duplicated documents
1251+
if `with_descendants` is set to true.
1252+
"""
12241253
with_accesses = serializer.validated_data.get("with_accesses", False)
1225-
user_role = document_to_duplicate.get_role(request.user)
1254+
with_descendants = serializer.validated_data.get("with_descendants", False)
1255+
1256+
user_role = document_to_duplicate.get_role(user)
12261257
is_owner_or_admin = user_role in models.PRIVILEGED_ROLES
12271258

12281259
base64_yjs_content = document_to_duplicate.content
@@ -1241,68 +1272,106 @@ def duplicate(self, request, *args, **kwargs):
12411272
extracted_attachments & set(document_to_duplicate.attachments)
12421273
)
12431274
title = capfirst(_("copy of {title}").format(title=document_to_duplicate.title))
1244-
if not document_to_duplicate.is_root() and choices.RoleChoices.get_priority(
1245-
user_role
1246-
) < choices.RoleChoices.get_priority(models.RoleChoices.EDITOR):
1247-
duplicated_document = models.Document.add_root(
1248-
creator=self.request.user,
1275+
# If parent_duplicate is provided we must add the duplicated document as a child
1276+
if new_parent is not None:
1277+
duplicated_document = new_parent.add_child(
12491278
title=title,
12501279
content=base64_yjs_content,
12511280
attachments=attachments,
12521281
duplicated_from=document_to_duplicate,
1282+
creator=user,
12531283
**link_kwargs,
12541284
)
1255-
models.DocumentAccess.objects.create(
1256-
document=duplicated_document,
1257-
user=self.request.user,
1258-
role=models.RoleChoices.OWNER,
1259-
)
1260-
return drf_response.Response(
1261-
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
1262-
)
12631285

1264-
duplicated_document = document_to_duplicate.add_sibling(
1265-
"right",
1266-
title=title,
1267-
content=base64_yjs_content,
1268-
attachments=attachments,
1269-
duplicated_from=document_to_duplicate,
1270-
creator=request.user,
1271-
**link_kwargs,
1272-
)
1273-
1274-
# Always add the logged-in user as OWNER for root documents
1275-
if document_to_duplicate.is_root():
1276-
accesses_to_create = [
1277-
models.DocumentAccess(
1278-
document=duplicated_document,
1279-
user=request.user,
1280-
role=models.RoleChoices.OWNER,
1281-
)
1282-
]
1283-
1284-
# If accesses should be duplicated, add other users' accesses as per original document
1286+
# Handle access duplication for this child
12851287
if with_accesses and is_owner_or_admin:
12861288
original_accesses = models.DocumentAccess.objects.filter(
12871289
document=document_to_duplicate
1288-
).exclude(user=request.user)
1290+
).exclude(user=user)
12891291

1290-
accesses_to_create.extend(
1292+
accesses_to_create = [
12911293
models.DocumentAccess(
12921294
document=duplicated_document,
12931295
user_id=access.user_id,
12941296
team=access.team,
12951297
role=access.role,
12961298
)
12971299
for access in original_accesses
1298-
)
1300+
]
12991301

1300-
# Bulk create all the duplicated accesses
1301-
models.DocumentAccess.objects.bulk_create(accesses_to_create)
1302+
if accesses_to_create:
1303+
models.DocumentAccess.objects.bulk_create(accesses_to_create)
13021304

1303-
return drf_response.Response(
1304-
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
1305-
)
1305+
elif not document_to_duplicate.is_root() and choices.RoleChoices.get_priority(
1306+
user_role
1307+
) < choices.RoleChoices.get_priority(models.RoleChoices.EDITOR):
1308+
duplicated_document = models.Document.add_root(
1309+
creator=user,
1310+
title=title,
1311+
content=base64_yjs_content,
1312+
attachments=attachments,
1313+
duplicated_from=document_to_duplicate,
1314+
**link_kwargs,
1315+
)
1316+
models.DocumentAccess.objects.create(
1317+
document=duplicated_document,
1318+
user=user,
1319+
role=models.RoleChoices.OWNER,
1320+
)
1321+
else:
1322+
duplicated_document = document_to_duplicate.add_sibling(
1323+
"right",
1324+
title=title,
1325+
content=base64_yjs_content,
1326+
attachments=attachments,
1327+
duplicated_from=document_to_duplicate,
1328+
creator=user,
1329+
**link_kwargs,
1330+
)
1331+
1332+
# Always add the logged-in user as OWNER for root documents
1333+
if document_to_duplicate.is_root():
1334+
accesses_to_create = [
1335+
models.DocumentAccess(
1336+
document=duplicated_document,
1337+
user=user,
1338+
role=models.RoleChoices.OWNER,
1339+
)
1340+
]
1341+
1342+
# If accesses should be duplicated,
1343+
# add other users' accesses as per original document
1344+
if with_accesses and is_owner_or_admin:
1345+
original_accesses = models.DocumentAccess.objects.filter(
1346+
document=document_to_duplicate
1347+
).exclude(user=user)
1348+
1349+
accesses_to_create.extend(
1350+
models.DocumentAccess(
1351+
document=duplicated_document,
1352+
user_id=access.user_id,
1353+
team=access.team,
1354+
role=access.role,
1355+
)
1356+
for access in original_accesses
1357+
)
1358+
1359+
# Bulk create all the duplicated accesses
1360+
models.DocumentAccess.objects.bulk_create(accesses_to_create)
1361+
1362+
if with_descendants:
1363+
for child in document_to_duplicate.get_children().filter(
1364+
ancestors_deleted_at__isnull=True
1365+
):
1366+
# When duplicating descendants, attach duplicates under the duplicated_document
1367+
self._duplicate_document(
1368+
document_to_duplicate=child,
1369+
serializer=serializer,
1370+
user=user,
1371+
new_parent=duplicated_document,
1372+
)
1373+
1374+
return duplicated_document
13061375

13071376
def _search_simple(self, request, text):
13081377
"""

0 commit comments

Comments
 (0)