(backend) add new "descendants" action to document API endpoint

We want to be able to make a search query inside a hierchical document.
It's elegant to do it as a document detail action so that we benefit
from access control.
This commit is contained in:
Samuel Paccoud - DINUM
2025-02-17 10:25:07 +01:00
committed by Manuel Raynaud
parent 56aa69f56a
commit 2203d49a52
10 changed files with 824 additions and 23 deletions

View File

@@ -34,6 +34,7 @@ and this project adheres to
## Added ## Added
- ✨(backend) add new "descendants" action to document API endpoint #645
- ✨(backend) new "tree" action on document detail endpoint #645 - ✨(backend) new "tree" action on document detail endpoint #645
- ✨(backend) allow forcing page size within limits #645 - ✨(backend) allow forcing page size within limits #645
- 💄(frontend) add error pages #643 - 💄(frontend) add error pages #643

View File

@@ -177,7 +177,14 @@ class ListDocumentSerializer(serializers.ModelSerializer):
request = self.context.get("request") request = self.context.get("request")
if 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 {} return {}

View File

@@ -4,7 +4,6 @@
import logging import logging
import re import re
import uuid import uuid
from collections import defaultdict
from urllib.parse import urlparse from urllib.parse import urlparse
from django.conf import settings from django.conf import settings
@@ -21,7 +20,6 @@ from django.http import Http404
import rest_framework as drf import rest_framework as drf
from botocore.exceptions import ClientError 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 filters, status, viewsets
from rest_framework import response as drf_response from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
@@ -424,6 +422,7 @@ class DocumentViewSet(
serializer_class = serializers.DocumentSerializer serializer_class = serializers.DocumentSerializer
ai_translate_serializer_class = serializers.AITranslateSerializer ai_translate_serializer_class = serializers.AITranslateSerializer
children_serializer_class = serializers.ListDocumentSerializer children_serializer_class = serializers.ListDocumentSerializer
descendants_serializer_class = serializers.ListDocumentSerializer
list_serializer_class = serializers.ListDocumentSerializer list_serializer_class = serializers.ListDocumentSerializer
trashbin_serializer_class = serializers.ListDocumentSerializer trashbin_serializer_class = serializers.ListDocumentSerializer
tree_serializer_class = serializers.ListDocumentSerializer tree_serializer_class = serializers.ListDocumentSerializer
@@ -841,17 +840,24 @@ class DocumentViewSet(
else drf.exceptions.NotAuthenticated() else drf.exceptions.NotAuthenticated()
) )
ancestors_links_definitions = defaultdict(set) paths_links_mapping = {}
ancestors_links = []
children_clause = db.Q() children_clause = db.Q()
for ancestor in ancestors: for ancestor in ancestors:
if ancestor.depth < highest_readable.depth: if ancestor.depth < highest_readable.depth:
continue continue
ancestors_links_definitions[ancestor.link_reach].add(ancestor.link_role)
children_clause |= db.Q( children_clause |= db.Q(
path__startswith=ancestor.path, depth=ancestor.depth + 1 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) children = self.queryset.filter(children_clause, deleted_at__isnull=True)
queryset = ancestors.filter(depth__gte=highest_readable.depth) | children queryset = ancestors.filter(depth__gte=highest_readable.depth) | children
@@ -866,7 +872,7 @@ class DocumentViewSet(
many=True, many=True,
context={ context={
"request": request, "request": request,
"ancestors_links_definitions": ancestors_links_definitions, "paths_links_mapping": paths_links_mapping,
}, },
) )
return drf.response.Response( return drf.response.Response(

View File

@@ -6,6 +6,7 @@ Declare and configure the models for the impress core application
import hashlib import hashlib
import smtplib import smtplib
import uuid import uuid
from collections import defaultdict
from datetime import timedelta from datetime import timedelta
from logging import getLogger from logging import getLogger
@@ -649,22 +650,27 @@ class Document(MP_Node, BaseModel):
roles = [] roles = []
return roles return roles
@cached_property def get_links_definitions(self, ancestors_links=None):
def links_definitions(self):
"""Get links reach/role definitions for the current document and its ancestors.""" """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 links_definitions = defaultdict(set)
# ancestor to which the current user has access. Look for the annotation: links_definitions[self.link_reach].add(self.link_role)
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"]
)
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. 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, taking into account its ancestors
# Add roles provided by the document link # 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()) public_roles = links_definitions.get(LinkReachChoices.PUBLIC, set())
authenticated_roles = ( authenticated_roles = (
links_definitions.get(LinkReachChoices.AUTHENTICATED, set()) links_definitions.get(LinkReachChoices.AUTHENTICATED, set())
@@ -724,6 +730,7 @@ class Document(MP_Node, BaseModel):
"children_list": can_get, "children_list": can_get,
"children_create": can_update and user.is_authenticated, "children_create": can_update and user.is_authenticated,
"collaboration_auth": can_get, "collaboration_auth": can_get,
"descendants": can_get,
"destroy": is_owner, "destroy": is_owner,
"favorite": can_get and user.is_authenticated, "favorite": can_get and user.is_authenticated,
"link_configuration": is_owner_or_admin, "link_configuration": is_owner_or_admin,

View File

@@ -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],
},
],
}

View File

@@ -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()

View File

@@ -34,6 +34,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"children_create": False, "children_create": False,
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"descendants": True,
"destroy": False, "destroy": False,
# Anonymous user can't favorite a document even with read access # Anonymous user can't favorite a document even with read access
"favorite": False, "favorite": False,
@@ -91,6 +92,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
"children_create": False, "children_create": False,
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"descendants": True,
"destroy": False, "destroy": False,
# Anonymous user can't favorite a document even with read access # Anonymous user can't favorite a document even with read access
"favorite": False, "favorite": False,
@@ -182,6 +184,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"children_create": document.link_role == "editor", "children_create": document.link_role == "editor",
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"descendants": True,
"destroy": False, "destroy": False,
"favorite": True, "favorite": True,
"invite_owner": False, "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_create": grand_parent.link_role == "editor",
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"descendants": True,
"destroy": False, "destroy": False,
"favorite": True, "favorite": True,
"invite_owner": False, "invite_owner": False,
@@ -419,6 +423,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
"children_create": access.role != "reader", "children_create": access.role != "reader",
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"descendants": True,
"destroy": access.role == "owner", "destroy": access.role == "owner",
"favorite": True, "favorite": True,
"invite_owner": access.role == "owner", "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. 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} 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}/") response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200 assert response.status_code == 200

View File

@@ -78,6 +78,7 @@ def test_api_documents_trashbin_format():
"children_create": True, "children_create": True,
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"descendants": True,
"destroy": True, "destroy": True,
"favorite": True, "favorite": True,
"invite_owner": True, "invite_owner": True,

View File

@@ -18,10 +18,12 @@ pytestmark = pytest.mark.django_db
def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_queries): 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.""" """Anonymous users should be allowed to retrieve the tree of a public document."""
parent = factories.DocumentFactory(link_reach="public") 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) 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/") APIClient().get(f"/api/v1.0/documents/{document.id!s}/tree/")
with django_assert_num_queries(4): with django_assert_num_queries(4):

View File

@@ -157,6 +157,7 @@ def test_models_documents_get_abilities_forbidden(
"children_create": False, "children_create": False,
"children_list": False, "children_list": False,
"collaboration_auth": False, "collaboration_auth": False,
"descendants": False,
"destroy": False, "destroy": False,
"favorite": False, "favorite": False,
"invite_owner": False, "invite_owner": False,
@@ -209,6 +210,7 @@ def test_models_documents_get_abilities_reader(
"children_create": False, "children_create": False,
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"descendants": True,
"destroy": False, "destroy": False,
"favorite": is_authenticated, "favorite": is_authenticated,
"invite_owner": False, "invite_owner": False,
@@ -258,6 +260,7 @@ def test_models_documents_get_abilities_editor(
"children_create": is_authenticated, "children_create": is_authenticated,
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"descendants": True,
"destroy": False, "destroy": False,
"favorite": is_authenticated, "favorite": is_authenticated,
"invite_owner": False, "invite_owner": False,
@@ -297,6 +300,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"children_create": True, "children_create": True,
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"descendants": True,
"destroy": True, "destroy": True,
"favorite": True, "favorite": True,
"invite_owner": True, "invite_owner": True,
@@ -337,6 +341,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
"children_create": True, "children_create": True,
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"descendants": True,
"destroy": False, "destroy": False,
"favorite": True, "favorite": True,
"invite_owner": False, "invite_owner": False,
@@ -376,6 +381,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"children_create": True, "children_create": True,
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"descendants": True,
"destroy": False, "destroy": False,
"favorite": True, "favorite": True,
"invite_owner": False, "invite_owner": False,
@@ -422,6 +428,7 @@ def test_models_documents_get_abilities_reader_user(
"children_create": access_from_link, "children_create": access_from_link,
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"descendants": True,
"destroy": False, "destroy": False,
"favorite": True, "favorite": True,
"invite_owner": False, "invite_owner": False,
@@ -466,6 +473,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"children_create": False, "children_create": False,
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
"descendants": True,
"destroy": False, "destroy": False,
"favorite": True, "favorite": True,
"invite_owner": False, "invite_owner": False,