Skip to content

Commit 883be71

Browse files
committed
✨(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 0abd530 commit 883be71

File tree

4 files changed

+195
-1
lines changed

4 files changed

+195
-1
lines changed

src/backend/core/api/serializers.py

+9-1
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

+8
Original file line numberDiff line numberDiff line change
@@ -1395,9 +1395,16 @@ def list(self, request, *args, **kwargs):
13951395
)
13961396

13971397
# Annotate more information on roles
1398+
key_to_max_ancestors_role = defaultdict(lambda: None)
13981399
path_to_ancestors_roles = defaultdict(list)
13991400
path_to_role = defaultdict(lambda: None)
14001401
for access in accesses:
1402+
key = access.target_key
1403+
if access.document_path != self.document.path:
1404+
key_to_max_ancestors_role[key] = choices.RoleChoices.max(
1405+
key_to_max_ancestors_role[key], access.role
1406+
)
1407+
14011408
if access.user_id == user.id or access.team in user.teams:
14021409
parent_path = access.document_path[: -models.Document.steplen]
14031410
if parent_path:
@@ -1419,6 +1426,7 @@ def list(self, request, *args, **kwargs):
14191426
serializer_class = self.get_serializer_class()
14201427
serialized_data = []
14211428
for access in accesses:
1429+
access.max_ancestors_role = key_to_max_ancestors_role[access.target_key]
14221430
access.user_roles_tuple = (
14231431
choices.RoleChoices.max(*path_to_ancestors_roles[access.document_path]),
14241432
path_to_role.get(access.document_path),

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

+175
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,9 @@ 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": access.role
152+
if access.document_id != document.id
153+
else None,
151154
"abilities": {
152155
"destroy": False,
153156
"partial_update": False,
@@ -248,6 +251,9 @@ def test_api_document_accesses_list_authenticated_related_privileged(
248251
}
249252
if access.user
250253
else None,
254+
"max_ancestors_role": access.role
255+
if access.document_id != document.id
256+
else None,
251257
"team": access.team,
252258
"role": access.role,
253259
"abilities": access.get_abilities(user),
@@ -258,6 +264,174 @@ def test_api_document_accesses_list_authenticated_related_privileged(
258264
)
259265

260266

267+
@pytest.mark.parametrize(
268+
"roles,results",
269+
[
270+
[
271+
["administrator", "reader", "reader", "reader"],
272+
[
273+
["reader", "editor", "administrator"],
274+
[],
275+
[],
276+
["reader", "editor", "administrator"],
277+
],
278+
],
279+
[
280+
["owner", "reader", "reader", "reader"],
281+
[[], [], [], ["reader", "editor", "administrator", "owner"]],
282+
],
283+
[
284+
["owner", "reader", "reader", "owner"],
285+
[
286+
["reader", "editor", "administrator", "owner"],
287+
[],
288+
[],
289+
["reader", "editor", "administrator", "owner"],
290+
],
291+
],
292+
],
293+
)
294+
def test_api_document_accesses_list_authenticated_related_same_user(roles, results):
295+
"""
296+
The maximum role across ancestor documents and set_role_to optionsfor
297+
a given user should be filled as expected.
298+
"""
299+
user = factories.UserFactory()
300+
client = APIClient()
301+
client.force_login(user)
302+
303+
# Create documents structured as a tree
304+
grand_parent = factories.DocumentFactory(link_reach="authenticated")
305+
parent = factories.DocumentFactory(parent=grand_parent)
306+
document = factories.DocumentFactory(parent=parent)
307+
308+
# Create accesses for another user
309+
other_user = factories.UserFactory()
310+
accesses = [
311+
factories.UserDocumentAccessFactory(
312+
document=document, user=user, role=roles[0]
313+
),
314+
factories.UserDocumentAccessFactory(
315+
document=grand_parent, user=other_user, role=roles[1]
316+
),
317+
factories.UserDocumentAccessFactory(
318+
document=parent, user=other_user, role=roles[2]
319+
),
320+
factories.UserDocumentAccessFactory(
321+
document=document, user=other_user, role=roles[3]
322+
),
323+
]
324+
325+
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
326+
327+
assert response.status_code == 200
328+
content = response.json()
329+
assert len(content) == 4
330+
331+
for result in content:
332+
assert (
333+
result["max_ancestors_role"] is None
334+
if result["user"]["id"] == str(user.id)
335+
else choices.RoleChoices.max(roles[1], roles[2])
336+
)
337+
338+
result_dict = {
339+
result["id"]: result["abilities"]["set_role_to"] for result in content
340+
}
341+
assert [result_dict[str(access.id)] for access in accesses] == results
342+
343+
344+
@pytest.mark.parametrize(
345+
"roles,results",
346+
[
347+
[
348+
["administrator", "reader", "reader", "reader"],
349+
[
350+
["reader", "editor", "administrator"],
351+
[],
352+
[],
353+
["reader", "editor", "administrator"],
354+
],
355+
],
356+
[
357+
["owner", "reader", "reader", "reader"],
358+
[[], [], [], ["reader", "editor", "administrator", "owner"]],
359+
],
360+
[
361+
["owner", "reader", "reader", "owner"],
362+
[
363+
["reader", "editor", "administrator", "owner"],
364+
[],
365+
[],
366+
["reader", "editor", "administrator", "owner"],
367+
],
368+
],
369+
[
370+
["reader", "reader", "reader", "owner"],
371+
[["reader", "editor", "administrator", "owner"], [], [], []],
372+
],
373+
[
374+
["reader", "administrator", "reader", "editor"],
375+
[[], ["reader", "editor", "administrator"], [], []],
376+
],
377+
[
378+
["reader", "editor", "administrator", "editor"],
379+
[[], [], ["editor", "administrator"], []],
380+
],
381+
],
382+
)
383+
def test_api_document_accesses_list_authenticated_related_same_team(
384+
roles, results, mock_user_teams
385+
):
386+
"""
387+
The maximum role across ancestor documents and set_role_to optionsfor
388+
a given team should be filled as expected.
389+
"""
390+
user = factories.UserFactory()
391+
client = APIClient()
392+
client.force_login(user)
393+
394+
# Create documents structured as a tree
395+
grand_parent = factories.DocumentFactory(link_reach="authenticated")
396+
parent = factories.DocumentFactory(parent=grand_parent)
397+
document = factories.DocumentFactory(parent=parent)
398+
399+
mock_user_teams.return_value = ["lasuite", "unknown"]
400+
accesses = [
401+
factories.UserDocumentAccessFactory(
402+
document=document, user=user, role=roles[0]
403+
),
404+
# Create accesses for a team
405+
factories.TeamDocumentAccessFactory(
406+
document=grand_parent, team="lasuite", role=roles[1]
407+
),
408+
factories.TeamDocumentAccessFactory(
409+
document=parent, team="lasuite", role=roles[2]
410+
),
411+
factories.TeamDocumentAccessFactory(
412+
document=document, team="lasuite", role=roles[3]
413+
),
414+
]
415+
416+
response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/")
417+
418+
assert response.status_code == 200
419+
content = response.json()
420+
assert len(content) == 4
421+
422+
for result in content:
423+
assert (
424+
result["max_ancestors_role"] is None
425+
if result["user"] and result["user"]["id"] == str(user.id)
426+
else choices.RoleChoices.max(roles[1], roles[2])
427+
)
428+
429+
result_dict = {
430+
result["id"]: result["abilities"]["set_role_to"] for result in content
431+
}
432+
assert [result_dict[str(access.id)] for access in accesses] == results
433+
434+
261435
def test_api_document_accesses_retrieve_anonymous():
262436
"""
263437
Anonymous users should not be allowed to retrieve a document access.
@@ -353,6 +527,7 @@ def test_api_document_accesses_retrieve_authenticated_related(
353527
"user": access_user,
354528
"team": "",
355529
"role": access.role,
530+
"max_ancestors_role": None,
356531
"abilities": access.get_abilities(user),
357532
}
358533

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

+3
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
169169
"id": str(new_document_access.id),
170170
"team": "",
171171
"role": role,
172+
"max_ancestors_role": None,
172173
"user": other_user,
173174
}
174175
assert len(mail.outbox) == 1
@@ -228,6 +229,7 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
228229
"user": other_user,
229230
"team": "",
230231
"role": role,
232+
"max_ancestors_role": None,
231233
"abilities": new_document_access.get_abilities(user),
232234
}
233235
assert len(mail.outbox) == 1
@@ -293,6 +295,7 @@ def test_api_document_accesses_create_email_in_receivers_language(via, mock_user
293295
"user": other_user_data,
294296
"team": "",
295297
"role": role,
298+
"max_ancestors_role": None,
296299
"abilities": new_document_access.get_abilities(user),
297300
}
298301
assert len(mail.outbox) == index + 1

0 commit comments

Comments
 (0)