Compare commits

...

23 Commits

Author SHA1 Message Date
Samuel Paccoud - DINUM f3244c347d (backend) add child_set_role_to field to document access abilities
The frontend needs to know what options to propose for an access that
would be created on a child document. This depends on the access
a user/team already has with ancestors...
2025-07-01 13:19:58 +02:00
Samuel Paccoud - DINUM b193a6e72b 🚸(backend) validate document access roles on submit
The frontend should only let users choose override roles that
make sense.
2025-07-01 13:19:58 +02:00
Samuel Paccoud - DINUM 1c501aaca4 (backend) add max_role field to the document access API endpoint
The frontend needs to know what to display on an access. The maximum
role between the access role and the role equivalent to all accesses
on the document's ancestors should be computed on the backend.
2025-07-01 13:19:58 +02:00
Samuel Paccoud - DINUM c24066d3f7 ♻️(backend) simplify further select options on link reach/role
We reduce the number of options even more by treating link reach
and link role independently: link reach must be higher than its
ancestors' equivalent link reach and link role must be higher than
its ancestors' link role.

This reduces the number of possibilities but we decided to start
with the most restrictive and simple offer and extend it if we
realize it faces too many criticism instead of risking to offer
too many options that are too complex and must be reduced afterwards.
2025-07-01 13:19:58 +02:00
Samuel Paccoud - DINUM 2966323cb0 🐛(backend) fix creating/updating document accesses for teams
This use case was forgotten when the support for team accesses
was added. We add tests to stabilize the feature and its security.
2025-07-01 13:19:58 +02:00
Samuel Paccoud - DINUM 21ef3cda00 (backend) add document path and depth to accesses endpoint
The frontend requires this information about the ancestor document
to which each access is related. We make sure it does not generate
more db queries and does not fetch useless and heavy fields from
the document like "excerpt".
2025-07-01 13:19:58 +02:00
Samuel Paccoud - DINUM ca0ac7e79c 🐛(backend) allow creating accesses when privileged by heritage
We took the opportunity of this bug to refactor serializers and
permissions as advised one day by @qbey: no permission checks in
serializers.
2025-07-01 13:19:58 +02:00
Samuel Paccoud - DINUM 5021eac019 (backend) fix randomly failing test due to delay before check
There is a delay between the time the signature is issued and the
time it is checked. Although this delay is minimal, if the signature
is issued at the end of a second, both timestamps can differ of 1s.

> assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
AssertionError: assert equals failed '20250504T175307Z'  '20250504T175308Z'
2025-07-01 13:19:58 +02:00
Samuel Paccoud - DINUM 619ef9050c ♻️(backend) stop requiring owner for non-root documents
If root documents are guaranteed to have a owner, non-root documents
will automatically have them as owner by inheritance. We should not
require non-root documents to have their own direct owner because
this will make it difficult to manage access rights when we move
documents around or when we want to remove access rights for someone
on a document subtree... There should be as few overrides as possible.
2025-07-01 13:19:58 +02:00
Samuel Paccoud - DINUM 68b8e52ab0 (backend) add max ancestors role field to document access endpoint
This field is set only on the list view when all accesses for a given
document and all its ancestors are listed. It gives the highest role
among all accesses related to each document.
2025-07-01 13:19:58 +02:00
Samuel Paccoud - DINUM 859f4ade9c ♻️(backend) optimize refactoring access abilities and fix inheritance
The latest refactoring in a445278 kept some factorizations that are
not legit anymore after the refactoring.

It is also cleaner to not make serializer choice in the list view if
the reason for this choice is related to something else b/c other
views would then use the wrong serializer and that would be a
security leak.

This commit also fixes a bug in the access rights inheritance: if a
user is allowed to see accesses on a document, he should see all
acesses related to ancestors, even the ancestors that he can not
read. This is because the access that was granted on all ancestors
also apply on the current document... so it must be displayed.

Lastly, we optimize database queries because the number of accesses
we fetch is going up with multi-pages and we were generating a lot
of useless queries.
2025-07-01 13:19:58 +02:00
Samuel Paccoud - DINUM fd424a0372 (backend) add computed link reach and role to document API
On a document, we need to display the status of the link (reach and
role) taking into account the ancestors link reach/role as well as
the current document.
2025-07-01 13:19:58 +02:00
Samuel Paccoud - DINUM 70328a721b (backend) add ancestors link reach and role to document API
On a document, we need to display the status of the link (reach and
role) as inherited from its ancestors.
2025-07-01 13:19:58 +02:00
Samuel Paccoud - DINUM 42197bc31a ♻️(backend) simplify roles by returning only the max role
We were returning the list of roles a user has on a document (direct
and inherited). Now that we introduced priority on roles, we are able
to determine what is the max role and return only this one.

This commit also changes the role that is returned for the restricted
reach: we now return None because the role is not relevant in this
case.
2025-07-01 13:19:58 +02:00
Samuel Paccoud - DINUM 561398eb66 (backend) fix randomly failing test on user search
The user account created to query the API had a random email
that could randomly interfere with our search results.
2025-07-01 13:19:58 +02:00
Samuel Paccoud - DINUM 1f7a3d8501 ♻️(backend) refactor get_select_options to take definitions dict
This will allow us to simplify the get_abilities method. It is also
more efficient because we have computed this definitions dict and
the the get_select_options method was doing the conversion again.
2025-07-01 13:19:58 +02:00
Samuel Paccoud - DINUM 2baba1182d (backend) give an order to choices
We are going to need to compare choices to materialize the fact that
choices are ordered. For example an admin role is higer than an
editor role but lower than an owner role.

We will need this to compute the reach and role resulting from all
the document accesses (resp. link accesses) assigned on a document's
ancestors.
2025-07-01 13:19:58 +02:00
Samuel Paccoud - DINUM a17332cace (backend) we want to display ancestors accesses on a document share
The document accesses a user have on a document's ancestors also apply
to this document. The frontend needs to list them as "inherited" so we
need to add them to the list.
Adding a "document_id" field on the output will allow the frontend to
differentiate between inherited and direct accesses on a document.
2025-07-01 13:19:58 +02:00
Samuel Paccoud - DINUM 5008916a58 ♻️(backend) factorize document query set annotation
The methods to annotate a document queryset were factorized on the
viewset but the correct place is the custom queryset itself now that
we have one.
2025-07-01 13:19:58 +02:00
Samuel Paccoud - DINUM c717f7af3b ♻️(backend) refactor resource access viewset
The document viewset was overriding the get_queryset method from its
own mixin. This was a sign that the mixin was not optimal anymore.
In the next commit I will need to complexify it further so it's time
to refactor the mixin.
2025-07-01 13:19:58 +02:00
Samuel Paccoud - DINUM a5cb1056de ♻️(backend) remove different reach for authenticated and anonymous
If anonymous users have reader access on a parent, we were considering
that an edge use case was interesting: allowing an authenticated user
to still be editor on the child.

Although this use case could be interesting, we consider, as a first
approach, that the value it carries is not big enough to justify the
complexity for the user to understand this complex access right heritage.
2025-07-01 13:19:58 +02:00
Samuel Paccoud - DINUM 402d007295 (backend) add ancestors links definitions to document abilities
The frontend needs to display inherited link accesses when it displays
possible selection options. We need to return this information to the
client.
2025-07-01 13:19:56 +02:00
Samuel Paccoud - DINUM 7347329e8d 🐛(backend) fix link definition select options linked to ancestors
We were returning too many select options for the restricted link reach:
- when the "restricted" reach is an option (key present in the returned
  dictionary), the possible values for link roles are now always None to
  make it clearer that they don't matter and no select box should be
  shown for roles.
- Never propose "restricted" as option for link reach when the ancestors
  already offer a public access. Indeed, restricted/editor was shown when
  the ancestors had public/read access. The logic was to propose editor
  role on more restricted reaches... but this does not make sense for
  restricted since the role does is not taken into account for this reach.
  Roles are set by each access line assign to users/teams.
