diff --git a/CHANGELOG.md b/CHANGELOG.md index dd6c1cd0..65156052 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,8 @@ and this project adheres to ## Added -- ✨(backend) allow forcing page size within limits +- ✨(backend) new "tree" action on document detail endpoint #645 +- ✨(backend) allow forcing page size within limits #645 - 💄(frontend) add error pages #643 - 🔒️ Manage unsafe attachments #663 - ✨(frontend) Custom block quote with export #646 diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 4365884a..4b8f526c 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -128,26 +128,13 @@ class TemplateAccessSerializer(BaseAccessSerializer): read_only_fields = ["id", "abilities"] -class BaseResourceSerializer(serializers.ModelSerializer): - """Serialize documents.""" - - abilities = serializers.SerializerMethodField(read_only=True) - accesses = TemplateAccessSerializer(many=True, read_only=True) - - def get_abilities(self, document) -> dict: - """Return abilities of the logged-in user on the instance.""" - request = self.context.get("request") - if request: - return document.get_abilities(request.user) - return {} - - -class ListDocumentSerializer(BaseResourceSerializer): +class ListDocumentSerializer(serializers.ModelSerializer): """Serialize documents with limited fields for display in lists.""" is_favorite = serializers.BooleanField(read_only=True) nb_accesses = serializers.IntegerField(read_only=True) user_roles = serializers.SerializerMethodField(read_only=True) + abilities = serializers.SerializerMethodField(read_only=True) class Meta: model = models.Document @@ -185,6 +172,18 @@ class ListDocumentSerializer(BaseResourceSerializer): "user_roles", ] + def get_abilities(self, document) -> dict: + """Return abilities of the logged-in user on the instance.""" + request = self.context.get("request") + + if request: + paths_links_mapping = self.context.get("paths_links_mapping", None) + return document.get_abilities( + request.user, paths_links_mapping=paths_links_mapping + ) + + return {} + def get_user_roles(self, document): """ Return roles of the logged-in user for the current document, @@ -359,7 +358,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer): raise NotImplementedError("Update is not supported for this serializer.") -class LinkDocumentSerializer(BaseResourceSerializer): +class LinkDocumentSerializer(serializers.ModelSerializer): """ Serialize link configuration for documents. We expose it separately from document in order to simplify and secure access control. @@ -431,9 +430,12 @@ class FileUploadSerializer(serializers.Serializer): return attrs -class TemplateSerializer(BaseResourceSerializer): +class TemplateSerializer(serializers.ModelSerializer): """Serialize templates.""" + abilities = serializers.SerializerMethodField(read_only=True) + accesses = TemplateAccessSerializer(many=True, read_only=True) + class Meta: model = models.Template fields = [ @@ -447,6 +449,13 @@ class TemplateSerializer(BaseResourceSerializer): ] read_only_fields = ["id", "accesses", "abilities"] + def get_abilities(self, document) -> dict: + """Return abilities of the logged-in user on the instance.""" + request = self.context.get("request") + if request: + return document.get_abilities(request.user) + return {} + # pylint: disable=abstract-method class DocumentGenerationSerializer(serializers.Serializer): diff --git a/src/backend/core/api/utils.py b/src/backend/core/api/utils.py index 9fd059e1..98dc6548 100644 --- a/src/backend/core/api/utils.py +++ b/src/backend/core/api/utils.py @@ -11,6 +11,35 @@ import botocore from rest_framework.throttling import BaseThrottle +def nest_tree(flat_list, steplen): + """ + Convert a flat list of serialized documents into a nested tree making advantage + of the`path` field and its step length. + """ + node_dict = {} + roots = [] + + # Sort the flat list by path to ensure parent nodes are processed first + flat_list.sort(key=lambda x: x["path"]) + + for node in flat_list: + node["children"] = [] # Initialize children list + node_dict[node["path"]] = node + + # Determine parent path + parent_path = node["path"][:-steplen] + + if parent_path in node_dict: + node_dict[parent_path]["children"].append(node) + else: + roots.append(node) # Collect root nodes + + if len(roots) > 1: + raise ValueError("More than one root element detected.") + + return roots[0] if roots else None + + def filter_root_paths(paths, skip_sorting=False): """ Filters root paths from a list of paths representing a tree structure. diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 5c0d7eeb..34dc5903 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -4,6 +4,7 @@ import logging import re import uuid +from collections import defaultdict from urllib.parse import urlparse from django.conf import settings @@ -424,10 +425,11 @@ class DocumentViewSet( ] queryset = models.Document.objects.all() serializer_class = serializers.DocumentSerializer + ai_translate_serializer_class = serializers.AITranslateSerializer + children_serializer_class = serializers.ListDocumentSerializer list_serializer_class = serializers.ListDocumentSerializer trashbin_serializer_class = serializers.ListDocumentSerializer - children_serializer_class = serializers.ListDocumentSerializer - ai_translate_serializer_class = serializers.AITranslateSerializer + tree_serializer_class = serializers.ListDocumentSerializer def annotate_is_favorite(self, queryset): """ @@ -523,7 +525,7 @@ class DocumentViewSet( queryset = queryset.filter(path__in=root_paths) # Annotate the queryset with an attribute marking instances as highest ancestor - # in order to save some time while computing abilities in the instance + # in order to save some time while computing abilities on the instance queryset = queryset.annotate( is_highest_ancestor_for_user=db.Value( True, output_field=db.BooleanField() @@ -767,6 +769,68 @@ class DocumentViewSet( queryset = self.annotate_user_roles(queryset) return self.get_response_for_queryset(queryset) + @drf.decorators.action( + detail=True, + methods=["get"], + ordering=["path"], + ) + def tree(self, request, pk, *args, **kwargs): + """ + List ancestors tree above the document. + What we need to display is the tree structure opened for the current document. + """ + try: + current_document = self.queryset.only("depth", "path").get(pk=pk) + except models.Document.DoesNotExist as excpt: + raise drf.exceptions.NotFound from excpt + + ancestors = ( + (current_document.get_ancestors() | self.queryset.filter(pk=pk)) + .filter(ancestors_deleted_at__isnull=True) + .order_by("path") + ) + + # Get the highest readable ancestor + highest_readable = ancestors.readable_per_se(request.user).only("depth").first() + if highest_readable is None: + raise ( + drf.exceptions.PermissionDenied() + if request.user.is_authenticated + else drf.exceptions.NotAuthenticated() + ) + + ancestors_links_definitions = defaultdict(set) + children_clause = db.Q() + for ancestor in ancestors: + if ancestor.depth < highest_readable.depth: + continue + + ancestors_links_definitions[ancestor.link_reach].add(ancestor.link_role) + children_clause |= db.Q( + path__startswith=ancestor.path, depth=ancestor.depth + 1 + ) + + children = self.queryset.filter(children_clause, deleted_at__isnull=True) + + queryset = ancestors.filter(depth__gte=highest_readable.depth) | children + queryset = queryset.order_by("path") + queryset = self.annotate_user_roles(queryset) + queryset = self.annotate_is_favorite(queryset) + + # Pass ancestors' links definitions to the serializer as a context variable + # in order to allow saving time while computing abilities on the instance + serializer = self.get_serializer( + queryset, + many=True, + context={ + "request": request, + "ancestors_links_definitions": ancestors_links_definitions, + }, + ) + return drf.response.Response( + utils.nest_tree(serializer.data, self.queryset.model.steplen) + ) + @drf.decorators.action(detail=True, methods=["get"], url_path="versions") def versions_list(self, request, *args, **kwargs): """ diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 0e355879..159e954b 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -29,7 +29,7 @@ from django.utils.translation import gettext_lazy as _ from botocore.exceptions import ClientError from rest_framework.exceptions import ValidationError from timezone_field import TimeZoneField -from treebeard.mp_tree import MP_Node +from treebeard.mp_tree import MP_Node, MP_NodeManager, MP_NodeQuerySet logger = getLogger(__name__) @@ -369,6 +369,51 @@ class BaseAccess(BaseModel): } +class DocumentQuerySet(MP_NodeQuerySet): + """ + Custom queryset for the Document model, providing additional methods + to filter documents based on user permissions. + """ + + def readable_per_se(self, user): + """ + Filters the queryset to return documents that the given user has + permission to read. + :param user: The user for whom readable documents are to be fetched. + :return: A queryset of documents readable by the user. + """ + if user.is_authenticated: + return self.filter( + models.Q(accesses__user=user) + | models.Q(accesses__team__in=user.teams) + | ~models.Q(link_reach=LinkReachChoices.RESTRICTED) + ) + + return self.filter(link_reach=LinkReachChoices.PUBLIC) + + +class DocumentManager(MP_NodeManager): + """ + Custom manager for the Document model, enabling the use of the custom + queryset methods directly from the model manager. + """ + + def get_queryset(self): + """ + Overrides the default get_queryset method to return a custom queryset. + :return: An instance of DocumentQuerySet. + """ + return DocumentQuerySet(self.model, using=self._db) + + def readable_per_se(self, user): + """ + Filters documents based on user permissions using the custom queryset. + :param user: The user for whom readable documents are to be fetched. + :return: A queryset of documents readable by the user. + """ + return self.get_queryset().readable_per_se(user) + + class Document(MP_Node, BaseModel): """Pad document carrying the content.""" @@ -401,6 +446,8 @@ class Document(MP_Node, BaseModel): path = models.CharField(max_length=7 * 36, unique=True, db_collation="C") + objects = DocumentManager() + class Meta: db_table = "impress_document" ordering = ("path",) @@ -574,7 +621,11 @@ class Document(MP_Node, BaseModel): def invalidate_nb_accesses_cache(self): """ Invalidate the cache for number of accesses, including on affected descendants. + Args: + path: can optionally be passed as argument (useful when invalidating cache for a + document we just deleted) """ + for document in Document.objects.filter(path__startswith=self.path).only("id"): cache_key = document.get_nb_accesses_cache_key() cache.delete(cache_key) @@ -682,6 +733,7 @@ class Document(MP_Node, BaseModel): "restore": is_owner, "retrieve": can_get, "media_auth": can_get, + "tree": can_get, "update": can_update, "versions_destroy": is_owner_or_admin, "versions_list": has_access_role, diff --git a/src/backend/core/tests/documents/test_api_documents_children_list.py b/src/backend/core/tests/documents/test_api_documents_children_list.py index 846fc9dc..43fa6f4f 100644 --- a/src/backend/core/tests/documents/test_api_documents_children_list.py +++ b/src/backend/core/tests/documents/test_api_documents_children_list.py @@ -15,7 +15,7 @@ pytestmark = pytest.mark.django_db def test_api_documents_children_list_anonymous_public_standalone(): - """Anonymous users should be allowed to retrieve the children of a public documents.""" + """Anonymous users should be allowed to retrieve the children of a public document.""" document = factories.DocumentFactory(link_reach="public") child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) factories.UserDocumentAccessFactory(document=child1) diff --git a/src/backend/core/tests/documents/test_api_documents_retrieve.py b/src/backend/core/tests/documents/test_api_documents_retrieve.py index cc7ebfe5..d1ba5851 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -44,6 +44,7 @@ def test_api_documents_retrieve_anonymous_public_standalone(): "partial_update": document.link_role == "editor", "restore": False, "retrieve": True, + "tree": True, "update": document.link_role == "editor", "versions_destroy": False, "versions_list": False, @@ -100,6 +101,7 @@ def test_api_documents_retrieve_anonymous_public_parent(): "partial_update": grand_parent.link_role == "editor", "restore": False, "retrieve": True, + "tree": True, "update": grand_parent.link_role == "editor", "versions_destroy": False, "versions_list": False, @@ -189,6 +191,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "partial_update": document.link_role == "editor", "restore": False, "retrieve": True, + "tree": True, "update": document.link_role == "editor", "versions_destroy": False, "versions_list": False, @@ -252,6 +255,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea "partial_update": grand_parent.link_role == "editor", "restore": False, "retrieve": True, + "tree": True, "update": grand_parent.link_role == "editor", "versions_destroy": False, "versions_list": False, @@ -424,6 +428,7 @@ def test_api_documents_retrieve_authenticated_related_parent(): "partial_update": access.role != "reader", "restore": access.role == "owner", "retrieve": True, + "tree": True, "update": access.role != "reader", "versions_destroy": access.role in ["administrator", "owner"], "versions_list": True, diff --git a/src/backend/core/tests/documents/test_api_documents_trashbin.py b/src/backend/core/tests/documents/test_api_documents_trashbin.py index b1a8b3b5..6ca649fc 100644 --- a/src/backend/core/tests/documents/test_api_documents_trashbin.py +++ b/src/backend/core/tests/documents/test_api_documents_trashbin.py @@ -87,6 +87,7 @@ def test_api_documents_trashbin_format(): "partial_update": True, "restore": True, "retrieve": True, + "tree": True, "update": True, "versions_destroy": True, "versions_list": True, diff --git a/src/backend/core/tests/documents/test_api_documents_tree.py b/src/backend/core/tests/documents/test_api_documents_tree.py new file mode 100644 index 00000000..17c9bb93 --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_tree.py @@ -0,0 +1,1029 @@ +""" +Tests for Documents API endpoint in impress's core app: retrieve +""" +# pylint: disable=too-many-lines + +import random + +from django.contrib.auth.models import AnonymousUser + +import pytest +from rest_framework.test import APIClient + +from core import factories + +pytestmark = pytest.mark.django_db + + +def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_queries): + """Anonymous users should be allowed to retrieve the tree of a public document.""" + parent = factories.DocumentFactory(link_reach="public") + document, sibling = factories.DocumentFactory.create_batch(2, parent=parent) + child = factories.DocumentFactory(link_reach="public", parent=document) + + with django_assert_num_queries(8): + APIClient().get(f"/api/v1.0/documents/{document.id!s}/tree/") + + with django_assert_num_queries(4): + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/tree/") + + assert response.status_code == 200 + assert response.json() == { + "abilities": parent.get_abilities(AnonymousUser()), + "children": [ + { + "abilities": document.get_abilities(AnonymousUser()), + "children": [ + { + "abilities": child.get_abilities(AnonymousUser()), + "children": [], + "created_at": child.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(child.creator.id), + "depth": 3, + "excerpt": child.excerpt, + "id": str(child.id), + "is_favorite": False, + "link_reach": child.link_reach, + "link_role": child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": child.path, + "title": child.title, + "updated_at": child.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + ], + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(document.creator.id), + "depth": 2, + "excerpt": document.excerpt, + "id": str(document.id), + "is_favorite": False, + "link_reach": document.link_reach, + "link_role": document.link_role, + "numchild": 1, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": sibling1.get_abilities(AnonymousUser()), + "children": [], + "created_at": sibling1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(sibling1.creator.id), + "depth": 2, + "excerpt": sibling1.excerpt, + "id": str(sibling1.id), + "is_favorite": False, + "link_reach": sibling1.link_reach, + "link_role": sibling1.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": sibling1.path, + "title": sibling1.title, + "updated_at": sibling1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": sibling2.get_abilities(AnonymousUser()), + "children": [], + "created_at": sibling2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(sibling2.creator.id), + "depth": 2, + "excerpt": sibling2.excerpt, + "id": str(sibling2.id), + "is_favorite": False, + "link_reach": sibling2.link_reach, + "link_role": sibling2.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": sibling2.path, + "title": sibling2.title, + "updated_at": sibling2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + ], + "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(parent.creator.id), + "depth": 1, + "excerpt": parent.excerpt, + "id": str(parent.id), + "is_favorite": False, + "link_reach": parent.link_reach, + "link_role": parent.link_role, + "numchild": 3, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": parent.path, + "title": parent.title, + "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + } + + +def test_api_documents_tree_list_anonymous_public_parent(): + """ + Anonymous users should be allowed to retrieve the tree of a document who + has a public ancestor but only up to the highest public ancestor. + """ + great_grand_parent = factories.DocumentFactory( + link_reach=random.choice(["authenticated", "restricted"]) + ) + grand_parent = factories.DocumentFactory( + link_reach="public", parent=great_grand_parent + ) + factories.DocumentFactory(link_reach="public", parent=great_grand_parent) + factories.DocumentFactory( + link_reach=random.choice(["authenticated", "restricted"]), + parent=great_grand_parent, + ) + + parent = factories.DocumentFactory( + parent=grand_parent, link_reach=random.choice(["authenticated", "restricted"]) + ) + parent_sibling = factories.DocumentFactory(parent=grand_parent) + document = factories.DocumentFactory( + link_reach=random.choice(["authenticated", "restricted"]), parent=parent + ) + document_sibling = factories.DocumentFactory(parent=parent) + child = factories.DocumentFactory(link_reach="public", parent=document) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/tree/") + + assert response.status_code == 200 + assert response.json() == { + "abilities": grand_parent.get_abilities(AnonymousUser()), + "children": [ + { + "abilities": parent.get_abilities(AnonymousUser()), + "children": [ + { + "abilities": document.get_abilities(AnonymousUser()), + "children": [ + { + "abilities": child.get_abilities(AnonymousUser()), + "children": [], + "created_at": child.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(child.creator.id), + "depth": 5, + "excerpt": child.excerpt, + "id": str(child.id), + "is_favorite": False, + "link_reach": child.link_reach, + "link_role": child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": child.path, + "title": child.title, + "updated_at": child.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + ], + "created_at": document.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(document.creator.id), + "depth": 4, + "excerpt": document.excerpt, + "id": str(document.id), + "is_favorite": False, + "link_reach": document.link_reach, + "link_role": document.link_role, + "numchild": 1, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + { + "abilities": document_sibling.get_abilities(AnonymousUser()), + "children": [], + "created_at": document_sibling.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(document_sibling.creator.id), + "depth": 4, + "excerpt": document_sibling.excerpt, + "id": str(document_sibling.id), + "is_favorite": False, + "link_reach": document_sibling.link_reach, + "link_role": document_sibling.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": document_sibling.path, + "title": document_sibling.title, + "updated_at": document_sibling.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + ], + "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(parent.creator.id), + "depth": 3, + "excerpt": parent.excerpt, + "id": str(parent.id), + "is_favorite": False, + "link_reach": parent.link_reach, + "link_role": parent.link_role, + "numchild": 2, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": parent.path, + "title": parent.title, + "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": parent_sibling.get_abilities(AnonymousUser()), + "children": [], + "created_at": parent_sibling.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(parent_sibling.creator.id), + "depth": 3, + "excerpt": parent_sibling.excerpt, + "id": str(parent_sibling.id), + "is_favorite": False, + "link_reach": parent_sibling.link_reach, + "link_role": parent_sibling.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": parent_sibling.path, + "title": parent_sibling.title, + "updated_at": parent_sibling.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + ], + "created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_parent.creator.id), + "depth": 2, + "excerpt": grand_parent.excerpt, + "id": str(grand_parent.id), + "is_favorite": False, + "link_reach": grand_parent.link_reach, + "link_role": grand_parent.link_role, + "numchild": 2, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": grand_parent.path, + "title": grand_parent.title, + "updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + } + + +@pytest.mark.parametrize("reach", ["restricted", "authenticated"]) +def test_api_documents_tree_list_anonymous_restricted_or_authenticated(reach): + """ + Anonymous users should not be able to retrieve the tree of a document that is not public. + """ + parent = factories.DocumentFactory(link_reach=reach) + document = factories.DocumentFactory(parent=parent, link_reach=reach) + factories.DocumentFactory(parent=parent) + factories.DocumentFactory(link_reach="public", parent=document) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/tree/") + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +@pytest.mark.parametrize("reach", ["public", "authenticated"]) +def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated( + reach, django_assert_num_queries +): + """ + Authenticated users should be able to retrieve the tree of a public/authenticated + document to which they are not related. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory(link_reach=reach) + document, sibling = factories.DocumentFactory.create_batch(2, parent=parent) + child = factories.DocumentFactory(link_reach="public", parent=document) + + with django_assert_num_queries(9): + client.get(f"/api/v1.0/documents/{document.id!s}/tree/") + + with django_assert_num_queries(5): + response = client.get(f"/api/v1.0/documents/{document.id!s}/tree/") + + assert response.status_code == 200 + assert response.json() == { + "abilities": parent.get_abilities(user), + "children": [ + { + "abilities": document.get_abilities(user), + "children": [ + { + "abilities": child.get_abilities(user), + "children": [], + "created_at": child.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(child.creator.id), + "depth": 3, + "excerpt": child.excerpt, + "id": str(child.id), + "is_favorite": False, + "link_reach": child.link_reach, + "link_role": child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": child.path, + "title": child.title, + "updated_at": child.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + ], + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(document.creator.id), + "depth": 2, + "excerpt": document.excerpt, + "id": str(document.id), + "is_favorite": False, + "link_reach": document.link_reach, + "link_role": document.link_role, + "numchild": 1, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": sibling.get_abilities(user), + "children": [], + "created_at": sibling.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(sibling.creator.id), + "depth": 2, + "excerpt": sibling.excerpt, + "id": str(sibling.id), + "is_favorite": False, + "link_reach": sibling.link_reach, + "link_role": sibling.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": sibling.path, + "title": sibling.title, + "updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + ], + "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(parent.creator.id), + "depth": 1, + "excerpt": parent.excerpt, + "id": str(parent.id), + "is_favorite": False, + "link_reach": parent.link_reach, + "link_role": parent.link_role, + "numchild": 2, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": parent.path, + "title": parent.title, + "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + } + + +@pytest.mark.parametrize("reach", ["public", "authenticated"]) +def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( + reach, +): + """ + Authenticated users should be allowed to retrieve the tree of a document who + has a public or authenticated ancestor. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + great_grand_parent = factories.DocumentFactory(link_reach="restricted") + grand_parent = factories.DocumentFactory( + link_reach=reach, parent=great_grand_parent + ) + factories.DocumentFactory( + link_reach=random.choice(["public", "authenticated"]), parent=great_grand_parent + ) + factories.DocumentFactory( + link_reach="restricted", + parent=great_grand_parent, + ) + + parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted") + parent_sibling = factories.DocumentFactory(parent=grand_parent) + document = factories.DocumentFactory(link_reach="restricted", parent=parent) + document_sibling = factories.DocumentFactory(parent=parent) + child = factories.DocumentFactory( + link_reach=random.choice(["public", "authenticated"]), parent=document + ) + + response = client.get(f"/api/v1.0/documents/{document.id!s}/tree/") + + assert response.status_code == 200 + assert response.json() == { + "abilities": grand_parent.get_abilities(user), + "children": [ + { + "abilities": parent.get_abilities(user), + "children": [ + { + "abilities": document.get_abilities(user), + "children": [ + { + "abilities": child.get_abilities(user), + "children": [], + "created_at": child.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(child.creator.id), + "depth": 5, + "excerpt": child.excerpt, + "id": str(child.id), + "is_favorite": False, + "link_reach": child.link_reach, + "link_role": child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": child.path, + "title": child.title, + "updated_at": child.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + ], + "created_at": document.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(document.creator.id), + "depth": 4, + "excerpt": document.excerpt, + "id": str(document.id), + "is_favorite": False, + "link_reach": document.link_reach, + "link_role": document.link_role, + "numchild": 1, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + { + "abilities": document_sibling.get_abilities(user), + "children": [], + "created_at": document_sibling.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(document_sibling.creator.id), + "depth": 4, + "excerpt": document_sibling.excerpt, + "id": str(document_sibling.id), + "is_favorite": False, + "link_reach": document_sibling.link_reach, + "link_role": document_sibling.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": document_sibling.path, + "title": document_sibling.title, + "updated_at": document_sibling.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + ], + "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(parent.creator.id), + "depth": 3, + "excerpt": parent.excerpt, + "id": str(parent.id), + "is_favorite": False, + "link_reach": parent.link_reach, + "link_role": parent.link_role, + "numchild": 2, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": parent.path, + "title": parent.title, + "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": parent_sibling.get_abilities(user), + "children": [], + "created_at": parent_sibling.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(parent_sibling.creator.id), + "depth": 3, + "excerpt": parent_sibling.excerpt, + "id": str(parent_sibling.id), + "is_favorite": False, + "link_reach": parent_sibling.link_reach, + "link_role": parent_sibling.link_role, + "numchild": 0, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": parent_sibling.path, + "title": parent_sibling.title, + "updated_at": parent_sibling.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [], + }, + ], + "created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_parent.creator.id), + "depth": 2, + "excerpt": grand_parent.excerpt, + "id": str(grand_parent.id), + "is_favorite": False, + "link_reach": grand_parent.link_reach, + "link_role": grand_parent.link_role, + "numchild": 2, + "nb_accesses_ancestors": 0, + "nb_accesses_direct": 0, + "path": grand_parent.path, + "title": grand_parent.title, + "updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + } + + +def test_api_documents_tree_list_authenticated_unrelated_restricted(): + """ + Authenticated users should not be allowed to retrieve the tree of a document that is + restricted and to which they are not related. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory(link_reach="restricted") + document, _sibling = factories.DocumentFactory.create_batch( + 2, link_reach="restricted", parent=parent + ) + factories.DocumentFactory(link_reach="public", parent=document) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/tree/", + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_documents_tree_list_authenticated_related_direct(): + """ + Authenticated users should be allowed to retrieve the tree of a document + to which they are directly related whatever the role. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory(link_reach="restricted") + access = factories.UserDocumentAccessFactory(document=parent, user=user) + factories.UserDocumentAccessFactory(document=parent) + + document, sibling = factories.DocumentFactory.create_batch(2, parent=parent) + child = factories.DocumentFactory(link_reach="public", parent=document) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/tree/", + ) + assert response.status_code == 200 + assert response.json() == { + "abilities": parent.get_abilities(user), + "children": [ + { + "abilities": document.get_abilities(user), + "children": [ + { + "abilities": child.get_abilities(user), + "children": [], + "created_at": child.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(child.creator.id), + "depth": 3, + "excerpt": child.excerpt, + "id": str(child.id), + "is_favorite": False, + "link_reach": child.link_reach, + "link_role": child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": child.path, + "title": child.title, + "updated_at": child.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [access.role], + }, + ], + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(document.creator.id), + "depth": 2, + "excerpt": document.excerpt, + "id": str(document.id), + "is_favorite": False, + "link_reach": document.link_reach, + "link_role": document.link_role, + "numchild": 1, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + { + "abilities": sibling.get_abilities(user), + "children": [], + "created_at": sibling.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(sibling.creator.id), + "depth": 2, + "excerpt": sibling.excerpt, + "id": str(sibling.id), + "is_favorite": False, + "link_reach": sibling.link_reach, + "link_role": sibling.link_role, + "numchild": 0, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": sibling.path, + "title": sibling.title, + "updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + ], + "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(parent.creator.id), + "depth": 1, + "excerpt": parent.excerpt, + "id": str(parent.id), + "is_favorite": False, + "link_reach": parent.link_reach, + "link_role": parent.link_role, + "numchild": 2, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 2, + "path": parent.path, + "title": parent.title, + "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + } + + +def test_api_documents_tree_list_authenticated_related_parent(): + """ + Authenticated users should be allowed to retrieve the tree of a document if they + are related to one of its ancestors whatever the role. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + great_grand_parent = factories.DocumentFactory( + link_reach="restricted", link_role="reader" + ) + grand_parent = factories.DocumentFactory( + link_reach="restricted", link_role="reader", parent=great_grand_parent + ) + access = factories.UserDocumentAccessFactory(document=grand_parent, user=user) + factories.UserDocumentAccessFactory(document=grand_parent) + factories.DocumentFactory(link_reach="restricted", parent=great_grand_parent) + factories.DocumentFactory(link_reach="public", parent=great_grand_parent) + + parent = factories.DocumentFactory( + parent=grand_parent, link_reach="restricted", link_role="reader" + ) + parent_sibling = factories.DocumentFactory( + parent=grand_parent, link_reach="restricted", link_role="reader" + ) + document = factories.DocumentFactory( + link_reach="restricted", link_role="reader", parent=parent + ) + document_sibling = factories.DocumentFactory( + link_reach="restricted", link_role="reader", parent=parent + ) + child = factories.DocumentFactory( + link_reach="restricted", link_role="reader", parent=document + ) + + response = client.get(f"/api/v1.0/documents/{document.id!s}/tree/") + + assert response.status_code == 200 + assert response.json() == { + "abilities": grand_parent.get_abilities(user), + "children": [ + { + "abilities": parent.get_abilities(user), + "children": [ + { + "abilities": document.get_abilities(user), + "children": [ + { + "abilities": child.get_abilities(user), + "children": [], + "created_at": child.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(child.creator.id), + "depth": 5, + "excerpt": child.excerpt, + "id": str(child.id), + "is_favorite": False, + "link_reach": child.link_reach, + "link_role": child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": child.path, + "title": child.title, + "updated_at": child.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [access.role], + }, + ], + "created_at": document.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(document.creator.id), + "depth": 4, + "excerpt": document.excerpt, + "id": str(document.id), + "is_favorite": False, + "link_reach": document.link_reach, + "link_role": document.link_role, + "numchild": 1, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [access.role], + }, + { + "abilities": document_sibling.get_abilities(user), + "children": [], + "created_at": document_sibling.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(document_sibling.creator.id), + "depth": 4, + "excerpt": document_sibling.excerpt, + "id": str(document_sibling.id), + "is_favorite": False, + "link_reach": document_sibling.link_reach, + "link_role": document_sibling.link_role, + "numchild": 0, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": document_sibling.path, + "title": document_sibling.title, + "updated_at": document_sibling.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [access.role], + }, + ], + "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(parent.creator.id), + "depth": 3, + "excerpt": parent.excerpt, + "id": str(parent.id), + "is_favorite": False, + "link_reach": parent.link_reach, + "link_role": parent.link_role, + "numchild": 2, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": parent.path, + "title": parent.title, + "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + { + "abilities": parent_sibling.get_abilities(user), + "children": [], + "created_at": parent_sibling.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(parent_sibling.creator.id), + "depth": 3, + "excerpt": parent_sibling.excerpt, + "id": str(parent_sibling.id), + "is_favorite": False, + "link_reach": parent_sibling.link_reach, + "link_role": parent_sibling.link_role, + "numchild": 0, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": parent_sibling.path, + "title": parent_sibling.title, + "updated_at": parent_sibling.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [access.role], + }, + ], + "created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_parent.creator.id), + "depth": 2, + "excerpt": grand_parent.excerpt, + "id": str(grand_parent.id), + "is_favorite": False, + "link_reach": grand_parent.link_reach, + "link_role": grand_parent.link_role, + "numchild": 2, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 2, + "path": grand_parent.path, + "title": grand_parent.title, + "updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + } + + +def test_api_documents_tree_list_authenticated_related_team_none(mock_user_teams): + """ + Authenticated users should not be able to retrieve the tree of a restricted document + related to teams in which the user is not. + """ + mock_user_teams.return_value = [] + + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory(link_reach="restricted") + document, _sibling = factories.DocumentFactory.create_batch( + 2, link_reach="restricted", parent=parent + ) + factories.DocumentFactory(link_reach="public", parent=document) + + factories.TeamDocumentAccessFactory(document=document, team="myteam") + + response = client.get(f"/api/v1.0/documents/{document.id!s}/tree/") + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_documents_tree_list_authenticated_related_team_members( + mock_user_teams, +): + """ + Authenticated users should be allowed to retrieve the tree of a document to which they + are related via a team whatever the role. + """ + mock_user_teams.return_value = ["myteam"] + + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory(link_reach="restricted") + document, sibling = factories.DocumentFactory.create_batch( + 2, link_reach="restricted", parent=parent + ) + child = factories.DocumentFactory(link_reach="public", parent=document) + + access = factories.TeamDocumentAccessFactory(document=parent, team="myteam") + factories.TeamDocumentAccessFactory(document=parent, team="another-team") + + response = client.get(f"/api/v1.0/documents/{document.id!s}/tree/") + + # pylint: disable=R0801 + assert response.status_code == 200 + assert response.json() == { + "abilities": parent.get_abilities(user), + "children": [ + { + "abilities": document.get_abilities(user), + "children": [ + { + "abilities": child.get_abilities(user), + "children": [], + "created_at": child.created_at.isoformat().replace( + "+00:00", "Z" + ), + "creator": str(child.creator.id), + "depth": 3, + "excerpt": child.excerpt, + "id": str(child.id), + "is_favorite": False, + "link_reach": child.link_reach, + "link_role": child.link_role, + "numchild": 0, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": child.path, + "title": child.title, + "updated_at": child.updated_at.isoformat().replace( + "+00:00", "Z" + ), + "user_roles": [access.role], + }, + ], + "created_at": document.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(document.creator.id), + "depth": 2, + "excerpt": document.excerpt, + "id": str(document.id), + "is_favorite": False, + "link_reach": document.link_reach, + "link_role": document.link_role, + "numchild": 1, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": document.path, + "title": document.title, + "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + { + "abilities": sibling.get_abilities(user), + "children": [], + "created_at": sibling.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(sibling.creator.id), + "depth": 2, + "excerpt": sibling.excerpt, + "id": str(sibling.id), + "is_favorite": False, + "link_reach": sibling.link_reach, + "link_role": sibling.link_role, + "numchild": 0, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 0, + "path": sibling.path, + "title": sibling.title, + "updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + ], + "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(parent.creator.id), + "depth": 1, + "excerpt": parent.excerpt, + "id": str(parent.id), + "is_favorite": False, + "link_reach": parent.link_reach, + "link_role": parent.link_role, + "numchild": 2, + "nb_accesses_ancestors": 2, + "nb_accesses_direct": 2, + "path": parent.path, + "title": parent.title, + "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + } diff --git a/src/backend/core/tests/test_api_utils_nest_tree.py b/src/backend/core/tests/test_api_utils_nest_tree.py new file mode 100644 index 00000000..11d2d2f0 --- /dev/null +++ b/src/backend/core/tests/test_api_utils_nest_tree.py @@ -0,0 +1,107 @@ +"""Unit tests for the nest_tree utility function.""" + +import pytest + +from core.api.utils import nest_tree + + +def test_api_utils_nest_tree_empty_list(): + """Test that an empty list returns an empty nested structure.""" + # pylint: disable=use-implicit-booleaness-not-comparison + assert nest_tree([], 4) is None + + +def test_api_utils_nest_tree_single_document(): + """Test that a single document is returned as the only root element.""" + documents = [{"id": "1", "path": "0001"}] + expected = {"id": "1", "path": "0001", "children": []} + assert nest_tree(documents, 4) == expected + + +def test_api_utils_nest_tree_multiple_root_documents(): + """Test that multiple root-level documents are correctly added to the root.""" + documents = [ + {"id": "1", "path": "0001"}, + {"id": "2", "path": "0002"}, + ] + with pytest.raises( + ValueError, + match="More than one root element detected.", + ): + nest_tree(documents, 4) + + +def test_api_utils_nest_tree_nested_structure(): + """Test that documents are correctly nested based on path levels.""" + documents = [ + {"id": "1", "path": "0001"}, + {"id": "2", "path": "00010001"}, + {"id": "3", "path": "000100010001"}, + {"id": "4", "path": "00010002"}, + ] + expected = { + "id": "1", + "path": "0001", + "children": [ + { + "id": "2", + "path": "00010001", + "children": [{"id": "3", "path": "000100010001", "children": []}], + }, + {"id": "4", "path": "00010002", "children": []}, + ], + } + assert nest_tree(documents, 4) == expected + + +def test_api_utils_nest_tree_siblings_at_same_path(): + """ + Test that sibling documents with the same path are correctly grouped under the same parent. + """ + documents = [ + {"id": "1", "path": "0001"}, + {"id": "2", "path": "00010001"}, + {"id": "3", "path": "00010002"}, + ] + expected = { + "id": "1", + "path": "0001", + "children": [ + {"id": "2", "path": "00010001", "children": []}, + {"id": "3", "path": "00010002", "children": []}, + ], + } + assert nest_tree(documents, 4) == expected + + +def test_api_utils_nest_tree_decreasing_path_resets_parent(): + """Test that a document at a lower path resets the parent assignment correctly.""" + documents = [ + {"id": "1", "path": "0001"}, + {"id": "6", "path": "00010001"}, + {"id": "2", "path": "00010002"}, # unordered + {"id": "5", "path": "000100010001"}, + {"id": "3", "path": "000100010002"}, + {"id": "4", "path": "00010003"}, + ] + expected = { + "id": "1", + "path": "0001", + "children": [ + { + "id": "6", + "path": "00010001", + "children": [ + {"id": "5", "path": "000100010001", "children": []}, + {"id": "3", "path": "000100010002", "children": []}, + ], + }, + { + "id": "2", + "path": "00010002", + "children": [], + }, + {"id": "4", "path": "00010003", "children": []}, + ], + } + assert nest_tree(documents, 4) == expected diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index b8500637..ecd48a7a 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -166,6 +166,7 @@ def test_models_documents_get_abilities_forbidden( "partial_update": False, "restore": False, "retrieve": False, + "tree": False, "update": False, "versions_destroy": False, "versions_list": False, @@ -217,6 +218,7 @@ def test_models_documents_get_abilities_reader( "partial_update": False, "restore": False, "retrieve": True, + "tree": True, "update": False, "versions_destroy": False, "versions_list": False, @@ -265,6 +267,7 @@ def test_models_documents_get_abilities_editor( "partial_update": True, "restore": False, "retrieve": True, + "tree": True, "update": True, "versions_destroy": False, "versions_list": False, @@ -303,6 +306,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries): "partial_update": True, "restore": True, "retrieve": True, + "tree": True, "update": True, "versions_destroy": True, "versions_list": True, @@ -342,6 +346,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries) "partial_update": True, "restore": False, "retrieve": True, + "tree": True, "update": True, "versions_destroy": True, "versions_list": True, @@ -380,6 +385,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries): "partial_update": True, "restore": False, "retrieve": True, + "tree": True, "update": True, "versions_destroy": False, "versions_list": True, @@ -425,6 +431,7 @@ def test_models_documents_get_abilities_reader_user( "partial_update": access_from_link, "restore": False, "retrieve": True, + "tree": True, "update": access_from_link, "versions_destroy": False, "versions_list": True, @@ -468,6 +475,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries): "partial_update": False, "restore": False, "retrieve": True, + "tree": True, "update": False, "versions_destroy": False, "versions_list": True,