Skip to content

Commit a4758ff

Browse files
sampaccoudPanchoutNathan
authored andcommitted
✨(backend) add max ancestors role field to document access endpoint
This field is set only on the list view when all accesses for a given document and all its ancestors are listed. It gives the highest role among all accesses related to each document.
1 parent b868b6d commit a4758ff

File tree

5 files changed

+291
-17
lines changed

5 files changed

+291
-17
lines changed

src/backend/core/api/serializers.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ class DocumentAccessSerializer(BaseAccessSerializer):
124124
allow_null=True,
125125
)
126126
user = UserSerializer(read_only=True)
127+
max_ancestors_role = serializers.SerializerMethodField(read_only=True)
127128

128129
class Meta:
129130
model = models.DocumentAccess
@@ -136,8 +137,13 @@ class Meta:
136137
"team",
137138
"role",
138139
"abilities",
140+
"max_ancestors_role",
139141
]
140-
read_only_fields = ["id", "document_id", "abilities"]
142+
read_only_fields = ["id", "document_id", "abilities", "max_ancestors_role"]
143+
144+
def get_max_ancestors_role(self, instance):
145+
"""Return max_ancestors_role if annotated; else None."""
146+
return getattr(instance, "max_ancestors_role", None)
141147

142148

143149
class DocumentAccessLightSerializer(DocumentAccessSerializer):
@@ -155,13 +161,15 @@ class Meta:
155161
"team",
156162
"role",
157163
"abilities",
164+
"max_ancestors_role",
158165
]
159166
read_only_fields = [
160167
"id",
161168
"document_id",
162169
"team",
163170
"role",
164171
"abilities",
172+
"max_ancestors_role",
165173
]
166174

167175

src/backend/core/api/viewsets.py

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1472,33 +1472,52 @@ def list(self, request, *args, **kwargs):
14721472
)
14731473

14741474
# Annotate more information on roles
1475+
path_to_key_to_max_ancestors_role = defaultdict(
1476+
lambda: defaultdict(lambda: None)
1477+
)
14751478
path_to_ancestors_roles = defaultdict(list)
14761479
path_to_role = defaultdict(lambda: None)
14771480
for access in accesses:
1478-
if access.user_id == user.id or access.team in user.teams:
1479-
parent_path = access.document_path[: -models.Document.steplen]
1480-
if parent_path:
1481-
path_to_ancestors_roles[access.document_path].extend(
1482-
path_to_ancestors_roles[parent_path]
1483-
)
1484-
path_to_ancestors_roles[access.document_path].append(
1485-
path_to_role[parent_path]
1486-
)
1487-
else:
1488-
path_to_ancestors_roles[access.document_path] = []
1481+
key = access.target_key
1482+
path = access.document.path
1483+
parent_path = path[: -models.Document.steplen]
1484+
1485+
path_to_key_to_max_ancestors_role[path][key] = choices.RoleChoices.max(
1486+
path_to_key_to_max_ancestors_role[path][key], access.role
1487+
)
14891488