2025-07-01 13:17:13 +02:00
26 changed files with 2895 additions and 1151 deletions
+5
View File
@@ -85,6 +85,8 @@ and this project adheres to
## Added
- ✨(backend) include ancestors accesses on document accesses list view #846
- ✨(backend) add ancestors links reach and role to document API #846
- 🚸(backend) make document search on title accent-insensitive #874
- 🚩 add homepage feature flag #861
- 📝(doc) update contributing policy (commit signatures are now mandatory) #895
@@ -96,12 +98,15 @@ and this project adheres to
## Changed
- ♻️(backend) stop requiring owner for non-root documents #846
- ♻️(backend) simplify roles by ranking them and return only the max role #846
- ⚡️(frontend) reduce unblocking time for config #867
- ♻️(frontend) bind UI with ability access #900
- ♻️(frontend) use built-in Quote block #908
## Fixed
- 🐛(backend) fix link definition select options linked to ancestors #846
- 🐛(nginx) fix 404 when accessing a doc #866
- 🔒️(drf) disable browsable HTML API renderer #919
- 🔒(frontend) enhance file download security #889
+54 -24
View File
@@ -1,12 +1,12 @@
"""Permission handlers for the impress core app."""
from django.core import exceptions
from django.db.models import Q
from django.http import Http404
from rest_framework import permissions
from core.models import DocumentAccess, RoleChoices, get_trashbin_cutoff
from core import choices
from core.models import RoleChoices, get_trashbin_cutoff
ACTION_FOR_METHOD_TO_PERMISSION = {
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"},
@@ -80,42 +80,37 @@ class CanCreateInvitationPermission(permissions.BasePermission):
if view.action != "create":
return True
# Check if resource_id is passed in the context
try:
document_id = view.kwargs["resource_id"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a document ID in kwargs to manage document invitations."
) from exc
# Check if the user has access to manage invitations (Owner/Admin roles)
return DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
document=document_id,
role__in=[RoleChoices.OWNER, RoleChoices.ADMIN],
).exists()
role = view.document.get_role(user)
if role not in choices.PRIVILEGED_ROLES:
raise exceptions.PermissionDenied(
"You are not allowed to create invitations for this document."
)
return True
class AccessPermission(permissions.BasePermission):
"""Permission class for access objects."""
class ResourceWithAccessPermission(permissions.BasePermission):
"""A permission class for templates and invitations."""
def has_permission(self, request, view):
"""check create permission for templates."""
return request.user.is_authenticated or view.action != "create"
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
abilities = obj.get_abilities(request.user)
action = view.action
try:
action = ACTION_FOR_METHOD_TO_PERMISSION[view.action][request.method]
except KeyError:
pass
return abilities.get(action, False)
class DocumentAccessPermission(AccessPermission):
class DocumentPermission(permissions.BasePermission):
"""Subclass to handle soft deletion specificities."""
def has_permission(self, request, view):
"""check create permission for documents."""
return request.user.is_authenticated or view.action != "create"
def has_object_permission(self, request, view, obj):
"""
Return a 404 on deleted documents
@@ -127,10 +122,45 @@ class DocumentAccessPermission(AccessPermission):
) and deleted_at < get_trashbin_cutoff():
raise Http404
# Compute permission first to ensure the "user_roles" attribute is set
has_permission = super().has_object_permission(request, view, obj)
abilities = obj.get_abilities(request.user)
action = view.action
try:
action = ACTION_FOR_METHOD_TO_PERMISSION[view.action][request.method]
except KeyError:
pass
has_permission = abilities.get(action, False)
if obj.ancestors_deleted_at and not RoleChoices.OWNER in obj.user_roles:
raise Http404
return has_permission
class ResourceAccessPermission(IsAuthenticated):
"""Permission class for document access objects."""
def has_permission(self, request, view):
"""check create permission for accesses in documents tree."""
if super().has_permission(request, view) is False:
return False
if view.action == "create":
role = getattr(view, view.resource_field_name).get_role(request.user)
if role not in choices.PRIVILEGED_ROLES:
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this resource."
)
return True
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
abilities = obj.get_abilities(request.user)
requested_role = request.data.get("role")
if requested_role and requested_role not in abilities.get("set_role_to", []):
return False
action = view.action
return abilities.get(action, False)
+201 -143
View File
@@ -5,14 +5,13 @@ import mimetypes
from base64 import b64decode
from django.conf import settings
from django.db.models import Q
from django.utils.functional import lazy
from django.utils.translation import gettext_lazy as _
import magic
from rest_framework import exceptions, serializers
from rest_framework import serializers
from core import enums, models, utils
from core import choices, enums, models, utils
from core.services.ai_services import AI_ACTIONS
from core.services.converter_services import (
ConversionError,
@@ -32,134 +31,35 @@ class UserSerializer(serializers.ModelSerializer):
class UserLightSerializer(UserSerializer):
"""Serialize users with limited fields."""
id = serializers.SerializerMethodField(read_only=True)
email = serializers.SerializerMethodField(read_only=True)
def get_id(self, _user):
"""Return always None. Here to have the same fields than in UserSerializer."""
return None
def get_email(self, _user):
"""Return always None. Here to have the same fields than in UserSerializer."""
return None
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name"]
read_only_fields = ["id", "email", "full_name", "short_name"]
fields = ["full_name", "short_name"]
read_only_fields = ["full_name", "short_name"]
class BaseAccessSerializer(serializers.ModelSerializer):
class TemplateAccessSerializer(serializers.ModelSerializer):
"""Serialize template accesses."""
abilities = serializers.SerializerMethodField(read_only=True)
def update(self, instance, validated_data):
"""Make "user" field is readonly but only on update."""
validated_data.pop("user", None)
return super().update(instance, validated_data)
def get_abilities(self, access) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return access.get_abilities(request.user)
return {}
def validate(self, attrs):
"""
Check access rights specific to writing (create/update)
"""
request = self.context.get("request")
user = getattr(request, "user", None)
role = attrs.get("role")
# Update
if self.instance:
can_set_role_to = self.instance.get_abilities(user)["set_role_to"]
if role and role not in can_set_role_to:
message = (
f"You are only allowed to set role to {', '.join(can_set_role_to)}"
if can_set_role_to
else "You are not allowed to set this role for this template."
)
raise exceptions.PermissionDenied(message)
# Create
else:
try:
resource_id = self.context["resource_id"]
except KeyError as exc:
raise exceptions.ValidationError(
"You must set a resource ID in kwargs to create a new access."
) from exc
if not self.Meta.model.objects.filter( # pylint: disable=no-member
Q(user=user) | Q(team__in=user.teams),
role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN],
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
).exists():
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this resource."
)
if (
role == models.RoleChoices.OWNER
and not self.Meta.model.objects.filter( # pylint: disable=no-member
Q(user=user) | Q(team__in=user.teams),
role=models.RoleChoices.OWNER,
**{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member
).exists()
):
raise exceptions.PermissionDenied(
"Only owners of a resource can assign other users as owners."
)
# pylint: disable=no-member
attrs[f"{self.Meta.resource_field_name}_id"] = self.context["resource_id"]
return attrs
class DocumentAccessSerializer(BaseAccessSerializer):
"""Serialize document accesses."""
user_id = serializers.PrimaryKeyRelatedField(
queryset=models.User.objects.all(),
write_only=True,
source="user",
required=False,
allow_null=True,
)
user = UserSerializer(read_only=True)
class Meta:
model = models.DocumentAccess
resource_field_name = "document"
fields = ["id", "user", "user_id", "team", "role", "abilities"]
read_only_fields = ["id", "abilities"]
class DocumentAccessLightSerializer(DocumentAccessSerializer):
"""Serialize document accesses with limited fields."""
user = UserLightSerializer(read_only=True)
class Meta:
model = models.DocumentAccess
fields = ["id", "user", "team", "role", "abilities"]
read_only_fields = ["id", "team", "role", "abilities"]
class TemplateAccessSerializer(BaseAccessSerializer):
"""Serialize template accesses."""
class Meta:
model = models.TemplateAccess
resource_field_name = "template"
fields = ["id", "user", "team", "role", "abilities"]
read_only_fields = ["id", "abilities"]
def get_abilities(self, instance) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return instance.get_abilities(request.user)
return {}
def update(self, instance, validated_data):
"""Make "user" field is readonly but only on update."""
validated_data.pop("user", None)
return super().update(instance, validated_data)
class ListDocumentSerializer(serializers.ModelSerializer):
"""Serialize documents with limited fields for display in lists."""
@@ -167,7 +67,7 @@ class ListDocumentSerializer(serializers.ModelSerializer):
is_favorite = serializers.BooleanField(read_only=True)
nb_accesses_ancestors = serializers.IntegerField(read_only=True)
nb_accesses_direct = serializers.IntegerField(read_only=True)
user_roles = serializers.SerializerMethodField(read_only=True)
user_role = serializers.SerializerMethodField(read_only=True)
abilities = serializers.SerializerMethodField(read_only=True)
class Meta:
@@ -175,6 +75,10 @@ class ListDocumentSerializer(serializers.ModelSerializer):
fields = [
"id",
"abilities",
"ancestors_link_reach",
"ancestors_link_role",
"computed_link_reach",
"computed_link_role",
"created_at",
"creator",
"depth",
@@ -188,11 +92,15 @@ class ListDocumentSerializer(serializers.ModelSerializer):
"path",
"title",
"updated_at",
"user_roles",
"user_role",
]
read_only_fields = [
"id",
"abilities",
"ancestors_link_reach",
"ancestors_link_role",
"computed_link_reach",
"computed_link_role",
"created_at",
"creator",
"depth",
@@ -205,34 +113,45 @@ class ListDocumentSerializer(serializers.ModelSerializer):
"numchild",
"path",
"updated_at",
"user_roles",
"user_role",
]
def get_abilities(self, document) -> dict:
def to_representation(self, instance):
"""Precompute once per instance"""
paths_links_mapping = self.context.get("paths_links_mapping")
if paths_links_mapping is not None:
links = paths_links_mapping.get(instance.path[: -instance.steplen], [])
instance.ancestors_link_definition = choices.get_equivalent_link_definition(
links
)
return super().to_representation(instance)
def get_abilities(self, instance) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if not request:
return {}
if request:
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 instance.get_abilities(request.user)
return {}
def get_user_roles(self, document):
def get_user_role(self, instance):
"""
Return roles of the logged-in user for the current document,
taking into account ancestors.
"""
request = self.context.get("request")
if request:
return document.get_roles(request.user)
return []
return instance.get_role(request.user) if request else None
class DocumentLightSerializer(serializers.ModelSerializer):
"""Minial document serializer for nesting in document accesses."""
class Meta:
model = models.Document
fields = ["id", "path", "depth"]
read_only_fields = ["id", "path", "depth"]
class DocumentSerializer(ListDocumentSerializer):
@@ -245,6 +164,10 @@ class DocumentSerializer(ListDocumentSerializer):
fields = [
"id",
"abilities",
"ancestors_link_reach",
"ancestors_link_role",
"computed_link_reach",
"computed_link_role",
"content",
"created_at",
"creator",
@@ -259,11 +182,15 @@ class DocumentSerializer(ListDocumentSerializer):
"path",
"title",
"updated_at",
"user_roles",
"user_role",
]
read_only_fields = [
"id",
"abilities",
"ancestors_link_reach",
"ancestors_link_role",
"computed_link_reach",
"computed_link_role",
"created_at",
"creator",
"depth",
@@ -275,7 +202,7 @@ class DocumentSerializer(ListDocumentSerializer):
"numchild",
"path",
"updated_at",
"user_roles",
"user_role",
]
def get_fields(self):
@@ -361,6 +288,140 @@ class DocumentSerializer(ListDocumentSerializer):
return super().save(**kwargs)
class DocumentAccessSerializer(serializers.ModelSerializer):
"""Serialize document accesses."""
document = DocumentLightSerializer(read_only=True)
user_id = serializers.PrimaryKeyRelatedField(
queryset=models.User.objects.all(),
write_only=True,
source="user",
required=False,
allow_null=True,
)
user = UserSerializer(read_only=True)
team = serializers.CharField(required=False, allow_blank=True)
abilities = serializers.SerializerMethodField(read_only=True)
max_ancestors_role = serializers.SerializerMethodField(read_only=True)
max_role = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.DocumentAccess
resource_field_name = "document"
fields = [
"id",
"document",
"user",
"user_id",
"team",
"role",
"abilities",
"max_ancestors_role",
"max_role",
]
read_only_fields = [
"id",
"document",
"abilities",
"max_ancestors_role",
"max_role",
]
def get_abilities(self, instance) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if request:
return instance.get_abilities(request.user)
return {}
def get_max_ancestors_role(self, instance):
"""Return max_ancestors_role if annotated; else None."""
return getattr(instance, "max_ancestors_role", None)
def get_max_role(self, instance):
"""Return max role."""
return choices.RoleChoices.max(
getattr(instance, "max_ancestors_role", None),
instance.role,
)
def validate(self, attrs):
"""
Ensure the selected role is greater than or equal to the maximum role
found in any ancestor document for the same user/team.
"""
if self.instance:
document = self.instance.document
user = self.instance.user
team = self.instance.team
else:
document_id = self.context["resource_id"]
document = models.Document.objects.get(id=document_id)
team = attrs.get("team")
user = attrs.get("user")
ancestors = document.get_ancestors().filter(ancestors_deleted_at__isnull=True)
access_qs = models.DocumentAccess.objects.filter(document__in=ancestors)
if user:
access_qs = access_qs.filter(user=user)
if team:
access_qs = access_qs.filter(team=team)
ancestor_roles = access_qs.values_list("role", flat=True)
inherited_role = choices.RoleChoices.max(*ancestor_roles)
role = attrs.get("role")
get_priority = choices.RoleChoices.get_priority
if inherited_role and get_priority(inherited_role) >= get_priority(role):
raise serializers.ValidationError(
{
"role": (
"Role overrides must be greater than the inherited role: "
f"{inherited_role}/{role}"
)
}
)
return super().validate(attrs)
def update(self, instance, validated_data):
"""Make "user" field readonly but only on update."""
validated_data.pop("team", None)
validated_data.pop("user", None)
return super().update(instance, validated_data)
class DocumentAccessLightSerializer(DocumentAccessSerializer):
"""Serialize document accesses with limited fields."""
user = UserLightSerializer(read_only=True)
class Meta:
model = models.DocumentAccess
resource_field_name = "document"
fields = [
"id",
"document",
"user",
"team",
"role",
"abilities",
"max_ancestors_role",
"max_role",
]
read_only_fields = [
"id",
"document",
"team",
"role",
"abilities",
"max_ancestors_role",
"max_role",
]
class ServerCreateDocumentSerializer(serializers.Serializer):
"""
Serializer for creating a document from a server-to-server request.
@@ -653,11 +714,8 @@ class InvitationSerializer(serializers.ModelSerializer):
# If the role is OWNER, check if the user has OWNER access
if role == models.RoleChoices.OWNER:
if not models.DocumentAccess.objects.filter(
Q(user=user) | Q(team__in=user.teams),
document=document_id,
role=models.RoleChoices.OWNER,
).exists():
user_role = models.Document.objects.get(id=document_id).get_role(user)
if user_role != models.RoleChoices.OWNER:
raise serializers.ValidationError(
"Only owners of a document can invite other users as owners."
)
+270 -223
View File
@@ -4,11 +4,11 @@
import json
import logging
import uuid
from collections import defaultdict
from urllib.parse import unquote, urlencode, urlparse
from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.search import TrigramSimilarity
from django.core.cache import cache
from django.core.exceptions import ValidationError
@@ -19,6 +19,7 @@ from django.db.models.expressions import RawSQL
from django.db.models.functions import Left, Length
from django.http import Http404, StreamingHttpResponse
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
@@ -33,7 +34,7 @@ from rest_framework import response as drf_response
from rest_framework.permissions import AllowAny
from rest_framework.throttling import UserRateThrottle
from core import authentication, enums, models
from core import authentication, choices, enums, models
from core.services.ai_services import AIService
from core.services.collaboration_services import CollaborationService
from core.tasks.mail import send_ask_for_access_mail
@@ -223,14 +224,10 @@ class UserViewSet(
class ResourceAccessViewsetMixin:
"""Mixin with methods common to all access viewsets."""
def get_permissions(self):
"""User only needs to be authenticated to list resource accesses"""
if self.action == "list":
permission_classes = [permissions.IsAuthenticated]
else:
return super().get_permissions()
return [permission() for permission in permission_classes]
def filter_queryset(self, queryset):
"""Override to filter on related resource."""
queryset = super().filter_queryset(queryset)
return queryset.filter(**{self.resource_field_name: self.kwargs["resource_id"]})
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
@@ -238,80 +235,6 @@ class ResourceAccessViewsetMixin:
context["resource_id"] = self.kwargs["resource_id"]
return context
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
queryset = queryset.filter(
**{self.resource_field_name: self.kwargs["resource_id"]}
)
if self.action == "list":
user = self.request.user
teams = user.teams
user_roles_query = (
queryset.filter(
db.Q(user=user) | db.Q(team__in=teams),
**{self.resource_field_name: self.kwargs["resource_id"]},
)
.values(self.resource_field_name)
.annotate(roles_array=ArrayAgg("role"))
.values("roles_array")
)
# Limit to resource access instances related to a resource THAT also has
# a resource access
# instance for the logged-in user (we don't want to list only the resource
# access instances pointing to the logged-in user)
queryset = (
queryset.filter(
db.Q(**{f"{self.resource_field_name}__accesses__user": user})
| db.Q(
**{f"{self.resource_field_name}__accesses__team__in": teams}
),
**{self.resource_field_name: self.kwargs["resource_id"]},
)
.annotate(user_roles=db.Subquery(user_roles_query))
.distinct()
)
return queryset
def destroy(self, request, *args, **kwargs):
"""Forbid deleting the last owner access"""
instance = self.get_object()
resource = getattr(instance, self.resource_field_name)
# Check if the access being deleted is the last owner access for the resource
if (
instance.role == "owner"
and resource.accesses.filter(role="owner").count() == 1
):
return drf.response.Response(
{"detail": "Cannot delete the last owner access for the resource."},
status=drf.status.HTTP_403_FORBIDDEN,
)
return super().destroy(request, *args, **kwargs)
def perform_update(self, serializer):
"""Check that we don't change the role if it leads to losing the last owner."""
instance = serializer.instance
# Check if the role is being updated and the new role is not "owner"
if (
"role" in self.request.data
and self.request.data["role"] != models.RoleChoices.OWNER
):
resource = getattr(instance, self.resource_field_name)
# Check if the access being updated is the last owner access for the resource
if (
instance.role == models.RoleChoices.OWNER
and resource.accesses.filter(role=models.RoleChoices.OWNER).count() == 1
):
message = "Cannot change the role to a non-owner role for the last owner access."
raise drf.exceptions.PermissionDenied({"detail": message})
serializer.save()
class DocumentMetadata(drf.metadata.SimpleMetadata):
"""Custom metadata class to add information"""
@@ -434,7 +357,7 @@ class DocumentViewSet(
ordering_fields = ["created_at", "updated_at", "title"]
pagination_class = Pagination
permission_classes = [
permissions.DocumentAccessPermission,
permissions.DocumentPermission,
]
queryset = models.Document.objects.all()
serializer_class = serializers.DocumentSerializer
@@ -445,44 +368,6 @@ class DocumentViewSet(
trashbin_serializer_class = serializers.ListDocumentSerializer
tree_serializer_class = serializers.ListDocumentSerializer
def annotate_is_favorite(self, queryset):
"""
Annotate document queryset with the favorite status for the current user.
"""
user = self.request.user
if user.is_authenticated:
favorite_exists_subquery = models.DocumentFavorite.objects.filter(
document_id=db.OuterRef("pk"), user=user
)
return queryset.annotate(is_favorite=db.Exists(favorite_exists_subquery))
return queryset.annotate(is_favorite=db.Value(False))
def annotate_user_roles(self, queryset):
"""
Annotate document queryset with the roles of the current user
on the document or its ancestors.
"""
user = self.request.user
output_field = ArrayField(base_field=db.CharField())
if user.is_authenticated:
user_roles_subquery = models.DocumentAccess.objects.filter(
db.Q(user=user) | db.Q(team__in=user.teams),
document__path=Left(db.OuterRef("path"), Length("document__path")),
).values_list("role", flat=True)
return queryset.annotate(
user_roles=db.Func(
user_roles_subquery, function="ARRAY", output_field=output_field
)
)
return queryset.annotate(
user_roles=db.Value([], output_field=output_field),
)
def get_queryset(self):
"""Get queryset performing all annotation and filtering on the document tree structure."""
user = self.request.user
@@ -518,18 +403,20 @@ class DocumentViewSet(
def filter_queryset(self, queryset):
"""Override to apply annotations to generic views."""
queryset = super().filter_queryset(queryset)
queryset = self.annotate_is_favorite(queryset)
queryset = self.annotate_user_roles(queryset)
user = self.request.user
queryset = queryset.annotate_is_favorite(user)
queryset = queryset.annotate_user_roles(user)
return queryset
def get_response_for_queryset(self, queryset):
def get_response_for_queryset(self, queryset, context=None):
"""Return paginated response for the queryset if requested."""
context = context or self.get_serializer_context()
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
serializer = self.get_serializer(page, many=True, context=context)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
serializer = self.get_serializer(queryset, many=True, context=context)
return drf.response.Response(serializer.data)
def list(self, request, *args, **kwargs):
@@ -539,13 +426,11 @@ class DocumentViewSet(
This method applies filtering based on request parameters using `ListDocumentFilter`.
It performs early filtering on model fields, annotates user roles, and removes
descendant documents to keep only the highest ancestors readable by the current user.
Additional annotations (e.g., `is_highest_ancestor_for_user`, favorite status) are
applied before ordering and returning the response.
"""
queryset = (
self.get_queryset()
) # Not calling filter_queryset. We do our own cooking.
user = self.request.user
# Not calling filter_queryset. We do our own cooking.
queryset = self.get_queryset()
filterset = ListDocumentFilter(
self.request.GET, queryset=queryset, request=self.request
@@ -558,7 +443,7 @@ class DocumentViewSet(
for field in ["is_creator_me", "title"]:
queryset = filterset.filters[field].filter(queryset, filter_data[field])
queryset = self.annotate_user_roles(queryset)
queryset = queryset.annotate_user_roles(user)
# Among the results, we may have documents that are ancestors/descendants
# of each other. In this case we want to keep only the highest ancestors.
@@ -568,14 +453,8 @@ 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 on the instance
queryset = queryset.annotate(
is_highest_ancestor_for_user=db.Value(True, output_field=db.BooleanField())
)
# Annotate favorite status and filter if applicable as late as possible
queryset = self.annotate_is_favorite(queryset)
queryset = queryset.annotate_is_favorite(user)
queryset = filterset.filters["is_favorite"].filter(
queryset, filter_data["is_favorite"]
)
@@ -666,7 +545,7 @@ class DocumentViewSet(
deleted_at__isnull=False,
deleted_at__gte=models.get_trashbin_cutoff(),
)
queryset = self.annotate_user_roles(queryset)
queryset = queryset.annotate_user_roles(self.request.user)
queryset = queryset.filter(user_roles__contains=[models.RoleChoices.OWNER])
return self.get_response_for_queryset(queryset)
@@ -734,7 +613,7 @@ class DocumentViewSet(
position = validated_data["position"]
message = None
owner_accesses = []
if position in [
enums.MoveNodePositionChoices.FIRST_CHILD,
enums.MoveNodePositionChoices.LAST_CHILD,
@@ -744,12 +623,15 @@ class DocumentViewSet(
"You do not have permission to move documents "
"as a child to this target document."
)
elif not target_document.is_root():
if not target_document.get_parent().get_abilities(user).get("move"):
message = (
"You do not have permission to move documents "
"as a sibling of this target document."
)
elif target_document.is_root():
owner_accesses = document.get_root().accesses.filter(
role=models.RoleChoices.OWNER
)
elif not target_document.get_parent().get_abilities(user).get("move"):
message = (
"You do not have permission to move documents "
"as a sibling of this target document."
)
if message:
return drf.response.Response(
@@ -759,6 +641,19 @@ class DocumentViewSet(
document.move(target_document, pos=position)
# Make sure we have at least one owner
if (
owner_accesses
and not document.accesses.filter(role=models.RoleChoices.OWNER).exists()
):
for owner_access in owner_accesses:
models.DocumentAccess.objects.update_or_create(
document=document,
user=owner_access.user,
team=owner_access.team,
defaults={"role": models.RoleChoices.OWNER},
)
return drf.response.Response(
{"message": "Document moved successfully."}, status=status.HTTP_200_OK
)
@@ -805,11 +700,7 @@ class DocumentViewSet(
creator=request.user,
**serializer.validated_data,
)
models.DocumentAccess.objects.create(
document=child_document,
user=request.user,
role=models.RoleChoices.OWNER,
)
# Set the created instance to the serializer
serializer.instance = child_document
@@ -828,7 +719,17 @@ class DocumentViewSet(
queryset = filterset.qs
return self.get_response_for_queryset(queryset)
# Pass ancestors' links paths mapping to the serializer as a context variable
# in order to allow saving time while computing abilities on the instance
paths_links_mapping = document.compute_ancestors_links_paths_mapping()
return self.get_response_for_queryset(
queryset,
context={
"request": request,
"paths_links_mapping": paths_links_mapping,
},
)
@drf.decorators.action(
detail=True,
@@ -860,10 +761,12 @@ class DocumentViewSet(
List ancestors tree above the document.
What we need to display is the tree structure opened for the current document.
"""
user = self.request.user
try:
current_document = self.queryset.only("depth", "path").get(pk=pk)
except models.Document.DoesNotExist as excpt:
raise drf.exceptions.NotFound from excpt
raise drf.exceptions.NotFound() from excpt
ancestors = (
(current_document.get_ancestors() | self.queryset.filter(pk=pk))
@@ -885,13 +788,6 @@ class DocumentViewSet(
ancestors_links = []
children_clause = db.Q()
for ancestor in ancestors:
if ancestor.depth < highest_readable.depth:
continue
children_clause |= db.Q(
path__startswith=ancestor.path, depth=ancestor.depth + 1
)
# Compute cache for ancestors links to avoid many queries while computing
# abilities for his documents in the tree!
ancestors_links.append(
@@ -899,25 +795,21 @@ class DocumentViewSet(
)
paths_links_mapping[ancestor.path] = ancestors_links.copy()
if ancestor.depth < highest_readable.depth:
continue
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")
# Annotate if the current document is the highest ancestor for the user
queryset = queryset.annotate(
is_highest_ancestor_for_user=db.Case(
db.When(
path=db.Value(highest_readable.path),
then=db.Value(True),
),
default=db.Value(False),
output_field=db.BooleanField(),
)
)
queryset = self.annotate_user_roles(queryset)
queryset = self.annotate_is_favorite(queryset)
queryset = queryset.annotate_user_roles(user)
queryset = queryset.annotate_is_favorite(user)
# Pass ancestors' links definitions to the serializer as a context variable
# Pass ancestors' links paths mapping to the serializer as a context variable
# in order to allow saving time while computing abilities on the instance
serializer = self.get_serializer(
queryset,
@@ -934,7 +826,10 @@ class DocumentViewSet(
@drf.decorators.action(
detail=True,
methods=["post"],
permission_classes=[permissions.IsAuthenticated, permissions.AccessPermission],
permission_classes=[
permissions.IsAuthenticated,
permissions.DocumentPermission,
],
url_path="duplicate",
)
@transaction.atomic
@@ -1472,7 +1367,11 @@ class DocumentViewSet(
class DocumentAccessViewSet(
ResourceAccessViewsetMixin,
viewsets.ModelViewSet,
drf.mixins.CreateModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
drf.mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""
API ViewSet for all interactions with document accesses.
@@ -1499,50 +1398,146 @@ class DocumentAccessViewSet(
"""
lookup_field = "pk"
pagination_class = Pagination
permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission]
queryset = models.DocumentAccess.objects.select_related("user").all()
permission_classes = [permissions.ResourceAccessPermission]
queryset = models.DocumentAccess.objects.select_related("user", "document").only(
"id",
"created_at",
"role",
"team",
"user__id",
"user__short_name",
"user__full_name",
"user__email",
"user__language",
"document__id",
"document__path",
"document__depth",
)
resource_field_name = "document"
serializer_class = serializers.DocumentAccessSerializer
is_current_user_owner_or_admin = False
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
if self.action == "list":
try:
document = models.Document.objects.get(pk=self.kwargs["resource_id"])
except models.Document.DoesNotExist:
return queryset.none()
roles = set(document.get_roles(self.request.user))
is_owner_or_admin = bool(roles.intersection(set(models.PRIVILEGED_ROLES)))
self.is_current_user_owner_or_admin = is_owner_or_admin
if not is_owner_or_admin:
# Return only the document owner access
queryset = queryset.filter(role__in=models.PRIVILEGED_ROLES)
return queryset
@cached_property
def document(self):
"""Get related document from resource ID in url and annotate user roles."""
try:
return models.Document.objects.annotate_user_roles(self.request.user).get(
pk=self.kwargs["resource_id"]
)
except models.Document.DoesNotExist as excpt:
raise drf.exceptions.NotFound() from excpt
def get_serializer_class(self):
if self.action == "list" and not self.is_current_user_owner_or_admin:
return serializers.DocumentAccessLightSerializer
"""Use light serializer for unprivileged users."""
return (
serializers.DocumentAccessSerializer
if self.document.get_role(self.request.user) in choices.PRIVILEGED_ROLES
else serializers.DocumentAccessLightSerializer
)
return super().get_serializer_class()
def list(self, request, *args, **kwargs):
"""Return accesses for the current document with filters and annotations."""
user = request.user
role = self.document.get_role(user)
if not role:
return drf.response.Response([])
ancestors = (
self.document.get_ancestors()
| models.Document.objects.filter(pk=self.document.pk)
).filter(ancestors_deleted_at__isnull=True)
queryset = self.get_queryset().filter(document__in=ancestors)
if role not in choices.PRIVILEGED_ROLES:
queryset = queryset.filter(role__in=choices.PRIVILEGED_ROLES)
accesses = list(queryset.order_by("document__path"))
# Annotate more information on roles
# - accesses of the user (direct or via a team)
path_to_ancestors_roles = defaultdict(list)
path_to_role = defaultdict(lambda: None)
# - accesses of other users and teams
key_to_path_to_max_ancestors_role = defaultdict(
lambda: defaultdict(lambda: None)
)
for access in accesses:
key = access.target_key
path = access.document.path
parent_path = path[: -models.Document.steplen]
if parent_path:
key_to_path_to_max_ancestors_role[key][parent_path] = (
choices.RoleChoices.max(
*key_to_path_to_max_ancestors_role[key].values()
)
)
path_to_ancestors_roles[path].extend(
path_to_ancestors_roles[parent_path]
)
path_to_ancestors_roles[path].append(path_to_role[parent_path])
else:
path_to_ancestors_roles[path] = []
key_to_path_to_max_ancestors_role[key][path] = choices.RoleChoices.max(
key_to_path_to_max_ancestors_role[key][parent_path], access.role
)
if access.user_id == user.id or access.team in user.teams:
path_to_role[path] = choices.RoleChoices.max(
path_to_role[path], access.role
)
# serialize and return the response
context = self.get_serializer_context()
serializer_class = self.get_serializer_class()
serialized_data = []
for access in accesses:
path = access.document.path
parent_path = path[: -models.Document.steplen]
access.max_ancestors_role = (
key_to_path_to_max_ancestors_role[access.target_key][parent_path]
if parent_path
else None
)
access.set_user_roles_tuple(
choices.RoleChoices.max(*path_to_ancestors_roles[path]),
path_to_role.get(path),
)
serializer = serializer_class(access, context=context)
serialized_data.append(serializer.data)
return drf.response.Response(serialized_data)
def perform_create(self, serializer):
"""Add a new access to the document and send an email to the new added user."""
access = serializer.save()
"""
Actually create the new document access:
- Ensures the `document_id` is explicitly set from the URL
- If the assigned role is `OWNER`, checks that the requesting user is an owner
of the document. This is the only permission check deferred until this step;
all other access checks are handled earlier in the permission lifecycle.
- Sends an invitation email to the newly added user after saving the access.
"""
role = serializer.validated_data.get("role")
if (
role == choices.RoleChoices.OWNER
and self.document.get_role(self.request.user) != choices.RoleChoices.OWNER
):
raise drf.exceptions.PermissionDenied(
"Only owners of a document can assign other users as owners."
)
access.document.send_invitation_email(
access.user.email,
access.role,
self.request.user,
access.user.language
or self.request.user.language
or settings.LANGUAGE_CODE,
)
access = serializer.save(document_id=self.kwargs["resource_id"])
if access.user:
access.document.send_invitation_email(
access.user.email,
access.role,
self.request.user,
access.user.language
or self.request.user.language
or settings.LANGUAGE_CODE,
)
def perform_update(self, serializer):
"""Update an access to the document and notify the collaboration server."""
@@ -1579,7 +1574,7 @@ class TemplateViewSet(
filter_backends = [drf.filters.OrderingFilter]
permission_classes = [
permissions.IsAuthenticatedOrSafe,
permissions.AccessPermission,
permissions.ResourceWithAccessPermission,
]
ordering = ["-created_at"]
ordering_fields = ["created_at", "updated_at", "title"]
@@ -1641,7 +1636,6 @@ class TemplateAccessViewSet(
ResourceAccessViewsetMixin,
drf.mixins.CreateModelMixin,
drf.mixins.DestroyModelMixin,
drf.mixins.ListModelMixin,
drf.mixins.RetrieveModelMixin,
drf.mixins.UpdateModelMixin,
viewsets.GenericViewSet,
@@ -1671,12 +1665,55 @@ class TemplateAccessViewSet(
"""
lookup_field = "pk"
pagination_class = Pagination
permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission]
permission_classes = [permissions.ResourceAccessPermission]
queryset = models.TemplateAccess.objects.select_related("user").all()
resource_field_name = "template"
serializer_class = serializers.TemplateAccessSerializer
@cached_property
def template(self):
"""Get related template from resource ID in url."""
try:
return models.Template.objects.get(pk=self.kwargs["resource_id"])
except models.Template.DoesNotExist as excpt:
raise drf.exceptions.NotFound() from excpt
def list(self, request, *args, **kwargs):
"""Restrict templates returned by the list endpoint"""
user = self.request.user
teams = user.teams
queryset = self.filter_queryset(self.get_queryset())
# Limit to resource access instances related to a resource THAT also has
# a resource access instance for the logged-in user (we don't want to list
# only the resource access instances pointing to the logged-in user)
queryset = queryset.filter(
db.Q(template__accesses__user=user)
| db.Q(template__accesses__team__in=teams),
).distinct()
serializer = self.get_serializer(queryset, many=True)
return drf.response.Response(serializer.data)
def perform_create(self, serializer):
"""
Actually create the new template access:
- Ensures the `template_id` is explicitly set from the URL.
- If the assigned role is `OWNER`, checks that the requesting user is an owner
of the document. This is the only permission check deferred until this step;
all other access checks are handled earlier in the permission lifecycle.
"""
role = serializer.validated_data.get("role")
if (
role == choices.RoleChoices.OWNER
and self.template.get_role(self.request.user) != choices.RoleChoices.OWNER
):
raise drf.exceptions.PermissionDenied(
"Only owners of a template can assign other users as owners."
)
serializer.save(template_id=self.kwargs["resource_id"])
class InvitationViewset(
drf.mixins.CreateModelMixin,
@@ -1709,7 +1746,7 @@ class InvitationViewset(
pagination_class = Pagination
permission_classes = [
permissions.CanCreateInvitationPermission,
permissions.AccessPermission,
permissions.ResourceWithAccessPermission,
]
queryset = (
models.Invitation.objects.all()
@@ -1718,6 +1755,16 @@ class InvitationViewset(
)
serializer_class = serializers.InvitationSerializer
@cached_property
def document(self):
"""Get related document from resource ID in url and annotate user roles."""
try:
return models.Document.objects.annotate_user_roles(self.request.user).get(
pk=self.kwargs["resource_id"]
)
except models.Document.DoesNotExist as excpt:
raise drf.exceptions.NotFound() from excpt
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
@@ -1749,11 +1796,11 @@ class InvitationViewset(
queryset.filter(
db.Q(
document__accesses__user=user,
document__accesses__role__in=models.PRIVILEGED_ROLES,
document__accesses__role__in=choices.PRIVILEGED_ROLES,
)
| db.Q(
document__accesses__team__in=teams,
document__accesses__role__in=models.PRIVILEGED_ROLES,
document__accesses__role__in=choices.PRIVILEGED_ROLES,
),
)
# Abilities are computed based on logged-in user's role and
+115
View File
@@ -0,0 +1,115 @@
"""Declare and configure choices for Docs' core application."""
from django.db.models import TextChoices
from django.utils.translation import gettext_lazy as _
class PriorityTextChoices(TextChoices):
"""
This class inherits from Django's TextChoices and provides a method to get the priority
of a given value based on its position in the class.
"""
@classmethod
def get_priority(cls, role):
"""Returns the priority of the given role based on its order in the class."""
members = list(cls.__members__.values())
return members.index(role) + 1 if role in members else 0
@classmethod
def max(cls, *roles):
"""
Return the highest-priority role among the given roles, using get_priority().
If no valid roles are provided, returns None.
"""
valid_roles = [role for role in roles if cls.get_priority(role) is not None]
if not valid_roles:
return None
return max(valid_roles, key=cls.get_priority)
class LinkRoleChoices(PriorityTextChoices):
"""Defines the possible roles a link can offer on a document."""
READER = "reader", _("Reader") # Can read
EDITOR = "editor", _("Editor") # Can read and edit
class RoleChoices(PriorityTextChoices):
"""Defines the possible roles a user can have in a resource."""
READER = "reader", _("Reader") # Can read
EDITOR = "editor", _("Editor") # Can read and edit
ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share
OWNER = "owner", _("Owner")
PRIVILEGED_ROLES = [RoleChoices.ADMIN, RoleChoices.OWNER]
class LinkReachChoices(PriorityTextChoices):
"""Defines types of access for links"""
RESTRICTED = (
"restricted",
_("Restricted"),
) # Only users with a specific access can read/edit the document
AUTHENTICATED = (
"authenticated",
_("Authenticated"),
) # Any authenticated user can access the document
PUBLIC = "public", _("Public") # Even anonymous users can access the document
@classmethod
def get_select_options(cls, link_reach, link_role):
"""
Determines the valid select options for link reach and link role depending on the
ancestors' link reach/role given as arguments.
Returns:
Dictionary mapping possible reach levels to their corresponding possible roles.
"""
return {
reach: [
role
for role in LinkRoleChoices.values
if LinkRoleChoices.get_priority(role)
>= LinkRoleChoices.get_priority(link_role)
]
if reach != cls.RESTRICTED
else None
for reach in cls.values
if LinkReachChoices.get_priority(reach)
>= LinkReachChoices.get_priority(link_reach)
}
def get_equivalent_link_definition(ancestors_links):
"""
Return the (reach, role) pair with:
1. Highest reach
2. Highest role among links having that reach
"""
if not ancestors_links:
return {"link_reach": None, "link_role": None}
# 1) Find the highest reach
max_reach = max(
ancestors_links,
key=lambda link: LinkReachChoices.get_priority(link["link_reach"]),
)["link_reach"]
# 2) Among those, find the highest role (ignore role if RESTRICTED)
if max_reach == LinkReachChoices.RESTRICTED:
max_role = None
else:
max_role = max(
(
link["link_role"]
for link in ancestors_links
if link["link_reach"] == max_reach
),
key=LinkRoleChoices.get_priority,
)
return {"link_reach": max_reach, "link_role": max_role}
+303 -232
View File
@@ -6,7 +6,6 @@ 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
@@ -33,6 +32,14 @@ from rest_framework.exceptions import ValidationError
from timezone_field import TimeZoneField
from treebeard.mp_tree import MP_Node, MP_NodeManager, MP_NodeQuerySet
from .choices import (
PRIVILEGED_ROLES,
LinkReachChoices,
LinkRoleChoices,
RoleChoices,
get_equivalent_link_definition,
)
logger = getLogger(__name__)
@@ -50,88 +57,6 @@ def get_trashbin_cutoff():
return timezone.now() - timedelta(days=settings.TRASHBIN_CUTOFF_DAYS)
class LinkRoleChoices(models.TextChoices):
"""Defines the possible roles a link can offer on a document."""
READER = "reader", _("Reader") # Can read
EDITOR = "editor", _("Editor") # Can read and edit
class RoleChoices(models.TextChoices):
"""Defines the possible roles a user can have in a resource."""
READER = "reader", _("Reader") # Can read
EDITOR = "editor", _("Editor") # Can read and edit
ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share
OWNER = "owner", _("Owner")
PRIVILEGED_ROLES = [RoleChoices.ADMIN, RoleChoices.OWNER]
class LinkReachChoices(models.TextChoices):
"""Defines types of access for links"""
RESTRICTED = (
"restricted",
_("Restricted"),
) # Only users with a specific access can read/edit the document
AUTHENTICATED = (
"authenticated",
_("Authenticated"),
) # Any authenticated user can access the document
PUBLIC = "public", _("Public") # Even anonymous users can access the document
@classmethod
def get_select_options(cls, ancestors_links):
"""
Determines the valid select options for link reach and link role depending on the
list of ancestors' link reach/role.
Args:
ancestors_links: List of dictionaries, each with 'link_reach' and 'link_role' keys
representing the reach and role of ancestors links.
Returns:
Dictionary mapping possible reach levels to their corresponding possible roles.
"""
# If no ancestors, return all options
if not ancestors_links:
return dict.fromkeys(cls.values, LinkRoleChoices.values)
# Initialize result with all possible reaches and role options as sets
result = {reach: set(LinkRoleChoices.values) for reach in cls.values}
# Group roles by reach level
reach_roles = defaultdict(set)
for link in ancestors_links:
reach_roles[link["link_reach"]].add(link["link_role"])
# Apply constraints based on ancestor links
if LinkRoleChoices.EDITOR in reach_roles[cls.RESTRICTED]:
result[cls.RESTRICTED].discard(LinkRoleChoices.READER)
if LinkRoleChoices.EDITOR in reach_roles[cls.AUTHENTICATED]:
result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER)
result.pop(cls.RESTRICTED, None)
elif LinkRoleChoices.READER in reach_roles[cls.AUTHENTICATED]:
result[cls.RESTRICTED].discard(LinkRoleChoices.READER)
if LinkRoleChoices.EDITOR in reach_roles[cls.PUBLIC]:
result[cls.PUBLIC].discard(LinkRoleChoices.READER)
result.pop(cls.AUTHENTICATED, None)
result.pop(cls.RESTRICTED, None)
elif LinkRoleChoices.READER in reach_roles[cls.PUBLIC]:
result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER)
result.get(cls.RESTRICTED, set()).discard(LinkRoleChoices.READER)
# Convert roles sets to lists while maintaining the order from LinkRoleChoices
for reach, roles in result.items():
result[reach] = [role for role in LinkRoleChoices.values if role in roles]
return result
class DuplicateEmailError(Exception):
"""Raised when an email is already associated with a pre-existing user."""
@@ -364,69 +289,6 @@ class BaseAccess(BaseModel):
class Meta:
abstract = True
def _get_roles(self, resource, user):
"""
Get the roles a user has on a resource.
"""
roles = []
if user.is_authenticated:
teams = user.teams
try:
roles = self.user_roles or []
except AttributeError:
try:
roles = resource.accesses.filter(
models.Q(user=user) | models.Q(team__in=teams),
).values_list("role", flat=True)
except (self._meta.model.DoesNotExist, IndexError):
roles = []
return roles
def _get_abilities(self, resource, user):
"""
Compute and return abilities for a given user taking into account
the current state of the object.
"""
roles = self._get_roles(resource, user)
is_owner_or_admin = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
if self.role == RoleChoices.OWNER:
can_delete = (
RoleChoices.OWNER in roles
and resource.accesses.filter(role=RoleChoices.OWNER).count() > 1
)
set_role_to = (
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
if can_delete
else []
)
else:
can_delete = is_owner_or_admin
set_role_to = []
if RoleChoices.OWNER in roles:
set_role_to.append(RoleChoices.OWNER)
if is_owner_or_admin:
set_role_to.extend(
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
)
# Remove the current role as we don't want to propose it as an option
try:
set_role_to.remove(self.role)
except ValueError:
pass
return {
"destroy": can_delete,
"update": bool(set_role_to),
"partial_update": bool(set_role_to),
"retrieve": bool(roles),
"set_role_to": set_role_to,
}
class DocumentQuerySet(MP_NodeQuerySet):
"""
@@ -452,6 +314,41 @@ class DocumentQuerySet(MP_NodeQuerySet):
return self.filter(link_reach=LinkReachChoices.PUBLIC)
def annotate_is_favorite(self, user):
"""
Annotate document queryset with the favorite status for the current user.
"""
if user.is_authenticated:
favorite_exists_subquery = DocumentFavorite.objects.filter(
document_id=models.OuterRef("pk"), user=user
)
return self.annotate(is_favorite=models.Exists(favorite_exists_subquery))
return self.annotate(is_favorite=models.Value(False))
def annotate_user_roles(self, user):
"""
Annotate document queryset with the roles of the current user
on the document or its ancestors.
"""
output_field = ArrayField(base_field=models.CharField())
if user.is_authenticated:
user_roles_subquery = DocumentAccess.objects.filter(
models.Q(user=user) | models.Q(team__in=user.teams),
document__path=Left(models.OuterRef("path"), Length("document__path")),
).values_list("role", flat=True)
return self.annotate(
user_roles=models.Func(
user_roles_subquery, function="ARRAY", output_field=output_field
)
)
return self.annotate(
user_roles=models.Value([], output_field=output_field),
)
class DocumentManager(MP_NodeManager.from_queryset(DocumentQuerySet)):
"""
@@ -464,6 +361,7 @@ class DocumentManager(MP_NodeManager.from_queryset(DocumentQuerySet)):
return self._queryset_class(self.model).order_by("path")
# pylint: disable=too-many-public-methods
class Document(MP_Node, BaseModel):
"""Pad document carrying the content."""
@@ -531,6 +429,12 @@ class Document(MP_Node, BaseModel):
def __str__(self):
return str(self.title) if self.title else str(_("Untitled Document"))
def __init__(self, *args, **kwargs):
"""Initialize cache property."""
super().__init__(*args, **kwargs)
self._ancestors_link_definition = None
self._computed_link_definition = None
def save(self, *args, **kwargs):
"""Write content to object storage only if _content has changed."""
super().save(*args, **kwargs)
@@ -718,38 +622,22 @@ class Document(MP_Node, BaseModel):
cache_key = document.get_nb_accesses_cache_key()
cache.delete(cache_key)
def get_roles(self, user):
def get_role(self, user):
"""Return the roles a user has on a document."""
if not user.is_authenticated:
return []
return None
try:
roles = self.user_roles or []
except AttributeError:
try:
roles = DocumentAccess.objects.filter(
models.Q(user=user) | models.Q(team__in=user.teams),
document__path=Left(
models.Value(self.path), Length("document__path")
),
).values_list("role", flat=True)
except (models.ObjectDoesNotExist, IndexError):
roles = []
return roles
roles = DocumentAccess.objects.filter(
models.Q(user=user) | models.Q(team__in=user.teams),
document__path=Left(models.Value(self.path), Length("document__path")),
).values_list("role", flat=True)
def get_links_definitions(self, ancestors_links):
"""Get links reach/role definitions for the current document and its ancestors."""
return RoleChoices.max(*roles)
links_definitions = defaultdict(set)
links_definitions[self.link_reach].add(self.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 default dict back to a normal dict
def compute_ancestors_links(self, user):
def compute_ancestors_links_paths_mapping(self):
"""
Compute the ancestors links for the current document up to the highest readable ancestor.
"""
@@ -758,63 +646,114 @@ class Document(MP_Node, BaseModel):
.filter(ancestors_deleted_at__isnull=True)
.order_by("path")
)
highest_readable = ancestors.readable_per_se(user).only("depth").first()
if highest_readable is None:
return []
ancestors_links = []
paths_links_mapping = {}
for ancestor in ancestors.filter(depth__gte=highest_readable.depth):
for ancestor in ancestors:
ancestors_links.append(
{"link_reach": ancestor.link_reach, "link_role": ancestor.link_role}
)
paths_links_mapping[ancestor.path] = ancestors_links.copy()
ancestors_links = paths_links_mapping.get(self.path[: -self.steplen], [])
return paths_links_mapping
return ancestors_links
@property
def link_definition(self):
"""Returns link reach/role as a definition in dictionary format."""
return {"link_reach": self.link_reach, "link_role": self.link_role}
def get_abilities(self, user, ancestors_links=None):
@property
def ancestors_link_definition(self):
"""Link definition equivalent to all document's ancestors."""
if getattr(self, "_ancestors_link_definition", None) is None:
if self.depth <= 1:
ancestors_links = []
else:
mapping = self.compute_ancestors_links_paths_mapping()
ancestors_links = mapping.get(self.path[: -self.steplen], [])
self._ancestors_link_definition = get_equivalent_link_definition(
ancestors_links
)
return self._ancestors_link_definition
@ancestors_link_definition.setter
def ancestors_link_definition(self, definition):
"""Cache the ancestors_link_definition."""
self._ancestors_link_definition = definition
@property
def ancestors_link_reach(self):
"""Link reach equivalent to all document's ancestors."""
return self.ancestors_link_definition["link_reach"]
@property
def ancestors_link_role(self):
"""Link role equivalent to all document's ancestors."""
return self.ancestors_link_definition["link_role"]
@property
def computed_link_definition(self):
"""
Link reach/role on the document, combining inherited ancestors' link
definitions and the document's own link definition.
"""
if getattr(self, "_computed_link_definition", None) is None:
self._computed_link_definition = get_equivalent_link_definition(
[self.ancestors_link_definition, self.link_definition]
)
return self._computed_link_definition
@property
def computed_link_reach(self):
"""Actual link reach on the document."""
return self.computed_link_definition["link_reach"]
@property
def computed_link_role(self):
"""Actual link role on the document."""
return self.computed_link_definition["link_role"]
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the document.
"""
if self.depth <= 1 or getattr(self, "is_highest_ancestor_for_user", False):
ancestors_links = []
elif ancestors_links is None:
ancestors_links = self.compute_ancestors_links(user=user)
roles = set(
self.get_roles(user)
) # at this point only roles based on specific access
# First get the role based on specific access
role = self.get_role(user)
# Characteristics that are based only on specific access
is_owner = RoleChoices.OWNER in roles
is_owner = role == RoleChoices.OWNER
is_deleted = self.ancestors_deleted_at and not is_owner
is_owner_or_admin = (is_owner or RoleChoices.ADMIN in roles) and not is_deleted
is_owner_or_admin = (is_owner or role == RoleChoices.ADMIN) and not is_deleted
# Compute access roles before adding link roles because we don't
# want anonymous users to access versions (we wouldn't know from
# which date to allow them anyway)
# Anonymous users should also not see document accesses
has_access_role = bool(roles) and not is_deleted
has_access_role = bool(role) and not is_deleted
can_update_from_access = (
is_owner_or_admin or RoleChoices.EDITOR in roles
is_owner_or_admin or role == RoleChoices.EDITOR
) and not is_deleted
# Add roles provided by the document link, taking into account its ancestors
links_definitions = self.get_links_definitions(ancestors_links)
public_roles = links_definitions.get(LinkReachChoices.PUBLIC, set())
authenticated_roles = (
links_definitions.get(LinkReachChoices.AUTHENTICATED, set())
if user.is_authenticated
else set()
link_select_options = LinkReachChoices.get_select_options(
**self.ancestors_link_definition
)
link_definition = get_equivalent_link_definition(
[
self.ancestors_link_definition,
{"link_reach": self.link_reach, "link_role": self.link_role},
]
)
roles = roles | public_roles | authenticated_roles
can_get = bool(roles) and not is_deleted
link_reach = link_definition["link_reach"]
if link_reach == LinkReachChoices.PUBLIC or (
link_reach == LinkReachChoices.AUTHENTICATED and user.is_authenticated
):
role = RoleChoices.max(role, link_definition["link_role"])
can_get = bool(role) and not is_deleted
can_update = (
is_owner_or_admin or RoleChoices.EDITOR in roles
is_owner_or_admin or role == RoleChoices.EDITOR
) and not is_deleted
ai_allow_reach_from = settings.AI_ALLOW_REACH_FROM
@@ -851,7 +790,7 @@ class Document(MP_Node, BaseModel):
"restore": is_owner,
"retrieve": can_get,
"media_auth": can_get,
"link_select_options": LinkReachChoices.get_select_options(ancestors_links),
"link_select_options": link_select_options,
"tree": can_get,
"update": can_update,
"versions_destroy": is_owner_or_admin,
@@ -1098,54 +1037,133 @@ class DocumentAccess(BaseAccess):
def __str__(self):
return f"{self.user!s} is {self.role:s} in document {self.document!s}"
def __init__(self, *args, **kwargs):
"""initialize cache attribute."""
super().__init__(*args, **kwargs)
self._prefetched_user_roles_tuple = None
def save(self, *args, **kwargs):
"""Override save to clear the document's cache for number of accesses."""
super().save(*args, **kwargs)
self.document.invalidate_nb_accesses_cache()
@property
def target_key(self):
"""Get a unique key for the actor targeted by the access, without possible conflict."""
return f"user:{self.user_id!s}" if self.user_id else f"team:{self.team:s}"
def delete(self, *args, **kwargs):
"""Override delete to clear the document's cache for number of accesses."""
super().delete(*args, **kwargs)
self.document.invalidate_nb_accesses_cache()
def set_user_roles_tuple(self, ancestors_role, current_role):
"""
Set a precomputed (ancestor_role, current_role) tuple for this instance.
This avoids querying the database in `get_roles_tuple()` and is useful
when roles are already known, such as in bulk serialization.
Args:
ancestor_role (str | None): Highest role on any ancestor document.
current_role (str | None): Role on the current document.
"""
self._prefetched_user_roles_tuple = (ancestors_role, current_role)
def get_user_roles_tuple(self, user):
"""
Return a tuple of:
- the highest role the user has on any ancestor of the document
- the role the user has on the current document
If roles have been explicitly set using `set_user_roles_tuple()`,
those will be returned instead of querying the database.
This allows viewsets or serializers to precompute roles for performance
when handling multiple documents at once.
Args:
user (User): The user whose roles are being evaluated.
Returns:
tuple[str | None, str | None]: (max_ancestor_role, current_document_role)
"""
if not user.is_authenticated:
return None, None
if self._prefetched_user_roles_tuple is not None:
return self._prefetched_user_roles_tuple
ancestors = (
self.document.get_ancestors() | Document.objects.filter(pk=self.document_id)
).filter(ancestors_deleted_at__isnull=True)
access_tuples = DocumentAccess.objects.filter(
models.Q(user=user) | models.Q(team__in=user.teams),
document__in=ancestors,
).values_list("document_id", "role")
ancestors_roles = []
current_roles = []
for doc_id, role in access_tuples:
if doc_id == self.document_id:
current_roles.append(role)
else:
ancestors_roles.append(role)
return RoleChoices.max(*ancestors_roles), RoleChoices.max(*current_roles)
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the document access.
"""
roles = self._get_roles(self.document, user)
is_owner_or_admin = bool(set(roles).intersection(set(PRIVILEGED_ROLES)))
ancestors_role, current_role = self.get_user_roles_tuple(user)
role = RoleChoices.max(ancestors_role, current_role)
is_owner_or_admin = role in PRIVILEGED_ROLES
if self.role == RoleChoices.OWNER:
can_delete = (
RoleChoices.OWNER in roles
and self.document.accesses.filter(role=RoleChoices.OWNER).count() > 1
)
set_role_to = (
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
if can_delete
else []
can_delete = role == RoleChoices.OWNER and (
# check if document is not root trying to avoid an extra query
self.document.depth > 1
or DocumentAccess.objects.filter(
document_id=self.document_id, role=RoleChoices.OWNER
).count()
> 1
)
set_role_to = RoleChoices.values if can_delete else []
else:
can_delete = is_owner_or_admin
set_role_to = []
if RoleChoices.OWNER in roles:
set_role_to.append(RoleChoices.OWNER)
if is_owner_or_admin:
set_role_to.extend(
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
[RoleChoices.READER, RoleChoices.EDITOR, RoleChoices.ADMIN]
)
if role == RoleChoices.OWNER:
set_role_to.append(RoleChoices.OWNER)
# Remove the current role as we don't want to propose it as an option
try:
set_role_to.remove(self.role)
except ValueError:
pass
# Filter out roles that would be lower than the one the user already has
ancestors_role_priority = RoleChoices.get_priority(
getattr(self, "max_ancestors_role", None)
)
set_role_to = [
candidate_role
for candidate_role in set_role_to
if RoleChoices.get_priority(candidate_role) > ancestors_role_priority
]
child_set_role_to = [
candidate_role
for candidate_role in set_role_to
if RoleChoices.get_priority(candidate_role)
> RoleChoices.get_priority(self.role)
]
return {
"destroy": can_delete,
"update": bool(set_role_to) and is_owner_or_admin,
"partial_update": bool(set_role_to) and is_owner_or_admin,
"retrieve": self.user and self.user.id == user.id or is_owner_or_admin,
"retrieve": (self.user and self.user.id == user.id) or is_owner_or_admin,
"set_role_to": set_role_to,
"child_set_role_to": child_set_role_to,
}
@@ -1277,10 +1295,10 @@ class Template(BaseModel):
def __str__(self):
return self.title
def get_roles(self, user):
def get_role(self, user):
"""Return the roles a user has on a resource as an iterable."""
if not user.is_authenticated:
return []
return None
try:
roles = self.user_roles or []
@@ -1291,21 +1309,20 @@ class Template(BaseModel):
).values_list("role", flat=True)
except (models.ObjectDoesNotExist, IndexError):
roles = []
return roles
return RoleChoices.max(*roles)
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the template.
"""
roles = self.get_roles(user)
is_owner_or_admin = bool(
set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
)
can_get = self.is_public or bool(roles)
can_update = is_owner_or_admin or RoleChoices.EDITOR in roles
role = self.get_role(user)
is_owner_or_admin = role in PRIVILEGED_ROLES
can_get = self.is_public or bool(role)
can_update = is_owner_or_admin or role == RoleChoices.EDITOR
return {
"destroy": RoleChoices.OWNER in roles,
"destroy": role == RoleChoices.OWNER,
"generate_document": can_get,
"accesses_manage": is_owner_or_admin,
"update": can_update,
@@ -1352,11 +1369,65 @@ class TemplateAccess(BaseAccess):
def __str__(self):
return f"{self.user!s} is {self.role:s} in template {self.template!s}"
def get_role(self, user):
"""
Get the role a user has on a resource.
"""
if not user.is_authenticated:
return None
try:
roles = self.user_roles or []
except AttributeError:
teams = user.teams
try:
roles = self.template.accesses.filter(
models.Q(user=user) | models.Q(team__in=teams),
).values_list("role", flat=True)
except (Template.DoesNotExist, IndexError):
roles = []
return RoleChoices.max(*roles)
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the template access.
"""
return self._get_abilities(self.template, user)
role = self.get_role(user)
is_owner_or_admin = role in PRIVILEGED_ROLES
if self.role == RoleChoices.OWNER:
can_delete = (role == RoleChoices.OWNER) and self.template.accesses.filter(
role=RoleChoices.OWNER
).count() > 1
set_role_to = (
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
if can_delete
else []
)
else:
can_delete = is_owner_or_admin
set_role_to = []
if role == RoleChoices.OWNER:
set_role_to.append(RoleChoices.OWNER)
if is_owner_or_admin:
set_role_to.extend(
[RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER]
)
# Remove the current role as we don't want to propose it as an option
try:
set_role_to.remove(self.role)
except ValueError:
pass
return {
"destroy": can_delete,
"update": bool(set_role_to),
"partial_update": bool(set_role_to),
"retrieve": bool(role),
"set_role_to": set_role_to,
}
class Invitation(BaseModel):
File diff suppressed because it is too large Load Diff
@@ -9,7 +9,7 @@ from django.core import mail
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core import choices, factories, models
from core.api import serializers
from core.tests.conftest import TEAM, USER, VIA
@@ -29,7 +29,7 @@ def test_api_document_accesses_create_anonymous():
{
"user_id": str(other_user.id),
"document": str(document.id),
"role": random.choice(models.RoleChoices.values),
"role": random.choice(choices.RoleChoices.values),
},
format="json",
)
@@ -88,7 +88,7 @@ def test_api_document_accesses_create_authenticated_reader_or_editor(
other_user = factories.UserFactory()
for new_role in [role[0] for role in models.RoleChoices.choices]:
for new_role in choices.RoleChoices.values:
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
@@ -103,32 +103,37 @@ def test_api_document_accesses_create_authenticated_reader_or_editor(
assert not models.DocumentAccess.objects.filter(user=other_user).exists()
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_administrator(via, mock_user_teams):
def test_api_document_accesses_create_authenticated_administrator_share_to_user(
via, depth, mock_user_teams
):
"""
Administrators of a document should be able to create document accesses
except for the "owner" role.
Administrators of a document (direct or by heritage) should be able to create
document accesses for a user except for the "owner" role.
An email should be sent to the accesses to notify them of the adding.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
documents = []
for i in range(depth):
parent = documents[i - 1] if i > 0 else None
documents.append(factories.DocumentFactory(parent=parent))
if via == USER:
factories.UserDocumentAccessFactory(
document=document, user=user, role="administrator"
document=documents[0], user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="administrator"
document=documents[0], team="lasuite", role="administrator"
)
other_user = factories.UserFactory(language="en-us")
# It should not be allowed to create an owner access
document = documents[-1]
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
@@ -140,12 +145,12 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a resource can assign other users as owners."
"detail": "Only owners of a document can assign other users as owners."
}
# It should be allowed to create a lower access
role = random.choice(
[role[0] for role in models.RoleChoices.choices if role[0] != "owner"]
[role for role in choices.RoleChoices.values if role != "owner"]
)
assert len(mail.outbox) == 0
@@ -165,9 +170,16 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"abilities": new_document_access.get_abilities(user),
"document": {
"id": str(new_document_access.document_id),
"depth": new_document_access.document.depth,
"path": new_document_access.document.path,
},
"id": str(new_document_access.id),
"team": "",
"max_ancestors_role": None,
"max_role": role,
"role": role,
"team": "",
"user": other_user,
}
assert len(mail.outbox) == 1
@@ -182,29 +194,121 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
assert "docs/" + str(document.id) + "/" in email_content
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
def test_api_document_accesses_create_authenticated_administrator_share_to_team(
via, depth, mock_user_teams
):
"""
Owners of a document should be able to create document accesses whatever the role.
Administrators of a document (direct or by heritage) should be able to create
document accesses for a team except for the "owner" role.
An email should be sent to the accesses to notify them of the adding.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
documents = []
for i in range(depth):
parent = documents[i - 1] if i > 0 else None
documents.append(factories.DocumentFactory(parent=parent))
if via == USER:
factories.UserDocumentAccessFactory(
document=documents[0], user=user, role="administrator"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=documents[0], team="lasuite", role="administrator"
)
other_user = factories.UserFactory(language="en-us")
document = documents[-1]
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"team": "new-team",
"role": "owner",
},
format="json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a document can assign other users as owners."
}
# It should be allowed to create a lower access
role = random.choice(
[role for role in choices.RoleChoices.values if role != "owner"]
)
assert len(mail.outbox) == 0
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"team": "new-team",
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.DocumentAccess.objects.filter(team="new-team").count() == 1
new_document_access = models.DocumentAccess.objects.filter(team="new-team").get()
other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"abilities": new_document_access.get_abilities(user),
"document": {
"id": str(new_document_access.document_id),
"depth": new_document_access.document.depth,
"path": new_document_access.document.path,
},
"id": str(new_document_access.id),
"max_ancestors_role": None,
"max_role": role,
"role": role,
"team": "new-team",
"user": None,
}
assert len(mail.outbox) == 0
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_owner_share_to_user(
via, depth, mock_user_teams
):
"""
Owners of a document (direct or by heritage) should be able to create document accesses
for a user, whatever the role. An email should be sent to the accesses to notify them
of the adding.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
documents = []
for i in range(depth):
parent = documents[i - 1] if i > 0 else None
documents.append(factories.DocumentFactory(parent=parent))
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
factories.UserDocumentAccessFactory(
document=documents[0], user=user, role="owner"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
document=documents[0], team="lasuite", role="owner"
)
other_user = factories.UserFactory(language="en-us")
role = random.choice([role[0] for role in models.RoleChoices.choices])
document = documents[-1]
role = random.choice(choices.RoleChoices.values)
assert len(mail.outbox) == 0
@@ -222,11 +326,18 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
new_document_access = models.DocumentAccess.objects.filter(user=other_user).get()
other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"id": str(new_document_access.id),
"user": other_user,
"team": "",
"role": role,
"abilities": new_document_access.get_abilities(user),
"document": {
"id": str(new_document_access.document_id),
"depth": new_document_access.document.depth,
"path": new_document_access.document.path,
},
"id": str(new_document_access.id),
"max_ancestors_role": None,
"max_role": role,
"role": role,
"team": "",
"user": other_user,
}
assert len(mail.outbox) == 1
email = mail.outbox[0]
@@ -240,6 +351,187 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
assert "docs/" + str(document.id) + "/" in email_content
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_authenticated_owner_share_to_team(
via, depth, mock_user_teams
):
"""
Owners of a document (direct or by heritage) should be able to create document accesses
for a team whatever the role. An email should be sent to the accesses to notify them of
the adding.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents = []
for i in range(depth):
parent = documents[i - 1] if i > 0 else None
documents.append(factories.DocumentFactory(parent=parent))
if via == USER:
factories.UserDocumentAccessFactory(
document=documents[0], user=user, role="owner"
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=documents[0], team="lasuite", role="owner"
)
other_user = factories.UserFactory(language="en-us")
document = documents[-1]
role = random.choice(choices.RoleChoices.values)
assert len(mail.outbox) == 0
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"team": "new-team",
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.DocumentAccess.objects.filter(team="new-team").count() == 1
new_document_access = models.DocumentAccess.objects.filter(team="new-team").get()
other_user = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"abilities": new_document_access.get_abilities(user),
"document": {
"id": str(new_document_access.document_id),
"path": new_document_access.document.path,
"depth": new_document_access.document.depth,
},
"id": str(new_document_access.id),
"max_ancestors_role": None,
"max_role": role,
"role": role,
"team": "new-team",
"user": None,
}
assert len(mail.outbox) == 0
@pytest.mark.parametrize("override_role", choices.RoleChoices.values)
@pytest.mark.parametrize("parent_role", choices.RoleChoices.values)
def test_api_document_accesses_create_authenticated_higher_role_to_user(
parent_role, override_role
):
"""
It should not be allowed to create a document access override for a user
with a role lower or equal to the inherited role.
"""
user, other_user = factories.UserFactory.create_batch(2)
client = APIClient()
client.force_login(user)
parent = factories.DocumentFactory(
users=([user, "owner"], [other_user, parent_role])
)
document = factories.DocumentFactory(parent=parent)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user_id": str(other_user.id),
"role": override_role,
},
format="json",
)
get_priority = choices.RoleChoices.get_priority
if get_priority(override_role) > get_priority(parent_role):
assert response.status_code == 201
assert models.DocumentAccess.objects.filter(user=other_user).count() == 2
else:
assert response.status_code == 400
assert response.json() == {
"role": [
"Role overrides must be greater than the inherited role: "
f"{parent_role}/{override_role}"
],
}
assert models.DocumentAccess.objects.filter(user=other_user).count() == 1
@pytest.mark.parametrize("override_role", choices.RoleChoices.values)
@pytest.mark.parametrize("parent_role", choices.RoleChoices.values)
def test_api_document_accesses_create_authenticated_higher_role_to_team(
parent_role, override_role, mock_user_teams
):
"""
It should not be allowed to create a document access override for a team
with a role lower or equal to the inherited role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
mock_user_teams.return_value = ["lasuite", "unknown"]
parent = factories.DocumentFactory(
users=[[user, "owner"]], teams=[["lasuite", parent_role]]
)
document = factories.DocumentFactory(parent=parent)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"team": "lasuite",
"role": override_role,
},
format="json",
)
get_priority = choices.RoleChoices.get_priority
if get_priority(override_role) > get_priority(parent_role):
assert response.status_code == 201
assert models.DocumentAccess.objects.filter(team="lasuite").count() == 2
else:
assert response.status_code == 400
assert response.json() == {
"role": [
"Role overrides must be greater than the inherited role: "
f"{parent_role}/{override_role}"
],
}
assert models.DocumentAccess.objects.filter(team="lasuite").count() == 1
def test_api_document_accesses_create_authenticated_user_and_team():
"""Trying to create a document access with a user and a team should return a 400 error."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[[user, "owner"]])
other_user = factories.UserFactory(language="en-us")
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user_id": str(other_user.id),
"team": "lasuite",
"role": "reader",
},
format="json",
)
assert response.status_code == 400
assert response.json() == {
"__all__": ["Either user or team must be set, not both."]
}
assert models.DocumentAccess.objects.count() == 1
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_email_in_receivers_language(via, mock_user_teams):
"""
@@ -259,7 +551,7 @@ def test_api_document_accesses_create_email_in_receivers_language(via, mock_user
document=document, team="lasuite", role="owner"
)
role = random.choice([role[0] for role in models.RoleChoices.choices])
role = random.choice(choices.RoleChoices.values)
assert len(mail.outbox) == 0
@@ -286,11 +578,18 @@ def test_api_document_accesses_create_email_in_receivers_language(via, mock_user
).get()
other_user_data = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"id": str(new_document_access.id),
"user": other_user_data,
"team": "",
"role": role,
"abilities": new_document_access.get_abilities(user),
"document": {
"id": str(new_document_access.document_id),
"path": new_document_access.document.path,
"depth": new_document_access.document.depth,
},
"id": str(new_document_access.id),
"max_ancestors_role": None,
"max_role": role,
"role": role,
"team": "",
"user": other_user_data,
}
assert len(mail.outbox) == index + 1
email = mail.outbox[index]
@@ -2,9 +2,11 @@
Unit tests for the Invitation model
"""
# pylint: disable=too-many-positional-arguments, too-many-arguments
import random
from datetime import timedelta
from unittest import mock
from uuid import uuid4
from django.core import mail
from django.test import override_settings
@@ -341,6 +343,7 @@ def test_api_document_invitations_create_authenticated_outsider():
@override_settings(EMAIL_BRAND_NAME="My brand name", EMAIL_LOGO_IMG="my-img.jpg")
@pytest.mark.parametrize("depth", [1, 2, 3])
@pytest.mark.parametrize(
"inviting,invited,response_code",
(
@@ -363,22 +366,31 @@ def test_api_document_invitations_create_authenticated_outsider():
),
)
@pytest.mark.parametrize("via", VIA)
def test_api_document_invitations_create_privileged_members(
via, inviting, invited, response_code, mock_user_teams
def test_api_document_invitations_create_privileged_members( # noqa: PLR0913
via, inviting, invited, response_code, depth, mock_user_teams
):
"""
Only owners and administrators should be able to invite new users.
Only owners can invite owners.
"""
user = factories.UserFactory(language="en-us")
documents = []
for i in range(depth):
parent = documents[i - 1] if i > 0 else None
documents.append(factories.DocumentFactory(parent=parent))
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=inviting)
factories.UserDocumentAccessFactory(
document=documents[0], user=user, role=inviting
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=inviting
document=documents[0], team="lasuite", role=inviting
)
document = documents[-1]
invitation_values = {
"email": "guest@example.com",
@@ -422,6 +434,30 @@ def test_api_document_invitations_create_privileged_members(
}
def test_api_document_invitations_create_unknown_document():
"""Trying to create an invitation for an unknown document should return a 404."""
user = factories.UserFactory(language="en-us")
invitation_values = {
"email": "guest@example.com",
"role": "reader",
}
assert len(mail.outbox) == 0
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{uuid4()!s}/invitations/",
invitation_values,
format="json",
)
assert response.status_code == 404
assert models.Invitation.objects.exists() is False
assert response.json() == {"detail": "Not found."}
def test_api_document_invitations_create_email_from_senders_language():
"""
When inviting on a document a user who does not exist yet in our database,
@@ -98,7 +98,9 @@ def test_api_documents_children_create_authenticated_success(reach, role, depth)
if i == 0:
document = factories.DocumentFactory(link_reach=reach, link_role=role)
else:
document = factories.DocumentFactory(parent=document, link_role="reader")
document = factories.DocumentFactory(
parent=document, link_reach="restricted"
)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/children/",
@@ -112,7 +114,8 @@ def test_api_documents_children_create_authenticated_success(reach, role, depth)
child = Document.objects.get(id=response.json()["id"])
assert child.title == "my child"
assert child.link_reach == "restricted"
assert child.accesses.filter(role="owner", user=user).exists()
# Access objects on the child are not necessary
assert child.accesses.exists() is False
@pytest.mark.parametrize("depth", [1, 2, 3])
@@ -180,7 +183,8 @@ def test_api_documents_children_create_related_success(role, depth):
child = Document.objects.get(id=response.json()["id"])
assert child.title == "my child"
assert child.link_reach == "restricted"
assert child.accesses.filter(role="owner", user=user).exists()
# Access objects on the child are not necessary
assert child.accesses.exists() is False
def test_api_documents_children_create_authenticated_title_null():
@@ -14,13 +14,18 @@ from core import factories
pytestmark = pytest.mark.django_db
def test_api_documents_children_list_anonymous_public_standalone():
def test_api_documents_children_list_anonymous_public_standalone(
django_assert_num_queries,
):
"""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)
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
with django_assert_num_queries(8):
APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
with django_assert_num_queries(4):
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
assert response.status_code == 200
assert response.json() == {
@@ -30,6 +35,10 @@ def test_api_documents_children_list_anonymous_public_standalone():
"results": [
{
"abilities": child1.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": document.link_role,
"computed_link_reach": "public",
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 2,
@@ -44,10 +53,14 @@ def test_api_documents_children_list_anonymous_public_standalone():
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
{
"abilities": child2.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": document.link_role,
"computed_link_reach": "public",
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 2,
@@ -62,13 +75,13 @@ def test_api_documents_children_list_anonymous_public_standalone():
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
],
}
def test_api_documents_children_list_anonymous_public_parent():
def test_api_documents_children_list_anonymous_public_parent(django_assert_num_queries):
"""
Anonymous users should be allowed to retrieve the children of a document who
has a public ancestor.
@@ -83,7 +96,10 @@ def test_api_documents_children_list_anonymous_public_parent():
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
with django_assert_num_queries(9):
APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
with django_assert_num_queries(5):
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/")
assert response.status_code == 200
assert response.json() == {
@@ -93,6 +109,10 @@ def test_api_documents_children_list_anonymous_public_parent():
"results": [
{
"abilities": child1.get_abilities(AnonymousUser()),
"ancestors_link_reach": child1.ancestors_link_reach,
"ancestors_link_role": child1.ancestors_link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 4,
@@ -107,10 +127,14 @@ def test_api_documents_children_list_anonymous_public_parent():
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
{
"abilities": child2.get_abilities(AnonymousUser()),
"ancestors_link_reach": child2.ancestors_link_reach,
"ancestors_link_role": child2.ancestors_link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 4,
@@ -125,7 +149,7 @@ def test_api_documents_children_list_anonymous_public_parent():
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
],
}
@@ -149,7 +173,7 @@ def test_api_documents_children_list_anonymous_restricted_or_authenticated(reach
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_children_list_authenticated_unrelated_public_or_authenticated(
reach,
reach, django_assert_num_queries
):
"""
Authenticated users should be able to retrieve the children of a public/authenticated
@@ -163,9 +187,13 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
with django_assert_num_queries(9):
client.get(f"/api/v1.0/documents/{document.id!s}/children/")
with django_assert_num_queries(5):
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
assert response.status_code == 200
assert response.json() == {
"count": 2,
@@ -174,6 +202,10 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
"results": [
{
"abilities": child1.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": document.link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 2,
@@ -188,10 +220,14 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
{
"abilities": child2.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": document.link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 2,
@@ -206,7 +242,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
],
}
@@ -214,7 +250,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_children_list_authenticated_public_or_authenticated_parent(
reach,
reach, django_assert_num_queries
):
"""
Authenticated users should be allowed to retrieve the children of a document who
@@ -231,7 +267,11 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
with django_assert_num_queries(10):
client.get(f"/api/v1.0/documents/{document.id!s}/children/")
with django_assert_num_queries(6):
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
assert response.status_code == 200
assert response.json() == {
@@ -241,6 +281,10 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
"results": [
{
"abilities": child1.get_abilities(user),
"ancestors_link_reach": child1.ancestors_link_reach,
"ancestors_link_role": child1.ancestors_link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 4,
@@ -255,10 +299,14 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
{
"abilities": child2.get_abilities(user),
"ancestors_link_reach": child2.ancestors_link_reach,
"ancestors_link_role": child2.ancestors_link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 4,
@@ -273,13 +321,15 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
],
}
def test_api_documents_children_list_authenticated_unrelated_restricted():
def test_api_documents_children_list_authenticated_unrelated_restricted(
django_assert_num_queries,
):
"""
Authenticated users should not be allowed to retrieve the children of a document that is
restricted and to which they are not related.
@@ -293,16 +343,20 @@ def test_api_documents_children_list_authenticated_unrelated_restricted():
child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
with django_assert_num_queries(2):
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_api_documents_children_list_authenticated_related_direct():
def test_api_documents_children_list_authenticated_related_direct(
django_assert_num_queries,
):
"""
Authenticated users should be allowed to retrieve the children of a document
to which they are directly related whatever the role.
@@ -319,10 +373,13 @@ def test_api_documents_children_list_authenticated_related_direct():
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
factories.UserDocumentAccessFactory(document=child1)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
with django_assert_num_queries(9):
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
assert response.status_code == 200
link_role = None if document.link_reach == "restricted" else document.link_role
assert response.json() == {
"count": 2,
"next": None,
@@ -330,6 +387,10 @@ def test_api_documents_children_list_authenticated_related_direct():
"results": [
{
"abilities": child1.get_abilities(user),
"ancestors_link_reach": document.link_reach,
"ancestors_link_role": link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 2,
@@ -344,10 +405,14 @@ def test_api_documents_children_list_authenticated_related_direct():
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
},
{
"abilities": child2.get_abilities(user),
"ancestors_link_reach": document.link_reach,
"ancestors_link_role": link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 2,
@@ -362,13 +427,15 @@ def test_api_documents_children_list_authenticated_related_direct():
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
},
],
}
def test_api_documents_children_list_authenticated_related_parent():
def test_api_documents_children_list_authenticated_related_parent(
django_assert_num_queries,
):
"""
Authenticated users should be allowed to retrieve the children of a document if they
are related to one of its ancestors whatever the role.
@@ -389,9 +456,11 @@ def test_api_documents_children_list_authenticated_related_parent():
document=grand_parent, user=user
)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
with django_assert_num_queries(10):
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
assert response.status_code == 200
assert response.json() == {
"count": 2,
@@ -400,6 +469,10 @@ def test_api_documents_children_list_authenticated_related_parent():
"results": [
{
"abilities": child1.get_abilities(user),
"ancestors_link_reach": "restricted",
"ancestors_link_role": None,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 4,
@@ -414,10 +487,14 @@ def test_api_documents_children_list_authenticated_related_parent():
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [grand_parent_access.role],
"user_role": grand_parent_access.role,
},
{
"abilities": child2.get_abilities(user),
"ancestors_link_reach": "restricted",
"ancestors_link_role": None,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 4,
@@ -432,13 +509,15 @@ def test_api_documents_children_list_authenticated_related_parent():
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [grand_parent_access.role],
"user_role": grand_parent_access.role,
},
],
}
def test_api_documents_children_list_authenticated_related_child():
def test_api_documents_children_list_authenticated_related_child(
django_assert_num_queries,
):
"""
Authenticated users should not be allowed to retrieve all the children of a document
as a result of being related to one of its children.
@@ -454,16 +533,20 @@ def test_api_documents_children_list_authenticated_related_child():
factories.UserDocumentAccessFactory(document=child1, user=user)
factories.UserDocumentAccessFactory(document=document)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
with django_assert_num_queries(2):
response = client.get(
f"/api/v1.0/documents/{document.id!s}/children/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_api_documents_children_list_authenticated_related_team_none(mock_user_teams):
def test_api_documents_children_list_authenticated_related_team_none(
mock_user_teams, django_assert_num_queries
):
"""
Authenticated users should not be able to retrieve the children of a restricted document
related to teams in which the user is not.
@@ -480,7 +563,9 @@ def test_api_documents_children_list_authenticated_related_team_none(mock_user_t
factories.TeamDocumentAccessFactory(document=document, team="myteam")
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
with django_assert_num_queries(2):
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
@@ -488,7 +573,7 @@ def test_api_documents_children_list_authenticated_related_team_none(mock_user_t
def test_api_documents_children_list_authenticated_related_team_members(
mock_user_teams,
mock_user_teams, django_assert_num_queries
):
"""
Authenticated users should be allowed to retrieve the children of a document to which they
@@ -506,7 +591,8 @@ def test_api_documents_children_list_authenticated_related_team_members(
access = factories.TeamDocumentAccessFactory(document=document, team="myteam")
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
with django_assert_num_queries(9):
response = client.get(f"/api/v1.0/documents/{document.id!s}/children/")
# pylint: disable=R0801
assert response.status_code == 200
@@ -517,6 +603,10 @@ def test_api_documents_children_list_authenticated_related_team_members(
"results": [
{
"abilities": child1.get_abilities(user),
"ancestors_link_reach": "restricted",
"ancestors_link_role": None,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 2,
@@ -531,10 +621,14 @@ def test_api_documents_children_list_authenticated_related_team_members(
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
},
{
"abilities": child2.get_abilities(user),
"ancestors_link_reach": "restricted",
"ancestors_link_role": None,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 2,
@@ -549,7 +643,7 @@ def test_api_documents_children_list_authenticated_related_team_members(
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
},
],
}
@@ -32,6 +32,10 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"results": [
{
"abilities": child1.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": document.link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 2,
@@ -46,10 +50,16 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
{
"abilities": grand_child.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": "editor"
if (child1.link_reach == "public" and child1.link_role == "editor")
else document.link_role,
"computed_link_reach": "public",
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"depth": 3,
@@ -64,10 +74,14 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"path": grand_child.path,
"title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
{
"abilities": child2.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": document.link_role,
"computed_link_reach": "public",
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 2,
@@ -82,7 +96,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone():
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
],
}
@@ -115,6 +129,10 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"results": [
{
"abilities": child1.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": grand_parent.link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 4,
@@ -129,10 +147,14 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
{
"abilities": grand_child.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": grand_child.ancestors_link_role,
"computed_link_reach": "public",
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"depth": 5,
@@ -147,10 +169,14 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"path": grand_child.path,
"title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
{
"abilities": child2.get_abilities(AnonymousUser()),
"ancestors_link_reach": "public",
"ancestors_link_role": grand_parent.link_role,
"computed_link_reach": "public",
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 4,
@@ -165,7 +191,7 @@ def test_api_documents_descendants_list_anonymous_public_parent():
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
],
}
@@ -201,7 +227,9 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
child1, child2 = factories.DocumentFactory.create_batch(2, parent=document)
child1, child2 = factories.DocumentFactory.create_batch(
2, parent=document, link_reach="restricted"
)
grand_child = factories.DocumentFactory(parent=child1)
factories.UserDocumentAccessFactory(document=child1)
@@ -217,6 +245,10 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"results": [
{
"abilities": child1.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": document.link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 2,
@@ -231,10 +263,14 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
{
"abilities": grand_child.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": document.link_role,
"computed_link_reach": grand_child.computed_link_reach,
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"depth": 3,
@@ -249,10 +285,14 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"path": grand_child.path,
"title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
{
"abilities": child2.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": document.link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 2,
@@ -267,7 +307,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
],
}
@@ -289,7 +329,9 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
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)
child1, child2 = factories.DocumentFactory.create_batch(
2, parent=document, link_reach="restricted"
)
grand_child = factories.DocumentFactory(parent=child1)
factories.UserDocumentAccessFactory(document=child1)
@@ -304,6 +346,10 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"results": [
{
"abilities": child1.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": grand_parent.link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 4,
@@ -318,10 +364,14 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
{
"abilities": grand_child.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": grand_parent.link_role,
"computed_link_reach": grand_child.computed_link_reach,
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"depth": 5,
@@ -336,10 +386,14 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"path": grand_child.path,
"title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
{
"abilities": child2.get_abilities(user),
"ancestors_link_reach": reach,
"ancestors_link_role": grand_parent.link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 4,
@@ -354,7 +408,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
],
}
@@ -414,6 +468,10 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"results": [
{
"abilities": child1.get_abilities(user),
"ancestors_link_reach": child1.ancestors_link_reach,
"ancestors_link_role": child1.ancestors_link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 2,
@@ -428,10 +486,14 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
},
{
"abilities": grand_child.get_abilities(user),
"ancestors_link_reach": grand_child.ancestors_link_reach,
"ancestors_link_role": grand_child.ancestors_link_role,
"computed_link_reach": grand_child.computed_link_reach,
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"depth": 3,
@@ -446,10 +508,14 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"path": grand_child.path,
"title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
},
{
"abilities": child2.get_abilities(user),
"ancestors_link_reach": child2.ancestors_link_reach,
"ancestors_link_role": child2.ancestors_link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 2,
@@ -464,7 +530,7 @@ def test_api_documents_descendants_list_authenticated_related_direct():
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
},
],
}
@@ -504,6 +570,10 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"results": [
{
"abilities": child1.get_abilities(user),
"ancestors_link_reach": child1.ancestors_link_reach,
"ancestors_link_role": child1.ancestors_link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 4,
@@ -518,10 +588,14 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [grand_parent_access.role],
"user_role": grand_parent_access.role,
},
{
"abilities": grand_child.get_abilities(user),
"ancestors_link_reach": grand_child.ancestors_link_reach,
"ancestors_link_role": grand_child.ancestors_link_role,
"computed_link_reach": grand_child.computed_link_reach,
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"depth": 5,
@@ -536,10 +610,14 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"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],
"user_role": grand_parent_access.role,
},
{
"abilities": child2.get_abilities(user),
"ancestors_link_reach": child2.ancestors_link_reach,
"ancestors_link_role": child2.ancestors_link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 4,
@@ -554,7 +632,7 @@ def test_api_documents_descendants_list_authenticated_related_parent():
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [grand_parent_access.role],
"user_role": grand_parent_access.role,
},
],
}
@@ -640,6 +718,10 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"results": [
{
"abilities": child1.get_abilities(user),
"ancestors_link_reach": child1.ancestors_link_reach,
"ancestors_link_role": child1.ancestors_link_role,
"computed_link_reach": child1.computed_link_reach,
"computed_link_role": child1.computed_link_role,
"created_at": child1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child1.creator.id),
"depth": 2,
@@ -654,10 +736,14 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"path": child1.path,
"title": child1.title,
"updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
},
{
"abilities": grand_child.get_abilities(user),
"ancestors_link_reach": grand_child.ancestors_link_reach,
"ancestors_link_role": grand_child.ancestors_link_role,
"computed_link_reach": grand_child.computed_link_reach,
"computed_link_role": grand_child.computed_link_role,
"created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_child.creator.id),
"depth": 3,
@@ -672,10 +758,14 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"path": grand_child.path,
"title": grand_child.title,
"updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
},
{
"abilities": child2.get_abilities(user),
"ancestors_link_reach": child2.ancestors_link_reach,
"ancestors_link_role": child2.ancestors_link_role,
"computed_link_reach": child2.computed_link_reach,
"computed_link_role": child2.computed_link_role,
"created_at": child2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(child2.creator.id),
"depth": 2,
@@ -690,7 +780,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members(
"path": child2.path,
"title": child2.title,
"updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
},
],
}
@@ -14,6 +14,7 @@ from django.utils import timezone
import pycrdt
import pytest
import requests
from freezegun import freeze_time
from rest_framework.test import APIClient
from core import factories, models
@@ -133,19 +134,21 @@ def test_api_documents_duplicate_success(index):
# Ensure access persists after the owner loses access to the original document
models.DocumentAccess.objects.filter(document=document).delete()
response = client.get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=image_refs[0][1]
)
now = timezone.now()
with freeze_time(now):
response = client.get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=image_refs[0][1]
)
assert response.status_code == 200
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
response = requests.get(
@@ -59,6 +59,10 @@ def test_api_document_favorite_list_authenticated_with_favorite():
"results": [
{
"abilities": document.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"content": document.content,
@@ -74,7 +78,7 @@ def test_api_document_favorite_list_authenticated_with_favorite():
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": ["reader"],
"user_role": "reader",
}
],
}
@@ -63,6 +63,10 @@ def test_api_documents_list_format():
assert results[0] == {
"id": str(document.id),
"abilities": document.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
@@ -76,7 +80,7 @@ def test_api_documents_list_format():
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
}
@@ -148,11 +152,11 @@ def test_api_documents_list_authenticated_direct(django_assert_num_queries):
str(child4_with_access.id),
}
with django_assert_num_queries(12):
with django_assert_num_queries(14):
response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached
with django_assert_num_queries(4):
with django_assert_num_queries(6):
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
@@ -268,11 +272,11 @@ def test_api_documents_list_authenticated_link_reach_public_or_authenticated(
expected_ids = {str(document1.id), str(document2.id), str(visible_child.id)}
with django_assert_num_queries(10):
with django_assert_num_queries(11):
response = client.get("/api/v1.0/documents/")
# nb_accesses should now be cached
with django_assert_num_queries(4):
with django_assert_num_queries(5):
response = client.get("/api/v1.0/documents/")
assert response.status_code == 200
@@ -12,6 +12,7 @@ from django.utils import timezone
import pytest
import requests
from freezegun import freeze_time
from rest_framework.test import APIClient
from core import factories, models
@@ -52,9 +53,11 @@ def test_api_documents_media_auth_anonymous_public():
factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key])
original_url = f"http://localhost/media/{key:s}"
response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
now = timezone.now()
with freeze_time(now):
response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
@@ -64,7 +67,7 @@ def test_api_documents_media_auth_anonymous_public():
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
@@ -167,9 +170,11 @@ def test_api_documents_media_auth_anonymous_attachments():
parent = factories.DocumentFactory(link_reach="public")
factories.DocumentFactory(parent=parent, link_reach="restricted", attachments=[key])
response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
now = timezone.now()
with freeze_time(now):
response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 200
@@ -179,7 +184,7 @@ def test_api_documents_media_auth_anonymous_attachments():
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
@@ -221,9 +226,11 @@ def test_api_documents_media_auth_authenticated_public_or_authenticated(reach):
factories.DocumentFactory(id=document_id, link_reach=reach, attachments=[key])
response = client.get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
now = timezone.now()
with freeze_time(now):
response = client.get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 200
@@ -233,7 +240,7 @@ def test_api_documents_media_auth_authenticated_public_or_authenticated(reach):
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
@@ -307,9 +314,11 @@ def test_api_documents_media_auth_related(via, mock_user_teams):
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
response = client.get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
now = timezone.now()
with freeze_time(now):
response = client.get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 200
@@ -319,7 +328,7 @@ def test_api_documents_media_auth_related(via, mock_user_teams):
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
@@ -373,10 +382,12 @@ def test_api_documents_media_auth_missing_status_metadata():
factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key])
now = timezone.now()
original_url = f"http://localhost/media/{key:s}"
response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
with freeze_time(now):
response = APIClient().get(
"/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
@@ -386,7 +397,7 @@ def test_api_documents_media_auth_missing_status_metadata():
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
@@ -124,8 +124,8 @@ def test_api_documents_move_authenticated_target_roles_mocked(
target_role, target_parent_role, position
):
"""
Authenticated users with insufficient permissions on the target document (or its
parent depending on the position chosen), should not be allowed to move documents.
Only authenticated users with sufficient permissions on the target document (or its
parent depending on the position chosen), should be allowed to move documents.
"""
user = factories.UserFactory()
@@ -208,6 +208,107 @@ def test_api_documents_move_authenticated_target_roles_mocked(
assert document.is_root() is True
def test_api_documents_move_authenticated_no_owner_user_and_team():
"""
Moving a document with no owner to the root of the tree should automatically declare
the owner of the previous root of the document as owner of the document itself.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent_owner = factories.UserFactory()
parent = factories.DocumentFactory(
users=[(parent_owner, "owner")], teams=[("lasuite", "owner")]
)
# A document with no owner
document = factories.DocumentFactory(parent=parent, users=[(user, "administrator")])
child = factories.DocumentFactory(parent=document)
target = factories.DocumentFactory()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/move/",
data={"target_document_id": str(target.id), "position": "first-sibling"},
)
assert response.status_code == 200
assert response.json() == {"message": "Document moved successfully."}
assert list(target.get_siblings()) == [document, parent, target]
document.refresh_from_db()
assert list(document.get_children()) == [child]
assert document.accesses.count() == 3
assert document.accesses.get(user__isnull=False, role="owner").user == parent_owner
assert document.accesses.get(user__isnull=True, role="owner").team == "lasuite"
assert document.accesses.get(role="administrator").user == user
def test_api_documents_move_authenticated_no_owner_same_user():
"""
Moving a document should not fail if the user moving a document with no owner was
at the same time owner of the previous root and has a role on the document being moved.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent = factories.DocumentFactory(
users=[(user, "owner")], teams=[("lasuite", "owner")]
)
# A document with no owner
document = factories.DocumentFactory(parent=parent, users=[(user, "reader")])
child = factories.DocumentFactory(parent=document)
target = factories.DocumentFactory()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/move/",
data={"target_document_id": str(target.id), "position": "first-sibling"},
)
assert response.status_code == 200
assert response.json() == {"message": "Document moved successfully."}
assert list(target.get_siblings()) == [document, parent, target]
document.refresh_from_db()
assert list(document.get_children()) == [child]
assert document.accesses.count() == 2
assert document.accesses.get(user__isnull=False, role="owner").user == user
assert document.accesses.get(user__isnull=True, role="owner").team == "lasuite"
def test_api_documents_move_authenticated_no_owner_same_team():
"""
Moving a document should not fail if the team that is owner of the document root was
already declared on the document with a different role.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
parent = factories.DocumentFactory(teams=[("lasuite", "owner")])
# A document with no owner but same team
document = factories.DocumentFactory(
parent=parent, users=[(user, "administrator")], teams=[("lasuite", "reader")]
)
child = factories.DocumentFactory(parent=document)
target = factories.DocumentFactory()
response = client.post(
f"/api/v1.0/documents/{document.id!s}/move/",
data={"target_document_id": str(target.id), "position": "first-sibling"},
)
assert response.status_code == 200
assert response.json() == {"message": "Document moved successfully."}
assert list(target.get_siblings()) == [document, parent, target]
document.refresh_from_db()
assert list(document.get_children()) == [child]
assert document.accesses.count() == 2
assert document.accesses.get(user__isnull=False, role="administrator").user == user
assert document.accesses.get(user__isnull=True, role="owner").team == "lasuite"
def test_api_documents_move_authenticated_deleted_document():
"""
It should not be possible to move a deleted document or its descendants, even
@@ -1,6 +1,7 @@
"""
Tests for Documents API endpoint in impress's core app: retrieve
"""
# pylint: disable=too-many-lines
import random
from datetime import timedelta
@@ -11,7 +12,7 @@ from django.utils import timezone
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core import choices, factories, models
pytestmark = pytest.mark.django_db
@@ -45,7 +46,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
"media_auth": True,
"media_check": True,
@@ -59,6 +60,10 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"versions_list": False,
"versions_retrieve": False,
},
"ancestors_link_reach": None,
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
@@ -73,7 +78,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
}
@@ -91,6 +96,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
assert response.status_code == 200
links = document.get_ancestors().values("link_reach", "link_role")
links_definition = choices.get_equivalent_link_definition(links)
assert response.json() == {
"id": str(document.id),
"abilities": {
@@ -110,7 +116,9 @@ def test_api_documents_retrieve_anonymous_public_parent():
"favorite": False,
"invite_owner": False,
"link_configuration": False,
"link_select_options": models.LinkReachChoices.get_select_options(links),
"link_select_options": models.LinkReachChoices.get_select_options(
**links_definition
),
"media_auth": True,
"media_check": True,
"move": False,
@@ -123,6 +131,10 @@ def test_api_documents_retrieve_anonymous_public_parent():
"versions_list": False,
"versions_retrieve": False,
},
"ancestors_link_reach": "public",
"ancestors_link_role": grand_parent.link_role,
"computed_link_reach": "public",
"computed_link_role": grand_parent.link_role,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
@@ -137,7 +149,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
}
@@ -209,7 +221,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
"media_auth": True,
"media_check": True,
@@ -223,6 +235,10 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"versions_list": False,
"versions_retrieve": False,
},
"ancestors_link_reach": None,
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
@@ -237,7 +253,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
}
assert (
models.LinkTrace.objects.filter(document=document, user=user).exists() is True
@@ -263,6 +279,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
assert response.status_code == 200
links = document.get_ancestors().values("link_reach", "link_role")
links_definition = choices.get_equivalent_link_definition(links)
assert response.json() == {
"id": str(document.id),
"abilities": {
@@ -281,7 +298,9 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"favorite": True,
"invite_owner": False,
"link_configuration": False,
"link_select_options": models.LinkReachChoices.get_select_options(links),
"link_select_options": models.LinkReachChoices.get_select_options(
**links_definition
),
"media_auth": True,
"media_check": True,
"move": False,
@@ -294,6 +313,10 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"versions_list": False,
"versions_retrieve": False,
},
"ancestors_link_reach": reach,
"ancestors_link_role": grand_parent.link_role,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
@@ -308,7 +331,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
}
@@ -404,6 +427,10 @@ def test_api_documents_retrieve_authenticated_related_direct():
assert response.json() == {
"id": str(document.id),
"abilities": document.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"content": document.content,
"creator": str(document.creator.id),
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
@@ -418,7 +445,7 @@ def test_api_documents_retrieve_authenticated_related_direct():
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
}
@@ -444,6 +471,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
)
assert response.status_code == 200
links = document.get_ancestors().values("link_reach", "link_role")
link_definition = choices.get_equivalent_link_definition(links)
assert response.json() == {
"id": str(document.id),
"abilities": {
@@ -462,7 +490,9 @@ def test_api_documents_retrieve_authenticated_related_parent():
"favorite": True,
"invite_owner": access.role == "owner",
"link_configuration": access.role in ["administrator", "owner"],
"link_select_options": models.LinkReachChoices.get_select_options(links),
"link_select_options": models.LinkReachChoices.get_select_options(
**link_definition
),
"media_auth": True,
"media_check": True,
"move": access.role in ["administrator", "owner"],
@@ -475,6 +505,10 @@ def test_api_documents_retrieve_authenticated_related_parent():
"versions_list": True,
"versions_retrieve": True,
},
"ancestors_link_reach": "restricted",
"ancestors_link_role": None,
"computed_link_reach": "restricted",
"computed_link_role": None,
"content": document.content,
"creator": str(document.creator.id),
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
@@ -489,7 +523,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
}
@@ -585,16 +619,16 @@ def test_api_documents_retrieve_authenticated_related_team_none(mock_user_teams)
@pytest.mark.parametrize(
"teams,roles",
"teams,role",
[
[["readers"], ["reader"]],
[["unknown", "readers"], ["reader"]],
[["editors"], ["editor"]],
[["unknown", "editors"], ["editor"]],
[["readers"], "reader"],
[["unknown", "readers"], "reader"],
[["editors"], "editor"],
[["unknown", "editors"], "editor"],
],
)
def test_api_documents_retrieve_authenticated_related_team_members(
teams, roles, mock_user_teams
teams, role, mock_user_teams
):
"""
Authenticated users should be allowed to retrieve a document to which they
@@ -627,6 +661,10 @@ def test_api_documents_retrieve_authenticated_related_team_members(
assert response.json() == {
"id": str(document.id),
"abilities": document.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
@@ -641,20 +679,20 @@ def test_api_documents_retrieve_authenticated_related_team_members(
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": roles,
"user_role": role,
}
@pytest.mark.parametrize(
"teams,roles",
"teams,role",
[
[["administrators"], ["administrator"]],
[["editors", "administrators"], ["administrator", "editor"]],
[["unknown", "administrators"], ["administrator"]],
[["administrators"], "administrator"],
[["editors", "administrators"], "administrator"],
[["unknown", "administrators"], "administrator"],
],
)
def test_api_documents_retrieve_authenticated_related_team_administrators(
teams, roles, mock_user_teams
teams, role, mock_user_teams
):
"""
Authenticated users should be allowed to retrieve a document to which they
@@ -689,6 +727,10 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
assert response.json() == {
"id": str(document.id),
"abilities": document.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
@@ -703,21 +745,21 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": roles,
"user_role": role,
}
@pytest.mark.parametrize(
"teams,roles",
"teams,role",
[
[["owners"], ["owner"]],
[["owners", "administrators"], ["owner", "administrator"]],
[["members", "administrators", "owners"], ["owner", "administrator"]],
[["unknown", "owners"], ["owner"]],
[["owners"], "owner"],
[["owners", "administrators"], "owner"],
[["members", "administrators", "owners"], "owner"],
[["unknown", "owners"], "owner"],
],
)
def test_api_documents_retrieve_authenticated_related_team_owners(
teams, roles, mock_user_teams
teams, role, mock_user_teams
):
"""
Authenticated users should be allowed to retrieve a restricted document to which
@@ -751,6 +793,10 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
assert response.json() == {
"id": str(document.id),
"abilities": document.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
@@ -765,11 +811,11 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": roles,
"user_role": role,
}
def test_api_documents_retrieve_user_roles(django_assert_max_num_queries):
def test_api_documents_retrieve_user_role(django_assert_max_num_queries):
"""
Roles should be annotated on querysets taking into account all documents ancestors.
"""
@@ -792,15 +838,14 @@ def test_api_documents_retrieve_user_roles(django_assert_max_num_queries):
factories.UserDocumentAccessFactory(document=parent, user=user),
factories.UserDocumentAccessFactory(document=document, user=user),
)
expected_roles = {access.role for access in accesses}
expected_role = choices.RoleChoices.max(*[access.role for access in accesses])
with django_assert_max_num_queries(14):
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200
user_roles = response.json()["user_roles"]
assert set(user_roles) == expected_roles
assert response.json()["user_role"] == expected_role
def test_api_documents_retrieve_numqueries_with_link_trace(django_assert_num_queries):
@@ -88,7 +88,7 @@ def test_api_documents_trashbin_format():
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
"media_auth": True,
"media_check": True,
@@ -102,6 +102,10 @@ def test_api_documents_trashbin_format():
"versions_list": True,
"versions_retrieve": True,
},
"ancestors_link_reach": None,
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
@@ -114,7 +118,7 @@ def test_api_documents_trashbin_format():
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": ["owner"],
"user_role": "owner",
}
@@ -32,13 +32,19 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
assert response.status_code == 200
assert response.json() == {
"abilities": parent.get_abilities(AnonymousUser()),
"ancestors_link_reach": parent.ancestors_link_reach,
"ancestors_link_role": parent.ancestors_link_role,
"children": [
{
"abilities": document.get_abilities(AnonymousUser()),
"children": [
{
"abilities": child.get_abilities(AnonymousUser()),
"ancestors_link_reach": child.ancestors_link_reach,
"ancestors_link_role": child.ancestors_link_role,
"children": [],
"computed_link_reach": child.computed_link_reach,
"computed_link_role": child.computed_link_role,
"created_at": child.created_at.isoformat().replace(
"+00:00", "Z"
),
@@ -57,9 +63,13 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
"updated_at": child.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [],
"user_role": None,
},
],
"ancestors_link_reach": document.ancestors_link_reach,
"ancestors_link_role": document.ancestors_link_role,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 2,
@@ -74,11 +84,15 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
{
"abilities": sibling1.get_abilities(AnonymousUser()),
"ancestors_link_reach": sibling1.ancestors_link_reach,
"ancestors_link_role": sibling1.ancestors_link_role,
"children": [],
"computed_link_reach": sibling1.computed_link_reach,
"computed_link_role": sibling1.computed_link_role,
"created_at": sibling1.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling1.creator.id),
"depth": 2,
@@ -93,11 +107,15 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
"path": sibling1.path,
"title": sibling1.title,
"updated_at": sibling1.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
{
"abilities": sibling2.get_abilities(AnonymousUser()),
"ancestors_link_reach": sibling2.ancestors_link_reach,
"ancestors_link_role": sibling2.ancestors_link_role,
"children": [],
"computed_link_reach": sibling2.computed_link_reach,
"computed_link_role": sibling2.computed_link_role,
"created_at": sibling2.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling2.creator.id),
"depth": 2,
@@ -112,9 +130,11 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
"path": sibling2.path,
"title": sibling2.title,
"updated_at": sibling2.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
],
"computed_link_reach": parent.computed_link_reach,
"computed_link_role": parent.computed_link_role,
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id),
"depth": 1,
@@ -129,7 +149,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q
"path": parent.path,
"title": parent.title,
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
}
@@ -163,18 +183,28 @@ def test_api_documents_tree_list_anonymous_public_parent():
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/tree/")
assert response.status_code == 200
assert response.json() == {
expected_tree = {
"abilities": grand_parent.get_abilities(AnonymousUser()),
"ancestors_link_reach": grand_parent.ancestors_link_reach,
"ancestors_link_role": grand_parent.ancestors_link_role,
"children": [
{
"abilities": parent.get_abilities(AnonymousUser()),
"ancestors_link_reach": parent.ancestors_link_reach,
"ancestors_link_role": parent.ancestors_link_role,
"children": [
{
"abilities": document.get_abilities(AnonymousUser()),
"ancestors_link_reach": document.ancestors_link_reach,
"ancestors_link_role": document.ancestors_link_role,
"children": [
{
"abilities": child.get_abilities(AnonymousUser()),
"ancestors_link_reach": child.ancestors_link_reach,
"ancestors_link_role": child.ancestors_link_role,
"children": [],
"computed_link_reach": child.computed_link_reach,
"computed_link_role": child.computed_link_role,
"created_at": child.created_at.isoformat().replace(
"+00:00", "Z"
),
@@ -193,9 +223,11 @@ def test_api_documents_tree_list_anonymous_public_parent():
"updated_at": child.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [],
"user_role": None,
},
],
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace(
"+00:00", "Z"
),
@@ -214,11 +246,15 @@ def test_api_documents_tree_list_anonymous_public_parent():
"updated_at": document.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [],
"user_role": None,
},
{
"abilities": document_sibling.get_abilities(AnonymousUser()),
"ancestors_link_reach": document_sibling.ancestors_link_reach,
"ancestors_link_role": document_sibling.ancestors_link_role,
"children": [],
"computed_link_reach": document_sibling.computed_link_reach,
"computed_link_role": document_sibling.computed_link_role,
"created_at": document_sibling.created_at.isoformat().replace(
"+00:00", "Z"
),
@@ -237,9 +273,11 @@ def test_api_documents_tree_list_anonymous_public_parent():
"updated_at": document_sibling.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [],
"user_role": None,
},
],
"computed_link_reach": parent.computed_link_reach,
"computed_link_role": parent.computed_link_role,
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id),
"depth": 3,
@@ -254,11 +292,15 @@ def test_api_documents_tree_list_anonymous_public_parent():
"path": parent.path,
"title": parent.title,
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
{
"abilities": parent_sibling.get_abilities(AnonymousUser()),
"ancestors_link_reach": parent_sibling.ancestors_link_reach,
"ancestors_link_role": parent_sibling.ancestors_link_role,
"children": [],
"computed_link_reach": parent_sibling.computed_link_reach,
"computed_link_role": parent_sibling.computed_link_role,
"created_at": parent_sibling.created_at.isoformat().replace(
"+00:00", "Z"
),
@@ -277,9 +319,11 @@ def test_api_documents_tree_list_anonymous_public_parent():
"updated_at": parent_sibling.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [],
"user_role": None,
},
],
"computed_link_reach": grand_parent.computed_link_reach,
"computed_link_role": grand_parent.computed_link_role,
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_parent.creator.id),
"depth": 2,
@@ -294,8 +338,9 @@ def test_api_documents_tree_list_anonymous_public_parent():
"path": grand_parent.path,
"title": grand_parent.title,
"updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
}
assert response.json() == expected_tree
@pytest.mark.parametrize("reach", ["restricted", "authenticated"])
@@ -341,13 +386,21 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
assert response.status_code == 200
assert response.json() == {
"abilities": parent.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"children": [
{
"abilities": document.get_abilities(user),
"ancestors_link_reach": document.ancestors_link_reach,
"ancestors_link_role": document.ancestors_link_role,
"children": [
{
"abilities": child.get_abilities(user),
"ancestors_link_reach": child.ancestors_link_reach,
"ancestors_link_role": child.ancestors_link_role,
"children": [],
"computed_link_reach": child.computed_link_reach,
"computed_link_role": child.computed_link_role,
"created_at": child.created_at.isoformat().replace(
"+00:00", "Z"
),
@@ -366,9 +419,11 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
"updated_at": child.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [],
"user_role": None,
},
],
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 2,
@@ -383,11 +438,15 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
{
"abilities": sibling.get_abilities(user),
"ancestors_link_reach": sibling.ancestors_link_reach,
"ancestors_link_role": sibling.ancestors_link_role,
"children": [],
"computed_link_reach": sibling.computed_link_reach,
"computed_link_role": sibling.computed_link_role,
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling.creator.id),
"depth": 2,
@@ -402,9 +461,11 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
"path": sibling.path,
"title": sibling.title,
"updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
],
"computed_link_reach": parent.computed_link_reach,
"computed_link_role": parent.computed_link_role,
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id),
"depth": 1,
@@ -419,7 +480,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated
"path": parent.path,
"title": parent.title,
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
}
@@ -460,16 +521,26 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
assert response.status_code == 200
assert response.json() == {
"abilities": grand_parent.get_abilities(user),
"ancestors_link_reach": grand_parent.ancestors_link_reach,
"ancestors_link_role": grand_parent.ancestors_link_role,
"children": [
{
"abilities": parent.get_abilities(user),
"ancestors_link_reach": parent.ancestors_link_reach,
"ancestors_link_role": parent.ancestors_link_role,
"children": [
{
"abilities": document.get_abilities(user),
"ancestors_link_reach": document.ancestors_link_reach,
"ancestors_link_role": document.ancestors_link_role,
"children": [
{
"abilities": child.get_abilities(user),
"ancestors_link_reach": child.ancestors_link_reach,
"ancestors_link_role": child.ancestors_link_role,
"children": [],
"computed_link_reach": child.computed_link_reach,
"computed_link_role": child.computed_link_role,
"created_at": child.created_at.isoformat().replace(
"+00:00", "Z"
),
@@ -488,9 +559,11 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
"updated_at": child.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [],
"user_role": None,
},
],
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace(
"+00:00", "Z"
),
@@ -509,11 +582,15 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
"updated_at": document.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [],
"user_role": None,
},
{
"abilities": document_sibling.get_abilities(user),
"ancestors_link_reach": document_sibling.ancestors_link_reach,
"ancestors_link_role": document_sibling.ancestors_link_role,
"children": [],
"computed_link_reach": document_sibling.computed_link_reach,
"computed_link_role": document_sibling.computed_link_role,
"created_at": document_sibling.created_at.isoformat().replace(
"+00:00", "Z"
),
@@ -532,9 +609,11 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
"updated_at": document_sibling.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [],
"user_role": None,
},
],
"computed_link_reach": parent.computed_link_reach,
"computed_link_role": parent.computed_link_role,
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id),
"depth": 3,
@@ -549,11 +628,15 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
"path": parent.path,
"title": parent.title,
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
},
{
"abilities": parent_sibling.get_abilities(user),
"ancestors_link_reach": parent_sibling.ancestors_link_reach,
"ancestors_link_role": parent_sibling.ancestors_link_role,
"children": [],
"computed_link_reach": parent_sibling.computed_link_reach,
"computed_link_role": parent_sibling.computed_link_role,
"created_at": parent_sibling.created_at.isoformat().replace(
"+00:00", "Z"
),
@@ -572,9 +655,11 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
"updated_at": parent_sibling.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [],
"user_role": None,
},
],
"computed_link_reach": grand_parent.computed_link_reach,
"computed_link_role": grand_parent.computed_link_role,
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_parent.creator.id),
"depth": 2,
@@ -589,7 +674,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent(
"path": grand_parent.path,
"title": grand_parent.title,
"updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [],
"user_role": None,
}
@@ -639,13 +724,21 @@ def test_api_documents_tree_list_authenticated_related_direct():
assert response.status_code == 200
assert response.json() == {
"abilities": parent.get_abilities(user),
"ancestors_link_reach": parent.ancestors_link_reach,
"ancestors_link_role": parent.ancestors_link_role,
"children": [
{
"abilities": document.get_abilities(user),
"ancestors_link_reach": document.ancestors_link_reach,
"ancestors_link_role": document.ancestors_link_role,
"children": [
{
"abilities": child.get_abilities(user),
"ancestors_link_reach": child.ancestors_link_reach,
"ancestors_link_role": child.ancestors_link_role,
"children": [],
"computed_link_reach": child.computed_link_reach,
"computed_link_role": child.computed_link_role,
"created_at": child.created_at.isoformat().replace(
"+00:00", "Z"
),
@@ -664,9 +757,11 @@ def test_api_documents_tree_list_authenticated_related_direct():
"updated_at": child.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [access.role],
"user_role": access.role,
},
],
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 2,
@@ -681,11 +776,15 @@ def test_api_documents_tree_list_authenticated_related_direct():
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
},
{
"abilities": sibling.get_abilities(user),
"ancestors_link_reach": sibling.ancestors_link_reach,
"ancestors_link_role": sibling.ancestors_link_role,
"children": [],
"computed_link_reach": sibling.computed_link_reach,
"computed_link_role": sibling.computed_link_role,
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling.creator.id),
"depth": 2,
@@ -700,9 +799,11 @@ def test_api_documents_tree_list_authenticated_related_direct():
"path": sibling.path,
"title": sibling.title,
"updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
},
],
"computed_link_reach": parent.computed_link_reach,
"computed_link_role": parent.computed_link_role,
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id),
"depth": 1,
@@ -717,7 +818,7 @@ def test_api_documents_tree_list_authenticated_related_direct():
"path": parent.path,
"title": parent.title,
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
}
@@ -762,16 +863,26 @@ def test_api_documents_tree_list_authenticated_related_parent():
assert response.status_code == 200
assert response.json() == {
"abilities": grand_parent.get_abilities(user),
"ancestors_link_reach": grand_parent.ancestors_link_reach,
"ancestors_link_role": grand_parent.ancestors_link_role,
"children": [
{
"abilities": parent.get_abilities(user),
"ancestors_link_reach": parent.ancestors_link_reach,
"ancestors_link_role": parent.ancestors_link_role,
"children": [
{
"abilities": document.get_abilities(user),
"ancestors_link_reach": document.ancestors_link_reach,
"ancestors_link_role": document.ancestors_link_role,
"children": [
{
"abilities": child.get_abilities(user),
"ancestors_link_reach": child.ancestors_link_reach,
"ancestors_link_role": child.ancestors_link_role,
"computed_link_reach": child.computed_link_reach,
"children": [],
"computed_link_role": child.computed_link_role,
"created_at": child.created_at.isoformat().replace(
"+00:00", "Z"
),
@@ -790,9 +901,11 @@ def test_api_documents_tree_list_authenticated_related_parent():
"updated_at": child.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [access.role],
"user_role": access.role,
},
],
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace(
"+00:00", "Z"
),
@@ -811,11 +924,15 @@ def test_api_documents_tree_list_authenticated_related_parent():
"updated_at": document.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [access.role],
"user_role": access.role,
},
{
"abilities": document_sibling.get_abilities(user),
"ancestors_link_reach": document_sibling.ancestors_link_reach,
"ancestors_link_role": document_sibling.ancestors_link_role,
"children": [],
"computed_link_reach": document_sibling.computed_link_reach,
"computed_link_role": document_sibling.computed_link_role,
"created_at": document_sibling.created_at.isoformat().replace(
"+00:00", "Z"
),
@@ -834,9 +951,11 @@ def test_api_documents_tree_list_authenticated_related_parent():
"updated_at": document_sibling.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [access.role],
"user_role": access.role,
},
],
"computed_link_reach": parent.computed_link_reach,
"computed_link_role": parent.computed_link_role,
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id),
"depth": 3,
@@ -851,11 +970,15 @@ def test_api_documents_tree_list_authenticated_related_parent():
"path": parent.path,
"title": parent.title,
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
},
{
"abilities": parent_sibling.get_abilities(user),
"ancestors_link_reach": parent_sibling.ancestors_link_reach,
"ancestors_link_role": parent_sibling.ancestors_link_role,
"children": [],
"computed_link_reach": parent_sibling.computed_link_reach,
"computed_link_role": parent_sibling.computed_link_role,
"created_at": parent_sibling.created_at.isoformat().replace(
"+00:00", "Z"
),
@@ -874,9 +997,11 @@ def test_api_documents_tree_list_authenticated_related_parent():
"updated_at": parent_sibling.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [access.role],
"user_role": access.role,
},
],
"computed_link_reach": grand_parent.computed_link_reach,
"computed_link_role": grand_parent.computed_link_role,
"created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(grand_parent.creator.id),
"depth": 2,
@@ -891,7 +1016,7 @@ def test_api_documents_tree_list_authenticated_related_parent():
"path": grand_parent.path,
"title": grand_parent.title,
"updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
}
@@ -949,13 +1074,21 @@ def test_api_documents_tree_list_authenticated_related_team_members(
assert response.status_code == 200
assert response.json() == {
"abilities": parent.get_abilities(user),
"ancestors_link_reach": None,
"ancestors_link_role": None,
"children": [
{
"abilities": document.get_abilities(user),
"ancestors_link_reach": "restricted",
"ancestors_link_role": None,
"children": [
{
"abilities": child.get_abilities(user),
"ancestors_link_reach": "restricted",
"ancestors_link_role": None,
"children": [],
"computed_link_reach": child.computed_link_reach,
"computed_link_role": child.computed_link_role,
"created_at": child.created_at.isoformat().replace(
"+00:00", "Z"
),
@@ -974,9 +1107,11 @@ def test_api_documents_tree_list_authenticated_related_team_members(
"updated_at": child.updated_at.isoformat().replace(
"+00:00", "Z"
),
"user_roles": [access.role],
"user_role": access.role,
},
],
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 2,
@@ -991,11 +1126,15 @@ def test_api_documents_tree_list_authenticated_related_team_members(
"path": document.path,
"title": document.title,
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
},
{
"abilities": sibling.get_abilities(user),
"ancestors_link_reach": "restricted",
"ancestors_link_role": None,
"children": [],
"computed_link_reach": sibling.computed_link_reach,
"computed_link_role": sibling.computed_link_role,
"created_at": sibling.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(sibling.creator.id),
"depth": 2,
@@ -1010,9 +1149,11 @@ def test_api_documents_tree_list_authenticated_related_team_members(
"path": sibling.path,
"title": sibling.title,
"updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
},
],
"computed_link_reach": parent.computed_link_reach,
"computed_link_role": parent.computed_link_role,
"created_at": parent.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(parent.creator.id),
"depth": 1,
@@ -1027,5 +1168,5 @@ def test_api_documents_tree_list_authenticated_related_team_members(
"path": parent.path,
"title": parent.title,
"updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"),
"user_roles": [access.role],
"user_role": access.role,
}
@@ -155,6 +155,10 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated(
for key, value in document_values.items():
if key in [
"id",
"ancestors_link_reach",
"ancestors_link_role",
"computed_link_reach",
"computed_link_role",
"accesses",
"created_at",
"creator",
@@ -270,6 +274,10 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
for key, value in document_values.items():
if key in [
"id",
"ancestors_link_reach",
"ancestors_link_role",
"computed_link_reach",
"computed_link_role",
"created_at",
"creator",
"depth",
@@ -48,12 +48,7 @@ def test_api_template_accesses_list_authenticated_unrelated():
f"/api/v1.0/templates/{template.id!s}/accesses/",
)
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
assert response.json() == []
@pytest.mark.parametrize("via", VIA)
@@ -96,8 +91,8 @@ def test_api_template_accesses_list_authenticated_related(via, mock_user_teams):
assert response.status_code == 200
content = response.json()
assert len(content["results"]) == 3
assert sorted(content["results"], key=lambda x: x["id"]) == sorted(
assert len(content) == 3
assert sorted(content, key=lambda x: x["id"]) == sorted(
[
{
"id": str(user_access.id),
@@ -133,7 +133,7 @@ def test_api_template_accesses_create_authenticated_administrator(via, mock_user
assert response.status_code == 403
assert response.json() == {
"detail": "Only owners of a resource can assign other users as owners."
"detail": "Only owners of a template can assign other users as owners."
}
# It should be allowed to create a lower access
+1 -1
View File
@@ -186,7 +186,7 @@ def test_api_users_list_query_short_queries():
"""
Queries shorter than 5 characters should return an empty result set.
"""
user = factories.UserFactory()
user = factories.UserFactory(email="paul@example.com")
client = APIClient()
client.force_login(user)
@@ -90,6 +90,7 @@ def test_models_document_access_get_abilities_anonymous():
"update": False,
"partial_update": False,
"set_role_to": [],
"child_set_role_to": [],
}
@@ -104,6 +105,7 @@ def test_models_document_access_get_abilities_authenticated():
"update": False,
"partial_update": False,
"set_role_to": [],
"child_set_role_to": [],
}
@@ -123,22 +125,53 @@ def test_models_document_access_get_abilities_for_owner_of_self_allowed():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "editor", "reader"],
"set_role_to": ["reader", "editor", "administrator", "owner"],
"child_set_role_to": [],
}
def test_models_document_access_get_abilities_for_owner_of_self_last():
def test_models_document_access_get_abilities_for_owner_of_self_last_on_root(
django_assert_num_queries,
):
"""
Check abilities of self access for the owner of a document when there is only one owner left.
Check abilities of self access for the owner of a root document when there
is only one owner left.
"""
access = factories.UserDocumentAccessFactory(role="owner")
abilities = access.get_abilities(access.user)
with django_assert_num_queries(2):
abilities = access.get_abilities(access.user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"partial_update": False,
"set_role_to": [],
"child_set_role_to": [],
}
def test_models_document_access_get_abilities_for_owner_of_self_last_on_child(
django_assert_num_queries,
):
"""
Check abilities of self access for the owner of a child document when there
is only one owner left.
"""
parent = factories.DocumentFactory()
access = factories.UserDocumentAccessFactory(document__parent=parent, role="owner")
with django_assert_num_queries(1):
abilities = access.get_abilities(access.user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["reader", "editor", "administrator", "owner"],
"child_set_role_to": [],
}
@@ -155,7 +188,8 @@ def test_models_document_access_get_abilities_for_owner_of_owner():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "editor", "reader"],
"set_role_to": ["reader", "editor", "administrator", "owner"],
"child_set_role_to": [],
}
@@ -172,7 +206,8 @@ def test_models_document_access_get_abilities_for_owner_of_administrator():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["owner", "editor", "reader"],
"set_role_to": ["reader", "editor", "administrator", "owner"],
"child_set_role_to": ["owner"],
}
@@ -189,7 +224,8 @@ def test_models_document_access_get_abilities_for_owner_of_editor():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["owner", "administrator", "reader"],
"set_role_to": ["reader", "editor", "administrator", "owner"],
"child_set_role_to": ["administrator", "owner"],
}
@@ -206,7 +242,8 @@ def test_models_document_access_get_abilities_for_owner_of_reader():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["owner", "administrator", "editor"],
"set_role_to": ["reader", "editor", "administrator", "owner"],
"child_set_role_to": ["editor", "administrator", "owner"],
}
@@ -227,6 +264,7 @@ def test_models_document_access_get_abilities_for_administrator_of_owner():
"update": False,
"partial_update": False,
"set_role_to": [],
"child_set_role_to": [],
}
@@ -243,7 +281,8 @@ def test_models_document_access_get_abilities_for_administrator_of_administrator
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["editor", "reader"],
"set_role_to": ["reader", "editor", "administrator"],
"child_set_role_to": [],
}
@@ -260,7 +299,8 @@ def test_models_document_access_get_abilities_for_administrator_of_editor():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "reader"],
"set_role_to": ["reader", "editor", "administrator"],
"child_set_role_to": ["administrator"],
}
@@ -277,7 +317,8 @@ def test_models_document_access_get_abilities_for_administrator_of_reader():
"retrieve": True,
"update": True,
"partial_update": True,
"set_role_to": ["administrator", "editor"],
"set_role_to": ["reader", "editor", "administrator"],
"child_set_role_to": ["editor", "administrator"],
}
@@ -298,6 +339,7 @@ def test_models_document_access_get_abilities_for_editor_of_owner():
"update": False,
"partial_update": False,
"set_role_to": [],
"child_set_role_to": [],
}
@@ -315,6 +357,7 @@ def test_models_document_access_get_abilities_for_editor_of_administrator():
"update": False,
"partial_update": False,
"set_role_to": [],
"child_set_role_to": [],
}
@@ -337,6 +380,7 @@ def test_models_document_access_get_abilities_for_editor_of_editor_user(
"update": False,
"partial_update": False,
"set_role_to": [],
"child_set_role_to": [],
}
@@ -357,6 +401,7 @@ def test_models_document_access_get_abilities_for_reader_of_owner():
"update": False,
"partial_update": False,
"set_role_to": [],
"child_set_role_to": [],
}
@@ -374,6 +419,7 @@ def test_models_document_access_get_abilities_for_reader_of_administrator():
"update": False,
"partial_update": False,
"set_role_to": [],
"child_set_role_to": [],
}
@@ -396,16 +442,17 @@ def test_models_document_access_get_abilities_for_reader_of_reader_user(
"update": False,
"partial_update": False,
"set_role_to": [],
"child_set_role_to": [],
}
def test_models_document_access_get_abilities_preset_role(django_assert_num_queries):
"""No query is done if the role is preset, e.g., with a query annotation."""
"""No query is done if user roles are preset on the document, e.g., with a query annotation."""
access = factories.UserDocumentAccessFactory(role="reader")
user = factories.UserDocumentAccessFactory(
document=access.document, role="reader"
).user
access.user_roles = ["reader"]
access.set_user_roles_tuple(None, "reader")
with django_assert_num_queries(0):
abilities = access.get_abilities(user)
@@ -416,6 +463,7 @@ def test_models_document_access_get_abilities_preset_role(django_assert_num_quer
"update": False,
"partial_update": False,
"set_role_to": [],
"child_set_role_to": [],
}
+106 -156
View File
@@ -171,7 +171,7 @@ def test_models_documents_get_abilities_forbidden(
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
"partial_update": False,
"restore": False,
@@ -229,7 +229,7 @@ def test_models_documents_get_abilities_reader(
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
"media_auth": True,
"media_check": True,
@@ -252,7 +252,7 @@ def test_models_documents_get_abilities_reader(
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key != "link_select_options"
if key not in ["link_select_options", "ancestors_links_definition"]
)
@@ -292,7 +292,7 @@ def test_models_documents_get_abilities_editor(
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
"media_auth": True,
"media_check": True,
@@ -314,7 +314,7 @@ def test_models_documents_get_abilities_editor(
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key != "link_select_options"
if key not in ["link_select_options", "ancestors_links_definition"]
)
@@ -344,7 +344,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
"media_auth": True,
"media_check": True,
@@ -393,7 +393,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
"media_auth": True,
"media_check": True,
@@ -415,7 +415,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key != "link_select_options"
if key not in ["link_select_options", "ancestors_links_definition"]
)
@@ -445,7 +445,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
"media_auth": True,
"media_check": True,
@@ -467,7 +467,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key != "link_select_options"
if key not in ["link_select_options", "ancestors_links_definition"]
)
@@ -504,7 +504,7 @@ def test_models_documents_get_abilities_reader_user(
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
"media_auth": True,
"media_check": True,
@@ -528,7 +528,7 @@ def test_models_documents_get_abilities_reader_user(
assert all(
value is False
for key, value in document.get_abilities(user).items()
if key != "link_select_options"
if key not in ["link_select_options", "ancestors_links_definition"]
)
@@ -561,7 +561,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"link_select_options": {
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
"media_auth": True,
"media_check": True,
@@ -1176,184 +1176,134 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
@pytest.mark.parametrize(
"ancestors_links, select_options",
"reach, role, select_options",
[
# One ancestor
(
[{"link_reach": "public", "link_role": "reader"}],
"public",
"reader",
{
"restricted": ["editor"],
"public": ["reader", "editor"],
},
),
("public", "editor", {"public": ["editor"]}),
(
"authenticated",
"reader",
{
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
},
),
(
"authenticated",
"editor",
{"authenticated": ["editor"], "public": ["editor"]},
),
(
"restricted",
"reader",
{
"restricted": None,
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
},
),
(
"restricted",
"editor",
{
"restricted": None,
"authenticated": ["editor"],
"public": ["reader", "editor"],
"public": ["editor"],
},
),
([{"link_reach": "public", "link_role": "editor"}], {"public": ["editor"]}),
# Edge cases
(
[{"link_reach": "authenticated", "link_role": "reader"}],
"public",
None,
{
"restricted": ["editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
},
),
(
[{"link_reach": "authenticated", "link_role": "editor"}],
{"authenticated": ["editor"], "public": ["reader", "editor"]},
),
(
[{"link_reach": "restricted", "link_role": "reader"}],
{
"restricted": ["reader", "editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
},
),
(
[{"link_reach": "restricted", "link_role": "editor"}],
{
"restricted": ["editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
},
),
# Multiple ancestors with different roles
(
[
{"link_reach": "public", "link_role": "reader"},
{"link_reach": "public", "link_role": "editor"},
],
{"public": ["editor"]},
),
(
[
{"link_reach": "authenticated", "link_role": "reader"},
{"link_reach": "authenticated", "link_role": "editor"},
],
{"authenticated": ["editor"], "public": ["reader", "editor"]},
),
(
[
{"link_reach": "restricted", "link_role": "reader"},
{"link_reach": "restricted", "link_role": "editor"},
],
{
"restricted": ["editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
},
),
# Multiple ancestors with different reaches
(
[
{"link_reach": "authenticated", "link_role": "reader"},
{"link_reach": "public", "link_role": "reader"},
],
{
"restricted": ["editor"],
"authenticated": ["editor"],
"public": ["reader", "editor"],
},
),
(
[
{"link_reach": "restricted", "link_role": "reader"},
{"link_reach": "authenticated", "link_role": "reader"},
{"link_reach": "public", "link_role": "reader"},
],
{
"restricted": ["editor"],
"authenticated": ["editor"],
"public": ["reader", "editor"],
},
),
# Multiple ancestors with mixed reaches and roles
(
[
{"link_reach": "authenticated", "link_role": "editor"},
{"link_reach": "public", "link_role": "reader"},
],
{"authenticated": ["editor"], "public": ["reader", "editor"]},
),
(
[
{"link_reach": "authenticated", "link_role": "reader"},
{"link_reach": "public", "link_role": "editor"},
],
{"public": ["editor"]},
),
(
[
{"link_reach": "restricted", "link_role": "editor"},
{"link_reach": "authenticated", "link_role": "reader"},
],
{
"restricted": ["editor"],
"authenticated": ["reader", "editor"],
"public": ["reader", "editor"],
},
),
(
[
{"link_reach": "restricted", "link_role": "reader"},
{"link_reach": "authenticated", "link_role": "editor"},
],
{"authenticated": ["editor"], "public": ["reader", "editor"]},
),
# No ancestors (edge case)
(
[],
None,
"reader",
{
"public": ["reader", "editor"],
"authenticated": ["reader", "editor"],
"restricted": ["reader", "editor"],
"restricted": None,
},
),
(
None,
None,
{
"public": ["reader", "editor"],
"authenticated": ["reader", "editor"],
"restricted": None,
},
),
],
)
def test_models_documents_get_select_options(ancestors_links, select_options):
def test_models_documents_get_select_options(reach, role, select_options):
"""Validate that the "get_select_options" method operates as expected."""
assert models.LinkReachChoices.get_select_options(ancestors_links) == select_options
assert models.LinkReachChoices.get_select_options(reach, role) == select_options
def test_models_documents_compute_ancestors_links_no_highest_readable():
"""Test the compute_ancestors_links method."""
document = factories.DocumentFactory(link_reach="public")
assert document.compute_ancestors_links(user=AnonymousUser()) == []
def test_models_documents_compute_ancestors_links_highest_readable(
def test_models_documents_compute_ancestors_links_paths_mapping_single(
django_assert_num_queries,
):
"""Test the compute_ancestors_links method."""
"""Test the compute_ancestors_links_paths_mapping method on a single document."""
document = factories.DocumentFactory(link_reach="public")
with django_assert_num_queries(1):
assert document.compute_ancestors_links_paths_mapping() == {
document.path: [{"link_reach": "public", "link_role": document.link_role}]
}
def test_models_documents_compute_ancestors_links_paths_mapping_structure(
django_assert_num_queries,
):
"""Test the compute_ancestors_links_paths_mapping method on a tree of documents."""
user = factories.UserFactory()
other_user = factories.UserFactory()
root = factories.DocumentFactory(
link_reach="restricted", link_role="reader", users=[user]
)
factories.DocumentFactory(
parent=root, link_reach="public", link_role="reader", users=[user]
)
child2 = factories.DocumentFactory(
root = factories.DocumentFactory(link_reach="restricted", users=[user])
document = factories.DocumentFactory(
parent=root,
link_reach="authenticated",
link_role="editor",
users=[user, other_user],
)
child3 = factories.DocumentFactory(
parent=child2,
sibling = factories.DocumentFactory(parent=root, link_reach="public", users=[user])
child = factories.DocumentFactory(
parent=document,
link_reach="authenticated",
link_role="reader",
users=[user, other_user],
)
with django_assert_num_queries(2):
assert child3.compute_ancestors_links(user=user) == [
{"link_reach": root.link_reach, "link_role": root.link_role},
{"link_reach": child2.link_reach, "link_role": child2.link_role},
]
# Child
with django_assert_num_queries(1):
assert child.compute_ancestors_links_paths_mapping() == {
root.path: [{"link_reach": "restricted", "link_role": root.link_role}],
document.path: [
{"link_reach": "restricted", "link_role": root.link_role},
{"link_reach": document.link_reach, "link_role": document.link_role},
],
child.path: [
{"link_reach": "restricted", "link_role": root.link_role},
{"link_reach": document.link_reach, "link_role": document.link_role},
{"link_reach": child.link_reach, "link_role": child.link_role},
],
}
with django_assert_num_queries(2):
assert child3.compute_ancestors_links(user=other_user) == [
{"link_reach": child2.link_reach, "link_role": child2.link_role},
]
# Sibling
with django_assert_num_queries(1):
assert sibling.compute_ancestors_links_paths_mapping() == {
root.path: [{"link_reach": "restricted", "link_role": root.link_role}],
sibling.path: [
{"link_reach": "restricted", "link_role": root.link_role},
{"link_reach": sibling.link_reach, "link_role": sibling.link_role},
],
}