diff --git a/CHANGELOG.md b/CHANGELOG.md index 365cb1fb..dc2b8e02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ and this project adheres to ## Added +- ✨(backend) add new "descendants" action to document API endpoint #645 - ✨(backend) new "tree" action on document detail endpoint #645 - ✨(backend) allow forcing page size within limits #645 - 💄(frontend) add error pages #643 diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index d6b557dd..fa1213ce 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -177,7 +177,14 @@ class ListDocumentSerializer(serializers.ModelSerializer): request = self.context.get("request") if request: - return document.get_abilities(request.user) + paths_links_mapping = self.context.get("paths_links_mapping", None) + # Retrieve ancestor links from paths_links_mapping (if provided) + ancestors_links = ( + paths_links_mapping.get(document.path[: -document.steplen]) + if paths_links_mapping + else None + ) + return document.get_abilities(request.user, ancestors_links=ancestors_links) return {} diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 687deb83..b01b07fb 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -4,7 +4,6 @@ import logging import re import uuid -from collections import defaultdict from urllib.parse import urlparse from django.conf import settings @@ -21,7 +20,6 @@ from django.http import Http404 import rest_framework as drf from botocore.exceptions import ClientError -from django_filters import rest_framework as drf_filters from rest_framework import filters, status, viewsets from rest_framework import response as drf_response from rest_framework.permissions import AllowAny @@ -424,6 +422,7 @@ class DocumentViewSet( serializer_class = serializers.DocumentSerializer ai_translate_serializer_class = serializers.AITranslateSerializer children_serializer_class = serializers.ListDocumentSerializer + descendants_serializer_class = serializers.ListDocumentSerializer list_serializer_class = serializers.ListDocumentSerializer trashbin_serializer_class = serializers.ListDocumentSerializer tree_serializer_class = serializers.ListDocumentSerializer @@ -841,17 +840,24 @@ class DocumentViewSet( else drf.exceptions.NotAuthenticated() ) - ancestors_links_definitions = defaultdict(set) + paths_links_mapping = {} + ancestors_links = [] 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 ) + # Compute cache for ancestors links to avoid many queries while computing + # abilties for his documents in the tree! + ancestors_links.append( + {"link_reach": ancestor.link_reach, "link_role": ancestor.link_role} + ) + paths_links_mapping[ancestor.path] = ancestors_links.copy() + children = self.queryset.filter(children_clause, deleted_at__isnull=True) queryset = ancestors.filter(depth__gte=highest_readable.depth) | children @@ -866,7 +872,7 @@ class DocumentViewSet( many=True, context={ "request": request, - "ancestors_links_definitions": ancestors_links_definitions, + "paths_links_mapping": paths_links_mapping, }, ) return drf.response.Response( diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 6077c0de..80e4a658 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -6,6 +6,7 @@ Declare and configure the models for the impress core application import hashlib import smtplib import uuid +from collections import defaultdict from datetime import timedelta from logging import getLogger @@ -649,22 +650,27 @@ class Document(MP_Node, BaseModel): roles = [] return roles - @cached_property - def links_definitions(self): + def get_links_definitions(self, ancestors_links=None): """Get links reach/role definitions for the current document and its ancestors.""" - links_definitions = {self.link_reach: {self.link_role}} - # Ancestors links definitions are only interesting if the document is not the highest - # ancestor to which the current user has access. Look for the annotation: - if self.depth > 1 and not getattr(self, "is_highest_ancestor_for_user", False): - for ancestor in self.get_ancestors().values("link_reach", "link_role"): - links_definitions.setdefault(ancestor["link_reach"], set()).add( - ancestor["link_role"] - ) + links_definitions = defaultdict(set) + links_definitions[self.link_reach].add(self.link_role) - return links_definitions + # Skip ancestor processing if the document is the highest accessible ancestor + if self.depth <= 1 or getattr(self, "is_highest_ancestor_for_user", False): + return links_definitions - def get_abilities(self, user): + # Fallback to querying the DB if ancestors links are not provided + if ancestors_links is None: + ancestors_links = self.get_ancestors().values("link_reach", "link_role") + + # Merge ancestor link definitions + for ancestor in ancestors_links: + links_definitions[ancestor["link_reach"]].add(ancestor["link_role"]) + + return dict(links_definitions) # Convert defaultdict back to a normal dict + + def get_abilities(self, user, ancestors_links=None): """ Compute and return abilities for a given user on the document. """ @@ -689,7 +695,7 @@ class Document(MP_Node, BaseModel): # Add roles provided by the document link, taking into account its ancestors # Add roles provided by the document link - links_definitions = self.links_definitions + links_definitions = self.get_links_definitions(ancestors_links=ancestors_links) public_roles = links_definitions.get(LinkReachChoices.PUBLIC, set()) authenticated_roles = ( links_definitions.get(LinkReachChoices.AUTHENTICATED, set()) @@ -724,6 +730,7 @@ class Document(MP_Node, BaseModel): "children_list": can_get, "children_create": can_update and user.is_authenticated, "collaboration_auth": can_get, + "descendants": can_get, "destroy": is_owner, "favorite": can_get and user.is_authenticated, "link_configuration": is_owner_or_admin, diff --git a/src/backend/core/tests/documents/test_api_documents_descendants.py b/src/backend/core/tests/documents/test_api_documents_descendants.py new file mode 100644 index 00000000..719db378 --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_descendants.py @@ -0,0 +1,676 @@ +""" +Tests for Documents API endpoint in impress's core app: descendants +""" + +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_descendants_list_anonymous_public_standalone(): + """Anonymous users should be allowed to retrieve the descendants of a public document.""" + document = factories.DocumentFactory(link_reach="public") + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + grand_child = factories.DocumentFactory(parent=child1) + + factories.UserDocumentAccessFactory(document=child1) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/descendants/") + + assert response.status_code == 200 + assert response.json() == { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(AnonymousUser()), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 2, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 1, + "nb_accesses": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": grand_child.get_abilities(AnonymousUser()), + "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_child.creator.id), + "depth": 3, + "excerpt": grand_child.excerpt, + "id": str(grand_child.id), + "is_favorite": False, + "link_reach": grand_child.link_reach, + "link_role": grand_child.link_role, + "numchild": 0, + "nb_accesses": 1, + "path": grand_child.path, + "title": grand_child.title, + "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": child2.get_abilities(AnonymousUser()), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 2, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + ], + } + + +def test_api_documents_descendants_list_anonymous_public_parent(): + """ + Anonymous users should be allowed to retrieve the descendants of a document who + has a public ancestor. + """ + grand_parent = factories.DocumentFactory(link_reach="public") + parent = factories.DocumentFactory( + parent=grand_parent, link_reach=random.choice(["authenticated", "restricted"]) + ) + document = factories.DocumentFactory( + link_reach=random.choice(["authenticated", "restricted"]), parent=parent + ) + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + grand_child = factories.DocumentFactory(parent=child1) + + factories.UserDocumentAccessFactory(document=child1) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/descendants/") + + assert response.status_code == 200 + assert response.json() == { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(AnonymousUser()), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 4, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 1, + "nb_accesses": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": grand_child.get_abilities(AnonymousUser()), + "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_child.creator.id), + "depth": 5, + "excerpt": grand_child.excerpt, + "id": str(grand_child.id), + "is_favorite": False, + "link_reach": grand_child.link_reach, + "link_role": grand_child.link_role, + "numchild": 0, + "nb_accesses": 1, + "path": grand_child.path, + "title": grand_child.title, + "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": child2.get_abilities(AnonymousUser()), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 4, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + ], + } + + +@pytest.mark.parametrize("reach", ["restricted", "authenticated"]) +def test_api_documents_descendants_list_anonymous_restricted_or_authenticated(reach): + """ + Anonymous users should not be able to retrieve descendants of a document that is not public. + """ + document = factories.DocumentFactory(link_reach=reach) + child = factories.DocumentFactory(parent=document) + _grand_child = factories.DocumentFactory(parent=child) + + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/descendants/") + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +@pytest.mark.parametrize("reach", ["public", "authenticated"]) +def test_api_documents_descendants_list_authenticated_unrelated_public_or_authenticated( + reach, +): + """ + Authenticated users should be able to retrieve the descendants of a public/authenticated + document to which they are not related. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach=reach) + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + grand_child = factories.DocumentFactory(parent=child1) + + factories.UserDocumentAccessFactory(document=child1) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/descendants/", + ) + assert response.status_code == 200 + assert response.json() == { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 2, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 1, + "nb_accesses": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": grand_child.get_abilities(user), + "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_child.creator.id), + "depth": 3, + "excerpt": grand_child.excerpt, + "id": str(grand_child.id), + "is_favorite": False, + "link_reach": grand_child.link_reach, + "link_role": grand_child.link_role, + "numchild": 0, + "nb_accesses": 1, + "path": grand_child.path, + "title": grand_child.title, + "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": child2.get_abilities(user), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 2, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + ], + } + + +@pytest.mark.parametrize("reach", ["public", "authenticated"]) +def test_api_documents_descendants_list_authenticated_public_or_authenticated_parent( + reach, +): + """ + Authenticated users should be allowed to retrieve the descendants of a document who + has a public or authenticated ancestor. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + grand_parent = factories.DocumentFactory(link_reach=reach) + parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted") + document = factories.DocumentFactory(link_reach="restricted", parent=parent) + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + grand_child = factories.DocumentFactory(parent=child1) + + factories.UserDocumentAccessFactory(document=child1) + + response = client.get(f"/api/v1.0/documents/{document.id!s}/descendants/") + + assert response.status_code == 200 + assert response.json() == { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 4, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 1, + "nb_accesses": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": grand_child.get_abilities(user), + "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_child.creator.id), + "depth": 5, + "excerpt": grand_child.excerpt, + "id": str(grand_child.id), + "is_favorite": False, + "link_reach": grand_child.link_reach, + "link_role": grand_child.link_role, + "numchild": 0, + "nb_accesses": 1, + "path": grand_child.path, + "title": grand_child.title, + "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + { + "abilities": child2.get_abilities(user), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 4, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses": 0, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [], + }, + ], + } + + +def test_api_documents_descendants_list_authenticated_unrelated_restricted(): + """ + Authenticated users should not be allowed to retrieve the descendants 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) + + document = factories.DocumentFactory(link_reach="restricted") + child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document) + _grand_child = factories.DocumentFactory(parent=child1) + + factories.UserDocumentAccessFactory(document=child1) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/descendants/", + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_documents_descendants_list_authenticated_related_direct(): + """ + Authenticated users should be allowed to retrieve the descendants of a document + to which they are directly related whatever the role. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory() + access = factories.UserDocumentAccessFactory(document=document, user=user) + factories.UserDocumentAccessFactory(document=document) + + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + factories.UserDocumentAccessFactory(document=child1) + + grand_child = factories.DocumentFactory(parent=child1) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/descendants/", + ) + assert response.status_code == 200 + assert response.json() == { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 2, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 1, + "nb_accesses": 3, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + { + "abilities": grand_child.get_abilities(user), + "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_child.creator.id), + "depth": 3, + "excerpt": grand_child.excerpt, + "id": str(grand_child.id), + "is_favorite": False, + "link_reach": grand_child.link_reach, + "link_role": grand_child.link_role, + "numchild": 0, + "nb_accesses": 3, + "path": grand_child.path, + "title": grand_child.title, + "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + { + "abilities": child2.get_abilities(user), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 2, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses": 2, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + ], + } + + +def test_api_documents_descendants_list_authenticated_related_parent(): + """ + Authenticated users should be allowed to retrieve the descendants of a document if they + are related to one of its ancestors whatever the role. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + grand_parent = factories.DocumentFactory(link_reach="restricted") + grand_parent_access = factories.UserDocumentAccessFactory( + document=grand_parent, user=user + ) + + parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted") + document = factories.DocumentFactory(parent=parent, link_reach="restricted") + + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + factories.UserDocumentAccessFactory(document=child1) + + grand_child = factories.DocumentFactory(parent=child1) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/descendants/", + ) + assert response.status_code == 200 + assert response.json() == { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 4, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 1, + "nb_accesses": 2, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [grand_parent_access.role], + }, + { + "abilities": grand_child.get_abilities(user), + "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_child.creator.id), + "depth": 5, + "excerpt": grand_child.excerpt, + "id": str(grand_child.id), + "is_favorite": False, + "link_reach": grand_child.link_reach, + "link_role": grand_child.link_role, + "numchild": 0, + "nb_accesses": 2, + "path": grand_child.path, + "title": grand_child.title, + "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [grand_parent_access.role], + }, + { + "abilities": child2.get_abilities(user), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 4, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses": 1, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [grand_parent_access.role], + }, + ], + } + + +def test_api_documents_descendants_list_authenticated_related_child(): + """ + Authenticated users should not be allowed to retrieve all the descendants of a document + as a result of being related to one of its children. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(link_reach="restricted") + child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document) + _grand_child = factories.DocumentFactory(parent=child1) + + factories.UserDocumentAccessFactory(document=child1, user=user) + factories.UserDocumentAccessFactory(document=document) + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/descendants/", + ) + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_documents_descendants_list_authenticated_related_team_none( + mock_user_teams, +): + """ + Authenticated users should not be able to retrieve the descendants 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) + + document = factories.DocumentFactory(link_reach="restricted") + factories.DocumentFactory.create_batch(2, parent=document) + + factories.TeamDocumentAccessFactory(document=document, team="myteam") + + response = client.get(f"/api/v1.0/documents/{document.id!s}/descendants/") + assert response.status_code == 403 + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_documents_descendants_list_authenticated_related_team_members( + mock_user_teams, +): + """ + Authenticated users should be allowed to retrieve the descendants 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) + + document = factories.DocumentFactory(link_reach="restricted") + child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + grand_child = factories.DocumentFactory(parent=child1) + + access = factories.TeamDocumentAccessFactory(document=document, team="myteam") + + response = client.get(f"/api/v1.0/documents/{document.id!s}/descendants/") + + # pylint: disable=R0801 + assert response.status_code == 200 + assert response.json() == { + "count": 3, + "next": None, + "previous": None, + "results": [ + { + "abilities": child1.get_abilities(user), + "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child1.creator.id), + "depth": 2, + "excerpt": child1.excerpt, + "id": str(child1.id), + "is_favorite": False, + "link_reach": child1.link_reach, + "link_role": child1.link_role, + "numchild": 1, + "nb_accesses": 1, + "path": child1.path, + "title": child1.title, + "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + { + "abilities": grand_child.get_abilities(user), + "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(grand_child.creator.id), + "depth": 3, + "excerpt": grand_child.excerpt, + "id": str(grand_child.id), + "is_favorite": False, + "link_reach": grand_child.link_reach, + "link_role": grand_child.link_role, + "numchild": 0, + "nb_accesses": 1, + "path": grand_child.path, + "title": grand_child.title, + "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + { + "abilities": child2.get_abilities(user), + "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), + "creator": str(child2.creator.id), + "depth": 2, + "excerpt": child2.excerpt, + "id": str(child2.id), + "is_favorite": False, + "link_reach": child2.link_reach, + "link_role": child2.link_role, + "numchild": 0, + "nb_accesses": 1, + "path": child2.path, + "title": child2.title, + "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), + "user_roles": [access.role], + }, + ], + } diff --git a/src/backend/core/tests/documents/test_api_documents_descendants_filters.py b/src/backend/core/tests/documents/test_api_documents_descendants_filters.py new file mode 100644 index 00000000..dec34895 --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_descendants_filters.py @@ -0,0 +1,88 @@ +""" +Tests for Documents API endpoint in impress's core app: list +""" + +import pytest +from faker import Faker +from rest_framework.test import APIClient + +from core import factories + +fake = Faker() +pytestmark = pytest.mark.django_db + + +# Filters: unknown field + + +def test_api_documents_descendants_filter_unknown_field(): + """ + Trying to filter by an unknown field should be ignored. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + factories.DocumentFactory() + + document = factories.DocumentFactory(users=[user]) + expected_ids = { + str(document.id) + for document in factories.DocumentFactory.create_batch(2, parent=document) + } + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/descendants/?unknown=true" + ) + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == 2 + assert {result["id"] for result in results} == expected_ids + + +# Filters: title + + +@pytest.mark.parametrize( + "query,nb_results", + [ + ("Project Alpha", 1), # Exact match + ("project", 2), # Partial match (case-insensitive) + ("Guide", 1), # Word match within a title + ("Special", 0), # No match (nonexistent keyword) + ("2024", 2), # Match by numeric keyword + ("", 5), # Empty string + ], +) +def test_api_documents_descendants_filter_title(query, nb_results): + """Authenticated users should be able to search documents by their title.""" + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + document = factories.DocumentFactory(users=[user]) + + # Create documents with predefined titles + titles = [ + "Project Alpha Documentation", + "Project Beta Overview", + "User Guide", + "Financial Report 2024", + "Annual Review 2024", + ] + for title in titles: + factories.DocumentFactory(title=title, parent=document) + + # Perform the search query + response = client.get( + f"/api/v1.0/documents/{document.id!s}/descendants/?title={query:s}" + ) + + assert response.status_code == 200 + results = response.json()["results"] + assert len(results) == nb_results + + # Ensure all results contain the query in their title + for result in results: + assert query.lower().strip() in result["title"].lower() 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 d1ba5851..b4024e62 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -34,6 +34,7 @@ def test_api_documents_retrieve_anonymous_public_standalone(): "children_create": False, "children_list": True, "collaboration_auth": True, + "descendants": True, "destroy": False, # Anonymous user can't favorite a document even with read access "favorite": False, @@ -91,6 +92,7 @@ def test_api_documents_retrieve_anonymous_public_parent(): "children_create": False, "children_list": True, "collaboration_auth": True, + "descendants": True, "destroy": False, # Anonymous user can't favorite a document even with read access "favorite": False, @@ -182,6 +184,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "children_create": document.link_role == "editor", "children_list": True, "collaboration_auth": True, + "descendants": True, "destroy": False, "favorite": True, "invite_owner": False, @@ -246,6 +249,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea "children_create": grand_parent.link_role == "editor", "children_list": True, "collaboration_auth": True, + "descendants": True, "destroy": False, "favorite": True, "invite_owner": False, @@ -419,6 +423,7 @@ def test_api_documents_retrieve_authenticated_related_parent(): "children_create": access.role != "reader", "children_list": True, "collaboration_auth": True, + "descendants": True, "destroy": access.role == "owner", "favorite": True, "invite_owner": access.role == "owner", @@ -724,7 +729,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners( } -def test_api_documents_retrieve_user_roles(django_assert_num_queries): +def test_api_documents_retrieve_user_roles(django_assert_max_num_queries): """ Roles should be annotated on querysets taking into account all documents ancestors. """ @@ -749,7 +754,7 @@ def test_api_documents_retrieve_user_roles(django_assert_num_queries): ) expected_roles = {access.role for access in accesses} - with django_assert_num_queries(10): + with django_assert_max_num_queries(11): response = client.get(f"/api/v1.0/documents/{document.id!s}/") assert response.status_code == 200 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 6ca649fc..9f1d8d7f 100644 --- a/src/backend/core/tests/documents/test_api_documents_trashbin.py +++ b/src/backend/core/tests/documents/test_api_documents_trashbin.py @@ -78,6 +78,7 @@ def test_api_documents_trashbin_format(): "children_create": True, "children_list": True, "collaboration_auth": True, + "descendants": True, "destroy": True, "favorite": True, "invite_owner": 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 index 17c9bb93..321912be 100644 --- a/src/backend/core/tests/documents/test_api_documents_tree.py +++ b/src/backend/core/tests/documents/test_api_documents_tree.py @@ -18,10 +18,12 @@ 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) + document, sibling1, sibling2 = factories.DocumentFactory.create_batch( + 3, parent=parent + ) child = factories.DocumentFactory(link_reach="public", parent=document) - with django_assert_num_queries(8): + with django_assert_num_queries(9): APIClient().get(f"/api/v1.0/documents/{document.id!s}/tree/") with django_assert_num_queries(4): diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index ecd48a7a..53a7cd79 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -157,6 +157,7 @@ def test_models_documents_get_abilities_forbidden( "children_create": False, "children_list": False, "collaboration_auth": False, + "descendants": False, "destroy": False, "favorite": False, "invite_owner": False, @@ -209,6 +210,7 @@ def test_models_documents_get_abilities_reader( "children_create": False, "children_list": True, "collaboration_auth": True, + "descendants": True, "destroy": False, "favorite": is_authenticated, "invite_owner": False, @@ -258,6 +260,7 @@ def test_models_documents_get_abilities_editor( "children_create": is_authenticated, "children_list": True, "collaboration_auth": True, + "descendants": True, "destroy": False, "favorite": is_authenticated, "invite_owner": False, @@ -297,6 +300,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries): "children_create": True, "children_list": True, "collaboration_auth": True, + "descendants": True, "destroy": True, "favorite": True, "invite_owner": True, @@ -337,6 +341,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries) "children_create": True, "children_list": True, "collaboration_auth": True, + "descendants": True, "destroy": False, "favorite": True, "invite_owner": False, @@ -376,6 +381,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries): "children_create": True, "children_list": True, "collaboration_auth": True, + "descendants": True, "destroy": False, "favorite": True, "invite_owner": False, @@ -422,6 +428,7 @@ def test_models_documents_get_abilities_reader_user( "children_create": access_from_link, "children_list": True, "collaboration_auth": True, + "descendants": True, "destroy": False, "favorite": True, "invite_owner": False, @@ -466,6 +473,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries): "children_create": False, "children_list": True, "collaboration_auth": True, + "descendants": True, "destroy": False, "favorite": True, "invite_owner": False,