1490-
path_to_role[access.document_path] = choices.RoleChoices.max(
1491-
path_to_role[access.document_path], access.role
1489+
if parent_path:
1490+
path_to_key_to_max_ancestors_role[path][key] = choices.RoleChoices.max(
1491+
path_to_key_to_max_ancestors_role[parent_path][key],
1492+
path_to_key_to_max_ancestors_role[path][key],
1493+
)
1494+
path_to_ancestors_roles[path].extend(
1495+
path_to_ancestors_roles[parent_path]
1496+
)
1497+
path_to_ancestors_roles[path].append(path_to_role[parent_path])
1498+
else:
1499+
path_to_ancestors_roles[path] = []
1500+
1501+
if access.user_id == user.id or access.team in user.teams:
1502+
path_to_role[path] = choices.RoleChoices.max(
1503+
path_to_role[path], access.role
14921504
)
14931505

14941506
# serialize and return the response
14951507
context = self.get_serializer_context()
14961508
serializer_class = self.get_serializer_class()
14971509
serialized_data = []
14981510
for access in accesses:
1511+
path = access.document.path
1512+
parent_path = path[: -models.Document.steplen]
1513+
access.max_ancestors_role = (
1514+
path_to_key_to_max_ancestors_role[parent_path][access.target_key]
1515+
if parent_path
1516+
else None
1517+
)
14991518
access.set_user_roles_tuple(
1500-
choices.RoleChoices.max(*path_to_ancestors_roles[access.document_path]),
1501-
path_to_role.get(access.document_path),
1519+
choices.RoleChoices.max(*path_to_ancestors_roles[path]),
1520+
path_to_role.get(path),
15021521
)
15031522
serializer = serializer_class(access, context=context)
15041523
serialized_data.append(serializer.data)

src/backend/core/models.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1139,7 +1139,9 @@ def get_abilities(self, user):
11391139
set_role_to.append(RoleChoices.OWNER)
11401140

11411141
# Filter out roles that would be lower than the one the user already has
1142-
ancestors_role_priority = RoleChoices.get_priority(ancestors_role)
1142+
ancestors_role_priority = RoleChoices.get_priority(
1143+
getattr(self, "max_ancestors_role", None)
1144+
)
11431145
set_role_to = [
11441146
candidate_role
11451147
for candidate_role in set_role_to

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

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ def test_api_document_accesses_list_authenticated_related_non_privileged(
148148
else None,
149149
"team": access.team,
150150
"role": access.role,
151+
"max_ancestors_role": None,
151152
"abilities": {
152153
"destroy": False,
153154
"partial_update": False,
@@ -248,6 +249,7 @@ def test_api_document_accesses_list_authenticated_related_privileged(
248249
}
249250
if access.user
250251
else None,
252+
"max_ancestors_role": None,
251253
"team": access.team,
252254
"role": access.role,
253255
"abilities": access.get_abilities(user),
@@ -258,6 +260,245 @@ def test_api_document_accesses_list_authenticated_related_privileged(
258260
)
259261

260262

263+
def test_api_document_accesses_retrieve_set_role_to_child():
264+
"""Check set_role_to for an access with no access on the ancestor."""
265+
user, other_user = factories.UserFactory.create_batch(2)
266+
client = APIClient()
267+
client.force_login(user)
268+
269+
parent = factories.DocumentFactory()
270+
parent_access = factories.UserDocumentAccessFactory(
271+
document=parent, user=user, role="owner"
272+
)
273+
274+
document = factories.DocumentFactory(parent=parent)
275+
document_access_other_user = factories.UserDocumentAccessFactory(
276+
document=document, user=other_user, role="editor"
277+
)
278+
279+
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
280+
281+
assert response.status_code == 200
282+
content = response.json()
283+
assert len(content) == 2
284+
285+
result_dict = {
286+
result["id"]: result["abilities"]["set_role_to"] for result in content
287+
}
288+
assert result_dict[str(document_access_other_user.id)] == [
289+
"reader",
290+
"editor",
291+
"administrator",
292+
"owner",
293+
]
294+
assert result_dict[str(parent_access.id)] == []
295+
296+
# Add an access for the other user on the parent
297+
parent_access_other_user = factories.UserDocumentAccessFactory(
298+
document=parent, user=other_user, role="editor"
299+
)
300+
301+
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
302+
303+
assert response.status_code == 200
304+
content = response.json()
305+
assert len(content) == 3
306+
307+
result_dict = {
308+
result["id"]: result["abilities"]["set_role_to"] for result in content
309+
}
310+
assert result_dict[str(document_access_other_user.id)] == [
311+
"editor",
312+
"administrator",
313+
"owner",
314+
]
315+
assert result_dict[str(parent_access.id)] == []
316+
assert result_dict[str(parent_access_other_user.id)] == [
317+
"reader",
318+
"editor",
319+
"administrator",
320+
"owner",
321+
]
322+
323+
324+
@pytest.mark.parametrize(
325+
"roles,results",
326+
[
327+
[
328+
["administrator", "reader", "reader", "reader"],
329+
[
330+
["reader", "editor", "administrator"],
331+
[],
332+
[],
333+
["reader", "editor", "administrator"],
334+
],
335+
],
336+
[
337+
["owner", "reader", "reader", "reader"],
338+
[[], [], [], ["reader", "editor", "administrator", "owner"]],
339+
],
340+
[
341+
["owner", "reader", "reader", "owner"],
342+
[
343+
["reader", "editor", "administrator", "owner"],
344+
[],
345+
[],
346+
["reader", "editor", "administrator", "owner"],
347+
],
348+
],
349+
],
350+
)
351+
def test_api_document_accesses_list_authenticated_related_same_user(roles, results):
352+
"""
353+
The maximum role across ancestor documents and set_role_to optionsfor
354+
a given user should be filled as expected.
355+
"""
356+
user = factories.UserFactory()
357+
client = APIClient()
358+
client.force_login(user)
359+
360+
# Create documents structured as a tree
361+
grand_parent = factories.DocumentFactory(link_reach="authenticated")
362+
parent = factories.DocumentFactory(parent=grand_parent)
363+
document = factories.DocumentFactory(parent=parent)
364+
365+
# Create accesses for another user
366+
other_user = factories.UserFactory()
367+
accesses = [
368+
factories.UserDocumentAccessFactory(
369+
document=document, user=user, role=roles[0]
370+
),
371+
factories.UserDocumentAccessFactory(
372+
document=grand_parent, user=other_user, role=roles[1]
373+
),
374+
factories.UserDocumentAccessFactory(
375+
document=parent, user=other_user, role=roles[2]
376+
),
377+
factories.UserDocumentAccessFactory(
378+
document=document, user=other_user, role=roles[3]
379+
),
380+
]
381+
382+
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
383+
384+
assert response.status_code == 200
385+
content = response.json()
386+
assert len(content) == 4
387+
388+
for result in content:
389+
assert (
390+
result["max_ancestors_role"] is None
391+
if result["user"]["id"] == str(user.id)
392+
else choices.RoleChoices.max(roles[1], roles[2])
393+
)
394+
395+
result_dict = {
396+
result["id"]: result["abilities"]["set_role_to"] for result in content
397+
}
398+
assert [result_dict[str(access.id)] for access in accesses] == results
399+
400+
401+
@pytest.mark.parametrize(
402+
"roles,results",
403+
[
404+
[
405+
["administrator", "reader", "reader", "reader"],
406+
[
407+
["reader", "editor", "administrator"],
408+
[],
409+
[],
410+
["reader", "editor", "administrator"],
411+
],
412+
],
413+
[
414+
["owner", "reader", "reader", "reader"],
415+
[[], [], [], ["reader", "editor", "administrator", "owner"]],
416+
],
417+
[
418+
["owner", "reader", "reader", "owner"],
419+
[
420+
["reader", "editor", "administrator", "owner"],
421+
[],
422+
[],
423+
["reader", "editor", "administrator", "owner"],
424+
],
425+
],
426+
[
427+
["reader", "reader", "reader", "owner"],
428+
[["reader", "editor", "administrator", "owner"], [], [], []],
429+
],
430+
[
431+
["reader", "administrator", "reader", "editor"],
432+
[
433+
["reader", "editor", "administrator"],
434+
["reader", "editor", "administrator"],
435+
[],
436+
[],
437+
],
438+
],
439+
[
440+
["editor", "editor", "administrator", "editor"],
441+
[
442+
["reader", "editor", "administrator"],
443+
[],
444+
["editor", "administrator"],
445+
[],
446+
],
447+
],
448+
],
449+
)
450+
def test_api_document_accesses_list_authenticated_related_same_team(
451+
roles, results, mock_user_teams
452+
):
453+
"""
454+
The maximum role across ancestor documents and set_role_to optionsfor
455+
a given team should be filled as expected.
456+
"""
457+
user = factories.UserFactory()
458+
client = APIClient()
459+
client.force_login(user)
460+
461+
# Create documents structured as a tree
462+
grand_parent = factories.DocumentFactory(link_reach="authenticated")
463+
parent = factories.DocumentFactory(parent=grand_parent)
464+
document = factories.DocumentFactory(parent=parent)
465+
466+
mock_user_teams.return_value = ["lasuite", "unknown"]
467+
accesses = [
468+
factories.UserDocumentAccessFactory(
469+
document=document, user=user, role=roles[0]
470+
),
471+
# Create accesses for a team
472+
factories.TeamDocumentAccessFactory(
473+
document=grand_parent, team="lasuite", role=roles[1]
474+
),
475+
factories.TeamDocumentAccessFactory(
476+
document=parent, team="lasuite", role=roles[2]
477+
),
478+
factories.TeamDocumentAccessFactory(
479+
document=document, team="lasuite", role=roles[3]
480+
),
481+
]
482+
483+
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
484+
485+
assert response.status_code == 200
486+
content = response.json()
487+
assert len(content) == 4
488+
489+
for result in content:
490+
assert (
491+
result["max_ancestors_role"] is None
492+
if result["user"] and result["user"]["id"] == str(user.id)
493+
else choices.RoleChoices.max(roles[1], roles[2])
494+
)
495+
496+
result_dict = {
497+
result["id"]: result["abilities"]["set_role_to"] for result in content
498+
}
499+
assert [result_dict[str(access.id)] for access in accesses] == results
500+
501+
261502
def test_api_document_accesses_retrieve_anonymous():
262503
"""
263504
Anonymous users should not be allowed to retrieve a document access.
@@ -353,6 +594,7 @@ def test_api_document_accesses_retrieve_authenticated_related(
353594
"user": access_user,
354595
"team": "",
355596
"role": access.role,
597+
"max_ancestors_role": None,
356598
"abilities": access.get_abilities(user),
357599
}
358600

0 commit comments

Comments
 (0)