(backend) add "tree" action on document API endpoint

We want to display the tree structure to which a document belongs
on the left side panel of its detail view. For this, we need an
endpoint to retrieve the list view of the document's ancestors
opened.

By opened, we mean that when display the document, we also need to
display its siblings. When displaying the parent of the current
document, we also need to display the siblings of the parent...
This commit is contained in:
Samuel Paccoud - DINUM
2025-02-26 06:49:41 +01:00
parent a3fb229a4f
commit 7f6e4cdeff
9 changed files with 1037 additions and 22 deletions
+2 -1
View File
@@ -10,7 +10,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
## Changed
+26 -17
View File
@@ -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.
@@ -429,9 +428,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 = [
@@ -445,6 +447,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):
+70 -3
View File
@@ -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()
@@ -766,6 +768,71 @@ 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, *args, **kwargs):
"""
List ancestors tree above the document.
What we need to display is the tree structure opened for the current document.
"""
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]}
current_document = (
self.queryset.filter(**filter_kwargs).only("depth", "path").first()
)
if current_document is None:
raise Http404
ancestors = (
(current_document.get_ancestors() | self.queryset.filter(**filter_kwargs))
.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(serializer.data)
@drf.decorators.action(detail=True, methods=["get"], url_path="versions")
def versions_list(self, request, *args, **kwargs):
"""
+48
View File
@@ -367,6 +367,51 @@ class BaseAccess(BaseModel):
}
class DocumentQuerySet(models.QuerySet):
"""
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(models.Q(link_reach=LinkReachChoices.PUBLIC))
class DocumentManager(models.Manager):
"""
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."""
@@ -399,6 +444,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",)
@@ -680,6 +727,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,
@@ -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)
@@ -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,
@@ -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,
@@ -0,0 +1,876 @@
"""
Tests for Documents API endpoint in impress's core app: retrieve
"""
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()),
"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": 0,
"path": parent.path,
"title": parent.title,
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
{
"abilities": document.get_abilities(AnonymousUser()),
"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": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
{
"abilities": child.get_abilities(AnonymousUser()),
"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": 0,
"path": child.path,
"title": child.title,
"updated_at": child.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
{
"abilities": sibling.get_abilities(AnonymousUser()),
"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": 0,
"path": sibling.path,
"title": sibling.title,
"updated_at": sibling.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()),
"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": 0,
"path": grand_parent.path,
"title": grand_parent.title,
"updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
{
"abilities": parent.get_abilities(AnonymousUser()),
"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": 0,
"path": parent.path,
"title": parent.title,
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
{
"abilities": document.get_abilities(AnonymousUser()),
"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": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
{
"abilities": child.get_abilities(AnonymousUser()),
"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": 0,
"path": child.path,
"title": child.title,
"updated_at": child.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
{
"abilities": document_sibling.get_abilities(AnonymousUser()),
"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": 0,
"path": document_sibling.path,
"title": document_sibling.title,
"updated_at": document_sibling.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [],
},
{
"abilities": parent_sibling.get_abilities(AnonymousUser()),
"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": 0,
"path": parent_sibling.path,
"title": parent_sibling.title,
"updated_at": parent_sibling.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),
"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": 0,
"path": parent.path,
"title": parent.title,
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
{
"abilities": document.get_abilities(user),
"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": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
{
"abilities": child.get_abilities(user),
"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": 0,
"path": child.path,
"title": child.title,
"updated_at": child.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
{
"abilities": sibling.get_abilities(user),
"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": 0,
"path": sibling.path,
"title": sibling.title,
"updated_at": sibling.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),
"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": 0,
"path": grand_parent.path,
"title": grand_parent.title,
"updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
{
"abilities": parent.get_abilities(user),
"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": 0,
"path": parent.path,
"title": parent.title,
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
{
"abilities": document.get_abilities(user),
"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": 0,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
{
"abilities": child.get_abilities(user),
"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": 0,
"path": child.path,
"title": child.title,
"updated_at": child.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
},
{
"abilities": document_sibling.get_abilities(user),
"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": 0,
"path": document_sibling.path,
"title": document_sibling.title,
"updated_at": document_sibling.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [],
},
{
"abilities": parent_sibling.get_abilities(user),
"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": 0,
"path": parent_sibling.path,
"title": parent_sibling.title,
"updated_at": parent_sibling.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),
"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": 2,
"path": parent.path,
"title": parent.title,
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
},
{
"abilities": document.get_abilities(user),
"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": 2,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
},
{
"abilities": child.get_abilities(user),
"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": 2,
"path": child.path,
"title": child.title,
"updated_at": child.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
},
{
"abilities": sibling.get_abilities(user),
"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": 2,
"path": sibling.path,
"title": sibling.title,
"updated_at": sibling.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")
grand_parent = factories.DocumentFactory(
link_reach="restricted", 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")
parent_sibling = factories.DocumentFactory(
parent=grand_parent, link_reach="restricted"
)
document = factories.DocumentFactory(link_reach="restricted", parent=parent)
document_sibling = factories.DocumentFactory(link_reach="restricted", parent=parent)
child = factories.DocumentFactory(link_reach="restricted", 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),
"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": 2,
"path": grand_parent.path,
"title": grand_parent.title,
"updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
},
{
"abilities": parent.get_abilities(user),
"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": 2,
"path": parent.path,
"title": parent.title,
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
},
{
"abilities": document.get_abilities(user),
"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": 2,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
},
{
"abilities": child.get_abilities(user),
"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": 2,
"path": child.path,
"title": child.title,
"updated_at": child.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
},
{
"abilities": document_sibling.get_abilities(user),
"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": 2,
"path": document_sibling.path,
"title": document_sibling.title,
"updated_at": document_sibling.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [access.role],
},
{
"abilities": parent_sibling.get_abilities(user),
"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": 2,
"path": parent_sibling.path,
"title": parent_sibling.title,
"updated_at": parent_sibling.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),
"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": 2,
"path": parent.path,
"title": parent.title,
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
},
{
"abilities": document.get_abilities(user),
"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": 2,
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
},
{
"abilities": child.get_abilities(user),
"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": 2,
"path": child.path,
"title": child.title,
"updated_at": child.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
},
{
"abilities": sibling.get_abilities(user),
"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": 2,
"path": sibling.path,
"title": sibling.title,
"updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
},
]
@@ -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,