diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml index 5971fcfa7..dbaff0f0e 100644 --- a/.github/workflows/docker-hub.yml +++ b/.github/workflows/docker-hub.yml @@ -6,6 +6,7 @@ on: push: branches: - 'main' + - 'feature/doc-dnd' tags: - 'v*' pull_request: diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b0a03a39..639e622dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,9 @@ and this project adheres to ## Added +- ✨(frontend) multi-pages #701 +- ✨(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 @@ -54,22 +57,30 @@ 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 - 🐛(backend) race condition create doc #633 - 🐛(frontend) fix breaklines in custom blocks #908 +## Fixed + +- 🐛(backend) fix link definition select options linked to ancestors #846 + ## [3.1.0] - 2025-04-07 ## Added +- ✨(backend) add ancestors links definitions to document abilities #846 - 🚩(backend) add feature flag for the footer #841 - 🔧(backend) add view to manage footer json #841 - ✨(frontend) add custom css style #771 @@ -81,6 +92,7 @@ and this project adheres to ## Fixed +- 🐛(backend) fix link definition select options linked to ancestors #846 - 🐛(back) validate document content in serializer #822 - 🐛(frontend) fix selection click past end of content #840 diff --git a/env.d/development/common.dist b/env.d/development/common.dist index 4b1389bf4..75e7460fa 100644 --- a/env.d/development/common.dist +++ b/env.d/development/common.dist @@ -64,3 +64,4 @@ COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/ # Frontend FRONTEND_THEME=default +FRONTEND_URL=http://localhost:3000 diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py index 43a0465f4..09007847b 100644 --- a/src/backend/core/api/permissions.py +++ b/src/backend/core/api/permissions.py @@ -6,6 +6,7 @@ from rest_framework import permissions +from core import choices from core.models import DocumentAccess, RoleChoices, get_trashbin_cutoff ACTION_FOR_METHOD_TO_PERMISSION = { @@ -96,26 +97,27 @@ def has_permission(self, request, view): ).exists() -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 +129,45 @@ def has_object_permission(self, request, view, obj): ) 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) diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index e86288bb3..8ba20392c 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -10,9 +10,9 @@ 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,133 +32,34 @@ class Meta: 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) + class Meta: + model = models.TemplateAccess + resource_field_name = "template" + fields = ["id", "user", "team", "role", "abilities"] + read_only_fields = ["id", "abilities"] - def get_abilities(self, access) -> dict: + def get_abilities(self, instance) -> 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 instance.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 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): @@ -167,7 +68,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 +76,10 @@ class Meta: fields = [ "id", "abilities", + "ancestors_link_reach", + "ancestors_link_role", + "computed_link_reach", + "computed_link_role", "created_at", "creator", "depth", @@ -188,11 +93,15 @@ class Meta: "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 +114,45 @@ class Meta: "numchild", "path", "updated_at", - "user_roles", + "user_role", ] - def get_abilities(self, document) -> dict: - """Return abilities of the logged-in user on the instance.""" - request = self.context.get("request") + def to_representation(self, instance): + """Precompute once per instance""" + paths_links_mapping = self.context.get("paths_links_mapping") - 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 + 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 document.get_abilities(request.user, ancestors_links=ancestors_links) - return {} + 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 {} + + return instance.get_abilities(request.user) - 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 +165,10 @@ class Meta: fields = [ "id", "abilities", + "ancestors_link_reach", + "ancestors_link_role", + "computed_link_reach", + "computed_link_role", "content", "created_at", "creator", @@ -259,11 +183,15 @@ class Meta: "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 +203,7 @@ class Meta: "numchild", "path", "updated_at", - "user_roles", + "user_role", ] def get_fields(self): @@ -361,6 +289,99 @@ def save(self, **kwargs): 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_ancestors_role if annotated; else None.""" + return choices.RoleChoices.max( + getattr(instance, "max_ancestors_role", None), + instance.role, + ) + + 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. diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 578c49d62..ae0223188 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -4,11 +4,11 @@ import json import logging import uuid -from urllib.parse import unquote, urlparse +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 @@ -18,6 +18,8 @@ from django.db.models.expressions import RawSQL from django.db.models.functions import Left, Length from django.http import Http404, StreamingHttpResponse +from django.shortcuts import redirect +from django.utils.functional import cached_property from django.utils.text import capfirst, slugify from django.utils.translation import gettext_lazy as _ @@ -30,11 +32,20 @@ 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.services.converter_services import YdocConverter +from core.services.notion_import import ( + ImportedDocument, + build_notion_session, + fetch_all_pages, + import_page, + link_child_page_to_parent, +) from core.utils import extract_attachments, filter_descendants +from ..notion_schemas.notion_page import NotionPage from . import permissions, serializers, utils from .filters import DocumentFilter, ListDocumentFilter @@ -219,14 +230,10 @@ def get_me(self, request): 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.""" @@ -234,80 +241,6 @@ def get_serializer_context(self): 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""" @@ -430,7 +363,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 @@ -441,44 +374,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 @@ -514,18 +409,20 @@ def get_queryset(self): 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): @@ -535,13 +432,11 @@ def list(self, request, *args, **kwargs): 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 @@ -554,7 +449,7 @@ def list(self, request, *args, **kwargs): 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. @@ -564,14 +459,8 @@ def list(self, request, *args, **kwargs): ) 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"] ) @@ -662,7 +551,7 @@ def trashbin(self, request, *args, **kwargs): 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) @@ -730,7 +619,7 @@ def move(self, request, *args, **kwargs): position = validated_data["position"] message = None - + owner_accesses = [] if position in [ enums.MoveNodePositionChoices.FIRST_CHILD, enums.MoveNodePositionChoices.LAST_CHILD, @@ -740,12 +629,15 @@ def move(self, request, *args, **kwargs): "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( @@ -755,6 +647,19 @@ def move(self, request, *args, **kwargs): 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 ) @@ -801,11 +706,7 @@ def children(self, request, *args, **kwargs): 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 @@ -824,7 +725,17 @@ def children(self, request, *args, **kwargs): 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, @@ -856,10 +767,12 @@ def tree(self, request, pk, *args, **kwargs): List ancestors tree above the document. What we need to display is the tree structure opened for the current document. """ + 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)) @@ -881,13 +794,6 @@ def tree(self, request, pk, *args, **kwargs): 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( @@ -895,25 +801,21 @@ def tree(self, request, pk, *args, **kwargs): ) 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, @@ -930,7 +832,10 @@ def tree(self, request, pk, *args, **kwargs): @drf.decorators.action( detail=True, methods=["post"], - permission_classes=[permissions.IsAuthenticated, permissions.AccessPermission], + permission_classes=[ + permissions.IsAuthenticated, + permissions.DocumentPermission, + ], url_path="duplicate", ) @transaction.atomic @@ -1413,7 +1318,11 @@ def cors_proxy(self, request, *args, **kwargs): 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. @@ -1440,50 +1349,143 @@ 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() + @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 - if self.action == "list": - try: - document = models.Document.objects.get(pk=self.kwargs["resource_id"]) - except models.Document.DoesNotExist: - return queryset.none() + def get_serializer_class(self): + """Use light serializer for unprivileged users.""" + return ( + serializers.DocumentAccessSerializer + if self.document.get_role(self.request.user) in choices.PRIVILEGED_ROLES + else serializers.DocumentAccessLightSerializer + ) - 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) + def list(self, request, *args, **kwargs): + """Return accesses for the current document with filters and annotations.""" + user = request.user - return queryset + role = self.document.get_role(user) + if not role: + return drf.response.Response([]) - def get_serializer_class(self): - if self.action == "list" and not self.is_current_user_owner_or_admin: - return serializers.DocumentAccessLightSerializer + ancestors = ( + self.document.get_ancestors() + | models.Document.objects.filter(pk=self.document.pk) + ).filter(ancestors_deleted_at__isnull=True) - return super().get_serializer_class() + queryset = self.get_queryset().filter(document__in=ancestors) - def perform_create(self, serializer): - """Add a new access to the document and send an email to the new added user.""" - access = serializer.save() + if role not in choices.PRIVILEGED_ROLES: + queryset = queryset.filter(role__in=choices.PRIVILEGED_ROLES) - 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, + accesses = list(queryset.order_by("document__path")) + + # Annotate more information on roles + path_to_key_to_max_ancestors_role = defaultdict( + lambda: defaultdict(lambda: None) ) + path_to_ancestors_roles = defaultdict(list) + path_to_role = defaultdict(lambda: None) + for access in accesses: + key = access.target_key + path = access.document.path + parent_path = path[: -models.Document.steplen] + + path_to_key_to_max_ancestors_role[path][key] = choices.RoleChoices.max( + path_to_key_to_max_ancestors_role[path][key], access.role + ) + + if parent_path: + path_to_key_to_max_ancestors_role[path][key] = choices.RoleChoices.max( + path_to_key_to_max_ancestors_role[parent_path][key], + path_to_key_to_max_ancestors_role[path][key], + ) + 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] = [] + + 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 = ( + path_to_key_to_max_ancestors_role[parent_path][access.target_key] + 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): + """ + 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 = 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.""" @@ -1520,7 +1522,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"] @@ -1582,7 +1584,6 @@ class TemplateAccessViewSet( ResourceAccessViewsetMixin, drf.mixins.CreateModelMixin, drf.mixins.DestroyModelMixin, - drf.mixins.ListModelMixin, drf.mixins.RetrieveModelMixin, drf.mixins.UpdateModelMixin, viewsets.GenericViewSet, @@ -1612,12 +1613,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, @@ -1650,7 +1694,7 @@ class InvitationViewset( pagination_class = Pagination permission_classes = [ permissions.CanCreateInvitationPermission, - permissions.AccessPermission, + permissions.ResourceWithAccessPermission, ] queryset = ( models.Invitation.objects.all() @@ -1690,11 +1734,11 @@ def get_queryset(self): 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 @@ -1783,3 +1827,175 @@ def _load_theme_customization(self): ) return theme_customization + + +@drf.decorators.api_view() +def notion_import_redirect(request): + query = urlencode( + { + "client_id": settings.NOTION_CLIENT_ID, + "response_type": "code", + "owner": "user", + "redirect_uri": settings.NOTION_REDIRECT_URI, + } + ) + return redirect("https://api.notion.com/v1/oauth/authorize?" + query) + + +@drf.decorators.api_view() +def notion_import_callback(request): + code = request.GET.get("code") + resp = requests.post( + "https://api.notion.com/v1/oauth/token", + auth=requests.auth.HTTPBasicAuth( + settings.NOTION_CLIENT_ID, settings.NOTION_CLIENT_SECRET + ), + headers={"Accept": "application/json"}, + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": settings.NOTION_REDIRECT_URI, + }, + ) + resp.raise_for_status() + data = resp.json() + request.session["notion_token"] = data["access_token"] + return redirect(f"{settings.FRONTEND_URL}/import-notion/") + + +def _import_notion_doc_content(imported_doc, obj, user): + for att in imported_doc.attachments: + extra_args = { + "Metadata": { + "owner": str(user.id), + "status": enums.DocumentAttachmentStatus.READY, # TODO + }, + } + file_id = uuid.uuid4() + key = f"{obj.key_base}/{enums.ATTACHMENTS_FOLDER:s}/{file_id!s}.raw" + with requests.get(att.file.file["url"], stream=True) as resp: + default_storage.connection.meta.client.upload_fileobj( + resp.raw, default_storage.bucket_name, key + ) + obj.attachments.append(key) + att.block["props"]["url"] = ( + f"{settings.MEDIA_BASE_URL}{settings.MEDIA_URL}{key}" + ) + + obj.content = YdocConverter().convert_blocks(imported_doc.blocks) + obj.save() + + +def _import_notion_child_page(imported_doc, parent_doc, user, imported_ids): + obj = parent_doc.add_child( + creator=user, + title=imported_doc.page.get_title() or "J'aime les carottes", + ) + + models.DocumentAccess.objects.create( + document=obj, + user=user, + role=models.RoleChoices.OWNER, + ) + + _import_notion_doc_content(imported_doc, obj, user) + + imported_ids.append(imported_doc.page.id) + + for child in imported_doc.children: + _import_notion_child_page(child, obj, user, imported_ids) + + +def _import_notion_root_page(imported_doc, user) -> list[str]: + obj = models.Document.add_root( + depth=1, + creator=user, + title=imported_doc.page.get_title() or "J'aime les courgettes", + link_reach=models.LinkReachChoices.RESTRICTED, + ) + + models.DocumentAccess.objects.create( + document=obj, + user=user, + role=models.RoleChoices.OWNER, + ) + + imported_ids = [imported_doc.page.id] + + _import_notion_doc_content(imported_doc, obj, user) + + for child in imported_doc.children: + _import_notion_child_page(child, obj, user, imported_ids) + + return imported_ids + + +def _generate_notion_progress( + all_pages: list[NotionPage], page_statuses: dict[str, str] +) -> str: + raw = json.dumps( + [ + { + "title": page.get_title(), + "status": page_statuses[page.id], + } + for page in all_pages + ] + ) + return f"data: {raw}\n\n" + + +def _notion_import_event_stream(request): + session = build_notion_session(request.session["notion_token"]) + all_pages = fetch_all_pages(session) + + page_statuses = {} + for page in all_pages: + page_statuses[page.id] = "pending" + + yield _generate_notion_progress(all_pages, page_statuses) + + docs_by_page_id: dict[str, ImportedDocument] = {} + child_page_blocs_ids_to_parent_page_ids: dict[str, str] = {} + + for page in all_pages: + docs_by_page_id[page.id] = import_page( + session, page, child_page_blocs_ids_to_parent_page_ids + ) + page_statuses[page.id] = "fetched" + yield _generate_notion_progress(all_pages, page_statuses) + + for page in all_pages: + link_child_page_to_parent( + page, docs_by_page_id, child_page_blocs_ids_to_parent_page_ids + ) + + root_docs = [doc for doc in docs_by_page_id.values() if doc.page.is_root()] + + for root_doc in root_docs: + imported_ids = _import_notion_root_page(root_doc, request.user) + for imported_id in imported_ids: + page_statuses[imported_id] = "imported" + + yield _generate_notion_progress(all_pages, page_statuses) + + +class IgnoreClientContentNegotiation(drf.negotiation.BaseContentNegotiation): + def select_parser(self, request, parsers): + return parsers[0] + + def select_renderer(self, request, renderers, format_suffix): + return (renderers[0], renderers[0].media_type) + + +class NotionImportRunView(drf.views.APIView): + content_negotiation_class = IgnoreClientContentNegotiation + + def get(self, request, format=None): + if "notion_token" not in request.session: + raise drf.exceptions.PermissionDenied() + + # return drf.response.Response({"sava": "oui et toi ?"}) + return StreamingHttpResponse( + _notion_import_event_stream(request), content_type="text/event-stream" + ) diff --git a/src/backend/core/choices.py b/src/backend/core/choices.py new file mode 100644 index 000000000..e6b975111 --- /dev/null +++ b/src/backend/core/choices.py @@ -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} diff --git a/src/backend/core/migrations/0022_remove_document_is_public_and_more.py b/src/backend/core/migrations/0022_remove_document_is_public_and_more.py new file mode 100644 index 000000000..cfce2c5ed --- /dev/null +++ b/src/backend/core/migrations/0022_remove_document_is_public_and_more.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.7 on 2025-03-14 14:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0021_activate_unaccent_extension"), + ] + + operations = [ + migrations.AddField( + model_name="document", + name="has_deleted_children", + field=models.BooleanField(default=False), + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 2c5239ead..48e257d73 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -6,7 +6,6 @@ import hashlib import smtplib import uuid -from collections import defaultdict from datetime import timedelta from logging import getLogger @@ -33,6 +32,14 @@ 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 @@ def readable_per_se(self, user): 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 @@ def get_queryset(self): 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.""" @@ -486,6 +384,7 @@ class Document(MP_Node, BaseModel): ) deleted_at = models.DateTimeField(null=True, blank=True) ancestors_deleted_at = models.DateTimeField(null=True, blank=True) + has_deleted_children = models.BooleanField(default=False) duplicated_from = models.ForeignKey( "self", on_delete=models.SET_NULL, @@ -531,6 +430,12 @@ class Meta: 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) @@ -561,6 +466,12 @@ def save(self, *args, **kwargs): content_file = ContentFile(bytes_content) default_storage.save(file_key, content_file) + def is_leaf(self): + """ + :returns: True if the node is has no children + """ + return not self.has_deleted_children and self.numchild == 0 + @property def key_base(self): """Key base of the location where the document is stored in object storage.""" @@ -718,38 +629,22 @@ def invalidate_nb_accesses_cache(self): 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 - - def get_links_definitions(self, ancestors_links): - """Get links reach/role definitions for the current document and its ancestors.""" - - 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"]) + 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) - return dict(links_definitions) # Convert defaultdict back to a normal dict + return RoleChoices.max(*roles) - 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 +653,114 @@ def compute_ancestors_links(self, user): .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 defintion 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): """ - Compute and return abilities for a given user on the document. + Link reach/role on the document, combining inherited ancestors' link + definitions and the document's own link definition. """ - 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) + 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"] - roles = set( - self.get_roles(user) - ) # at this point only roles based on specific access + def get_abilities(self, user): + """ + Compute and return abilities for a given user on the document. + """ + # 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 @@ -850,7 +796,7 @@ def get_abilities(self, user, ancestors_links=None): "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, @@ -945,7 +891,8 @@ def soft_delete(self): if self.depth > 1: self._meta.model.objects.filter(pk=self.get_parent().pk).update( - numchild=models.F("numchild") - 1 + numchild=models.F("numchild") - 1, + has_deleted_children=True, ) # Mark all descendants as soft deleted @@ -1102,48 +1049,120 @@ def save(self, *args, **kwargs): 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. + """ + # pylint: disable=attribute-defined-outside-init + 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 + + try: + return self._prefetched_user_roles_tuple + except AttributeError: + pass + + 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 + ] + if len(set_role_to) == 1: + set_role_to = [] 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, } @@ -1170,10 +1189,10 @@ class Meta: 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 [] @@ -1184,21 +1203,20 @@ def get_roles(self, user): ).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, @@ -1245,11 +1263,65 @@ class Meta: 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): diff --git a/src/backend/core/notion_schemas/notion_block.py b/src/backend/core/notion_schemas/notion_block.py new file mode 100644 index 000000000..74e7ea489 --- /dev/null +++ b/src/backend/core/notion_schemas/notion_block.py @@ -0,0 +1,296 @@ +from datetime import datetime +from enum import StrEnum +from typing import Annotated, Any, Literal + +from pydantic import BaseModel, Discriminator, Field, ValidationError, model_validator + +from .notion_color import NotionColor +from .notion_file import NotionFile +from .notion_rich_text import NotionRichText + +"""Usage: NotionBlock.model_validate(response.json())""" + + +class NotionBlock(BaseModel): + id: str + created_time: datetime + last_edited_time: datetime + archived: bool + specific: "NotionBlockSpecifics" + has_children: bool + children: list["NotionBlock"] = Field(init=False, default_factory=list) + # This is not part of the API response, but is used to store children blocks + + @model_validator(mode="before") + @classmethod + def move_type_inward_and_rename(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + + if "type" not in data: + raise ValidationError("Type must be specified") + + data_type = data.pop("type") + data["specific"] = data.pop(data_type) + data["specific"]["block_type"] = data_type + + return data + + +class NotionBlockType(StrEnum): + """https://developers.notion.com/reference/block""" + + BOOKMARK = "bookmark" + BREADCRUMB = "breadcrumb" + BULLETED_LIST_ITEM = "bulleted_list_item" + CALLOUT = "callout" + CHILD_DATABASE = "child_database" + CHILD_PAGE = "child_page" + CODE = "code" + COLUMN = "column" + COLUMN_LIST = "column_list" + DIVIDER = "divider" + EMBED = "embed" + EQUATION = "equation" + FILE = "file" + HEADING_1 = "heading_1" + HEADING_2 = "heading_2" + HEADING_3 = "heading_3" + IMAGE = "image" + LINK_PREVIEW = "link_preview" + LINK_TO_PAGE = "link_to_page" + NUMBERED_LIST_ITEM = "numbered_list_item" + PARAGRAPH = "paragraph" + PDF = "pdf" + QUOTE = "quote" + SYNCED_BLOCK = "synced_block" + TABLE = "table" + TABLE_OF_CONTENTS = "table_of_contents" + TABLE_ROW = "table_row" + TEMPLATE = "template" + TO_DO = "to_do" + TOGGLE = "toggle" + UNSUPPORTED = "unsupported" + VIDEO = "video" + + +class NotionHeadingBase(BaseModel): + """https://developers.notion.com/reference/block#headings""" + + rich_text: list[NotionRichText] + color: NotionColor + is_toggleable: bool = False + + +class NotionHeading1(NotionHeadingBase): + block_type: Literal[NotionBlockType.HEADING_1] = NotionBlockType.HEADING_1 + + +class NotionHeading2(NotionHeadingBase): + block_type: Literal[NotionBlockType.HEADING_2] = NotionBlockType.HEADING_2 + + +class NotionHeading3(NotionHeadingBase): + block_type: Literal[NotionBlockType.HEADING_3] = NotionBlockType.HEADING_3 + + +class NotionParagraph(BaseModel): + """https://developers.notion.com/reference/block#paragraph""" + + block_type: Literal[NotionBlockType.PARAGRAPH] = NotionBlockType.PARAGRAPH + rich_text: list[NotionRichText] + color: NotionColor + children: list["NotionBlock"] = Field(default_factory=list) + + +class NotionBulletedListItem(BaseModel): + """https://developers.notion.com/reference/block#bulleted-list-item""" + + block_type: Literal[NotionBlockType.BULLETED_LIST_ITEM] = ( + NotionBlockType.BULLETED_LIST_ITEM + ) + rich_text: list[NotionRichText] + color: NotionColor + children: list["NotionBlock"] = Field(default_factory=list) + + +class NotionNumberedListItem(BaseModel): + """https://developers.notion.com/reference/block#numbered-list-item""" + + block_type: Literal[NotionBlockType.NUMBERED_LIST_ITEM] = ( + NotionBlockType.NUMBERED_LIST_ITEM + ) + rich_text: list[NotionRichText] + color: NotionColor + children: list["NotionBlock"] = Field(default_factory=list) + + +class NotionToDo(BaseModel): + """https://developers.notion.com/reference/block#to-do""" + + block_type: Literal[NotionBlockType.TO_DO] = NotionBlockType.TO_DO + rich_text: list[NotionRichText] + checked: bool + color: NotionColor + children: list["NotionBlock"] = Field(default_factory=list) + + +class NotionCode(BaseModel): + """https://developers.notion.com/reference/block#code""" + + block_type: Literal[NotionBlockType.CODE] = NotionBlockType.CODE + caption: list[NotionRichText] + rich_text: list[NotionRichText] + language: str # Actually an enum + + +class NotionCallout(BaseModel): + """https://developers.notion.com/reference/block#callout""" + + block_type: Literal[NotionBlockType.CALLOUT] = NotionBlockType.CALLOUT + rich_text: list[NotionRichText] + # icon: Any # could be an emoji or an image + color: NotionColor + + +class NotionDivider(BaseModel): + """https://developers.notion.com/reference/block#divider""" + + block_type: Literal[NotionBlockType.DIVIDER] = NotionBlockType.DIVIDER + + +class NotionEmbed(BaseModel): + """https://developers.notion.com/reference/block#embed""" + + block_type: Literal[NotionBlockType.EMBED] = NotionBlockType.EMBED + url: str + + +class NotionBlockFile(BaseModel): + # FIXME: this is actually another occurrence of type discriminating + """https://developers.notion.com/reference/block#file""" + + block_type: Literal[NotionBlockType.FILE] = NotionBlockType.FILE + # TODO: NotionFile + + +class NotionImage(BaseModel): + """https://developers.notion.com/reference/block#image""" + + block_type: Literal[NotionBlockType.IMAGE] = NotionBlockType.IMAGE + file: NotionFile + + @model_validator(mode="before") + @classmethod + def move_file_type_inward_and_rename(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + return {"block_type": "image", "file": data} + + +class NotionVideo(BaseModel): + """https://developers.notion.com/reference/block#video""" + + block_type: Literal[NotionBlockType.VIDEO] = NotionBlockType.VIDEO + # FIXME: this actually contains a file reference which will be defined for the above, but with the "video" attribute + + +class NotionLinkPreview(BaseModel): + """https://developers.notion.com/reference/block#link-preview""" + + block_type: Literal[NotionBlockType.LINK_PREVIEW] = NotionBlockType.LINK_PREVIEW + url: str + + +class NotionBookmark(BaseModel): + """https://developers.notion.com/reference/block#bookmark""" + + block_type: Literal[NotionBlockType.BOOKMARK] = NotionBlockType.BOOKMARK + url: str + caption: list[NotionRichText] = Field(default_factory=list) + + +class NotionTable(BaseModel): + """https://developers.notion.com/reference/block#table + + The children of this block are NotionTableRow blocks.""" + + block_type: Literal[NotionBlockType.TABLE] = NotionBlockType.TABLE + table_width: int + has_column_header: bool + has_row_header: bool + + +class NotionTableRow(BaseModel): + """https://developers.notion.com/reference/block#table-row""" + + block_type: Literal[NotionBlockType.TABLE_ROW] = NotionBlockType.TABLE_ROW + cells: list[list[NotionRichText]] # Each cell is a list of rich text objects + + +class NotionColumnList(BaseModel): + """https://developers.notion.com/reference/block#column-list-and-column""" + + block_type: Literal[NotionBlockType.COLUMN_LIST] = NotionBlockType.COLUMN_LIST + + +class NotionColumn(BaseModel): + """https://developers.notion.com/reference/block#column-list-and-column""" + + block_type: Literal[NotionBlockType.COLUMN] = NotionBlockType.COLUMN + + +class NotionChildPage(BaseModel): + """https://developers.notion.com/reference/block#child-page + + My guess is that the actual child page is a child of this block ? We don't have the id...""" + + block_type: Literal[NotionBlockType.CHILD_PAGE] = NotionBlockType.CHILD_PAGE + title: str + + +class NotionUnsupported(BaseModel): + block_type: str + raw: dict[str, Any] | None = None + + @model_validator(mode="before") + @classmethod + def put_all_in_raw(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + + if "raw" not in data: + data["raw"] = data.copy() + + return data + + +NotionBlockSpecifics = Annotated[ + Annotated[ + NotionHeading1 + | NotionHeading2 + | NotionHeading3 + | NotionParagraph + | NotionNumberedListItem + | NotionBulletedListItem + | NotionToDo + | NotionCode + | NotionColumn + | NotionColumnList + | NotionDivider + | NotionEmbed + | NotionBlockFile + | NotionImage + | NotionVideo + | NotionLinkPreview + | NotionTable + | NotionTableRow + | NotionChildPage + | NotionCallout + | NotionLinkPreview + | NotionBookmark, + Discriminator(discriminator="block_type"), + ] + | NotionUnsupported, + Field(union_mode="left_to_right"), +] diff --git a/src/backend/core/notion_schemas/notion_color.py b/src/backend/core/notion_schemas/notion_color.py new file mode 100644 index 000000000..881a6de94 --- /dev/null +++ b/src/backend/core/notion_schemas/notion_color.py @@ -0,0 +1,23 @@ +from enum import StrEnum + + +class NotionColor(StrEnum): + DEFAULT = "default" + BLUE = "blue" + BLUE_BACKGROUND = "blue_background" + BROWN = "brown" + BROWN_BACKGROUND = "brown_background" + GRAY = "gray" + GRAY_BACKGROUND = "gray_background" + GREEN = "green" + GREEN_BACKGROUND = "green_background" + ORANGE = "orange" + ORANGE_BACKGROUND = "orange_background" + YELLOW = "yellow" + YELLOW_BACKGROUND = "yellow_background" + PINK = "pink" + PINK_BACKGROUND = "pink_background" + PURPLE = "purple" + PURPLE_BACKGROUND = "purple_background" + RED = "red" + RED_BACKGROUND = "red_background" diff --git a/src/backend/core/notion_schemas/notion_file.py b/src/backend/core/notion_schemas/notion_file.py new file mode 100644 index 000000000..7b1a11f39 --- /dev/null +++ b/src/backend/core/notion_schemas/notion_file.py @@ -0,0 +1,31 @@ +from enum import StrEnum +from typing import Annotated, Literal + +from pydantic import BaseModel, Discriminator + + +class NotionFileType(StrEnum): + HOSTED = "file" + UPLOAD = "file_upload" + EXTERNAL = "external" + + +class NotionFileHosted(BaseModel): + type: Literal[NotionFileType.HOSTED] = NotionFileType.HOSTED + file: dict # TODO + + +class NotionFileUpload(BaseModel): + type: Literal[NotionFileType.UPLOAD] = NotionFileType.UPLOAD + file_upload: dict # TODO + + +class NotionFileExternal(BaseModel): + type: Literal[NotionFileType.EXTERNAL] = NotionFileType.EXTERNAL + external: dict # TODO + + +NotionFile = Annotated[ + NotionFileHosted | NotionFileUpload | NotionFileExternal, + Discriminator(discriminator="type"), +] diff --git a/src/backend/core/notion_schemas/notion_page.py b/src/backend/core/notion_schemas/notion_page.py new file mode 100644 index 000000000..4d98856c5 --- /dev/null +++ b/src/backend/core/notion_schemas/notion_page.py @@ -0,0 +1,61 @@ +from enum import StrEnum +from typing import Annotated, Literal + +from pydantic import BaseModel, Discriminator + + +class NotionParentType(StrEnum): + DATABASE = "database_id" + PAGE = "page_id" + WORKSPACE = "workspace" + BLOCK = "block_id" + + +class NotionParentDatabase(BaseModel): + type: Literal[NotionParentType.DATABASE] = NotionParentType.DATABASE + database_id: str + + +class NotionParentPage(BaseModel): + type: Literal[NotionParentType.PAGE] = NotionParentType.PAGE + page_id: str + + +class NotionParentWorkspace(BaseModel): + type: Literal[NotionParentType.WORKSPACE] = NotionParentType.WORKSPACE + + +class NotionParentBlock(BaseModel): + type: Literal[NotionParentType.BLOCK] = NotionParentType.BLOCK + block_id: str + + +NotionParent = Annotated[ + NotionParentDatabase | NotionParentPage | NotionParentWorkspace | NotionParentBlock, + Discriminator(discriminator="type"), +] + + +class NotionPage(BaseModel): + id: str + archived: bool + parent: NotionParent + + # created_time: datetime + # last_edited_time: datetime + # icon: NotionFile + # cover: NotionFile + + properties: dict # This is a very messy dict, with some RichText somewhere + + def get_title(self) -> str | None: + title_property: dict | None = self.properties.get("title") + if title_property is None: + return None + + # This could be parsed using NotionRichText + rich_text = title_property["title"][0] + return rich_text["plain_text"] + + def is_root(self): + return isinstance(self.parent, NotionParentWorkspace) diff --git a/src/backend/core/notion_schemas/notion_rich_text.py b/src/backend/core/notion_schemas/notion_rich_text.py new file mode 100644 index 000000000..036a57d86 --- /dev/null +++ b/src/backend/core/notion_schemas/notion_rich_text.py @@ -0,0 +1,72 @@ +from enum import StrEnum +from typing import Annotated, Any, Literal + +from pydantic import BaseModel, Discriminator, Field, ValidationError, model_validator + +from .notion_color import NotionColor + + +class NotionRichTextAnnotation(BaseModel): + """https://developers.notion.com/reference/rich-text#the-annotation-object""" + + bold: bool = False + italic: bool = False + strikethrough: bool = False + underline: bool = False + code: bool = False + color: NotionColor = NotionColor.DEFAULT + + +class NotionRichText(BaseModel): + """https://developers.notion.com/reference/rich-text, not a block""" + + annotations: NotionRichTextAnnotation + plain_text: str + href: str | None = None + specific: "NotionRichTextSpecifics" + + @model_validator(mode="before") + @classmethod + def move_type_inward_and_rename(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + + if "type" not in data: + raise ValidationError("Type must be specified") + data_type = data.pop("type") + data["specific"] = data.pop(data_type) + data["specific"]["type"] = data_type + + return data + + +class NotionRichTextType(StrEnum): + TEXT = "text" + MENTION = "mention" + EQUATION = "equation" + + +class NotionLink(BaseModel): + url: str + + +class NotionRichTextText(BaseModel): + type: Literal[NotionRichTextType.TEXT] = NotionRichTextType.TEXT + content: str + link: NotionLink | None + + +class NotionRichTextMention(BaseModel): + type: Literal[NotionRichTextType.MENTION] = NotionRichTextType.MENTION + # Mention + + +class NotionRichTextEquation(BaseModel): + type: Literal[NotionRichTextType.EQUATION] = NotionRichTextType.EQUATION + expression: str # LaTeX expression + + +NotionRichTextSpecifics = Annotated[ + NotionRichTextText | NotionRichTextMention | NotionRichTextEquation, + Discriminator(discriminator="type"), +] diff --git a/src/backend/core/services/converter_services.py b/src/backend/core/services/converter_services.py index 5213bac86..7fa603a11 100644 --- a/src/backend/core/services/converter_services.py +++ b/src/backend/core/services/converter_services.py @@ -76,3 +76,46 @@ def convert_markdown(self, text): ) from err return document_content + + def convert_blocks(self, blocks): + """Convert a list of blocks into our internal format using an external microservice.""" + + try: + response = requests.post( + f"{settings.Y_PROVIDER_API_BASE_URL}{settings.BLOCKS_CONVERSION_API_ENDPOINT}/", + json={ + "blocks": blocks, + }, + headers={ + "Authorization": self.auth_header, + "Content-Type": "application/json", + }, + timeout=settings.CONVERSION_API_TIMEOUT, + verify=settings.CONVERSION_API_SECURE, + ) + if not response.ok: + raise ValueError( + f"Conversion service returned an error: {response.status_code} - {response.text}" + ) + conversion_response = response.json() + + except requests.RequestException as err: + raise ServiceUnavailableError( + "Failed to connect to conversion service", + ) from err + + except ValueError as err: + raise InvalidResponseError( + "Could not parse conversion service response" + ) from err + + try: + document_content = conversion_response[ + settings.CONVERSION_API_CONTENT_FIELD + ] + except KeyError as err: + raise MissingContentError( + f"Response missing required field: {settings.CONVERSION_API_CONTENT_FIELD}" + ) from err + + return document_content diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py new file mode 100644 index 000000000..7af2c26f6 --- /dev/null +++ b/src/backend/core/services/notion_import.py @@ -0,0 +1,484 @@ +import json +import logging +from typing import Any + +from pydantic import BaseModel, Field, TypeAdapter +from requests import Session + +from ..notion_schemas.notion_block import ( + NotionBlock, + NotionBookmark, + NotionBulletedListItem, + NotionCallout, + NotionChildPage, + NotionCode, + NotionColumn, + NotionColumnList, + NotionDivider, + NotionHeading1, + NotionHeading2, + NotionHeading3, + NotionImage, + NotionNumberedListItem, + NotionParagraph, + NotionTable, + NotionTableRow, + NotionToDo, + NotionUnsupported, +) +from ..notion_schemas.notion_file import NotionFileExternal, NotionFileHosted +from ..notion_schemas.notion_page import ( + NotionPage, + NotionParentBlock, + NotionParentPage, + NotionParentWorkspace, +) +from ..notion_schemas.notion_rich_text import NotionRichText, NotionRichTextAnnotation + +logger = logging.getLogger(__name__) + + +def build_notion_session(token: str) -> Session: + session = Session() + session.headers = { + "Authorization": f"Bearer {token}", + "Notion-Version": "2022-06-28", + } + return session + + +def search_notion(session: Session, start_cursor: str) -> dict[str, Any]: + req_data = { + "filter": { + "value": "page", + "property": "object", + }, + } + if start_cursor: + req_data = { + "start_cursor": start_cursor, + "filter": { + "value": "page", + "property": "object", + }, + } + + response = session.post( + "https://api.notion.com/v1/search", + json=req_data, + ) + + if response.status_code != 200: + print(response.json()) + + response.raise_for_status() + return response.json() + + +def fetch_all_pages(session: Session) -> list[NotionPage]: + pages = [] + cursor = "" + has_more = True + + while has_more: + response = search_notion(session, start_cursor=cursor) + + for item in response["results"]: + if item["object"] != "page": + logger.warning(f"Skipping non-page object: {item['object']}") + continue + + pages.append(NotionPage.model_validate(item)) + + has_more = response.get("has_more", False) + cursor = response.get("next_cursor", "") + + return pages + + +def fetch_blocks(session: Session, block_id: str, start_cursor: str) -> dict[str, Any]: + response = session.get( + f"https://api.notion.com/v1/blocks/{block_id}/children", + params={ + "start_cursor": start_cursor if start_cursor else None, + }, + ) + + response.raise_for_status() + return response.json() + + +def fetch_block_children(session: Session, block_id: str) -> list[NotionBlock]: + blocks: list[NotionBlock] = [] + cursor = "" + has_more = True + + while has_more: + response = fetch_blocks(session, block_id, cursor) + + blocks.extend( + TypeAdapter(list[NotionBlock]).validate_python(response["results"]) + ) + + has_more = response.get("has_more", False) + cursor = response.get("next_cursor", "") + + for block in blocks: + if block.has_children: + block.children = fetch_block_children(session, block.id) + + return blocks + + +def convert_rich_texts(rich_texts: list[NotionRichText]) -> list[dict[str, Any]]: + content = [] + for rich_text in rich_texts: + if rich_text.href: + content.append( + { + "type": "link", + "content": [convert_rich_text(rich_text)], + "href": rich_text.href, # FIXME: if it was a notion link, we should convert it to a link to the document + } + ) + else: + content.append(convert_rich_text(rich_text)) + return content + + +def convert_rich_text(rich_text: NotionRichText) -> dict[str, Any]: + return { + "type": "text", + "text": rich_text.plain_text, + "styles": convert_annotations(rich_text.annotations), + } + + +class ImportedAttachment(BaseModel): + block: Any + file: NotionFileHosted + + +class ImportedChildPage(BaseModel): + child_page_block: NotionBlock + block_to_update: Any + + +def convert_image( + image: NotionImage, attachments: list[ImportedAttachment] +) -> list[dict[str, Any]]: + # TODO: NotionFileUpload + match image.file: + case NotionFileExternal(): + return [ + { + "type": "image", + "props": { + "url": image.file.external["url"], + }, + } + ] + case NotionFileHosted(): + block = { + "type": "image", + "props": { + "url": "about:blank", # populated later on + }, + } + attachments.append(ImportedAttachment(block=block, file=image.file)) + + return [block] + case _: + return [{"paragraph": {"content": "Unsupported image type"}}] + + +def convert_block( + block: NotionBlock, + attachments: list[ImportedAttachment], + child_page_blocks: list[ImportedChildPage], +) -> list[dict[str, Any]]: + match block.specific: + case NotionColumnList(): + columns_content = [] + for column in block.children: + columns_content.extend( + convert_block(column, attachments, child_page_blocks) + ) + return columns_content + case NotionColumn(): + return [ + convert_block(child_content, attachments, child_page_blocks)[0] + for child_content in block.children + ] + + case NotionParagraph(): + content = convert_rich_texts(block.specific.rich_text) + return [ + { + "type": "paragraph", + "content": content, + } + ] + case NotionImage(): + return convert_image(block.specific, attachments) + case NotionHeading1() | NotionHeading2() | NotionHeading3(): + return [ + { + "type": "heading", + "content": convert_rich_texts(block.specific.rich_text), + "props": { + "level": block.specific.block_type.value.split("_")[ + -1 + ], # e.g., "1", "2", or "3" + }, + } + ] + # case NotionDivider(): + # return [{"type": "divider"}] + case NotionCallout(): + return [ + { + "type": "quote", + "content": convert_rich_texts(block.specific.rich_text), + "props": { + "backgroundColor": "yellow", # TODO: use the callout color + }, + } + ] + case NotionTable(): + rows: list[NotionTableRow] = [child.specific for child in block.children] # type: ignore # I don't know how to assert properly + if len(rows) == 0: + return [ + { + "type": "paragraph", + "content": "Empty table ?!", + } + ] + + n_columns = len( + rows[0].cells + ) # I'll assume all rows have the same number of cells + if n_columns == 0: + return [{"type": "paragraph", "content": "Empty row ?!"}] + if not all(len(row.cells) == n_columns for row in rows): + return [ + { + "type": "paragraph", + "content": "Rows have different number of cells ?!", + } + ] + SEEMINGLY_DEFAULT_WIDTH = 128 + return [ + { + "type": "table", + "content": { + "type": "tableContent", + "columnWidths": [ + SEEMINGLY_DEFAULT_WIDTH for _ in range(n_columns) + ], + "headerRows": int(block.specific.has_column_header), + "headerColumns": int(block.specific.has_row_header), + "props": { + "textColor": "default", # TODO + }, + "rows": [ + { + "cells": [ + { + "type": "tableCell", + "content": convert_rich_texts(cell), + } + for cell in row.cells + ] + } + for row in rows + ], + }, + } + ] + case NotionBulletedListItem(): + return [ + { + "type": "bulletListItem", + "content": convert_rich_texts(block.specific.rich_text), + "children": convert_block_list( + block.children, + attachments, + child_page_blocks, + ), + } + ] + case NotionNumberedListItem(): + return [ + { + "type": "numberedListItem", + "content": convert_rich_texts(block.specific.rich_text), + "children": convert_block_list( + block.children, + attachments, + child_page_blocks, + ), + } + ] + case NotionToDo(): + return [ + { + "type": "checkListItem", + "content": convert_rich_texts(block.specific.rich_text), + "checked": block.specific.checked, + "children": convert_block_list( + block.children, + attachments, + child_page_blocks, + ), + } + ] + case NotionCode(): + return [ + { + "type": "codeBlock", + "content": "".join( + rich_text.plain_text for rich_text in block.specific.rich_text + ), + "props": {"language": block.specific.language}, + } + ] + case NotionBookmark(): + caption = convert_rich_texts(block.specific.caption) or block.specific.url + return [ + { + "type": "paragraph", + "content": [ + { + "type": "link", + "content": caption, + "href": block.specific.url, + }, + ], + } + ] + case NotionChildPage(): + # TODO: convert to a link + res = { + "type": "paragraph", + "content": [ + { + "type": "link", + "content": f"Child page: {block.specific.title}", + "href": "about:blank", # populated later on + }, + ], + } + child_page_blocks.append( + ImportedChildPage(child_page_block=block, block_to_update=res) + ) + return [res] + case NotionUnsupported(): + return [ + { + "type": "paragraph", + "content": f"This should be a {block.specific.block_type}, not yet supported in docs", + }, + # { + # "type": "quote", + # "content": json.dumps(block.specific.raw, indent=2), + # }, + ] + case _: + return [ + { + "type": "paragraph", + "content": f"This should be a {block.specific.block_type}, not yet handled by the importer", + } + ] + + +def convert_annotations(annotations: NotionRichTextAnnotation) -> dict[str, str]: + res = {} + if annotations.bold: + res["bold"] = "true" + if annotations.italic: + res["italic"] = "true" + if annotations.underline: + res["underline"] = "true" + if annotations.strikethrough: + res["strike"] = "true" + + if "_" in annotations.color: + res["backgroundColor"] = annotations.color.split("_")[0].lower() + else: + res["textColor"] = annotations.color.lower() + return res + + +def convert_block_list( + blocks: list[NotionBlock], + attachments: list[ImportedAttachment], + child_page_blocks: list[ImportedChildPage], +) -> list[dict[str, Any]]: + converted_blocks = [] + for block in blocks: + converted_blocks.extend(convert_block(block, attachments, child_page_blocks)) + return converted_blocks + + +class ImportedDocument(BaseModel): + page: NotionPage + blocks: list[dict[str, Any]] = Field(default_factory=list) + children: list["ImportedDocument"] = Field(default_factory=list) + attachments: list[ImportedAttachment] = Field(default_factory=list) + child_page_blocks: list[ImportedChildPage] = Field(default_factory=list) + + +def find_block_child_page(block_id: str, all_pages: list[NotionPage]): + for page in all_pages: + if ( + isinstance(page.parent, NotionParentBlock) + and page.parent.block_id == block_id + ): + return page + return None + + +def import_page( + session: Session, + page: NotionPage, + child_page_blocs_ids_to_parent_page_ids: dict[str, str], +) -> ImportedDocument: + blocks = fetch_block_children(session, page.id) + logger.info(f"Page {page.get_title()} (id {page.id})") + logger.info(blocks) + attachments: list[ImportedAttachment] = [] + + child_page_blocks: list[ImportedChildPage] = [] + + converted_blocks = convert_block_list(blocks, attachments, child_page_blocks) + + for child_page_block in child_page_blocks: + child_page_blocs_ids_to_parent_page_ids[ + child_page_block.child_page_block.id + ] = page.id + + return ImportedDocument( + page=page, + blocks=converted_blocks, + attachments=attachments, + child_page_blocks=child_page_blocks, + ) + + +def link_child_page_to_parent( + page: NotionPage, + docs_by_page_id: dict[str, ImportedDocument], + child_page_blocs_ids_to_parent_page_ids: dict[str, str], +): + if isinstance(page.parent, NotionParentPage): + docs_by_page_id[page.parent.page_id].children.append(docs_by_page_id[page.id]) + elif isinstance(page.parent, NotionParentBlock): + parent_page_id = child_page_blocs_ids_to_parent_page_ids.get(page.id) + if parent_page_id: + docs_by_page_id[parent_page_id].children.append(docs_by_page_id[page.id]) + else: + logger.warning( + f"Page {page.id} has a parent block, but no parent page found." + ) diff --git a/src/backend/core/tests/documents/test_api_document_accesses.py b/src/backend/core/tests/documents/test_api_document_accesses.py index bf5ef1827..eb6fa92b7 100644 --- a/src/backend/core/tests/documents/test_api_document_accesses.py +++ b/src/backend/core/tests/documents/test_api_document_accesses.py @@ -1,6 +1,7 @@ """ Test document accesses API endpoints for users in impress's core app. """ +# pylint: disable=too-many-lines import random from uuid import uuid4 @@ -8,7 +9,7 @@ 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 from core.tests.test_services_collaboration_services import ( # pylint: disable=unused-import @@ -51,12 +52,7 @@ def test_api_document_accesses_list_authenticated_unrelated(): f"/api/v1.0/documents/{document.id!s}/accesses/", ) assert response.status_code == 200 - assert response.json() == { - "count": 0, - "next": None, - "previous": None, - "results": [], - } + assert response.json() == [] def test_api_document_accesses_list_unexisting_document(): @@ -69,39 +65,46 @@ def test_api_document_accesses_list_unexisting_document(): client.force_login(user) response = client.get(f"/api/v1.0/documents/{uuid4()!s}/accesses/") - assert response.status_code == 200 - assert response.json() == { - "count": 0, - "next": None, - "previous": None, - "results": [], - } + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} @pytest.mark.parametrize("via", VIA) @pytest.mark.parametrize( - "role", [role for role in models.RoleChoices if role not in models.PRIVILEGED_ROLES] + "role", + [role for role in choices.RoleChoices if role not in choices.PRIVILEGED_ROLES], ) def test_api_document_accesses_list_authenticated_related_non_privileged( - via, role, mock_user_teams + via, role, mock_user_teams, django_assert_num_queries ): """ - Authenticated users should be able to list document accesses for a document - to which they are directly related, whatever their role in the document. + Authenticated users with no privileged role should only be able to list document + accesses associated with privileged roles for a document, including from ancestors. """ user = factories.UserFactory() - client = APIClient() client.force_login(user) - owner = factories.UserFactory() - accesses = [] - - document_access = factories.UserDocumentAccessFactory( - user=owner, role=models.RoleChoices.OWNER + # Create documents structured as a tree + unreadable_ancestor = factories.DocumentFactory(link_reach="restricted") + # make all documents below the grand parent readable without a specific access for the user + grand_parent = factories.DocumentFactory( + parent=unreadable_ancestor, link_reach="authenticated" + ) + parent = factories.DocumentFactory(parent=grand_parent) + document = factories.DocumentFactory(parent=parent) + child = factories.DocumentFactory(parent=document) + + # Create accesses related to each document + accesses = ( + factories.UserDocumentAccessFactory(document=unreadable_ancestor), + factories.UserDocumentAccessFactory(document=grand_parent), + factories.UserDocumentAccessFactory(document=parent), + factories.UserDocumentAccessFactory(document=document), + factories.TeamDocumentAccessFactory(document=document), ) - accesses.append(document_access) - document = document_access.document + factories.UserDocumentAccessFactory(document=child) + if via == USER: models.DocumentAccess.objects.create( document=document, @@ -116,33 +119,32 @@ def test_api_document_accesses_list_authenticated_related_non_privileged( role=role, ) - access1 = factories.TeamDocumentAccessFactory(document=document) - access2 = factories.UserDocumentAccessFactory(document=document) - accesses.append(access1) - accesses.append(access2) - # Accesses for other documents to which the user is related should not be listed either other_access = factories.UserDocumentAccessFactory(user=user) factories.UserDocumentAccessFactory(document=other_access.document) - response = client.get( - f"/api/v1.0/documents/{document.id!s}/accesses/", - ) + with django_assert_num_queries(3): + response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/") - # Return only owners - owners_accesses = [ - access for access in accesses if access.role in models.PRIVILEGED_ROLES - ] assert response.status_code == 200 content = response.json() - assert content["count"] == len(owners_accesses) - assert sorted(content["results"], key=lambda x: x["id"]) == sorted( + + # Make sure only privileged roles are returned + privileged_accesses = [ + acc for acc in accesses if acc.role in choices.PRIVILEGED_ROLES + ] + assert len(content) == len(privileged_accesses) + + assert sorted(content, key=lambda x: x["id"]) == sorted( [ { "id": str(access.id), + "document": { + "id": str(access.document_id), + "path": access.document.path, + "depth": access.document.depth, + }, "user": { - "id": None, - "email": None, "full_name": access.user.full_name, "short_name": access.user.short_name, } @@ -150,40 +152,47 @@ def test_api_document_accesses_list_authenticated_related_non_privileged( else None, "team": access.team, "role": access.role, - "abilities": access.get_abilities(user), + "max_ancestors_role": None, + "max_role": access.role, + "abilities": { + "destroy": False, + "partial_update": False, + "retrieve": False, + "set_role_to": [], + "update": False, + }, } - for access in owners_accesses + for access in privileged_accesses ], key=lambda x: x["id"], ) - for access in content["results"]: - assert access["role"] in models.PRIVILEGED_ROLES - @pytest.mark.parametrize("via", VIA) -@pytest.mark.parametrize("role", models.PRIVILEGED_ROLES) -def test_api_document_accesses_list_authenticated_related_privileged_roles( - via, role, mock_user_teams +@pytest.mark.parametrize( + "role", [role for role in choices.RoleChoices if role in choices.PRIVILEGED_ROLES] +) +def test_api_document_accesses_list_authenticated_related_privileged( + via, role, mock_user_teams, django_assert_num_queries ): """ - Authenticated users should be able to list document accesses for a document - to which they are directly related, whatever their role in the document. + Authenticated users with a privileged role should be able to list all + document accesses whatever the role, including from ancestors. """ user = factories.UserFactory() - client = APIClient() client.force_login(user) - owner = factories.UserFactory() - accesses = [] - - document_access = factories.UserDocumentAccessFactory( - user=owner, role=models.RoleChoices.OWNER + # Create documents structured as a tree + unreadable_ancestor = factories.DocumentFactory(link_reach="restricted") + # make all documents below the grand parent readable without a specific access for the user + grand_parent = factories.DocumentFactory( + parent=unreadable_ancestor, link_reach="authenticated" ) - accesses.append(document_access) - document = document_access.document - user_access = None + parent = factories.DocumentFactory(parent=grand_parent) + document = factories.DocumentFactory(parent=parent) + child = factories.DocumentFactory(parent=document) + if via == USER: user_access = models.DocumentAccess.objects.create( document=document, @@ -197,61 +206,319 @@ def test_api_document_accesses_list_authenticated_related_privileged_roles( team="lasuite", role=role, ) - - access1 = factories.TeamDocumentAccessFactory(document=document) - access2 = factories.UserDocumentAccessFactory(document=document) - accesses.append(access1) - accesses.append(access2) + else: + raise RuntimeError() + + # Create accesses related to each document + ancestors_accesses = [ + # Access on unreadable ancestor should still be listed + # as the related user gains access to our document + factories.UserDocumentAccessFactory(document=unreadable_ancestor), + factories.UserDocumentAccessFactory(document=grand_parent), + factories.UserDocumentAccessFactory(document=parent), + ] + document_accesses = [ + factories.UserDocumentAccessFactory(document=document), + factories.TeamDocumentAccessFactory(document=document), + factories.UserDocumentAccessFactory(document=document), + user_access, + ] + factories.UserDocumentAccessFactory(document=child) # Accesses for other documents to which the user is related should not be listed either other_access = factories.UserDocumentAccessFactory(user=user) factories.UserDocumentAccessFactory(document=other_access.document) - response = client.get( - f"/api/v1.0/documents/{document.id!s}/accesses/", - ) - - access2_user = serializers.UserSerializer(instance=access2.user).data - base_user = serializers.UserSerializer(instance=user).data + with django_assert_num_queries(3): + response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/") assert response.status_code == 200 content = response.json() - assert len(content["results"]) == 4 - assert sorted(content["results"], key=lambda x: x["id"]) == sorted( + assert len(content) == 7 + assert sorted(content, key=lambda x: x["id"]) == sorted( [ { - "id": str(user_access.id), - "user": base_user if via == "user" else None, - "team": "lasuite" if via == "team" else "", - "role": user_access.role, - "abilities": user_access.get_abilities(user), - }, - { - "id": str(access1.id), - "user": None, - "team": access1.team, - "role": access1.role, - "abilities": access1.get_abilities(user), - }, - { - "id": str(access2.id), - "user": access2_user, - "team": "", - "role": access2.role, - "abilities": access2.get_abilities(user), - }, - { - "id": str(document_access.id), - "user": serializers.UserSerializer(instance=owner).data, - "team": "", - "role": models.RoleChoices.OWNER, - "abilities": document_access.get_abilities(user), - }, + "id": str(access.id), + "document": { + "id": str(access.document_id), + "path": access.document.path, + "depth": access.document.depth, + }, + "user": { + "id": str(access.user.id), + "email": access.user.email, + "language": access.user.language, + "full_name": access.user.full_name, + "short_name": access.user.short_name, + } + if access.user + else None, + "max_ancestors_role": None, + "max_role": access.role, + "team": access.team, + "role": access.role, + "abilities": access.get_abilities(user), + } + for access in ancestors_accesses + document_accesses ], key=lambda x: x["id"], ) +def test_api_document_accesses_retrieve_set_role_to_child(): + """Check set_role_to for an access with no access on the ancestor.""" + user, other_user = factories.UserFactory.create_batch(2) + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory() + parent_access = factories.UserDocumentAccessFactory( + document=parent, user=user, role="owner" + ) + + document = factories.DocumentFactory(parent=parent) + document_access_other_user = factories.UserDocumentAccessFactory( + document=document, user=other_user, role="editor" + ) + + response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/") + + assert response.status_code == 200 + content = response.json() + assert len(content) == 2 + + result_dict = { + result["id"]: result["abilities"]["set_role_to"] for result in content + } + assert result_dict[str(document_access_other_user.id)] == [ + "reader", + "editor", + "administrator", + "owner", + ] + assert result_dict[str(parent_access.id)] == [] + + # Add an access for the other user on the parent + parent_access_other_user = factories.UserDocumentAccessFactory( + document=parent, user=other_user, role="editor" + ) + + response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/") + + assert response.status_code == 200 + content = response.json() + assert len(content) == 3 + + result_dict = { + result["id"]: result["abilities"]["set_role_to"] for result in content + } + assert result_dict[str(document_access_other_user.id)] == [ + "editor", + "administrator", + "owner", + ] + assert result_dict[str(parent_access.id)] == [] + assert result_dict[str(parent_access_other_user.id)] == [ + "reader", + "editor", + "administrator", + "owner", + ] + + +@pytest.mark.parametrize( + "roles,results", + [ + [ + ["administrator", "reader", "reader", "reader"], + [ + ["reader", "editor", "administrator"], + [], + [], + ["reader", "editor", "administrator"], + ], + ], + [ + ["owner", "reader", "reader", "reader"], + [ + ["reader", "editor", "administrator", "owner"], + [], + [], + ["reader", "editor", "administrator", "owner"], + ], + ], + [ + ["owner", "reader", "reader", "owner"], + [ + ["reader", "editor", "administrator", "owner"], + [], + [], + ["reader", "editor", "administrator", "owner"], + ], + ], + ], +) +def test_api_document_accesses_list_authenticated_related_same_user(roles, results): + """ + The maximum role across ancestor documents and set_role_to optionsfor + a given user should be filled as expected. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # Create documents structured as a tree + grand_parent = factories.DocumentFactory(link_reach="authenticated") + parent = factories.DocumentFactory(parent=grand_parent) + document = factories.DocumentFactory(parent=parent) + + # Create accesses for another user + other_user = factories.UserFactory() + accesses = [ + factories.UserDocumentAccessFactory( + document=document, user=user, role=roles[0] + ), + factories.UserDocumentAccessFactory( + document=grand_parent, user=other_user, role=roles[1] + ), + factories.UserDocumentAccessFactory( + document=parent, user=other_user, role=roles[2] + ), + factories.UserDocumentAccessFactory( + document=document, user=other_user, role=roles[3] + ), + ] + + response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/") + + assert response.status_code == 200 + content = response.json() + assert len(content) == 4 + + for result in content: + assert ( + result["max_ancestors_role"] is None + if result["user"]["id"] == str(user.id) + else choices.RoleChoices.max(roles[1], roles[2]) + ) + + result_dict = { + result["id"]: result["abilities"]["set_role_to"] for result in content + } + assert [result_dict[str(access.id)] for access in accesses] == results + + +@pytest.mark.parametrize( + "roles,results", + [ + [ + ["administrator", "reader", "reader", "reader"], + [ + ["reader", "editor", "administrator"], + [], + [], + ["reader", "editor", "administrator"], + ], + ], + [ + ["owner", "reader", "reader", "reader"], + [ + ["reader", "editor", "administrator", "owner"], + [], + [], + ["reader", "editor", "administrator", "owner"], + ], + ], + [ + ["owner", "reader", "reader", "owner"], + [ + ["reader", "editor", "administrator", "owner"], + [], + [], + ["reader", "editor", "administrator", "owner"], + ], + ], + [ + ["reader", "reader", "reader", "owner"], + [ + ["reader", "editor", "administrator", "owner"], + [], + [], + ["reader", "editor", "administrator", "owner"], + ], + ], + [ + ["reader", "administrator", "reader", "editor"], + [ + ["reader", "editor", "administrator"], + ["reader", "editor", "administrator"], + [], + [], + ], + ], + [ + ["editor", "editor", "administrator", "editor"], + [ + ["reader", "editor", "administrator"], + [], + ["editor", "administrator"], + [], + ], + ], + ], +) +def test_api_document_accesses_list_authenticated_related_same_team( + roles, results, mock_user_teams +): + """ + The maximum role across ancestor documents and set_role_to optionsfor + a given team should be filled as expected. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # Create documents structured as a tree + grand_parent = factories.DocumentFactory(link_reach="authenticated") + parent = factories.DocumentFactory(parent=grand_parent) + document = factories.DocumentFactory(parent=parent) + + mock_user_teams.return_value = ["lasuite", "unknown"] + accesses = [ + factories.UserDocumentAccessFactory( + document=document, user=user, role=roles[0] + ), + # Create accesses for a team + factories.TeamDocumentAccessFactory( + document=grand_parent, team="lasuite", role=roles[1] + ), + factories.TeamDocumentAccessFactory( + document=parent, team="lasuite", role=roles[2] + ), + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role=roles[3] + ), + ] + + response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/") + + assert response.status_code == 200 + content = response.json() + assert len(content) == 4 + + for result in content: + assert ( + result["max_ancestors_role"] is None + if result["user"] and result["user"]["id"] == str(user.id) + else choices.RoleChoices.max(roles[1], roles[2]) + ) + + result_dict = { + result["id"]: result["abilities"]["set_role_to"] for result in content + } + assert [result_dict[str(access.id)] for access in accesses] == results + + def test_api_document_accesses_retrieve_anonymous(): """ Anonymous users should not be allowed to retrieve a document access. @@ -307,7 +574,9 @@ def test_api_document_accesses_retrieve_authenticated_unrelated(): @pytest.mark.parametrize("via", VIA) @pytest.mark.parametrize("role", models.RoleChoices) def test_api_document_accesses_retrieve_authenticated_related( - via, role, mock_user_teams + via, + role, + mock_user_teams, ): """ A user who is related to a document should be allowed to retrieve the @@ -333,7 +602,7 @@ def test_api_document_accesses_retrieve_authenticated_related( f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", ) - if not role in models.PRIVILEGED_ROLES: + if not role in choices.PRIVILEGED_ROLES: assert response.status_code == 403 else: access_user = serializers.UserSerializer(instance=access.user).data @@ -341,9 +610,16 @@ def test_api_document_accesses_retrieve_authenticated_related( assert response.status_code == 200 assert response.json() == { "id": str(access.id), + "document": { + "id": str(access.document_id), + "path": access.document.path, + "depth": access.document.depth, + }, "user": access_user, "team": "", "role": access.role, + "max_ancestors_role": None, + "max_role": access.role, "abilities": access.get_abilities(user), } @@ -448,7 +724,9 @@ def test_api_document_accesses_update_authenticated_reader_or_editor( @pytest.mark.parametrize("via", VIA) +@pytest.mark.parametrize("create_for", VIA) def test_api_document_accesses_update_administrator_except_owner( + create_for, via, mock_user_teams, mock_reset_connections, # pylint: disable=redefined-outer-name @@ -481,32 +759,31 @@ def test_api_document_accesses_update_administrator_except_owner( new_values = { "id": uuid4(), - "user_id": factories.UserFactory().id, "role": random.choice(["administrator", "editor", "reader"]), } + if create_for == USER: + new_values["user_id"] = factories.UserFactory().id + elif create_for == TEAM: + new_values["team"] = "new-team" for field, value in new_values.items(): new_data = {**old_values, field: value} - if new_data["role"] == old_values["role"]: + with mock_reset_connections(document.id, str(access.user_id)): response = client.put( f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", data=new_data, format="json", ) - assert response.status_code == 403 - else: - with mock_reset_connections(document.id, str(access.user_id)): - response = client.put( - f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", - data=new_data, - format="json", - ) - assert response.status_code == 200 + assert response.status_code == 200 access.refresh_from_db() updated_values = serializers.DocumentAccessSerializer(instance=access).data - if field == "role": - assert updated_values == {**old_values, "role": new_values["role"]} + if field in ["role", "max_role"]: + assert updated_values == { + **old_values, + "role": new_values["role"], + "max_role": new_values["role"], + } else: assert updated_values == old_values @@ -601,7 +878,7 @@ def test_api_document_accesses_update_administrator_to_owner( for field, value in new_values.items(): new_data = {**old_values, field: value} # We are not allowed or not really updating the role - if field == "role" or new_data["role"] == old_values["role"]: + if field == "role": response = client.put( f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", data=new_data, @@ -624,7 +901,9 @@ def test_api_document_accesses_update_administrator_to_owner( @pytest.mark.parametrize("via", VIA) +@pytest.mark.parametrize("create_for", VIA) def test_api_document_accesses_update_owner( + create_for, via, mock_user_teams, mock_reset_connections, # pylint: disable=redefined-outer-name @@ -655,42 +934,39 @@ def test_api_document_accesses_update_owner( new_values = { "id": uuid4(), - "user_id": factories.UserFactory().id, "role": random.choice(models.RoleChoices.values), } + if create_for == USER: + new_values["user_id"] = factories.UserFactory().id + elif create_for == TEAM: + new_values["team"] = "new-team" for field, value in new_values.items(): new_data = {**old_values, field: value} - if ( - new_data["role"] == old_values["role"] - ): # we are not really updating the role + with mock_reset_connections(document.id, str(access.user_id)): response = client.put( f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", data=new_data, format="json", ) - assert response.status_code == 403 - else: - with mock_reset_connections(document.id, str(access.user_id)): - response = client.put( - f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", - data=new_data, - format="json", - ) - assert response.status_code == 200 + assert response.status_code == 200 access.refresh_from_db() updated_values = serializers.DocumentAccessSerializer(instance=access).data - if field == "role": - assert updated_values == {**old_values, "role": new_values["role"]} + if field in ["role", "max_role"]: + assert updated_values == { + **old_values, + "role": new_values["role"], + "max_role": new_values["role"], + } else: assert updated_values == old_values @pytest.mark.parametrize("via", VIA) -def test_api_document_accesses_update_owner_self( +def test_api_document_accesses_update_owner_self_root( via, mock_user_teams, mock_reset_connections, # pylint: disable=redefined-outer-name @@ -751,6 +1027,51 @@ def test_api_document_accesses_update_owner_self( assert access.role == new_role +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_update_owner_self_child( + via, + mock_user_teams, + mock_reset_connections, # pylint: disable=redefined-outer-name +): + """ + A user who is owner of a document should be allowed to update + their own user access even if they are the only owner in the document, + provided the document is not a root. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory() + document = factories.DocumentFactory(parent=parent) + access = None + if via == USER: + access = factories.UserDocumentAccessFactory( + document=document, user=user, role="owner" + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + access = factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="owner" + ) + + old_values = serializers.DocumentAccessSerializer(instance=access).data + new_role = random.choice(["administrator", "editor", "reader"]) + + user_id = str(access.user_id) if via == USER else None + with mock_reset_connections(document.id, user_id): + response = client.put( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + data={**old_values, "role": new_role}, + format="json", + ) + + assert response.status_code == 200 + access.refresh_from_db() + assert access.role == new_role + + # Delete @@ -931,17 +1252,16 @@ def test_api_document_accesses_delete_owners( f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", ) - assert response.status_code == 204 - assert models.DocumentAccess.objects.count() == 1 + assert response.status_code == 204 + assert models.DocumentAccess.objects.count() == 1 @pytest.mark.parametrize("via", VIA) -def test_api_document_accesses_delete_owners_last_owner(via, mock_user_teams): +def test_api_document_accesses_delete_owners_last_owner_root(via, mock_user_teams): """ - It should not be possible to delete the last owner access from a document + It should not be possible to delete the last owner access from a root document """ user = factories.UserFactory(with_owned_document=True) - client = APIClient() client.force_login(user) @@ -964,3 +1284,63 @@ def test_api_document_accesses_delete_owners_last_owner(via, mock_user_teams): assert response.status_code == 403 assert models.DocumentAccess.objects.count() == 2 + + +def test_api_document_accesses_delete_owners_last_owner_child_user( + mock_reset_connections, # pylint: disable=redefined-outer-name +): + """ + It should be possible to delete the last owner access from a document that is not a root. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory() + document = factories.DocumentFactory(parent=parent) + access = None + access = factories.UserDocumentAccessFactory( + document=document, user=user, role="owner" + ) + + assert models.DocumentAccess.objects.count() == 2 + with mock_reset_connections(document.id, str(access.user_id)): + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 204 + assert models.DocumentAccess.objects.count() == 1 + + +@pytest.mark.skip( + reason="Pending fix on https://github.com/suitenumerique/docs/issues/969" +) +def test_api_document_accesses_delete_owners_last_owner_child_team( + mock_user_teams, + mock_reset_connections, # pylint: disable=redefined-outer-name +): + """ + It should be possible to delete the last owner access from a document that + is not a root. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory() + document = factories.DocumentFactory(parent=parent) + access = None + mock_user_teams.return_value = ["lasuite", "unknown"] + access = factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="owner" + ) + + assert models.DocumentAccess.objects.count() == 2 + with mock_reset_connections(document.id, str(access.user_id)): + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 204 + assert models.DocumentAccess.objects.count() == 1 diff --git a/src/backend/core/tests/documents/test_api_document_accesses_create.py b/src/backend/core/tests/documents/test_api_document_accesses_create.py index e356973ae..3c1e1b93a 100644 --- a/src/backend/core/tests/documents/test_api_document_accesses_create.py +++ b/src/backend/core/tests/documents/test_api_document_accesses_create.py @@ -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 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,7 +145,7 @@ 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 @@ -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,28 +194,119 @@ 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 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[0] for role in models.RoleChoices.choices if role[0] != "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 + 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") - + document = documents[-1] role = random.choice([role[0] for role in models.RoleChoices.choices]) assert len(mail.outbox) == 0 @@ -222,11 +325,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() == { + "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), - "user": other_user, - "team": "", + "max_ancestors_role": None, + "max_role": role, "role": role, - "abilities": new_document_access.get_abilities(user), + "team": "", + "user": other_user, } assert len(mail.outbox) == 1 email = mail.outbox[0] @@ -240,6 +350,71 @@ 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 + 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([role[0] for role in models.RoleChoices.choices]) + + 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("via", VIA) def test_api_document_accesses_create_email_in_receivers_language(via, mock_user_teams): """ @@ -286,11 +461,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() == { + "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), - "user": other_user_data, - "team": "", + "max_ancestors_role": None, + "max_role": role, "role": role, - "abilities": new_document_access.get_abilities(user), + "team": "", + "user": other_user_data, } assert len(mail.outbox) == index + 1 email = mail.outbox[index] diff --git a/src/backend/core/tests/documents/test_api_documents_children_create.py b/src/backend/core/tests/documents/test_api_documents_children_create.py index 5aea1b605..b39653d5f 100644 --- a/src/backend/core/tests/documents/test_api_documents_children_create.py +++ b/src/backend/core/tests/documents/test_api_documents_children_create.py @@ -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(): diff --git a/src/backend/core/tests/documents/test_api_documents_children_list.py b/src/backend/core/tests/documents/test_api_documents_children_list.py index 96e1d9b43..19bcfd192 100644 --- a/src/backend/core/tests/documents/test_api_documents_children_list.py +++ b/src/backend/core/tests/documents/test_api_documents_children_list.py @@ -14,13 +14,18 @@ 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, }, ], } diff --git a/src/backend/core/tests/documents/test_api_documents_descendants.py b/src/backend/core/tests/documents/test_api_documents_descendants.py index 302af2318..bd2785a7f 100644 --- a/src/backend/core/tests/documents/test_api_documents_descendants.py +++ b/src/backend/core/tests/documents/test_api_documents_descendants.py @@ -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, }, ], } diff --git a/src/backend/core/tests/documents/test_api_documents_duplicate.py b/src/backend/core/tests/documents/test_api_documents_duplicate.py index 82acfa984..a1d920354 100644 --- a/src/backend/core/tests/documents/test_api_documents_duplicate.py +++ b/src/backend/core/tests/documents/test_api_documents_duplicate.py @@ -14,6 +14,7 @@ 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] - ) - assert response.status_code == 200 + 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( diff --git a/src/backend/core/tests/documents/test_api_documents_favorite_list.py b/src/backend/core/tests/documents/test_api_documents_favorite_list.py index 8791a6bfd..7b9f5ec0c 100644 --- a/src/backend/core/tests/documents/test_api_documents_favorite_list.py +++ b/src/backend/core/tests/documents/test_api_documents_favorite_list.py @@ -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", } ], } diff --git a/src/backend/core/tests/documents/test_api_documents_list.py b/src/backend/core/tests/documents/test_api_documents_list.py index 1120123e1..cfaa3e0a1 100644 --- a/src/backend/core/tests/documents/test_api_documents_list.py +++ b/src/backend/core/tests/documents/test_api_documents_list.py @@ -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 diff --git a/src/backend/core/tests/documents/test_api_documents_media_auth.py b/src/backend/core/tests/documents/test_api_documents_media_auth.py index 37f88daa3..ee76ef944 100644 --- a/src/backend/core/tests/documents/test_api_documents_media_auth.py +++ b/src/backend/core/tests/documents/test_api_documents_media_auth.py @@ -12,6 +12,7 @@ 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}" diff --git a/src/backend/core/tests/documents/test_api_documents_move.py b/src/backend/core/tests/documents/test_api_documents_move.py index a0dd83500..ad4f68d41 100644 --- a/src/backend/core/tests/documents/test_api_documents_move.py +++ b/src/backend/core/tests/documents/test_api_documents_move.py @@ -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 diff --git a/src/backend/core/tests/documents/test_api_documents_retrieve.py b/src/backend/core/tests/documents/test_api_documents_retrieve.py index 38d66cd46..b4967ec07 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -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 @@ 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, "move": False, @@ -58,6 +59,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), @@ -72,7 +77,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, } @@ -90,6 +95,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": { @@ -109,7 +115,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, "move": False, "partial_update": grand_parent.link_role == "editor", @@ -121,6 +129,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), @@ -135,7 +147,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, } @@ -207,7 +219,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, "move": False, @@ -220,6 +232,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), @@ -234,7 +250,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 @@ -260,6 +276,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": { @@ -278,7 +295,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 + ), "move": False, "media_auth": True, "partial_update": grand_parent.link_role == "editor", @@ -290,6 +309,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), @@ -304,7 +327,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, } @@ -400,6 +423,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"), @@ -414,7 +441,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, } @@ -440,6 +467,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": { @@ -458,7 +486,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, "move": access.role in ["administrator", "owner"], "partial_update": access.role != "reader", @@ -470,6 +500,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"), @@ -484,7 +518,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, } @@ -580,16 +614,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 @@ -622,6 +656,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), @@ -636,20 +674,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 @@ -684,6 +722,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), @@ -698,21 +740,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 @@ -746,6 +788,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), @@ -760,11 +806,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. """ @@ -787,15 +833,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): diff --git a/src/backend/core/tests/documents/test_api_documents_trashbin.py b/src/backend/core/tests/documents/test_api_documents_trashbin.py index 6db898eaf..246633852 100644 --- a/src/backend/core/tests/documents/test_api_documents_trashbin.py +++ b/src/backend/core/tests/documents/test_api_documents_trashbin.py @@ -74,6 +74,7 @@ def test_api_documents_trashbin_format(): "accesses_view": True, "ai_transform": True, "ai_translate": True, + "ancestors_links_definitions": {}, "attachment_upload": True, "children_create": True, "children_list": True, @@ -88,7 +89,7 @@ def test_api_documents_trashbin_format(): "link_select_options": { "authenticated": ["reader", "editor"], "public": ["reader", "editor"], - "restricted": ["reader", "editor"], + "restricted": None, }, "media_auth": True, "move": False, # Can't move a deleted document @@ -101,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, @@ -113,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", } diff --git a/src/backend/core/tests/documents/test_api_documents_tree.py b/src/backend/core/tests/documents/test_api_documents_tree.py index 33fa614b8..0124b5075 100644 --- a/src/backend/core/tests/documents/test_api_documents_tree.py +++ b/src/backend/core/tests/documents/test_api_documents_tree.py @@ -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, } diff --git a/src/backend/core/tests/documents/test_api_documents_update.py b/src/backend/core/tests/documents/test_api_documents_update.py index 1c583bc95..ef9d4d938 100644 --- a/src/backend/core/tests/documents/test_api_documents_update.py +++ b/src/backend/core/tests/documents/test_api_documents_update.py @@ -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", diff --git a/src/backend/core/tests/templates/test_api_template_accesses.py b/src/backend/core/tests/templates/test_api_template_accesses.py index 86e5f2bd5..6d1107768 100644 --- a/src/backend/core/tests/templates/test_api_template_accesses.py +++ b/src/backend/core/tests/templates/test_api_template_accesses.py @@ -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), diff --git a/src/backend/core/tests/templates/test_api_template_accesses_create.py b/src/backend/core/tests/templates/test_api_template_accesses_create.py index f52a5344f..a33cd470a 100644 --- a/src/backend/core/tests/templates/test_api_template_accesses_create.py +++ b/src/backend/core/tests/templates/test_api_template_accesses_create.py @@ -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 diff --git a/src/backend/core/tests/test_api_users.py b/src/backend/core/tests/test_api_users.py index 91863dc53..179b19add 100644 --- a/src/backend/core/tests/test_api_users.py +++ b/src/backend/core/tests/test_api_users.py @@ -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) diff --git a/src/backend/core/tests/test_models_document_accesses.py b/src/backend/core/tests/test_models_document_accesses.py index fe0e7c1c7..2fa88cf1f 100644 --- a/src/backend/core/tests/test_models_document_accesses.py +++ b/src/backend/core/tests/test_models_document_accesses.py @@ -123,16 +123,22 @@ 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"], } -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, @@ -142,6 +148,28 @@ 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_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"], + } + + def test_models_document_access_get_abilities_for_owner_of_owner(): """Check abilities of owner access for the owner of a document.""" access = factories.UserDocumentAccessFactory(role="owner") @@ -155,7 +183,7 @@ 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"], } @@ -172,7 +200,7 @@ 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"], } @@ -189,7 +217,7 @@ 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"], } @@ -206,7 +234,7 @@ 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"], } @@ -243,7 +271,7 @@ 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"], } @@ -260,7 +288,7 @@ 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"], } @@ -277,7 +305,7 @@ 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"], } @@ -400,12 +428,12 @@ def test_models_document_access_get_abilities_for_reader_of_reader_user( 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) diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index 6599b737a..dbedd3205 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -170,7 +170,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, @@ -228,7 +228,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, "move": False, @@ -250,7 +250,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"] ) @@ -290,7 +290,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, "move": False, @@ -311,7 +311,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"] ) @@ -341,7 +341,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, "move": True, @@ -389,7 +389,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, "move": True, @@ -410,7 +410,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"] ) @@ -440,7 +440,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, "move": False, @@ -461,7 +461,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"] ) @@ -498,7 +498,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, "move": False, @@ -521,7 +521,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"] ) @@ -554,7 +554,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, "move": False, @@ -1168,184 +1168,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"], - "authenticated": ["editor"], "public": ["reader", "editor"], }, ), - ([{"link_reach": "public", "link_role": "editor"}], {"public": ["editor"]}), + ("public", "editor", {"public": ["editor"]}), ( - [{"link_reach": "authenticated", "link_role": "reader"}], + "authenticated", + "reader", { - "restricted": ["editor"], "authenticated": ["reader", "editor"], "public": ["reader", "editor"], }, ), ( - [{"link_reach": "authenticated", "link_role": "editor"}], - {"authenticated": ["editor"], "public": ["reader", "editor"]}, + "authenticated", + "editor", + {"authenticated": ["editor"], "public": ["editor"]}, ), ( - [{"link_reach": "restricted", "link_role": "reader"}], + "restricted", + "reader", { - "restricted": ["reader", "editor"], + "restricted": None, "authenticated": ["reader", "editor"], "public": ["reader", "editor"], }, ), ( - [{"link_reach": "restricted", "link_role": "editor"}], + "restricted", + "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"], + "restricted": None, "authenticated": ["editor"], - "public": ["reader", "editor"], + "public": ["editor"], }, ), + # Edge cases ( - [ - {"link_reach": "restricted", "link_role": "reader"}, - {"link_reach": "authenticated", "link_role": "reader"}, - {"link_reach": "public", "link_role": "reader"}, - ], + "public", + None, { - "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"}, - ], + None, + "reader", { - "restricted": ["editor"], - "authenticated": ["reader", "editor"], "public": ["reader", "editor"], + "authenticated": ["reader", "editor"], + "restricted": None, }, ), ( - [ - {"link_reach": "restricted", "link_role": "reader"}, - {"link_reach": "authenticated", "link_role": "editor"}, - ], - {"authenticated": ["editor"], "public": ["reader", "editor"]}, - ), - # No ancestors (edge case) - ( - [], + None, + None, { "public": ["reader", "editor"], "authenticated": ["reader", "editor"], - "restricted": ["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.""" +def test_models_documents_compute_ancestors_links_paths_mapping_single( + django_assert_num_queries, +): + """Test the compute_ancestors_links_paths_mapping method on a single document.""" document = factories.DocumentFactory(link_reach="public") - assert document.compute_ancestors_links(user=AnonymousUser()) == [] + 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_highest_readable( +def test_models_documents_compute_ancestors_links_paths_mapping_structure( django_assert_num_queries, ): - """Test the compute_ancestors_links method.""" + """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}, + ], + } diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 054418954..7c0f25943 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -52,6 +52,11 @@ r"^templates/(?P[0-9a-z-]*)/", include(template_related_router.urls), ), + path("notion_import/", include([ + path("redirect", viewsets.notion_import_redirect), + path("callback", viewsets.notion_import_callback), + path("run", viewsets.NotionImportRunView.as_view()), + ])) ] ), ), diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 571d7052d..2bb3b6f18 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -415,6 +415,9 @@ class Base(Configuration): ) # Frontend + FRONTEND_URL = values.Value( + None, environ_name="FRONTEND_URL", environ_prefix=None + ) FRONTEND_THEME = values.Value( None, environ_name="FRONTEND_THEME", environ_prefix=None ) @@ -628,6 +631,11 @@ class Base(Configuration): environ_name="CONVERSION_API_ENDPOINT", environ_prefix=None, ) + BLOCKS_CONVERSION_API_ENDPOINT = values.Value( + default="convert-blocks", + environ_name="BLOCKS_CONVERSION_API_ENDPOINT", + environ_prefix=None, + ) CONVERSION_API_CONTENT_FIELD = values.Value( default="content", environ_name="CONVERSION_API_CONTENT_FIELD", @@ -644,6 +652,22 @@ class Base(Configuration): environ_prefix=None, ) + NOTION_CLIENT_ID = values.Value( + default=None, + environ_name="NOTION_CLIENT_ID", + environ_prefix=None, + ) + NOTION_CLIENT_SECRET = values.Value( + default=None, + environ_name="NOTION_CLIENT_SECRET", + environ_prefix=None, + ) + NOTION_REDIRECT_URI = values.Value( + default=None, + environ_name="NOTION_REDIRECT_URI", + environ_prefix=None, + ) + # Logging # We want to make it easy to log to console but by default we log production # to Sentry and don't want to log to console. diff --git a/src/frontend/apps/e2e/__tests__/app-impress/common.ts b/src/frontend/apps/e2e/__tests__/app-impress/common.ts index 09e1ce749..0aa88f166 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/common.ts @@ -60,16 +60,19 @@ export const createDoc = async ( docName: string, browserName: string, length: number = 1, + isChild: boolean = false, ) => { const randomDocs = randomName(docName, browserName, length); for (let i = 0; i < randomDocs.length; i++) { - const header = page.locator('header').first(); - await header.locator('h2').getByText('Docs').click(); + if (!isChild) { + const header = page.locator('header').first(); + await header.locator('h2').getByText('Docs').click(); + } await page .getByRole('button', { - name: 'New doc', + name: isChild ? 'New page' : 'New doc', }) .click(); @@ -185,7 +188,26 @@ export const goToGridDoc = async ( return docTitle as string; }; -export const mockedDocument = async (page: Page, json: object) => { +export const updateDocTitle = async (page: Page, title: string) => { + const input = page.getByLabel('doc title input'); + await expect(input).toBeVisible(); + await expect(input).toHaveText(''); + await input.click(); + await input.fill(title); + await input.click(); + await verifyDocName(page, title); +}; + +export const getWaitForCreateDoc = (page: Page) => { + return page.waitForResponse( + (response) => + response.url().includes('/documents/') && + response.url().includes('/children/') && + response.request().method() === 'POST', + ); +}; + +export const mockedDocument = async (page: Page, data: object) => { await page.route('**/documents/**/', async (route) => { const request = route.request(); if ( @@ -195,12 +217,15 @@ export const mockedDocument = async (page: Page, json: object) => { !request.url().includes('accesses') && !request.url().includes('invitations') ) { + const { abilities, ...rest } = data as unknown as { + abilities?: Record; + }; await route.fulfill({ json: { id: 'mocked-document-id', content: '', title: 'Mocked document', - accesses: [], + path: '000000', abilities: { destroy: false, // Means not owner link_configuration: false, @@ -211,10 +236,22 @@ export const mockedDocument = async (page: Page, json: object) => { update: false, partial_update: false, // Means not editor retrieve: true, + link_select_options: { + public: ['reader', 'editor'], + authenticated: ['reader', 'editor'], + restricted: null, + }, + ...abilities, }, link_reach: 'restricted', + computed_link_reach: 'restricted', + computed_link_role: 'reader', + ancestors_link_reach: null, + ancestors_link_role: null, created_at: '2021-09-01T09:00:00Z', - ...json, + user_role: 'owner', + user_roles: ['owner'], + ...rest, }, }); } else { @@ -223,6 +260,22 @@ export const mockedDocument = async (page: Page, json: object) => { }); }; +export const mockedListDocs = async (page: Page, data: object[] = []) => { + await page.route('**/documents/**/', async (route) => { + const request = route.request(); + if (request.method().includes('GET') && request.url().includes('page=')) { + await route.fulfill({ + json: { + count: data.length, + next: null, + previous: null, + results: data, + }, + }); + } + }); +}; + export const mockedInvitations = async (page: Page, json?: object) => { await page.route('**/invitations/**/', async (route) => { const request = route.request(); @@ -265,36 +318,43 @@ export const mockedInvitations = async (page: Page, json?: object) => { export const mockedAccesses = async (page: Page, json?: object) => { await page.route('**/accesses/**/', async (route) => { const request = route.request(); + console.log('oui'); if ( request.method().includes('GET') && - request.url().includes('accesses') && - request.url().includes('page=') + request.url().includes('accesses') ) { await route.fulfill({ - json: { - count: 1, - next: null, - previous: null, - results: [ - { - id: 'bc8bbbc5-a635-4f65-9817-fd1e9ec8ef87', - user: { - id: 'b4a21bb3-722e-426c-9f78-9d190eda641c', - email: 'test@accesses.test', - }, - team: '', - role: 'reader', - abilities: { - destroy: true, - update: true, - partial_update: true, - retrieve: true, - set_role_to: ['administrator', 'editor'], + json: [ + { + id: 'bc8bbbc5-a635-4f65-9817-fd1e9ec8ef87', + user: { + id: 'b4a21bb3-722e-426c-9f78-9d190eda641c', + email: 'test@accesses.test', + }, + team: '', + max_ancestors_role: null, + max_role: 'reader', + role: 'reader', + document: { + id: 'mocked-document-id', + path: '000000', + depth: 1, + }, + abilities: { + destroy: true, + update: true, + partial_update: true, + retrieve: true, + link_select_options: { + public: ['reader', 'editor'], + authenticated: ['reader', 'editor'], + restricted: null, }, - ...json, + set_role_to: ['administrator', 'editor'], }, - ], - }, + ...json, + }, + ], }); } else { await route.continue(); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-create.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-create.spec.ts index 52aedce08..cf1b90370 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-create.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-create.spec.ts @@ -14,10 +14,10 @@ test.beforeEach(async ({ page }) => { test.describe('Doc Create', () => { test('it creates a doc', async ({ page, browserName }) => { - const [docTitle] = await createDoc(page, 'My new doc', browserName, 1); + const [docTitle] = await createDoc(page, 'my-new-doc', browserName, 1); await page.waitForFunction( - () => document.title.match(/My new doc - Docs/), + () => document.title.match(/my-new-doc - Docs/), { timeout: 5000 }, ); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts index 87d13c802..1b0ae56c8 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -173,6 +173,7 @@ test.describe('Doc Editor', () => { await expect(editor.getByText('Hello World Doc 2')).toBeHidden(); await expect(editor.getByText('Hello World Doc 1')).toBeVisible(); + await page.goto('/'); await page .getByRole('button', { name: 'New doc', diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts new file mode 100644 index 000000000..32c43c2fe --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts @@ -0,0 +1,314 @@ +import { expect, test } from '@playwright/test'; + +import { createDoc, mockedListDocs } from './common'; + +test.describe('Doc grid dnd', () => { + test('it creates a doc', async ({ page, browserName }) => { + await page.goto('/'); + const header = page.locator('header').first(); + await createDoc(page, 'Draggable doc', browserName, 1); + await header.locator('h2').getByText('Docs').click(); + await createDoc(page, 'Droppable doc', browserName, 1); + await header.locator('h2').getByText('Docs').click(); + + const response = await page.waitForResponse( + (response) => + response.url().endsWith('documents/?page=1') && + response.status() === 200, + ); + const responseJson = await response.json(); + + const items = responseJson.results; + + const docsGrid = page.getByTestId('docs-grid'); + await expect(docsGrid).toBeVisible(); + await expect(page.getByTestId('grid-loader')).toBeHidden(); + const draggableElement = page.getByTestId(`draggable-doc-${items[1].id}`); + const dropZone = page.getByTestId(`droppable-doc-${items[0].id}`); + await expect(draggableElement).toBeVisible(); + await expect(dropZone).toBeVisible(); + + // Obtenir les positions des éléments + const draggableBoundingBox = await draggableElement.boundingBox(); + const dropZoneBoundingBox = await dropZone.boundingBox(); + + expect(draggableBoundingBox).toBeDefined(); + expect(dropZoneBoundingBox).toBeDefined(); + + // eslint-disable-next-line playwright/no-conditional-in-test + if (!draggableBoundingBox || !dropZoneBoundingBox) { + throw new Error('Impossible de déterminer la position des éléments'); + } + + await page.mouse.move( + draggableBoundingBox.x + draggableBoundingBox.width / 2, + draggableBoundingBox.y + draggableBoundingBox.height / 2, + ); + await page.mouse.down(); + + // Déplacer vers la zone cible + await page.mouse.move( + dropZoneBoundingBox.x + dropZoneBoundingBox.width / 2, + dropZoneBoundingBox.y + dropZoneBoundingBox.height / 2, + { steps: 10 }, // Make the movement smoother + ); + + const dragOverlay = page.getByTestId('drag-doc-overlay'); + + await expect(dragOverlay).toBeVisible(); + await expect(dragOverlay).toHaveText(items[1].title as string); + await page.mouse.up(); + + await expect(dragOverlay).toBeHidden(); + }); + + test("it checks can't drop when we have not the minimum role", async ({ + page, + }) => { + await mockedListDocs(page, data); + await page.goto('/'); + const docsGrid = page.getByTestId('docs-grid'); + await expect(docsGrid).toBeVisible(); + await expect(page.getByTestId('grid-loader')).toBeHidden(); + + const canDropAndDrag = page.getByTestId('droppable-doc-can-drop-and-drag'); + + const noDropAndNoDrag = page.getByTestId( + 'droppable-doc-no-drop-and-no-drag', + ); + + await expect(canDropAndDrag).toBeVisible(); + + await expect(noDropAndNoDrag).toBeVisible(); + + const canDropAndDragBoundigBox = await canDropAndDrag.boundingBox(); + + const noDropAndNoDragBoundigBox = await noDropAndNoDrag.boundingBox(); + + // eslint-disable-next-line playwright/no-conditional-in-test + if (!canDropAndDragBoundigBox || !noDropAndNoDragBoundigBox) { + throw new Error('Impossible de déterminer la position des éléments'); + } + + await page.mouse.move( + canDropAndDragBoundigBox.x + canDropAndDragBoundigBox.width / 2, + canDropAndDragBoundigBox.y + canDropAndDragBoundigBox.height / 2, + ); + + await page.mouse.down(); + + await page.mouse.move( + noDropAndNoDragBoundigBox.x + noDropAndNoDragBoundigBox.width / 2, + noDropAndNoDragBoundigBox.y + noDropAndNoDragBoundigBox.height / 2, + { steps: 10 }, + ); + + const dragOverlay = page.getByTestId('drag-doc-overlay'); + + await expect(dragOverlay).toBeVisible(); + await expect(dragOverlay).toHaveText( + 'You must be at least the editor of the target document', + ); + + await page.mouse.up(); + }); + + test("it checks can't drag when we have not the minimum role", async ({ + page, + }) => { + await mockedListDocs(page, data); + await page.goto('/'); + const docsGrid = page.getByTestId('docs-grid'); + await expect(docsGrid).toBeVisible(); + await expect(page.getByTestId('grid-loader')).toBeHidden(); + + const canDropAndDrag = page.getByTestId('droppable-doc-can-drop-and-drag'); + + const noDropAndNoDrag = page.getByTestId( + 'droppable-doc-no-drop-and-no-drag', + ); + + await expect(canDropAndDrag).toBeVisible(); + + await expect(noDropAndNoDrag).toBeVisible(); + + const canDropAndDragBoundigBox = await canDropAndDrag.boundingBox(); + + const noDropAndNoDragBoundigBox = await noDropAndNoDrag.boundingBox(); + + // eslint-disable-next-line playwright/no-conditional-in-test + if (!canDropAndDragBoundigBox || !noDropAndNoDragBoundigBox) { + throw new Error('Impossible de déterminer la position des éléments'); + } + + await page.mouse.move( + noDropAndNoDragBoundigBox.x + noDropAndNoDragBoundigBox.width / 2, + noDropAndNoDragBoundigBox.y + noDropAndNoDragBoundigBox.height / 2, + ); + + await page.mouse.down(); + + await page.mouse.move( + canDropAndDragBoundigBox.x + canDropAndDragBoundigBox.width / 2, + canDropAndDragBoundigBox.y + canDropAndDragBoundigBox.height / 2, + { steps: 10 }, + ); + + const dragOverlay = page.getByTestId('drag-doc-overlay'); + + await expect(dragOverlay).toBeVisible(); + await expect(dragOverlay).toHaveText( + 'You must be the owner to move the document', + ); + + await page.mouse.up(); + }); +}); + +const data = [ + { + id: 'can-drop-and-drag', + abilities: { + accesses_manage: true, + accesses_view: true, + ai_transform: true, + ai_translate: true, + attachment_upload: true, + children_list: true, + children_create: true, + collaboration_auth: true, + descendants: true, + destroy: true, + favorite: true, + link_configuration: true, + invite_owner: true, + move: true, + partial_update: true, + restore: true, + retrieve: true, + media_auth: true, + link_select_options: { + restricted: ['reader', 'editor'], + authenticated: ['reader', 'editor'], + public: ['reader', 'editor'], + }, + tree: true, + update: true, + versions_destroy: true, + versions_list: true, + versions_retrieve: true, + }, + created_at: '2025-03-14T14:45:22.527221Z', + creator: 'bc6895e0-8f6d-4b00-827d-c143aa6b2ecb', + depth: 1, + excerpt: null, + is_favorite: false, + link_role: 'reader', + link_reach: 'restricted', + nb_accesses_ancestors: 1, + nb_accesses_direct: 1, + numchild: 5, + path: '000000o', + title: 'Can drop and drag', + updated_at: '2025-03-14T14:45:27.699542Z', + user_roles: ['owner'], + user_role: 'owner', + }, + { + id: 'can-only-drop', + title: 'Can only drop', + abilities: { + accesses_manage: true, + accesses_view: true, + ai_transform: true, + ai_translate: true, + attachment_upload: true, + children_list: true, + children_create: true, + collaboration_auth: true, + descendants: true, + destroy: true, + favorite: true, + link_configuration: true, + invite_owner: true, + move: true, + partial_update: true, + restore: true, + retrieve: true, + media_auth: true, + link_select_options: { + restricted: ['reader', 'editor'], + authenticated: ['reader', 'editor'], + public: ['reader', 'editor'], + }, + tree: true, + update: true, + versions_destroy: true, + versions_list: true, + versions_retrieve: true, + }, + created_at: '2025-03-14T14:45:22.527221Z', + creator: 'bc6895e0-8f6d-4b00-827d-c143aa6b2ecb', + depth: 1, + excerpt: null, + is_favorite: false, + link_role: 'reader', + link_reach: 'restricted', + nb_accesses_ancestors: 1, + nb_accesses_direct: 1, + numchild: 5, + path: '000000o', + + updated_at: '2025-03-14T14:45:27.699542Z', + user_roles: ['editor'], + user_role: 'editor', + }, + { + id: 'no-drop-and-no-drag', + abilities: { + accesses_manage: false, + accesses_view: true, + ai_transform: false, + ai_translate: false, + attachment_upload: false, + children_list: true, + children_create: false, + collaboration_auth: true, + descendants: true, + destroy: false, + favorite: true, + link_configuration: false, + invite_owner: false, + move: false, + partial_update: false, + restore: false, + retrieve: true, + media_auth: true, + link_select_options: { + restricted: ['reader', 'editor'], + authenticated: ['reader', 'editor'], + public: ['reader', 'editor'], + }, + tree: true, + update: false, + versions_destroy: false, + versions_list: true, + versions_retrieve: true, + }, + created_at: '2025-03-14T14:44:16.032773Z', + creator: '9264f420-f018-4bd6-96ae-4788f41af56d', + depth: 1, + excerpt: null, + is_favorite: false, + link_role: 'reader', + link_reach: 'restricted', + nb_accesses_ancestors: 14, + nb_accesses_direct: 14, + numchild: 0, + path: '000000l', + title: 'No drop and no drag', + updated_at: '2025-03-14T14:44:16.032774Z', + user_roles: ['reader'], + user_role: 'reader', + }, +]; diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts index 758c8712c..6a90eaf21 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts @@ -59,6 +59,7 @@ test.describe('Documents Grid mobile', () => { link_reach: 'public', created_at: '2024-10-07T13:02:41.085298Z', updated_at: '2024-10-07T13:30:21.829690Z', + user_roles: ['owner'], }, ], }, @@ -168,6 +169,8 @@ test.describe('Document grid item options', () => { }, link_reach: 'restricted', created_at: '2021-09-01T09:00:00Z', + user_roles: ['editor'], + user_role: 'editor', }, ], }, diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts index be1bfcad1..b6fd33810 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts @@ -54,6 +54,7 @@ test.describe('Doc Header', () => { retrieve: true, }, link_reach: 'public', + computed_link_reach: 'public', created_at: '2021-09-01T09:00:00Z', }); @@ -96,7 +97,9 @@ test.describe('Doc Header', () => { ).toBeVisible(); await expect( - page.getByText(`Are you sure you want to delete this document ?`), + page.getByText( + `Are you sure you want to delete the document "${randomDoc}"?`, + ), ).toBeVisible(); await page @@ -158,7 +161,7 @@ test.describe('Doc Header', () => { await expect(shareModal).toBeVisible(); await expect(page.getByText('Share the document')).toBeVisible(); - await expect(page.getByPlaceholder('Type a name or email')).toBeVisible(); + // await expect(page.getByPlaceholder('Type a name or email')).toBeVisible(); const invitationCard = shareModal.getByLabel('List invitation card'); await expect(invitationCard).toBeVisible(); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-inherited-share.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-inherited-share.spec.ts new file mode 100644 index 000000000..8d48abc58 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-inherited-share.spec.ts @@ -0,0 +1,208 @@ +import { expect, test } from '@playwright/test'; + +import { createDoc } from './common'; +import { + addMemberToDoc, + searchUserToInviteToDoc, + updateShareLink, + verifyLinkReachIsDisabled, + verifyLinkReachIsEnabled, + verifyLinkRoleIsDisabled, + verifyLinkRoleIsEnabled, + verifyMemberAddedToDoc, +} from './share-utils'; +import { createRootSubPage, createSubPageFromParent } from './sub-pages-utils'; + +test.describe('Inherited share accesses', () => { + test('it checks inherited accesses', async ({ page, browserName }) => { + await page.goto('/'); + const [titleParent] = await createDoc(page, 'root-doc', browserName, 1); + const docTree = page.getByTestId('doc-tree'); + + const addButton = page.getByRole('button', { name: 'New page' }); + // Wait for and intercept the POST request to create a new page + const responsePromise = page.waitForResponse( + (response) => + response.url().includes('/documents/') && + response.url().includes('/children/') && + response.request().method() === 'POST', + ); + await addButton.click(); + + const response = await responsePromise; + expect(response.ok()).toBeTruthy(); + const subPageJson = await response.json(); + + await expect(docTree).toBeVisible(); + const subPageItem = docTree + .getByTestId(`doc-sub-page-item-${subPageJson.id}`) + .first(); + + await expect(subPageItem).toBeVisible(); + await subPageItem.click(); + await page.getByRole('button', { name: 'Share' }).click(); + await expect(page.getByText('Inherited share')).toBeVisible(); + await expect(page.getByRole('link', { name: titleParent })).toBeVisible(); + await page.getByRole('button', { name: 'See access' }).click(); + await expect(page.getByText('Access inherited from the')).toBeVisible(); + const user = page.getByTestId( + `doc-share-member-row-user@${browserName}.e2e`, + ); + await expect(user).toBeVisible(); + await expect(user.getByText('E2E Chromium')).toBeVisible(); + await expect(user.getByText('Owner')).toBeVisible(); + }); + + test('it checks that the highest role is displayed', async ({ + page, + browserName, + }) => { + await page.goto('/'); + await createDoc(page, 'root-doc', browserName, 1); + + // Search user to add + let users = await searchUserToInviteToDoc(page); + let userToAdd = users[0]; + + // Add user as Administrator in root doc + await addMemberToDoc(page, 'Administrator', [userToAdd]); + await verifyMemberAddedToDoc(page, userToAdd, 'Administrator'); + await page.getByRole('button', { name: 'OK' }).click(); + + // Create sub page + const { name: subPageName, item: subPageJson } = await createRootSubPage( + page, + browserName, + 'sub-page', + ); + + // Add user as Editor in sub page + users = await searchUserToInviteToDoc(page); + userToAdd = users[0]; + await addMemberToDoc(page, 'Editor', [userToAdd]); + const userRow = await verifyMemberAddedToDoc(page, userToAdd, 'Editor'); + await userRow.getByRole('button', { name: 'doc-role-dropdown' }).click(); + await page.getByText('This user has access').click(); + await userRow.click(); + await page.getByRole('button', { name: 'OK' }).click(); + + // Add new sub page to sub page + await createSubPageFromParent( + page, + browserName, + subPageJson.id, + 'sub-page-2', + ); + + // // Check sub page inherited share + await page.getByRole('button', { name: 'Share' }).click(); + await expect(page.getByText('Inherited share')).toBeVisible(); + await expect(page.getByRole('link', { name: subPageName })).toBeVisible(); + await page.getByRole('button', { name: 'See access' }).click(); + await expect(page.getByText('Access inherited from the')).toBeVisible(); + const user = page.getByTestId(`doc-share-member-row-${userToAdd.email}`); + await expect(user).toBeVisible(); + await expect(user.getByText('Administrator')).toBeVisible(); + }); +}); + +test.describe('Inherited share link', () => { + test('it checks if the link is inherited', async ({ page, browserName }) => { + await page.goto('/'); + // Create root doc + await createDoc(page, 'root-doc', browserName, 1); + + // Update share link + await page.getByRole('button', { name: 'Share' }).click(); + await updateShareLink(page, 'Connected', 'Reading'); + await page.getByRole('button', { name: 'OK' }).click(); + + // Create sub page + await createRootSubPage(page, browserName, 'sub-page'); + + // // verify share link is restricted and reader + await page.getByRole('button', { name: 'Share' }).click(); + await expect(page.getByText('Inherited share')).toBeVisible(); + // await verifyShareLink(page, 'Connected', 'Reading'); + }); + + test('it checks warning message when sharing rules differ', async ({ + page, + browserName, + }) => { + await page.goto('/'); + // Create root doc + await createDoc(page, 'root-doc', browserName, 1); + + // Update share link + await page.getByRole('button', { name: 'Share' }).click(); + await updateShareLink(page, 'Connected', 'Reading'); + await page.getByRole('button', { name: 'OK' }).click(); + + // Create sub page + await createRootSubPage(page, browserName, 'sub-page'); + await page.getByRole('button', { name: 'Share' }).click(); + + // Update share link to public and edition + await updateShareLink(page, 'Public', 'Edition'); + await expect(page.getByText('Sharing rules differ from the')).toBeVisible(); + const restoreButton = page.getByRole('button', { name: 'Restore' }); + await expect(restoreButton).toBeVisible(); + await restoreButton.click(); + await expect( + page.getByText('The document visibility has been updated').first(), + ).toBeVisible(); + await expect(page.getByText('Sharing rules differ from the')).toBeHidden(); + }); + + test('it checks inherited link possibilities', async ({ + page, + browserName, + }) => { + await page.goto('/'); + // Create root doc + await createDoc(page, 'root-doc', browserName, 1); + + // Update share link + await page.getByRole('button', { name: 'Share' }).click(); + await updateShareLink(page, 'Connected', 'Reading'); + await page.getByRole('button', { name: 'OK' }).click(); + await expect( + page.getByText('Document accessible to any connected person'), + ).toBeVisible(); + + // Create sub page + const { item: subPageItem } = await createRootSubPage( + page, + browserName, + 'sub-page', + ); + await expect( + page.getByText('Document accessible to any connected person'), + ).toBeVisible(); + + // Update share link to public and edition + await page.getByRole('button', { name: 'Share' }).click(); + await verifyLinkReachIsDisabled(page, 'Private'); + await updateShareLink(page, 'Public', 'Edition'); + await page.getByRole('button', { name: 'OK' }).click(); + await expect(page.getByText('Public document')).toBeVisible(); + + // Create sub page + await createSubPageFromParent( + page, + browserName, + subPageItem.id, + 'sub-page-2', + ); + await expect(page.getByText('Public document')).toBeVisible(); + + // Verify share link and role + await page.getByRole('button', { name: 'Share' }).click(); + await verifyLinkReachIsDisabled(page, 'Private'); + await verifyLinkReachIsDisabled(page, 'Connected'); + await verifyLinkReachIsEnabled(page, 'Public'); + await verifyLinkRoleIsDisabled(page, 'Reading'); + await verifyLinkRoleIsEnabled(page, 'Edition'); + }); +}); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-member-list.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-member-list.spec.ts index 45f68b78c..3c2ed6b65 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-member-list.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-member-list.spec.ts @@ -8,47 +8,59 @@ test.beforeEach(async ({ page }) => { test.describe('Document list members', () => { test('it checks a big list of members', async ({ page }) => { - await page.route( - /.*\/documents\/.*\/accesses\/\?page=.*/, - async (route) => { - const request = route.request(); - const url = new URL(request.url()); - const pageId = url.searchParams.get('page') ?? '1'; - - const accesses = { - count: 40, - next: +pageId < 2 ? 'http://anything/?page=2' : undefined, - previous: null, - results: Array.from({ length: 20 }, (_, i) => ({ - id: `2ff1ec07-86c1-4534-a643-f41824a6c53a-${pageId}-${i}`, - user: { - id: `fc092149-cafa-4ffa-a29d-e4b18af751-${pageId}-${i}`, - email: `impress@impress.world-page-${pageId}-${i}`, - full_name: `Impress World Page ${pageId}-${i}`, - }, - team: '', - role: 'editor', - abilities: { - destroy: false, - partial_update: true, - set_role_to: [], - }, - })), - }; - - if (request.method().includes('GET')) { - await route.fulfill({ - json: accesses, - }); - } else { - await route.continue(); - } - }, - ); - const docTitle = await goToGridDoc(page); await verifyDocName(page, docTitle); + // Get the current URL and extract the last part + const currentUrl = page.url(); + console.log('Current URL:', currentUrl); + const currentDocId = (() => { + // Remove trailing slash if present + const cleanUrl = currentUrl.endsWith('/') + ? currentUrl.slice(0, -1) + : currentUrl; + + // Split by '/' and get the last part + return cleanUrl.split('/').pop() || ''; + })(); + + await page.route('**/documents/**/accesses/', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const pageId = url.searchParams.get('page') ?? '1'; + + const accesses = Array.from({ length: 20 }, (_, i) => ({ + id: `2ff1ec07-86c1-4534-a643-f41824a6c53a-${pageId}-${i}`, + document: { + id: currentDocId, + name: `Doc ${pageId}-${i}`, + path: `0000.${pageId}-${i}`, + }, + user: { + id: `fc092149-cafa-4ffa-a29d-e4b18af751-${pageId}-${i}`, + email: `impress@impress.world-page-${pageId}-${i}`, + full_name: `Impress World Page ${pageId}-${i}`, + }, + team: '', + role: 'editor', + max_ancestors_role: null, + max_role: 'editor', + abilities: { + destroy: false, + partial_update: true, + set_role_to: ['administrator', 'editor'], + }, + })); + + if (request.method().includes('GET')) { + await route.fulfill({ + json: accesses, + }); + } else { + await route.continue(); + } + }); + await page.getByRole('button', { name: 'Share' }).click(); const prefix = 'doc-share-member-row'; @@ -56,11 +68,6 @@ test.describe('Document list members', () => { const loadMore = page.getByTestId('load-more-members'); await expect(elements).toHaveCount(20); - await expect(page.getByText(`Impress World Page 1-16`)).toBeVisible(); - - await loadMore.click(); - await expect(elements).toHaveCount(40); - await expect(page.getByText(`Impress World Page 2-15`)).toBeVisible(); await expect(loadMore).toBeHidden(); }); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-routing.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-routing.spec.ts index 9aebb4084..84c861763 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-routing.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-routing.spec.ts @@ -60,7 +60,7 @@ test.describe('Doc Routing', () => { }); test('checks 401 on docs/[id] page', async ({ page, browserName }) => { - const [docTitle] = await createDoc(page, 'My new doc', browserName, 1); + const [docTitle] = await createDoc(page, '401-doc', browserName, 1); await verifyDocName(page, docTitle); const responsePromise = page.route( diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts index 27088c931..9bdbe01ec 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test'; -import { createDoc, verifyDocName } from './common'; +import { createDoc, randomName, verifyDocName } from './common'; test.beforeEach(async ({ page }) => { await page.goto('/'); @@ -25,7 +25,10 @@ test.describe('Document search', () => { ); await verifyDocName(page, doc2Title); await page.goto('/'); - await page.getByRole('button', { name: 'search' }).click(); + await page + .getByTestId('left-panel-desktop') + .getByRole('button', { name: 'search' }) + .click(); await expect( page.getByRole('img', { name: 'No active search' }), @@ -94,4 +97,85 @@ test.describe('Document search', () => { page.getByLabel('Search modal').getByText('search'), ).toBeHidden(); }); + + test("it checks we don't see filters in search modal", async ({ page }) => { + const searchButton = page + .getByTestId('left-panel-desktop') + .getByRole('button', { name: 'search' }); + + await expect(searchButton).toBeVisible(); + await page.getByRole('button', { name: 'search', exact: true }).click(); + await expect( + page.getByRole('combobox', { name: 'Quick search input' }), + ).toBeVisible(); + await expect(page.getByTestId('doc-search-filters')).toBeHidden(); + }); +}); + +test.describe('Sub page search', () => { + test('it check the presence of filters in search modal', async ({ + page, + browserName, + }) => { + await page.goto('/'); + const [doc1Title] = await createDoc( + page, + 'My sub page search', + browserName, + 1, + ); + await verifyDocName(page, doc1Title); + const searchButton = page + .getByTestId('left-panel-desktop') + .getByRole('button', { name: 'search' }); + await searchButton.click(); + const filters = page.getByTestId('doc-search-filters'); + await expect(filters).toBeVisible(); + await filters.click(); + await filters.getByRole('button', { name: 'Current doc' }).click(); + await expect( + page.getByRole('menuitem', { name: 'All docs' }), + ).toBeVisible(); + await expect( + page.getByRole('menuitem', { name: 'Current doc' }), + ).toBeVisible(); + await page.getByRole('menuitem', { name: 'Current doc' }).click(); + + await expect(page.getByRole('button', { name: 'Reset' })).toBeVisible(); + }); + + test('it searches sub pages', async ({ page, browserName }) => { + await page.goto('/'); + + const [doc1Title] = await createDoc( + page, + 'My sub page search', + browserName, + 1, + ); + await verifyDocName(page, doc1Title); + await page.getByRole('button', { name: 'New page' }).click(); + await verifyDocName(page, ''); + await page.getByRole('textbox', { name: 'doc title input' }).click(); + await page + .getByRole('textbox', { name: 'doc title input' }) + .press('ControlOrMeta+a'); + const [randomDocName] = randomName('doc-sub-page', browserName, 1); + await page + .getByRole('textbox', { name: 'doc title input' }) + .fill(randomDocName); + const searchButton = page + .getByTestId('left-panel-desktop') + .getByRole('button', { name: 'search' }); + + await searchButton.click(); + await expect( + page.getByRole('button', { name: 'Current doc' }), + ).toBeVisible(); + await page.getByRole('combobox', { name: 'Quick search input' }).click(); + await page + .getByRole('combobox', { name: 'Quick search input' }) + .fill('sub'); + await expect(page.getByLabel(randomDocName)).toBeVisible(); + }); }); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts new file mode 100644 index 000000000..194427d53 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts @@ -0,0 +1,319 @@ +/* eslint-disable playwright/no-conditional-in-test */ +import { expect, test } from '@playwright/test'; + +import { + createDoc, + expectLoginPage, + keyCloakSignIn, + randomName, + verifyDocName, +} from './common'; + +test.describe('Doc Tree', () => { + test('create new sub pages', async ({ page, browserName }) => { + await page.goto('/'); + const [titleParent] = await createDoc( + page, + 'doc-tree-content', + browserName, + 1, + ); + await verifyDocName(page, titleParent); + const addButton = page.getByRole('button', { name: 'New page' }); + const docTree = page.getByTestId('doc-tree'); + + await expect(addButton).toBeVisible(); + + // Wait for and intercept the POST request to create a new page + const responsePromise = page.waitForResponse( + (response) => + response.url().includes('/documents/') && + response.url().includes('/children/') && + response.request().method() === 'POST', + ); + + await addButton.click(); + const response = await responsePromise; + expect(response.ok()).toBeTruthy(); + const subPageJson = await response.json(); + + await expect(docTree).toBeVisible(); + const subPageItem = docTree + .getByTestId(`doc-sub-page-item-${subPageJson.id}`) + .first(); + + await expect(subPageItem).toBeVisible(); + await subPageItem.click(); + await verifyDocName(page, ''); + const input = page.getByRole('textbox', { name: 'doc title input' }); + await input.click(); + const [randomDocName] = randomName('doc-tree-test', browserName, 1); + await input.fill(randomDocName); + await input.press('Enter'); + await expect(subPageItem.getByText(randomDocName)).toBeVisible(); + await page.reload(); + await expect(subPageItem.getByText(randomDocName)).toBeVisible(); + }); + + test('check the reorder of sub pages', async ({ page, browserName }) => { + await page.goto('/'); + await createDoc(page, 'doc-tree-content', browserName, 1); + const addButton = page.getByRole('button', { name: 'New page' }); + await expect(addButton).toBeVisible(); + + const docTree = page.getByTestId('doc-tree'); + + // Create first sub page + const firstResponsePromise = page.waitForResponse( + (response) => + response.url().includes('/documents/') && + response.url().includes('/children/') && + response.request().method() === 'POST', + ); + + await addButton.click(); + const firstResponse = await firstResponsePromise; + expect(firstResponse.ok()).toBeTruthy(); + + const secondResponsePromise = page.waitForResponse( + (response) => + response.url().includes('/documents/') && + response.url().includes('/children/') && + response.request().method() === 'POST', + ); + + // Create second sub page + await addButton.click(); + const secondResponse = await secondResponsePromise; + expect(secondResponse.ok()).toBeTruthy(); + + const secondSubPageJson = await secondResponse.json(); + const firstSubPageJson = await firstResponse.json(); + + const firstSubPageItem = docTree + .getByTestId(`doc-sub-page-item-${firstSubPageJson.id}`) + .first(); + + const secondSubPageItem = docTree + .getByTestId(`doc-sub-page-item-${secondSubPageJson.id}`) + .first(); + + // check that the sub pages are visible in the tree + await expect(firstSubPageItem).toBeVisible(); + await expect(secondSubPageItem).toBeVisible(); + + // get the bounding boxes of the sub pages + const firstSubPageBoundingBox = await firstSubPageItem.boundingBox(); + const secondSubPageBoundingBox = await secondSubPageItem.boundingBox(); + + expect(firstSubPageBoundingBox).toBeDefined(); + expect(secondSubPageBoundingBox).toBeDefined(); + + if (!firstSubPageBoundingBox || !secondSubPageBoundingBox) { + throw new Error('Impossible de déterminer la position des éléments'); + } + + // move the first sub page to the second position + await page.mouse.move( + firstSubPageBoundingBox.x + firstSubPageBoundingBox.width / 2, + firstSubPageBoundingBox.y + firstSubPageBoundingBox.height / 2, + ); + + await page.mouse.down(); + + await page.mouse.move( + secondSubPageBoundingBox.x + secondSubPageBoundingBox.width / 2, + secondSubPageBoundingBox.y + secondSubPageBoundingBox.height + 4, + { steps: 10 }, + ); + + await page.mouse.up(); + + // check that the sub pages are visible in the tree + await expect(firstSubPageItem).toBeVisible(); + await expect(secondSubPageItem).toBeVisible(); + + // reload the page + await page.reload(); + + // check that the sub pages are visible in the tree + await expect(firstSubPageItem).toBeVisible(); + await expect(secondSubPageItem).toBeVisible(); + + // Check the position of the sub pages + const allSubPageItems = await docTree + .getByTestId(/^doc-sub-page-item/) + .all(); + + expect(allSubPageItems.length).toBe(2); + + // Check that the first element has the ID of the second sub page after the drag and drop + await expect(allSubPageItems[0]).toHaveAttribute( + 'data-testid', + `doc-sub-page-item-${secondSubPageJson.id}`, + ); + + // Check that the second element has the ID of the first sub page after the drag and drop + await expect(allSubPageItems[1]).toHaveAttribute( + 'data-testid', + `doc-sub-page-item-${firstSubPageJson.id}`, + ); + }); + + test('it detaches a document', async ({ page, browserName }) => { + await page.goto('/'); + const [docParent] = await createDoc( + page, + 'doc-tree-detach', + browserName, + 1, + ); + await verifyDocName(page, docParent); + + const [docChild] = await createDoc( + page, + 'doc-tree-detach-child', + browserName, + 1, + true, + ); + await verifyDocName(page, docChild); + + const docTree = page.getByTestId('doc-tree'); + const child = docTree + .getByRole('treeitem') + .locator('.--docs-sub-page-item') + .filter({ + hasText: docChild, + }); + await child.hover(); + const menu = child.getByText(`more_horiz`); + await menu.click(); + await page.getByText('Convert to doc').click(); + + await expect( + page.getByRole('textbox', { name: 'doc title input' }), + ).not.toHaveText(docChild); + + const header = page.locator('header').first(); + await header.locator('h2').getByText('Docs').click(); + await expect(page.getByText(docChild)).toBeVisible(); + }); +}); + +test.describe('Doc Tree: Inheritance', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('A child inherit from the parent', async ({ page, browserName }) => { + await page.goto('/'); + await keyCloakSignIn(page, browserName); + + const [docParent] = await createDoc( + page, + 'doc-tree-inheritance-parent', + browserName, + 1, + ); + await verifyDocName(page, docParent); + + await page.getByRole('button', { name: 'Share' }).click(); + const selectVisibility = page.getByLabel('Visibility', { exact: true }); + await selectVisibility.click(); + + await page + .getByRole('menuitem', { + name: 'Public', + }) + .click(); + + await expect( + page.getByText('The document visibility has been updated.'), + ).toBeVisible(); + + await page.getByRole('button', { name: 'close' }).click(); + + const [docChild] = await createDoc( + page, + 'doc-tree-inheritance-child', + browserName, + 1, + true, + ); + await verifyDocName(page, docChild); + + const urlDoc = page.url(); + + await page + .getByRole('button', { + name: 'Logout', + }) + .click(); + + await expectLoginPage(page); + + await page.goto(urlDoc); + + await expect(page.locator('h2').getByText(docChild)).toBeVisible(); + + const docTree = page.getByTestId('doc-tree'); + await expect(docTree.getByText(docParent)).toBeVisible(); + }); + + test('Do not show private parent from children', async ({ + page, + browserName, + }) => { + await page.goto('/'); + await keyCloakSignIn(page, browserName); + + const [docParent] = await createDoc( + page, + 'doc-tree-inheritance-private-parent', + browserName, + 1, + ); + await verifyDocName(page, docParent); + + const [docChild] = await createDoc( + page, + 'doc-tree-inheritance-private-child', + browserName, + 1, + true, + ); + await verifyDocName(page, docChild); + + await page.getByRole('button', { name: 'Share' }).click(); + const selectVisibility = page.getByLabel('Visibility', { exact: true }); + await selectVisibility.click(); + + await page + .getByRole('menuitem', { + name: 'Public', + }) + .click(); + + await expect( + page.getByText('The document visibility has been updated.'), + ).toBeVisible(); + + await page.getByRole('button', { name: 'close' }).click(); + + const urlDoc = page.url(); + + await page + .getByRole('button', { + name: 'Logout', + }) + .click(); + + await expectLoginPage(page); + + await page.goto(urlDoc); + + await expect(page.locator('h2').getByText(docChild)).toBeVisible(); + + const docTree = page.getByTestId('doc-tree'); + await expect(docTree.getByText(docParent)).toBeHidden(); + }); +}); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts index a28200b08..d27138cad 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts @@ -241,7 +241,7 @@ test.describe('Doc Visibility: Public', () => { ).toBeVisible(); await expect(page.getByRole('button', { name: 'search' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'New doc' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'New page' })).toBeVisible(); const urlDoc = page.url(); @@ -257,7 +257,7 @@ test.describe('Doc Visibility: Public', () => { await expect(page.locator('h2').getByText(docTitle)).toBeVisible(); await expect(page.getByRole('button', { name: 'search' })).toBeHidden(); - await expect(page.getByRole('button', { name: 'New doc' })).toBeHidden(); + await expect(page.getByRole('button', { name: 'New page' })).toBeHidden(); await expect(page.getByRole('button', { name: 'Share' })).toBeVisible(); const card = page.getByLabel('It is the card information'); await expect(card).toBeVisible(); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/share-utils.ts b/src/frontend/apps/e2e/__tests__/app-impress/share-utils.ts new file mode 100644 index 000000000..91062731b --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/share-utils.ts @@ -0,0 +1,158 @@ +import { Locator, Page, expect } from '@playwright/test'; + +export type UserSearchResult = { + email: string; + full_name?: string | null; +}; + +export type Role = 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader'; +export type LinkReach = 'Private' | 'Connected' | 'Public'; +export type LinkRole = 'Reading' | 'Edition'; + +export const searchUserToInviteToDoc = async ( + page: Page, + inputFill?: string, +): Promise => { + const inputFillValue = inputFill ?? 'user '; + + const responsePromise = page.waitForResponse( + (response) => + response + .url() + .includes(`/users/?q=${encodeURIComponent(inputFillValue)}`) && + response.status() === 200, + ); + + await page.getByRole('button', { name: 'Share' }).click(); + const inputSearch = page.getByRole('combobox', { + name: 'Quick search input', + }); + await expect(inputSearch).toBeVisible(); + await inputSearch.fill(inputFillValue); + const response = await responsePromise; + const users = (await response.json()) as UserSearchResult[]; + return users; +}; + +export const addMemberToDoc = async ( + page: Page, + role: Role, + users: UserSearchResult[], +) => { + const list = page.getByTestId('doc-share-add-member-list'); + await expect(list).toBeHidden(); + const quickSearchContent = page.getByTestId('doc-share-quick-search'); + for (const user of users) { + await quickSearchContent + .getByTestId(`search-user-row-${user.email}`) + .click(); + } + + await list.getByLabel('doc-role-dropdown').click(); + await expect(page.getByLabel(role)).toBeVisible(); + await page.getByLabel(role).click(); + await page.getByRole('button', { name: 'Invite' }).click(); +}; + +export const verifyMemberAddedToDoc = async ( + page: Page, + user: UserSearchResult, + role: Role, +): Promise => { + const container = page.getByLabel('List members card'); + await expect(container).toBeVisible(); + const userRow = container.getByTestId(`doc-share-member-row-${user.email}`); + await expect(userRow).toBeVisible(); + await expect(userRow.getByText(role)).toBeVisible(); + await expect(userRow.getByText(user.full_name || user.email)).toBeVisible(); + return userRow; +}; + +export const updateShareLink = async ( + page: Page, + linkReach: LinkReach, + linkRole?: LinkRole | null, +) => { + await page.getByRole('button', { name: 'Visibility', exact: true }).click(); + await page.getByRole('menuitem', { name: linkReach }).click(); + + const visibilityUpdatedText = page + .getByText('The document visibility has been updated') + .first(); + + await expect(visibilityUpdatedText).toBeVisible(); + + if (linkRole) { + await page + .getByRole('button', { name: 'Visibility mode', exact: true }) + .click(); + await page.getByRole('menuitem', { name: linkRole }).click(); + await expect(visibilityUpdatedText).toBeVisible(); + } +}; + +export const verifyLinkReachIsDisabled = async ( + page: Page, + linkReach: LinkReach, +) => { + await page.getByRole('button', { name: 'Visibility', exact: true }).click(); + const item = page.getByRole('menuitem', { name: linkReach }); + await expect(item).toBeDisabled(); + await page.click('body'); +}; + +export const verifyLinkReachIsEnabled = async ( + page: Page, + linkReach: LinkReach, +) => { + await page.getByRole('button', { name: 'Visibility', exact: true }).click(); + const item = page.getByRole('menuitem', { name: linkReach }); + await expect(item).toBeEnabled(); + await page.click('body'); +}; + +export const verifyLinkRoleIsDisabled = async ( + page: Page, + linkRole: LinkRole, +) => { + await page + .getByRole('button', { name: 'Visibility mode', exact: true }) + .click(); + const item = page.getByRole('menuitem', { name: linkRole }); + await expect(item).toBeDisabled(); + await page.click('body'); +}; + +export const verifyLinkRoleIsEnabled = async ( + page: Page, + linkRole: LinkRole, +) => { + await page + .getByRole('button', { name: 'Visibility mode', exact: true }) + .click(); + const item = page.getByRole('menuitem', { name: linkRole }); + await expect(item).toBeEnabled(); + await page.click('body'); +}; + +export const verifyShareLink = async ( + page: Page, + linkReach: LinkReach, + linkRole?: LinkRole | null, +) => { + const visibilityDropdownButton = page.getByRole('button', { + name: 'Visibility', + exact: true, + }); + await expect(visibilityDropdownButton).toBeVisible(); + await expect(visibilityDropdownButton.getByText(linkReach)).toBeVisible(); + + if (linkRole) { + const visibilityModeButton = page.getByRole('button', { + name: 'Visibility mode', + exact: true, + }); + await expect(visibilityModeButton).toBeVisible(); + await expect(page.getByText(linkRole)).toBeVisible(); + } +}; diff --git a/src/frontend/apps/e2e/__tests__/app-impress/sub-pages-utils.ts b/src/frontend/apps/e2e/__tests__/app-impress/sub-pages-utils.ts new file mode 100644 index 000000000..ca0a78b60 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/sub-pages-utils.ts @@ -0,0 +1,76 @@ +import { Page, expect } from '@playwright/test'; + +import { getWaitForCreateDoc, randomName, updateDocTitle } from './common'; + +export const createRootSubPage = async ( + page: Page, + browserName: string, + docName: string, +) => { + // Get add button + const addButton = page.getByRole('button', { name: 'New page' }); + + // Get response + const responsePromise = getWaitForCreateDoc(page); + await addButton.click(); + const response = await responsePromise; + expect(response.ok()).toBeTruthy(); + const subPageJson = (await response.json()) as { id: string }; + + // Get doc tree + const docTree = page.getByTestId('doc-tree'); + await expect(docTree).toBeVisible(); + + // Get sub page item + const subPageItem = docTree + .getByTestId(`doc-sub-page-item-${subPageJson.id}`) + .first(); + await expect(subPageItem).toBeVisible(); + await subPageItem.click(); + + // Update sub page name + const randomDocs = randomName(docName, browserName, 1); + await updateDocTitle(page, randomDocs[0]); + + // Return sub page data + return { name: randomDocs[0], docTreeItem: subPageItem, item: subPageJson }; +}; + +export const createSubPageFromParent = async ( + page: Page, + browserName: string, + parentId: string, + subPageName: string, +) => { + // Get parent doc tree item + const parentDocTreeItem = page.getByTestId(`doc-sub-page-item-${parentId}`); + await expect(parentDocTreeItem).toBeVisible(); + await parentDocTreeItem.hover(); + + // Create sub page + const responsePromise = getWaitForCreateDoc(page); + await parentDocTreeItem.getByRole('button', { name: 'add_box' }).click(); + + // Get response + const response = await responsePromise; + expect(response.ok()).toBeTruthy(); + const subPageJson = (await response.json()) as { id: string }; + + // Get doc tree + const docTree = page.getByTestId('doc-tree'); + await expect(docTree).toBeVisible(); + + // Get sub page item + const subPageItem = docTree + .getByTestId(`doc-sub-page-item-${subPageJson.id}`) + .first(); + await expect(subPageItem).toBeVisible(); + await subPageItem.click(); + + // Update sub page name + const subPageTitle = randomName(subPageName, browserName, 1)[0]; + await updateDocTitle(page, subPageTitle); + + // Return sub page data + return { name: subPageTitle, docTreeItem: subPageItem, item: subPageJson }; +}; diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index dc1d0c59e..799961318 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -22,6 +22,8 @@ "@blocknote/react": "0.30.0", "@blocknote/xl-docx-exporter": "0.30.0", "@blocknote/xl-pdf-exporter": "0.30.0", + "@dnd-kit/core": "6.3.1", + "@dnd-kit/modifiers": "9.0.0", "@emoji-mart/data": "1.2.1", "@emoji-mart/react": "1.1.1", "@fontsource/material-icons": "5.2.5", diff --git a/src/frontend/apps/impress/src/api/helpers.tsx b/src/frontend/apps/impress/src/api/helpers.tsx index e36b9d41b..cbc4d0b3c 100644 --- a/src/frontend/apps/impress/src/api/helpers.tsx +++ b/src/frontend/apps/impress/src/api/helpers.tsx @@ -21,6 +21,11 @@ export type DefinedInitialDataInfiniteOptionsAPI< TPageParam >; +export type InfiniteQueryConfig = Omit< + DefinedInitialDataInfiniteOptionsAPI, + 'queryKey' | 'initialData' | 'getNextPageParam' | 'initialPageParam' +>; + /** * Custom React hook that wraps React Query's `useInfiniteQuery` for paginated API requests. * @@ -38,7 +43,7 @@ export const useAPIInfiniteQuery = ['next'] }>( key: string, api: (props: T & { page: number }) => Promise, param: T, - queryConfig?: DefinedInitialDataInfiniteOptionsAPI, + queryConfig?: InfiniteQueryConfig, ) => { return useInfiniteQuery, QueryKey, number>({ initialPageParam: 1, diff --git a/src/frontend/apps/impress/src/components/DropdownMenu.tsx b/src/frontend/apps/impress/src/components/DropdownMenu.tsx index 8758588ed..d2a722316 100644 --- a/src/frontend/apps/impress/src/components/DropdownMenu.tsx +++ b/src/frontend/apps/impress/src/components/DropdownMenu.tsx @@ -8,10 +8,12 @@ export type DropdownMenuOption = { icon?: string; label: string; testId?: string; + value?: string; callback?: () => void | Promise; danger?: boolean; isSelected?: boolean; disabled?: boolean; + padding?: BoxProps['$padding']; show?: boolean; }; @@ -23,6 +25,8 @@ export type DropdownMenuProps = { buttonCss?: BoxProps['$css']; disabled?: boolean; topMessage?: string; + selectedValues?: string[]; + afterOpenChange?: (isOpen: boolean) => void; }; export const DropdownMenu = ({ @@ -34,6 +38,8 @@ export const DropdownMenu = ({ buttonCss, label, topMessage, + afterOpenChange, + selectedValues, }: PropsWithChildren) => { const { spacingsTokens, colorsTokens } = useCunninghamTheme(); const [isOpen, setIsOpen] = useState(false); @@ -41,6 +47,7 @@ export const DropdownMenu = ({ const onOpenChange = (isOpen: boolean) => { setIsOpen(isOpen); + afterOpenChange?.(isOpen); }; if (disabled) { @@ -93,6 +100,9 @@ export const DropdownMenu = ({ $size="xs" $weight="bold" $padding={{ vertical: 'xs', horizontal: 'base' }} + $css={css` + white-space: pre-line; + `} > {topMessage} @@ -120,7 +130,9 @@ export const DropdownMenu = ({ $justify="space-between" $background={colorsTokens['greyscale-000']} $color={colorsTokens['primary-600']} - $padding={{ vertical: 'xs', horizontal: 'base' }} + $padding={ + option.padding ?? { vertical: 'xs', horizontal: 'base' } + } $width="100%" $gap={spacingsTokens['base']} $css={css` @@ -163,7 +175,8 @@ export const DropdownMenu = ({ {option.label} - {option.isSelected && ( + {(option.isSelected || + selectedValues?.includes(option.value ?? '')) && ( )} diff --git a/src/frontend/apps/impress/src/components/filter/FilterDropdown.tsx b/src/frontend/apps/impress/src/components/filter/FilterDropdown.tsx new file mode 100644 index 000000000..313209bf4 --- /dev/null +++ b/src/frontend/apps/impress/src/components/filter/FilterDropdown.tsx @@ -0,0 +1,63 @@ +import { css } from 'styled-components'; + +import { Box } from '../Box'; +import { DropdownMenu, DropdownMenuOption } from '../DropdownMenu'; +import { Icon } from '../Icon'; +import { Text } from '../Text'; + +export type FilterDropdownProps = { + options: DropdownMenuOption[]; + selectedValue?: string; +}; + +export const FilterDropdown = ({ + options, + selectedValue, +}: FilterDropdownProps) => { + const selectedOption = options.find( + (option) => option.value === selectedValue, + ); + + if (options.length === 0) { + return null; + } + + return ( + + + + {selectedOption?.label ?? options[0].label} + + + + + ); +}; diff --git a/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx b/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx index 9ab52f53b..e78f6a564 100644 --- a/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx +++ b/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx @@ -56,6 +56,9 @@ export const QuickSearchInput = ({ /* eslint-disable-next-line jsx-a11y/no-autofocus */ autoFocus={true} aria-label={t('Quick search input')} + onClick={(e) => { + e.stopPropagation(); + }} value={inputValue} role="combobox" placeholder={placeholder ?? t('Search')} diff --git a/src/frontend/apps/impress/src/components/quick-search/QuickSearchStyle.tsx b/src/frontend/apps/impress/src/components/quick-search/QuickSearchStyle.tsx index b6fa0ad6a..99bbc57fa 100644 --- a/src/frontend/apps/impress/src/components/quick-search/QuickSearchStyle.tsx +++ b/src/frontend/apps/impress/src/components/quick-search/QuickSearchStyle.tsx @@ -65,9 +65,7 @@ export const QuickSearchStyle = createGlobalStyle` [cmdk-list] { - padding: 0 var(--c--theme--spacings--base) var(--c--theme--spacings--base) - var(--c--theme--spacings--base); - + flex:1; overflow-y: auto; overscroll-behavior: contain; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/ModalConfirmDownloadUnsafe.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/ModalConfirmDownloadUnsafe.tsx index e38c8639c..3929175cb 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/ModalConfirmDownloadUnsafe.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/ModalConfirmDownloadUnsafe.tsx @@ -32,7 +32,6 @@ export const ModalConfirmDownloadUnsafe = ({ aria-label={t('Download')} color="danger" onClick={() => { - console.log('onClick'); if (onConfirm) { void onConfirm(); } diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx index 6f07096e6..5cf1db3b9 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx @@ -26,9 +26,7 @@ interface DocEditorProps { export const DocEditor = ({ doc, versionId }: DocEditorProps) => { const { isDesktop } = useResponsiveStore(); const isVersion = !!versionId && typeof versionId === 'string'; - const { colorsTokens } = useCunninghamTheme(); - const { provider } = useProviderStore(); if (!provider) { @@ -58,11 +56,7 @@ export const DocEditor = ({ doc, versionId }: DocEditorProps) => { $padding={{ horizontal: isDesktop ? '54px' : 'base' }} className="--docs--doc-editor-header" > - {isVersion ? ( - - ) : ( - - )} + {isVersion ? : } { const [format, setFormat] = useState( DocDownloadFormat.PDF, ); - const { untitledDocument } = useTrans(); - + const { untitledDocument } = useTrans(doc); const templateOptions = useMemo(() => { const templateOptions = (templates?.pages || []) .map((page) => diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx index cae723092..bd152eb7c 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx @@ -8,6 +8,7 @@ import { LinkReach, Role, currentDocRole, + getDocLinkReach, useIsCollaborativeEditable, useTrans, } from '@/docs/doc-management'; @@ -28,8 +29,8 @@ export const DocHeader = ({ doc }: DocHeaderProps) => { const { t } = useTranslation(); const { transRole } = useTrans(); const { isEditable } = useIsCollaborativeEditable(doc); - const docIsPublic = doc.link_reach === LinkReach.PUBLIC; - const docIsAuth = doc.link_reach === LinkReach.AUTHENTICATED; + const docIsPublic = getDocLinkReach(doc) === LinkReach.PUBLIC; + const docIsAuth = getDocLinkReach(doc) === LinkReach.AUTHENTICATED; return ( <> diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx index b838ef440..b9440e6e9 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx @@ -1,10 +1,7 @@ /* eslint-disable jsx-a11y/click-events-have-key-events */ /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ -import { - Tooltip, - VariantType, - useToastProvider, -} from '@openfun/cunningham-react'; +import { Tooltip } from '@openfun/cunningham-react'; +import { useQueryClient } from '@tanstack/react-query'; import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -15,6 +12,8 @@ import { Doc, KEY_DOC, KEY_LIST_DOC, + KEY_SUB_PAGE, + useDocStore, useTrans, useUpdateDoc, } from '@/docs/doc-management'; @@ -26,19 +25,16 @@ interface DocTitleProps { export const DocTitle = ({ doc }: DocTitleProps) => { if (!doc.abilities.partial_update) { - return ; + return ; } return ; }; -interface DocTitleTextProps { - title?: string; -} - -export const DocTitleText = ({ title }: DocTitleTextProps) => { +export const DocTitleText = () => { const { isMobile } = useResponsiveStore(); - const { untitledDocument } = useTrans(); + const { currentDoc } = useDocStore(); + const { untitledDocument } = useTrans(currentDoc); return ( { $size={isMobile ? 'h4' : 'h2'} $variation="1000" > - {title || untitledDocument} + {currentDoc?.title || untitledDocument} ); }; const DocTitleInput = ({ doc }: DocTitleProps) => { const { isDesktop } = useResponsiveStore(); + const queryClient = useQueryClient(); const { t } = useTranslation(); const { colorsTokens } = useCunninghamTheme(); const [titleDisplay, setTitleDisplay] = useState(doc.title); - const { toast } = useToastProvider(); - const { untitledDocument } = useTrans(); + + const { untitledDocument } = useTrans(doc); const { broadcast } = useBroadcastStore(); const { mutate: updateDoc } = useUpdateDoc({ listInvalideQueries: [KEY_DOC, KEY_LIST_DOC], - onSuccess(data) { - toast(t('Document title updated successfully'), VariantType.SUCCESS); - + onSuccess(updatedDoc) { // Broadcast to every user connected to the document - broadcast(`${KEY_DOC}-${data.id}`); + broadcast(`${KEY_DOC}-${updatedDoc.id}`); + queryClient.setQueryData( + [KEY_SUB_PAGE, { id: updatedDoc.id }], + updatedDoc, + ); }, }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx index 3cdadfabb..3ff83d179 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx @@ -1,7 +1,8 @@ +import { useTreeContext } from '@gouvfr-lasuite/ui-kit'; import { Button, useModal } from '@openfun/cunningham-react'; import { useQueryClient } from '@tanstack/react-query'; import dynamic from 'next/dynamic'; -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -23,7 +24,20 @@ const DocToolBoxLicence = dynamic(() => export const DocToolBox = ({ doc }: DocToolBoxProps) => { const { t } = useTranslation(); - const hasAccesses = doc.nb_accesses_direct > 1 && doc.abilities.accesses_view; + const treeContext = useTreeContext(); + + /** + * Following the change where there is no default owner when adding a sub-page, + * we need to handle both the case where the doc is the root and the case of sub-pages. + */ + const hasAccesses = useMemo(() => { + if (treeContext?.root?.id === doc.id) { + return doc.nb_accesses_direct > 1 && doc.abilities.accesses_view; + } + + return doc.nb_accesses_direct >= 1 && doc.abilities.accesses_view; + }, [doc, treeContext?.root]); + const queryClient = useQueryClient(); const { spacingsTokens } = useCunninghamTheme(); diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocVersionHeader.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocVersionHeader.tsx index fd8e91999..511b803c9 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocVersionHeader.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocVersionHeader.tsx @@ -5,11 +5,7 @@ import { useCunninghamTheme } from '@/cunningham'; import { DocTitleText } from './DocTitle'; -interface DocVersionHeaderProps { - title?: string; -} - -export const DocVersionHeader = ({ title }: DocVersionHeaderProps) => { +export const DocVersionHeader = () => { const { spacingsTokens } = useCunninghamTheme(); const { t } = useTranslation(); @@ -23,7 +19,7 @@ export const DocVersionHeader = ({ title }: DocVersionHeaderProps) => { aria-label={t('It is the document title')} className="--docs--doc-version-header" > - + diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts b/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts index 11123c6bb..df4123600 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts @@ -1,8 +1,9 @@ export * from './useCreateDoc'; +export * from './useCreateFavoriteDoc'; export * from './useDeleteFavoriteDoc'; export * from './useDoc'; export * from './useDocOptions'; export * from './useDocs'; -export * from './useCreateFavoriteDoc'; +export * from './useSubDocs'; export * from './useUpdateDoc'; export * from './useUpdateDocLink'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDoc.tsx index ebbb1d543..5365ad4d9 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDoc.tsx @@ -19,6 +19,7 @@ export const getDoc = async ({ id }: DocParams): Promise => { }; export const KEY_DOC = 'doc'; +export const KEY_SUB_PAGE = 'sub-page'; export const KEY_DOC_VISIBILITY = 'doc-visibility'; export function useDoc( @@ -26,7 +27,7 @@ export function useDoc( queryConfig?: UseQueryOptions, ) { return useQuery({ - queryKey: [KEY_DOC, param], + queryKey: queryConfig?.queryKey ?? [KEY_DOC, param], queryFn: () => getDoc(param), ...queryConfig, }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx index c9881ad70..88f385df5 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx @@ -8,22 +8,7 @@ import { useAPIInfiniteQuery, } from '@/api'; -import { Doc } from '../types'; - -export const isDocsOrdering = (data: string): data is DocsOrdering => { - return !!docsOrdering.find((validKey) => validKey === data); -}; - -const docsOrdering = [ - 'created_at', - '-created_at', - 'updated_at', - '-updated_at', - 'title', - '-title', -] as const; - -export type DocsOrdering = (typeof docsOrdering)[number]; +import { Doc, DocsOrdering } from '../types'; export type DocsParams = { page: number; @@ -33,20 +18,18 @@ export type DocsParams = { is_favorite?: boolean; }; -export type DocsResponse = APIList; -export const getDocs = async (params: DocsParams): Promise => { +export const constructParams = (params: DocsParams): URLSearchParams => { const searchParams = new URLSearchParams(); + if (params.page) { searchParams.set('page', params.page.toString()); } - if (params.ordering) { searchParams.set('ordering', params.ordering); } if (params.is_creator_me !== undefined) { searchParams.set('is_creator_me', params.is_creator_me.toString()); } - if (params.title && params.title.length > 0) { searchParams.set('title', params.title); } @@ -54,6 +37,12 @@ export const getDocs = async (params: DocsParams): Promise => { searchParams.set('is_favorite', params.is_favorite.toString()); } + return searchParams; +}; + +export type DocsResponse = APIList; +export const getDocs = async (params: DocsParams): Promise => { + const searchParams = constructParams(params); const response = await fetchAPI(`documents/?${searchParams.toString()}`); if (!response.ok) { diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useImportNotion.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useImportNotion.tsx new file mode 100644 index 000000000..e24479d0c --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useImportNotion.tsx @@ -0,0 +1,74 @@ +import { useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; + +import { baseApiUrl } from '@/api'; + +type ImportState = { + title: string; + status: 'pending' | 'fetched' | 'imported'; +}[]; + +const computeSuccessPercentage = (importState?: ImportState) => { + if (!importState) { + return 0; + } + if (!importState.length) { + return 100; + } + + let fetchedFiles = 0; + let importedFiles = 0; + + for (const file of importState) { + if (file.status === 'fetched') { + fetchedFiles += 1; + } else if (file.status === 'imported') { + fetchedFiles += 1; + importedFiles += 1; + } + } + + const filesNb = importState.length; + + return Math.round(((fetchedFiles + importedFiles) / (2 * filesNb)) * 100); +}; + +export function useImportNotion() { + const router = useRouter(); + + const [importState, setImportState] = useState(); + + useEffect(() => { + // send the request with an Event Source + const eventSource = new EventSource( + `${baseApiUrl('1.0')}notion_import/run`, + { + withCredentials: true, + }, + ); + + eventSource.onmessage = (event) => { + console.log('hello', event.data); + const files = JSON.parse(event.data as string) as ImportState; + + // si tous les fichiers sont chargés, rediriger vers la home page + if (files.some((file) => file.status === 'imported')) { + eventSource.close(); + router.push('/'); + } + + // mettre à jour le state d'import + setImportState(files); + }; + + return () => { + eventSource.close(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { + importState, + percentageValue: computeSuccessPercentage(importState), + }; +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useSubDocs.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useSubDocs.tsx new file mode 100644 index 000000000..e76c8bc4e --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useSubDocs.tsx @@ -0,0 +1,62 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query'; + +import { + APIError, + InfiniteQueryConfig, + errorCauses, + fetchAPI, + useAPIInfiniteQuery, +} from '@/api'; + +import { DocsOrdering } from '../types'; + +import { DocsResponse, constructParams } from './useDocs'; + +export type SubDocsParams = { + page: number; + ordering?: DocsOrdering; + is_creator_me?: boolean; + title?: string; + is_favorite?: boolean; + parent_id: string; +}; + +export const getSubDocs = async ( + params: SubDocsParams, +): Promise => { + const searchParams = constructParams(params); + searchParams.set('parent_id', params.parent_id); + + const response: Response = await fetchAPI( + `documents/${params.parent_id}/descendants/?${searchParams.toString()}`, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to get the sub docs', + await errorCauses(response), + ); + } + + return response.json() as Promise; +}; + +export const KEY_LIST_SUB_DOC = 'sub-docs'; + +export function useSubDocs( + params: SubDocsParams, + queryConfig?: UseQueryOptions, +) { + return useQuery({ + queryKey: [KEY_LIST_SUB_DOC, params], + queryFn: () => getSubDocs(params), + ...queryConfig, + }); +} + +export const useInfiniteSubDocs = ( + params: SubDocsParams, + queryConfig?: InfiniteQueryConfig, +) => { + return useAPIInfiniteQuery(KEY_LIST_SUB_DOC, getSubDocs, params, queryConfig); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx index c3ff4b5b7..f852e694e 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx @@ -12,21 +12,27 @@ import { useRouter } from 'next/router'; import { Box, Text, TextErrors } from '@/components'; import { useRemoveDoc } from '../api/useRemoveDoc'; +import { useTrans } from '../hooks'; import { Doc } from '../types'; interface ModalRemoveDocProps { onClose: () => void; doc: Doc; + afterDelete?: (doc: Doc) => void; } -export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => { +export const ModalRemoveDoc = ({ + onClose, + doc, + afterDelete, +}: ModalRemoveDocProps) => { const { toast } = useToastProvider(); const { push } = useRouter(); const pathname = usePathname(); + const { untitledDocument } = useTrans(doc); const { mutate: removeDoc, - isError, error, } = useRemoveDoc({ @@ -34,6 +40,11 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => { toast(t('The document has been deleted.'), VariantType.SUCCESS, { duration: 4000, }); + if (afterDelete) { + afterDelete(doc); + return; + } + if (pathname === '/') { onClose(); } else { @@ -90,7 +101,9 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => { > {!isError && ( - {t('Are you sure you want to delete this document ?')} + {t('Are you sure you want to delete the document "{{title}}"?', { + title: doc.title ?? untitledDocument, + })} )} diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useTrans.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useTrans.tsx index d97bd85ed..7664a10c3 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useTrans.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useTrans.tsx @@ -1,9 +1,10 @@ import { useTranslation } from 'react-i18next'; -import { Role } from '../types'; +import { Doc, Role } from '../types'; -export const useTrans = () => { +export const useTrans = (doc?: Doc) => { const { t } = useTranslation(); + const isChild = doc && doc.nb_accesses_ancestors > 1; const translatedRoles = { [Role.READER]: t('Reader'), @@ -16,7 +17,7 @@ export const useTrans = () => { transRole: (role: Role) => { return translatedRoles[role]; }, - untitledDocument: t('Untitled document'), + untitledDocument: isChild ? t('Untitled page') : t('Untitled document'), translatedRoles, }; }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx index e57dc6e14..f2bdbc421 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx @@ -2,9 +2,16 @@ import { User } from '@/features/auth'; export interface Access { id: string; + max_ancestors_role: Role; role: Role; + max_role: Role; team: string; user: User; + document: { + id: string; + path: string; + depth: number; + }; abilities: { destroy: boolean; partial_update: boolean; @@ -21,10 +28,17 @@ export enum Role { OWNER = 'owner', } +export const RoleImportance = { + [Role.READER]: 1, + [Role.EDITOR]: 2, + [Role.ADMIN]: 3, + [Role.OWNER]: 4, +}; + export enum LinkReach { RESTRICTED = 'restricted', - PUBLIC = 'public', AUTHENTICATED = 'authenticated', + PUBLIC = 'public', } export enum LinkRole { @@ -37,15 +51,26 @@ export type Base64 = string; export interface Doc { id: string; title?: string; + children?: Doc[]; + childrenCount?: number; content: Base64; + created_at: string; creator: string; + depth: number; + path: string; is_favorite: boolean; link_reach: LinkReach; link_role: LinkRole; - nb_accesses_ancestors: number; nb_accesses_direct: number; - created_at: string; + nb_accesses_ancestors: number; + computed_link_reach: LinkReach; + computed_link_role?: LinkRole; + ancestors_link_reach: LinkReach; + ancestors_link_role?: LinkRole; + numchild: number; updated_at: string; + user_role: Role; + user_roles: Role[]; abilities: { accesses_manage: boolean; accesses_view: boolean; @@ -68,11 +93,27 @@ export interface Doc { versions_destroy: boolean; versions_list: boolean; versions_retrieve: boolean; + link_select_options: LinkSelectOption; }; } +export interface LinkSelectOption { + public?: LinkRole[]; + authenticated?: LinkRole[]; + restricted?: LinkRole[]; +} + export enum DocDefaultFilter { ALL_DOCS = 'all_docs', MY_DOCS = 'my_docs', SHARED_WITH_ME = 'shared_with_me', } + +export type DocsOrdering = + | 'title' + | 'created_at' + | '-created_at' + | 'updated_at' + | '-updated_at' + | '-title' + | undefined; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/utils.ts b/src/frontend/apps/impress/src/features/docs/doc-management/utils.ts index 2c229128e..abd7ec383 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/utils.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-management/utils.ts @@ -22,3 +22,29 @@ export const base64ToYDoc = (base64: string) => { export const base64ToBlocknoteXmlFragment = (base64: string) => { return base64ToYDoc(base64).getXmlFragment('document-store'); }; + +export const getDocLinkReach = (doc: Doc) => { + if (doc.computed_link_reach) { + return doc.computed_link_reach; + } + return doc.link_reach; +}; + +export const getDocLinkRole = (doc: Doc) => { + if (doc.computed_link_role) { + return doc.computed_link_role; + } + return doc.link_role; +}; + +export const docLinkIsDesync = (doc: Doc) => { + // If the document has no ancestors + if (!doc.ancestors_link_reach) { + return false; + } + + return ( + doc.computed_link_reach !== doc.ancestors_link_reach || + doc.computed_link_role !== doc.ancestors_link_role + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchContent.tsx b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchContent.tsx new file mode 100644 index 000000000..fc14d8bc6 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchContent.tsx @@ -0,0 +1,68 @@ +import { t } from 'i18next'; +import { useEffect, useMemo } from 'react'; +import { InView } from 'react-intersection-observer'; + +import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search'; + +import { Doc, useInfiniteDocs } from '../../doc-management'; + +import { DocSearchFiltersValues } from './DocSearchFilters'; +import { DocSearchItem } from './DocSearchItem'; + +type DocSearchContentProps = { + search: string; + filters: DocSearchFiltersValues; + onSelect: (doc: Doc) => void; + onLoadingChange?: (loading: boolean) => void; +}; + +export const DocSearchContent = ({ + search, + filters, + onSelect, + onLoadingChange, +}: DocSearchContentProps) => { + const { + data, + isFetching, + isRefetching, + isLoading, + fetchNextPage, + hasNextPage, + } = useInfiniteDocs({ + page: 1, + title: search, + ...filters, + }); + + const loading = isFetching || isRefetching || isLoading; + + const docsData: QuickSearchData = useMemo(() => { + const docs = data?.pages.flatMap((page) => page.results) || []; + + return { + groupName: docs.length > 0 ? t('Select a document') : '', + elements: search ? docs : [], + emptyString: t('No document found'), + endActions: hasNextPage + ? [ + { + content: void fetchNextPage()} />, + }, + ] + : [], + }; + }, [search, data?.pages, fetchNextPage, hasNextPage]); + + useEffect(() => { + onLoadingChange?.(loading); + }, [loading, onLoadingChange]); + + return ( + } + /> + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchFilters.tsx b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchFilters.tsx new file mode 100644 index 000000000..96edcf71d --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchFilters.tsx @@ -0,0 +1,67 @@ +import { Button } from '@openfun/cunningham-react'; +import { useTranslation } from 'react-i18next'; + +import { Box } from '@/components'; +import { FilterDropdown } from '@/components/filter/FilterDropdown'; + +export enum DocSearchTarget { + ALL = 'all', + CURRENT = 'current', +} + +export type DocSearchFiltersValues = { + target?: DocSearchTarget; +}; + +export type DocSearchFiltersProps = { + values?: DocSearchFiltersValues; + onValuesChange?: (values: DocSearchFiltersValues) => void; + onReset?: () => void; +}; + +export const DocSearchFilters = ({ + values, + onValuesChange, + onReset, +}: DocSearchFiltersProps) => { + const { t } = useTranslation(); + const hasFilters = Object.keys(values ?? {}).length > 0; + const handleTargetChange = (target: DocSearchTarget) => { + onValuesChange?.({ ...values, target }); + }; + + return ( + + + handleTargetChange(DocSearchTarget.ALL), + }, + { + label: t('Current doc'), + value: DocSearchTarget.CURRENT, + callback: () => handleTargetChange(DocSearchTarget.CURRENT), + }, + ]} + /> + + {hasFilters && ( + + )} + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx index 48fbbf48c..caede5f1a 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx @@ -1,65 +1,61 @@ import { Modal, ModalSize } from '@openfun/cunningham-react'; import Image from 'next/image'; -import { useRouter } from 'next/navigation'; -import { useMemo, useState } from 'react'; +import { useRouter } from 'next/router'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { InView } from 'react-intersection-observer'; import { useDebouncedCallback } from 'use-debounce'; import { Box } from '@/components'; -import { - QuickSearch, - QuickSearchData, - QuickSearchGroup, -} from '@/components/quick-search'; -import { Doc, useInfiniteDocs } from '@/docs/doc-management'; +import { QuickSearch } from '@/components/quick-search'; import { useResponsiveStore } from '@/stores'; +import { Doc } from '../../doc-management'; import EmptySearchIcon from '../assets/illustration-docs-empty.png'; -import { DocSearchItem } from './DocSearchItem'; +import { DocSearchContent } from './DocSearchContent'; +import { + DocSearchFilters, + DocSearchFiltersValues, + DocSearchTarget, +} from './DocSearchFilters'; +import { DocSearchSubPageContent } from './DocSearchSubPageContent'; type DocSearchModalProps = { onClose: () => void; isOpen: boolean; + showFilters?: boolean; + defaultFilters?: DocSearchFiltersValues; }; -export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => { +export const DocSearchModal = ({ + showFilters = false, + defaultFilters, + ...modalProps +}: DocSearchModalProps) => { const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const router = useRouter(); + const isDocPage = router.pathname === '/docs/[id]'; + const [search, setSearch] = useState(''); + const [filters, setFilters] = useState( + defaultFilters ?? {}, + ); + + const target = filters.target ?? DocSearchTarget.ALL; const { isDesktop } = useResponsiveStore(); - const { - data, - isFetching, - isRefetching, - isLoading, - fetchNextPage, - hasNextPage, - } = useInfiniteDocs({ - page: 1, - title: search, - }); - const loading = isFetching || isRefetching || isLoading; + const handleInputSearch = useDebouncedCallback(setSearch, 300); const handleSelect = (doc: Doc) => { - router.push(`/docs/${doc.id}`); + void router.push(`/docs/${doc.id}`); modalProps.onClose?.(); }; - const docsData: QuickSearchData = useMemo(() => { - const docs = data?.pages.flatMap((page) => page.results) || []; - - return { - groupName: docs.length > 0 ? t('Select a document') : '', - elements: search ? docs : [], - emptyString: t('No document found'), - endActions: hasNextPage - ? [{ content: void fetchNextPage()} /> }] - : [], - }; - }, [data, hasNextPage, fetchNextPage, t, search]); + const handleResetFilters = () => { + setFilters({}); + }; return ( { onFilter={handleInputSearch} > + {showFilters && ( + + )} {search.length === 0 && ( { )} {search && ( - } - /> + <> + {target === DocSearchTarget.ALL && ( + + )} + {isDocPage && target === DocSearchTarget.CURRENT && ( + + )} + )} diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchSubPageContent.tsx b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchSubPageContent.tsx new file mode 100644 index 000000000..e4fa2c7e7 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchSubPageContent.tsx @@ -0,0 +1,73 @@ +import { useTreeContext } from '@gouvfr-lasuite/ui-kit'; +import { t } from 'i18next'; +import { useEffect, useMemo } from 'react'; +import { InView } from 'react-intersection-observer'; + +import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search'; + +import { Doc } from '../../doc-management'; +import { useInfiniteSubDocs } from '../../doc-management/api/useSubDocs'; + +import { DocSearchFiltersValues } from './DocSearchFilters'; +import { DocSearchItem } from './DocSearchItem'; + +type DocSearchSubPageContentProps = { + search: string; + filters: DocSearchFiltersValues; + onSelect: (doc: Doc) => void; + onLoadingChange?: (loading: boolean) => void; +}; + +export const DocSearchSubPageContent = ({ + search, + filters, + onSelect, + onLoadingChange, +}: DocSearchSubPageContentProps) => { + const treeContext = useTreeContext(); + + const { + data: subDocsData, + isFetching, + isRefetching, + isLoading, + fetchNextPage: subDocsFetchNextPage, + hasNextPage: subDocsHasNextPage, + } = useInfiniteSubDocs({ + page: 1, + title: search, + ...filters, + parent_id: treeContext?.root?.id ?? '', + }); + + const loading = isFetching || isRefetching || isLoading; + + const docsData: QuickSearchData = useMemo(() => { + const subDocs = subDocsData?.pages.flatMap((page) => page.results) || []; + + return { + groupName: subDocs.length > 0 ? t('Select a page') : '', + elements: search ? subDocs : [], + emptyString: t('No document found'), + endActions: subDocsHasNextPage + ? [ + { + content: void subDocsFetchNextPage()} />, + }, + ] + : [], + }; + }, [search, subDocsData, subDocsFetchNextPage, subDocsHasNextPage]); + + useEffect(() => { + onLoadingChange?.(loading); + }, [loading, onLoadingChange]); + + return ( + } + /> + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/index.ts b/src/frontend/apps/impress/src/features/docs/doc-search/components/index.ts index a5cb98858..1a0889239 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-search/components/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/index.ts @@ -1 +1,2 @@ export * from './DocSearchModal'; +export * from './DocSearchFilters'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccesses.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccesses.tsx index aa65e3f7b..962a248c4 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccesses.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccesses.tsx @@ -1,13 +1,6 @@ -import { - DefinedInitialDataInfiniteOptions, - InfiniteData, - QueryKey, - UseQueryOptions, - useInfiniteQuery, - useQuery, -} from '@tanstack/react-query'; +import { UseQueryOptions, useQuery } from '@tanstack/react-query'; -import { APIError, APIList, errorCauses, fetchAPI } from '@/api'; +import { APIError, errorCauses, fetchAPI } from '@/api'; import { Access } from '@/docs/doc-management'; export type DocAccessesParam = { @@ -15,18 +8,13 @@ export type DocAccessesParam = { ordering?: string; }; -export type DocAccessesAPIParams = DocAccessesParam & { - page: number; -}; - -type AccessesResponse = APIList; +export type DocAccessesAPIParams = DocAccessesParam & {}; export const getDocAccesses = async ({ - page, docId, ordering, -}: DocAccessesAPIParams): Promise => { - let url = `documents/${docId}/accesses/?page=${page}`; +}: DocAccessesAPIParams): Promise => { + let url = `documents/${docId}/accesses/`; if (ordering) { url += '&ordering=' + ordering; @@ -41,54 +29,18 @@ export const getDocAccesses = async ({ ); } - return response.json() as Promise; + return (await response.json()) as Access[]; }; export const KEY_LIST_DOC_ACCESSES = 'docs-accesses'; export function useDocAccesses( params: DocAccessesAPIParams, - queryConfig?: UseQueryOptions, + queryConfig?: UseQueryOptions, ) { - return useQuery({ + return useQuery({ queryKey: [KEY_LIST_DOC_ACCESSES, params], queryFn: () => getDocAccesses(params), ...queryConfig, }); } - -/** - * @param param Used for infinite scroll pagination - * @param queryConfig - * @returns - */ -export function useDocAccessesInfinite( - param: DocAccessesParam, - queryConfig?: DefinedInitialDataInfiniteOptions< - AccessesResponse, - APIError, - InfiniteData, - QueryKey, - number - >, -) { - return useInfiniteQuery< - AccessesResponse, - APIError, - InfiniteData, - QueryKey, - number - >({ - initialPageParam: 1, - queryKey: [KEY_LIST_DOC_ACCESSES, param], - queryFn: ({ pageParam }) => - getDocAccesses({ - ...param, - page: pageParam, - }), - getNextPageParam(lastPage, allPages) { - return lastPage.next ? allPages.length + 1 : undefined; - }, - ...queryConfig, - }); -} diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/assets/desynchro.svg b/src/frontend/apps/impress/src/features/docs/doc-share/assets/desynchro.svg new file mode 100644 index 000000000..92cbf5dd5 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-share/assets/desynchro.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/assets/undo.svg b/src/frontend/apps/impress/src/features/docs/doc-share/assets/undo.svg new file mode 100644 index 000000000..4150522fd --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-share/assets/undo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocInheritedShareContent.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocInheritedShareContent.tsx new file mode 100644 index 000000000..5d8aef9ab --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocInheritedShareContent.tsx @@ -0,0 +1,206 @@ +import { Button, Modal, ModalSize, useModal } from '@openfun/cunningham-react'; +import { Fragment, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { createGlobalStyle } from 'styled-components'; + +import { Box, StyledLink, Text } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; + +import { + Access, + RoleImportance, + useDoc, + useDocStore, +} from '../../doc-management'; +import SimpleFileIcon from '../../docs-grid/assets/simple-document.svg'; + +import { DocShareMemberItem } from './DocShareMemberItem'; +const ShareModalStyle = createGlobalStyle` + .c__modal__title { + padding-bottom: 0 !important; + } + .c__modal__scroller { + padding: 15px 15px !important; + } +`; + +type Props = { + rawAccesses: Access[]; +}; + +const getMaxRoleBetweenAccesses = (access1: Access, access2: Access) => { + const role1 = access1.max_role; + const role2 = access2.max_role; + + const roleImportance1 = RoleImportance[role1]; + const roleImportance2 = RoleImportance[role2]; + + return roleImportance1 > roleImportance2 ? role1 : role2; +}; + +export const DocInheritedShareContent = ({ rawAccesses }: Props) => { + const { t } = useTranslation(); + const { spacingsTokens } = useCunninghamTheme(); + const { currentDoc } = useDocStore(); + + const inheritedData = useMemo(() => { + if (!currentDoc || rawAccesses.length === 0) { + return null; + } + + let parentId = null; + let parentPathLength = 0; + const members: Access[] = []; + + // Find the parent document with the longest path that is different from currentDoc + for (const access of rawAccesses) { + const docPath = access.document.path; + + // Skip if it's the current document + if (access.document.id === currentDoc.id) { + continue; + } + + const findIndex = members.findIndex( + (member) => member.user.id === access.user.id, + ); + if (findIndex === -1) { + members.push(access); + } else { + const accessToUpdate = members[findIndex]; + const currentRole = accessToUpdate.max_role; + const maxRole = getMaxRoleBetweenAccesses(accessToUpdate, access); + + if (maxRole !== currentRole) { + members[findIndex] = access; + } + } + + // Check if this document has a longer path than our current candidate + if (docPath && (!parentId || docPath.length > parentPathLength)) { + parentId = access.document.id; + parentPathLength = docPath.length; + } + } + + return { parentId, members }; + }, [currentDoc, rawAccesses]); + + // Check if accesses map is empty + const hasAccesses = rawAccesses.length > 0; + + if (!hasAccesses) { + return null; + } + + return ( + + + + {t('Inherited share')} + + + {inheritedData && ( + + )} + + + ); +}; + +type DocInheritedShareContentItemProps = { + accesses: Access[]; + document_id: string; +}; +export const DocInheritedShareContentItem = ({ + accesses, + document_id, +}: DocInheritedShareContentItemProps) => { + const { t } = useTranslation(); + const { spacingsTokens } = useCunninghamTheme(); + const { data: doc, error, isLoading } = useDoc({ id: document_id }); + const errorCode = error?.status; + + const accessModal = useModal(); + if ((!doc && !isLoading && !error) || (error && errorCode !== 403)) { + return null; + } + + return ( + <> + + + + + {isLoading ? ( + + + + + ) : ( + <> + + + {error && errorCode === 403 + ? t('You do not have permission to view this document') + : (doc?.title ?? t('Untitled document'))} + + + + {t('Members of this page have access')} + + + )} + + + {!isLoading && ( + + )} + + {accessModal.isOpen && ( + + + {t('Access inherited from the parent page')} + + + } + size={ModalSize.MEDIUM} + > + + + {accesses.map((access) => ( + + + + ))} + + + )} + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocRoleDropdown.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocRoleDropdown.tsx index 261eb5f6f..b62db0147 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocRoleDropdown.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocRoleDropdown.tsx @@ -1,3 +1,5 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; import { DropdownMenu, DropdownMenuOption, Text } from '@/components'; @@ -18,30 +20,48 @@ export const DocRoleDropdown = ({ onSelectRole, rolesAllowed, }: DocRoleDropdownProps) => { + const { t } = useTranslation(); const { transRole, translatedRoles } = useTrans(); - if (!canUpdate) { - return ( - - {transRole(currentRole)} - - ); - } + /** + * When there is a higher role, the rolesAllowed are truncated + * We display a message to indicate that there is a higher role + */ + const topMessage = useMemo(() => { + if (!canUpdate || !rolesAllowed || rolesAllowed.length === 0) { + return message; + } + + const allRoles = Object.keys(translatedRoles); + + if (rolesAllowed.length < allRoles.length) { + let result = message ? `${message}\n\n` : ''; + result += t('This user has access inherited from a parent page.'); + return result; + } + + return message; + }, [canUpdate, rolesAllowed, translatedRoles, message, t]); const roles: DropdownMenuOption[] = Object.keys(translatedRoles).map( (key) => { return { label: transRole(key as Role), callback: () => onSelectRole?.(key as Role), - disabled: rolesAllowed && !rolesAllowed.includes(key as Role), isSelected: currentRole === (key as Role), }; }, ); - + if (!canUpdate) { + return ( + + {transRole(currentRole)} + + ); + } return ( { const { t } = useTranslation(); const { toast } = useToastProvider(); + const [isLoading, setIsLoading] = useState(false); const { spacingsTokens, colorsTokens } = useCunninghamTheme(); const [invitationRole, setInvitationRole] = useState(Role.EDITOR); const canShare = doc.abilities.accesses_manage; - + const queryClient = useQueryClient(); const { mutateAsync: createInvitation } = useCreateDocInvitation(); const { mutateAsync: createDocAccess } = useCreateDocAccess(); @@ -89,14 +91,32 @@ export const DocShareAddMemberList = ({ }; return isInvitationMode - ? createInvitation({ - ...payload, - email: user.email, - }) - : createDocAccess({ - ...payload, - memberId: user.id, - }); + ? createInvitation( + { + ...payload, + email: user.email, + }, + { + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [KEY_SUB_PAGE, { id: doc.id }], + }); + }, + }, + ) + : createDocAccess( + { + ...payload, + memberId: user.id, + }, + { + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [KEY_SUB_PAGE, { id: doc.id }], + }); + }, + }, + ); }); const settledPromises = await Promise.allSettled(promises); diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitationItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitationItem.tsx index 76a04fbd0..343bbb968 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitationItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitationItem.tsx @@ -1,4 +1,5 @@ import { VariantType, useToastProvider } from '@openfun/cunningham-react'; +import { useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { @@ -8,7 +9,7 @@ import { IconOptions, } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; -import { Doc, Role } from '@/docs/doc-management'; +import { Doc, KEY_SUB_PAGE, Role } from '@/docs/doc-management'; import { User } from '@/features/auth'; import { useDeleteDocInvitation, useUpdateDocInvitation } from '../api'; @@ -23,6 +24,7 @@ type Props = { }; export const DocShareInvitationItem = ({ doc, invitation }: Props) => { const { t } = useTranslation(); + const queryClient = useQueryClient(); const { spacingsTokens } = useCunninghamTheme(); const fakeUser: User = { id: invitation.email, @@ -36,6 +38,11 @@ export const DocShareInvitationItem = ({ doc, invitation }: Props) => { const canUpdate = doc.abilities.accesses_manage; const { mutate: updateDocInvitation } = useUpdateDocInvitation({ + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [KEY_SUB_PAGE, { id: doc.id }], + }); + }, onError: (error) => { toast( error?.data?.role?.[0] ?? t('Error during update invitation'), @@ -48,6 +55,11 @@ export const DocShareInvitationItem = ({ doc, invitation }: Props) => { }); const { mutate: removeDocInvitation } = useDeleteDocInvitation({ + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [KEY_SUB_PAGE, { id: doc.id }], + }); + }, onError: (error) => { toast( error?.data?.role?.[0] ?? t('Error during delete invitation'), diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMemberItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMemberItem.tsx index 4da05ec71..4438504ea 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMemberItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMemberItem.tsx @@ -1,4 +1,5 @@ import { VariantType, useToastProvider } from '@openfun/cunningham-react'; +import { useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { @@ -8,7 +9,7 @@ import { IconOptions, } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; -import { Access, Doc, Role } from '@/docs/doc-management/'; +import { Access, Doc, KEY_SUB_PAGE, Role } from '@/docs/doc-management/'; import { useResponsiveStore } from '@/stores'; import { useDeleteDocAccess, useUpdateDocAccess } from '../api'; @@ -18,13 +19,20 @@ import { DocRoleDropdown } from './DocRoleDropdown'; import { SearchUserRow } from './SearchUserRow'; type Props = { - doc: Doc; + doc?: Doc; access: Access; + isInherited?: boolean; }; -export const DocShareMemberItem = ({ doc, access }: Props) => { +export const DocShareMemberItem = ({ + doc, + access, + isInherited = false, +}: Props) => { const { t } = useTranslation(); + const queryClient = useQueryClient(); const { isLastOwner } = useWhoAmI(access); const { toast } = useToastProvider(); + const { isDesktop } = useResponsiveStore(); const { spacingsTokens } = useCunninghamTheme(); @@ -35,6 +43,14 @@ export const DocShareMemberItem = ({ doc, access }: Props) => { : undefined; const { mutate: updateDocAccess } = useUpdateDocAccess({ + onSuccess: () => { + if (!doc) { + return; + } + void queryClient.invalidateQueries({ + queryKey: [KEY_SUB_PAGE, { id: doc.id }], + }); + }, onError: () => { toast(t('Error during invitation update'), VariantType.ERROR, { duration: 4000, @@ -43,6 +59,14 @@ export const DocShareMemberItem = ({ doc, access }: Props) => { }); const { mutate: removeDocAccess } = useDeleteDocAccess({ + onSuccess: () => { + if (!doc) { + return; + } + void queryClient.invalidateQueries({ + queryKey: [KEY_SUB_PAGE, { id: doc.id }], + }); + }, onError: () => { toast(t('Error while deleting invitation'), VariantType.ERROR, { duration: 4000, @@ -51,6 +75,9 @@ export const DocShareMemberItem = ({ doc, access }: Props) => { }); const onUpdate = (newRole: Role) => { + if (!doc) { + return; + } updateDocAccess({ docId: doc.id, role: newRole, @@ -59,6 +86,9 @@ export const DocShareMemberItem = ({ doc, access }: Props) => { }; const onRemove = () => { + if (!doc) { + return; + } removeDocAccess({ accessId: access.id, docId: doc.id }); }; @@ -71,6 +101,10 @@ export const DocShareMemberItem = ({ doc, access }: Props) => { }, ]; + const canUpdate = isInherited + ? false + : (doc?.abilities.accesses_manage ?? false); + return ( { right={ - {isDesktop && doc.abilities.accesses_manage && ( + {isDesktop && canUpdate && ( { setInputValue(''); }; - const membersQuery = useDocAccessesInfinite({ + const { data: membersQuery } = useDocAccesses({ docId: doc.id, }); @@ -79,10 +80,10 @@ export const DocShareModal = ({ doc, onClose }: Props) => { ); const membersData: QuickSearchData = useMemo(() => { - const members = - membersQuery.data?.pages.flatMap((page) => page.results) || []; + const members: Access[] = + membersQuery?.filter((access) => access.document.id === doc.id) ?? []; - const count = membersQuery.data?.pages[0]?.count ?? 1; + const count = doc.nb_accesses_direct > 1 ? doc.nb_accesses_direct : 1; return { groupName: @@ -92,16 +93,8 @@ export const DocShareModal = ({ doc, onClose }: Props) => { count: count, }), elements: members, - endActions: membersQuery.hasNextPage - ? [ - { - content: , - onSelect: () => void membersQuery.fetchNextPage(), - }, - ] - : undefined, }; - }, [membersQuery, t]); + }, [membersQuery, doc.id, doc.nb_accesses_direct, t]); const onFilter = useDebouncedCallback((str: string) => { setUserQuery(str); @@ -129,6 +122,18 @@ export const DocShareModal = ({ doc, onClose }: Props) => { setListHeight(height); }; + const inheritedAccesses = useMemo(() => { + return ( + membersQuery?.filter((access) => access.document.id !== doc.id) ?? [] + ); + }, [membersQuery, doc.id]); + + // const rootDoc = treeContext?.root; + const isRootDoc = false; + + const showInheritedShareContent = + inheritedAccesses.length > 0 && showMemberSection && !isRootDoc; + return ( <> { loading={searchUsersQuery.isLoading} placeholder={t('Type a name or email')} > + {inheritedAccesses.length > 0 && + showInheritedShareContent && ( + access.document.id !== doc.id, + ) ?? [] + } + /> + )} {showMemberSection ? ( 0} membersData={membersData} /> ) : ( @@ -272,22 +288,29 @@ const QuickSearchInviteInputSection = ({ }, [onSelect, searchUsersRawData, t, userQuery]); return ( - } - /> + + } + /> + ); }; interface QuickSearchMemberSectionProps { doc: Doc; membersData: QuickSearchData; + hasInheritedShareContent?: boolean; } const QuickSearchMemberSection = ({ doc, membersData, + hasInheritedShareContent = false, }: QuickSearchMemberSectionProps) => { const { t } = useTranslation(); const { data, hasNextPage, fetchNextPage } = useDocInvitationsInfinite({ @@ -311,10 +334,25 @@ const QuickSearchMemberSection = ({ }; }, [data?.pages, fetchNextPage, hasNextPage, t]); + const showSeparator = + invitationsData.elements.length > 0 && membersData.elements.length > 0; + + if ( + invitationsData.elements.length === 0 && + membersData.elements.length === 0 + ) { + return null; + } + return ( <> + {hasInheritedShareContent && } {invitationsData.elements.length > 0 && ( - + ( @@ -324,7 +362,12 @@ const QuickSearchMemberSection = ({ )} - + {showSeparator && } + + ( diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocVisibility.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocVisibility.tsx index 14fa8cc1f..2e03d0654 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocVisibility.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocVisibility.tsx @@ -1,5 +1,9 @@ -import { VariantType, useToastProvider } from '@openfun/cunningham-react'; -import { useState } from 'react'; +import { + Button, + VariantType, + useToastProvider, +} from '@openfun/cunningham-react'; +import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -17,12 +21,17 @@ import { KEY_LIST_DOC, LinkReach, LinkRole, + docLinkIsDesync, + getDocLinkReach, useUpdateDocLink, } from '@/features/docs'; import { useResponsiveStore } from '@/stores'; import { useTranslatedShareSettings } from '../hooks/'; +import Desync from './../assets/desynchro.svg'; +import Undo from './../assets/undo.svg'; + interface DocVisibilityProps { doc: Doc; } @@ -33,11 +42,19 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => { const { isDesktop } = useResponsiveStore(); const { spacingsTokens, colorsTokens } = useCunninghamTheme(); const canManage = doc.abilities.accesses_manage; - const [linkReach, setLinkReach] = useState(doc.link_reach); - const [docLinkRole, setDocLinkRole] = useState(doc.link_role); + const [linkReach, setLinkReach] = useState(getDocLinkReach(doc)); + const [docLinkRole, setDocLinkRole] = useState( + doc.computed_link_role ?? LinkRole.READER, + ); + const { linkModeTranslations, linkReachChoices, linkReachTranslations } = useTranslatedShareSettings(); + const description = + docLinkRole === LinkRole.READER + ? linkReachChoices[linkReach].descriptionReadOnly + : linkReachChoices[linkReach].descriptionEdit; + const api = useUpdateDocLink({ onSuccess: () => { toast( @@ -51,38 +68,94 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => { listInvalideQueries: [KEY_LIST_DOC, KEY_DOC], }); - const updateReach = (link_reach: LinkReach) => { - api.mutate({ id: doc.id, link_reach }); - setLinkReach(link_reach); - }; + const updateReach = useCallback( + (link_reach: LinkReach, link_role?: LinkRole) => { + const params: { + id: string; + link_reach: LinkReach; + link_role?: LinkRole; + } = { + id: doc.id, + link_reach, + }; - const updateLinkRole = (link_role: LinkRole) => { - api.mutate({ id: doc.id, link_role }); - setDocLinkRole(link_role); - }; + api.mutate(params); + setLinkReach(link_reach); + if (link_role) { + params.link_role = link_role; + setDocLinkRole(link_role); + } + }, + [api, doc.id], + ); - const linkReachOptions: DropdownMenuOption[] = Object.keys( - linkReachTranslations, - ).map((key) => ({ - label: linkReachTranslations[key as LinkReach], - icon: linkReachChoices[key as LinkReach].icon, - callback: () => updateReach(key as LinkReach), - isSelected: linkReach === (key as LinkReach), - })); - - const linkMode: DropdownMenuOption[] = Object.keys(linkModeTranslations).map( - (key) => ({ - label: linkModeTranslations[key as LinkRole], - callback: () => updateLinkRole(key as LinkRole), - isSelected: docLinkRole === (key as LinkRole), - }), + const updateLinkRole = useCallback( + (link_role: LinkRole) => { + api.mutate({ id: doc.id, link_role }); + setDocLinkRole(link_role); + }, + [api, doc.id], ); - const showLinkRoleOptions = doc.link_reach !== LinkReach.RESTRICTED; - const description = - docLinkRole === LinkRole.READER - ? linkReachChoices[linkReach].descriptionReadOnly - : linkReachChoices[linkReach].descriptionEdit; + const linkReachOptions: DropdownMenuOption[] = useMemo(() => { + return Object.values(LinkReach).map((key) => { + const isDisabled = + doc.abilities.link_select_options[key as LinkReach] === undefined; + + return { + label: linkReachTranslations[key as LinkReach], + callback: () => updateReach(key as LinkReach), + isSelected: linkReach === (key as LinkReach), + disabled: isDisabled, + }; + }); + }, [doc, linkReach, linkReachTranslations, updateReach]); + + const haveDisabledOptions = linkReachOptions.some( + (option) => option.disabled, + ); + + const showLinkRoleOptions = doc.computed_link_reach !== LinkReach.RESTRICTED; + + const linkRoleOptions: DropdownMenuOption[] = useMemo(() => { + const options = doc.abilities.link_select_options[linkReach] ?? []; + return Object.values(LinkRole).map((key) => { + const isDisabled = !options.includes(key); + return { + label: linkModeTranslations[key], + callback: () => updateLinkRole(key), + isSelected: docLinkRole === key, + disabled: isDisabled, + }; + }); + }, [doc, docLinkRole, linkModeTranslations, updateLinkRole, linkReach]); + + const haveDisabledLinkRoleOptions = linkRoleOptions.some( + (option) => option.disabled, + ); + + const undoDesync = () => { + const params: { + id: string; + link_reach: LinkReach; + link_role?: LinkRole; + } = { + id: doc.id, + link_reach: doc.ancestors_link_reach, + }; + if (doc.ancestors_link_role) { + params.link_role = doc.ancestors_link_role; + } + api.mutate(params); + setLinkReach(doc.ancestors_link_reach); + if (doc.ancestors_link_role) { + setDocLinkRole(doc.ancestors_link_role); + } + }; + + const showDesync = useMemo(() => { + return docLinkIsDesync(doc); + }, [doc]); return ( { {t('Link parameters')} + {showDesync && ( + + + + + {t('Sharing rules differ from the parent page')} + + + {doc.abilities.accesses_manage && ( + + )} + + )} { `} disabled={!canManage} showArrow={true} + topMessage={ + haveDisabledOptions + ? t( + 'You cannot restrict access to a subpage relative to its parent page.', + ) + : undefined + } options={linkReachOptions} > @@ -145,7 +257,14 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => { diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/index.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/api/index.ts new file mode 100644 index 000000000..98d4836ff --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/index.ts @@ -0,0 +1 @@ +export * from './useDocChildren'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/useCreateChildren.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useCreateChildren.tsx new file mode 100644 index 000000000..b9f774a81 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useCreateChildren.tsx @@ -0,0 +1,44 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +import { Doc, KEY_LIST_DOC } from '../../doc-management'; + +export type CreateDocParam = Pick & { + parentId: string; +}; + +export const createDocChildren = async ({ + title, + parentId, +}: CreateDocParam): Promise => { + const response = await fetchAPI(`documents/${parentId}/children/`, { + method: 'POST', + body: JSON.stringify({ + title, + }), + }); + + if (!response.ok) { + throw new APIError('Failed to create the doc', await errorCauses(response)); + } + + return response.json() as Promise; +}; + +interface CreateDocProps { + onSuccess: (data: Doc) => void; +} + +export function useCreateChildrenDoc({ onSuccess }: CreateDocProps) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createDocChildren, + onSuccess: (data) => { + void queryClient.resetQueries({ + queryKey: [KEY_LIST_DOC], + }); + onSuccess(data); + }, + }); +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDetach.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDetach.tsx new file mode 100644 index 000000000..8e261c492 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDetach.tsx @@ -0,0 +1,51 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +import { KEY_DOC, KEY_LIST_DOC } from '../../doc-management'; + +export type DetachDocParam = { + documentId: string; + rootId: string; +}; + +enum POSITION_MOVE { + FIRST_CHILD = 'first-child', + LAST_CHILD = 'last-child', + FIRST_SIBLING = 'first-sibling', + LAST_SIBLING = 'last-sibling', + LEFT = 'left', + RIGHT = 'right', +} + +export const detachDoc = async ({ + documentId, + rootId, +}: DetachDocParam): Promise => { + const response = await fetchAPI(`documents/${documentId}/move/`, { + method: 'POST', + body: JSON.stringify({ + target_document_id: rootId, + position: POSITION_MOVE.LAST_SIBLING, + }), + }); + + if (!response.ok) { + throw new APIError('Failed to move the doc', await errorCauses(response)); + } + + return response.json() as Promise; +}; + +export function useDetachDoc() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: detachDoc, + onSuccess: (_data, variables) => { + void queryClient.invalidateQueries({ queryKey: [KEY_LIST_DOC] }); + void queryClient.invalidateQueries({ + queryKey: [KEY_DOC, { id: variables.documentId }], + }); + }, + }); +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocChildren.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocChildren.tsx new file mode 100644 index 000000000..406c32a77 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocChildren.tsx @@ -0,0 +1,58 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI, useAPIInfiniteQuery } from '@/api'; + +import { DocsResponse } from '../../doc-management'; + +export type DocsChildrenParams = { + docId: string; + page?: number; + page_size?: number; +}; + +export const getDocChildren = async ( + params: DocsChildrenParams, +): Promise => { + const { docId, page, page_size } = params; + const searchParams = new URLSearchParams(); + + if (page) { + searchParams.set('page', page.toString()); + } + if (page_size) { + searchParams.set('page_size', page_size.toString()); + } + + const response = await fetchAPI( + `documents/${docId}/children/?${searchParams.toString()}`, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to get the doc children', + await errorCauses(response), + ); + } + + return response.json() as Promise; +}; + +export const KEY_LIST_DOC_CHILDREN = 'doc-children'; + +export function useDocChildren( + params: DocsChildrenParams, + queryConfig?: Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' + >, +) { + return useQuery({ + queryKey: [KEY_LIST_DOC_CHILDREN, params], + queryFn: () => getDocChildren(params), + ...queryConfig, + }); +} + +export const useInfiniteDocChildren = (params: DocsChildrenParams) => { + return useAPIInfiniteQuery(KEY_LIST_DOC_CHILDREN, getDocChildren, params); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocTree.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocTree.tsx new file mode 100644 index 000000000..bebb1d828 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocTree.tsx @@ -0,0 +1,44 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +import { Doc } from '../../doc-management'; + +export type DocsTreeParams = { + docId: string; +}; + +export const getDocTree = async ({ docId }: DocsTreeParams): Promise => { + const searchParams = new URLSearchParams(); + + const response = await fetchAPI( + `documents/${docId}/tree/?${searchParams.toString()}`, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to get the doc tree', + await errorCauses(response), + ); + } + + return response.json() as Promise; +}; + +export const KEY_LIST_DOC_CHILDREN = 'doc-tree'; + +export function useDocTree( + params: DocsTreeParams, + queryConfig?: Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' + >, +) { + return useQuery({ + queryKey: [KEY_LIST_DOC_CHILDREN, params], + queryFn: () => getDocTree(params), + staleTime: 0, + refetchOnWindowFocus: false, + ...queryConfig, + }); +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/useMove.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useMove.tsx new file mode 100644 index 000000000..1ba87df43 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useMove.tsx @@ -0,0 +1,36 @@ +import { TreeViewMoveModeEnum } from '@gouvfr-lasuite/ui-kit'; +import { useMutation } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +export type MoveDocParam = { + sourceDocumentId: string; + targetDocumentId: string; + position: TreeViewMoveModeEnum; +}; + +export const moveDoc = async ({ + sourceDocumentId, + targetDocumentId, + position, +}: MoveDocParam): Promise => { + const response = await fetchAPI(`documents/${sourceDocumentId}/move/`, { + method: 'POST', + body: JSON.stringify({ + target_document_id: targetDocumentId, + position, + }), + }); + + if (!response.ok) { + throw new APIError('Failed to move the doc', await errorCauses(response)); + } + + return response.json() as Promise; +}; + +export function useMoveDoc() { + return useMutation({ + mutationFn: moveDoc, + }); +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/assets/doc-extract-bold.svg b/src/frontend/apps/impress/src/features/docs/doc-tree/assets/doc-extract-bold.svg new file mode 100644 index 000000000..47d4fa1a9 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/assets/doc-extract-bold.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/assets/sub-page-logo.svg b/src/frontend/apps/impress/src/features/docs/doc-tree/assets/sub-page-logo.svg new file mode 100644 index 000000000..790684c6e --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/assets/sub-page-logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx new file mode 100644 index 000000000..a49db979e --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx @@ -0,0 +1,170 @@ +import { + TreeViewItem, + TreeViewNodeProps, + useTreeContext, +} from '@gouvfr-lasuite/ui-kit'; +import { useRouter } from 'next/navigation'; +import { useEffect, useRef, useState } from 'react'; +import { css } from 'styled-components'; + +import { Box, Icon, Text } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; +import { + Doc, + KEY_SUB_PAGE, + useDoc, + useTrans, +} from '@/features/docs/doc-management'; +import { useLeftPanelStore } from '@/features/left-panel'; + +import Logo from './../assets/sub-page-logo.svg'; +import { DocTreeItemActions } from './DocTreeItemActions'; + +const ItemTextCss = css` + overflow: hidden; + text-overflow: ellipsis; + white-space: initial; + display: -webkit-box; + line-clamp: 1; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; +`; + +type Props = TreeViewNodeProps; +export const DocSubPageItem = (props: Props) => { + const doc = props.node.data.value as Doc; + const treeContext = useTreeContext(); + const { untitledDocument } = useTrans(doc); + const { node } = props; + const { spacingsTokens } = useCunninghamTheme(); + const [isHover, setIsHover] = useState(false); + + const router = useRouter(); + const { togglePanel } = useLeftPanelStore(); + + const isInitialLoad = useRef(false); + const { data: docQuery } = useDoc( + { id: doc.id }, + { + initialData: doc, + queryKey: [KEY_SUB_PAGE, { id: doc.id }], + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + ); + + useEffect(() => { + if (docQuery && isInitialLoad.current === true) { + treeContext?.treeData.updateNode(docQuery.id, docQuery); + } + + if (docQuery) { + isInitialLoad.current = true; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [docQuery]); + + const afterCreate = (createdDoc: Doc) => { + const actualChildren = node.data.children ?? []; + + if (actualChildren.length === 0) { + treeContext?.treeData + .handleLoadChildren(node?.data.value.id) + .then((allChildren) => { + node.open(); + + router.push(`/docs/${createdDoc.id}`); + treeContext?.treeData.setChildren(node.data.value.id, allChildren); + treeContext?.treeData.setSelectedNode(createdDoc); + togglePanel(); + }) + .catch(console.error); + } else { + const newDoc = { + ...createdDoc, + children: [], + childrenCount: 0, + parentId: node.id, + }; + treeContext?.treeData.addChild(node.data.value.id, newDoc); + node.open(); + router.push(`/docs/${createdDoc.id}`); + treeContext?.treeData.setSelectedNode(newDoc); + togglePanel(); + } + }; + + return ( + setIsHover(true)} + onMouseLeave={() => setIsHover(false)} + $css={css` + &:not(:has(.isSelected)):has(.light-doc-item-actions) { + background-color: var(--c--theme--colors--greyscale-100); + } + `} + > + { + treeContext?.treeData.setSelectedNode(props.node.data.value as Doc); + router.push(`/docs/${props.node.data.value.id}`); + }} + > + + + + + + + + {doc.title || untitledDocument} + + {doc.nb_accesses_direct >= 1 && ( + + )} + + + {isHover && ( + + + + )} + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx new file mode 100644 index 000000000..e1cd5e3bb --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx @@ -0,0 +1,247 @@ +import { + OpenMap, + TreeView, + TreeViewMoveResult, + useTreeContext, +} from '@gouvfr-lasuite/ui-kit'; +import { useRouter } from 'next/navigation'; +import { useEffect, useRef, useState } from 'react'; +import { css } from 'styled-components'; + +import { Box, StyledLink } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; + +import { Doc, KEY_SUB_PAGE, useDoc, useDocStore } from '../../doc-management'; +import { SimpleDocItem } from '../../docs-grid'; +import { useDocTree } from '../api/useDocTree'; +import { useMoveDoc } from '../api/useMove'; +import { canDrag, canDrop, serializeDocToSubPage } from '../utils'; + +import { DocSubPageItem } from './DocSubPageItem'; +import { DocTreeItemActions } from './DocTreeItemActions'; + +type DocTreeProps = { + initialTargetId: string; +}; +export const DocTree = ({ initialTargetId }: DocTreeProps) => { + const { spacingsTokens } = useCunninghamTheme(); + + const treeContext = useTreeContext(); + const { currentDoc } = useDocStore(); + const router = useRouter(); + + const previousDocId = useRef(initialTargetId); + + const { data: rootNode } = useDoc( + { id: treeContext?.root?.id ?? '' }, + { + enabled: !!treeContext?.root?.id, + initialData: treeContext?.root ?? undefined, + queryKey: [KEY_SUB_PAGE, { id: treeContext?.root?.id ?? '' }], + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + ); + + const [initialOpenState, setInitialOpenState] = useState( + undefined, + ); + + const { mutate: moveDoc } = useMoveDoc(); + + const { data } = useDocTree({ + docId: initialTargetId, + }); + + const handleMove = (result: TreeViewMoveResult) => { + moveDoc({ + sourceDocumentId: result.sourceId, + targetDocumentId: result.targetModeId, + position: result.mode, + }); + treeContext?.treeData.handleMove(result); + }; + + useEffect(() => { + if (!data) { + return; + } + + const { children: rootChildren, ...root } = data; + const children = rootChildren ?? []; + treeContext?.setRoot(root); + const initialOpenState: OpenMap = {}; + initialOpenState[root.id] = true; + const serialize = (children: Doc[]) => { + children.forEach((child) => { + child.childrenCount = child.numchild ?? 0; + if (child?.children?.length && child?.children?.length > 0) { + initialOpenState[child.id] = true; + } + serialize(child.children ?? []); + }); + }; + serialize(children); + + treeContext?.treeData.resetTree(children); + setInitialOpenState(initialOpenState); + if (initialTargetId === root.id) { + treeContext?.treeData.setSelectedNode(root); + } else { + treeContext?.treeData.selectNodeById(initialTargetId); + } + + // Because treeData change in the treeContext, we have a infinite loop + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data, initialTargetId]); + + useEffect(() => { + if ( + !currentDoc || + (previousDocId.current && previousDocId.current === currentDoc.id) + ) { + return; + } + + const item = treeContext?.treeData.getNode(currentDoc?.id ?? ''); + if (!item && currentDoc.id !== rootNode?.id) { + treeContext?.treeData.resetTree([]); + treeContext?.setRoot(currentDoc); + treeContext?.setInitialTargetId(currentDoc.id); + } else if (item) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { children, ...rest } = currentDoc; + treeContext?.treeData.updateNode( + currentDoc.id, + serializeDocToSubPage(rest), + ); + } + if (currentDoc?.id && currentDoc?.id !== previousDocId.current) { + previousDocId.current = currentDoc?.id; + } + + treeContext?.treeData.setSelectedNode(currentDoc); + + // we don't need to run this effect on every change of treeContext.data because it cause an infinite loop + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentDoc, rootNode?.id]); + + const rootIsSelected = + treeContext?.treeData.selectedNode?.id === treeContext?.root?.id; + + if (!initialTargetId || !treeContext) { + return null; + } + + return ( + + + + {treeContext.root !== null && rootNode && ( + { + e.stopPropagation(); + e.preventDefault(); + treeContext.treeData.setSelectedNode( + treeContext.root ?? undefined, + ); + router.push(`/docs/${treeContext?.root?.id}`); + }} + > + + +
+ { + const newDoc = { + ...createdDoc, + children: [], + childrenCount: 0, + parentId: treeContext.root?.id ?? undefined, + }; + treeContext?.treeData.addChild(null, newDoc); + }} + /> +
+
+
+ )} +
+
+ + {initialOpenState && treeContext.treeData.nodes.length > 0 && ( + { + if (!rootNode) { + return false; + } + const parentDoc = parentNode?.data.value as Doc; + if (!parentDoc) { + return canDrop(rootNode); + } + return canDrop(parentDoc); + }} + canDrag={(node) => { + const doc = node.value as Doc; + return canDrag(doc); + }} + rootNodeId={treeContext.root?.id ?? ''} + renderNode={DocSubPageItem} + /> + )} +
+ ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx new file mode 100644 index 000000000..2fadbdd86 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx @@ -0,0 +1,167 @@ +import { + DropdownMenu, + DropdownMenuOption, + useTreeContext, +} from '@gouvfr-lasuite/ui-kit'; +import { useModal } from '@openfun/cunningham-react'; +import { useRouter } from 'next/router'; +import { Fragment, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; + +import { Box, BoxButton, Icon } from '@/components'; + +import { Doc, ModalRemoveDoc, useCopyDocLink } from '../../doc-management'; +import { useCreateChildrenDoc } from '../api/useCreateChildren'; +import { useDetachDoc } from '../api/useDetach'; +import MoveDocIcon from '../assets/doc-extract-bold.svg'; +import { useTreeUtils } from '../hooks'; +import { isOwnerOrAdmin } from '../utils'; + +type DocTreeItemActionsProps = { + doc: Doc; + parentId?: string | null; + onCreateSuccess?: (newDoc: Doc) => void; +}; + +export const DocTreeItemActions = ({ + doc, + parentId, + onCreateSuccess, +}: DocTreeItemActionsProps) => { + const [isOpen, setIsOpen] = useState(false); + const router = useRouter(); + const { t } = useTranslation(); + const deleteModal = useModal(); + + const copyLink = useCopyDocLink(doc.id); + const canUpdate = isOwnerOrAdmin(doc); + const { isCurrentParent } = useTreeUtils(doc); + const { mutate: detachDoc } = useDetachDoc(); + const treeContext = useTreeContext(); + + const handleDetachDoc = () => { + if (!treeContext?.root) { + return; + } + + detachDoc( + { documentId: doc.id, rootId: treeContext.root.id }, + { + onSuccess: () => { + treeContext.treeData.deleteNode(doc.id); + if (treeContext.root) { + treeContext.treeData.setSelectedNode(treeContext.root); + void router.push(`/docs/${treeContext.root.id}`); + } + }, + }, + ); + }; + + const options: DropdownMenuOption[] = [ + { + label: t('Copy link'), + icon: , + callback: copyLink, + }, + ...(!isCurrentParent + ? [ + { + label: t('Convert to doc'), + isDisabled: !canUpdate, + icon: ( + + + + ), + callback: handleDetachDoc, + }, + ] + : []), + { + label: t('Delete'), + isDisabled: !canUpdate, + icon: , + callback: deleteModal.open, + }, + ]; + + const { mutate: createChildrenDoc } = useCreateChildrenDoc({ + onSuccess: (newDoc) => { + onCreateSuccess?.(newDoc); + }, + }); + + const afterDelete = () => { + if (parentId) { + treeContext?.treeData.deleteNode(doc.id); + void router.push(`/docs/${parentId}`); + } else if (doc.id === treeContext?.root?.id && !parentId) { + void router.push(`/docs/`); + } else if (treeContext && treeContext.root) { + treeContext?.treeData.deleteNode(doc.id); + void router.push(`/docs/${treeContext.root.id}`); + } + }; + + return ( + + + + { + e.stopPropagation(); + e.preventDefault(); + setIsOpen(!isOpen); + }} + iconName="more_horiz" + variant="filled" + $theme="primary" + $variation="600" + /> + + {canUpdate && ( + { + e.stopPropagation(); + e.preventDefault(); + + createChildrenDoc({ + parentId: doc.id, + }); + }} + color="primary" + > + + + )} + + {deleteModal.isOpen && ( + + )} + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/index.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/index.ts new file mode 100644 index 000000000..3fb57a348 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/index.ts @@ -0,0 +1 @@ +export * from './useTreeUtils'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useTreeUtils.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useTreeUtils.tsx new file mode 100644 index 000000000..55ebff958 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useTreeUtils.tsx @@ -0,0 +1,13 @@ +import { useTreeContext } from '@gouvfr-lasuite/ui-kit'; + +import { Doc } from '@/docs/doc-management'; + +export const useTreeUtils = (doc: Doc) => { + const treeContext = useTreeContext(); + + return { + isParent: doc.nb_accesses_ancestors <= 1, // it is a parent + isChild: doc.nb_accesses_ancestors > 1, // it is a child + isCurrentParent: treeContext?.root?.id === doc.id || doc.depth === 1, // it can be a child but not for the current user + } as const; +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/index.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/index.ts new file mode 100644 index 000000000..608f00da5 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/index.ts @@ -0,0 +1,3 @@ +export * from './api'; +export * from './hooks'; +export * from './utils'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/utils.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/utils.ts new file mode 100644 index 000000000..a35e94105 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/utils.ts @@ -0,0 +1,28 @@ +import { TreeViewDataType } from '@gouvfr-lasuite/ui-kit'; + +import { Doc, Role } from '../doc-management'; + +export const serializeDocToSubPage = (doc: Doc): Doc => { + return { ...doc, childrenCount: doc.numchild }; +}; + +export const subPageToTree = (children: Doc[]): TreeViewDataType[] => { + children.forEach((child) => { + child.childrenCount = child.numchild ?? 0; + subPageToTree(child.children ?? []); + }); + return children; +}; + +export const isOwnerOrAdmin = (doc: Doc): boolean => { + const userRole = doc.user_role; + return userRole === Role.OWNER || userRole === Role.ADMIN; +}; + +export const canDrag = (doc: Doc): boolean => { + return isOwnerOrAdmin(doc); +}; + +export const canDrop = (doc: Doc): boolean => { + return isOwnerOrAdmin(doc); +}; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx new file mode 100644 index 000000000..ea756924d --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx @@ -0,0 +1,174 @@ +import { DndContext, DragOverlay, Modifier } from '@dnd-kit/core'; +import { getEventCoordinates } from '@dnd-kit/utilities'; +import { TreeViewMoveModeEnum } from '@gouvfr-lasuite/ui-kit'; +import { useQueryClient } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Box, Text } from '@/components'; +import { Doc, KEY_LIST_DOC, Role } from '@/docs/doc-management'; +import { useMoveDoc } from '@/docs/doc-tree/api/useMove'; + +import { useDragAndDrop } from '../hooks/useDragAndDrop'; + +import { DocsGridItem } from './DocsGridItem'; +import { Draggable } from './Draggable'; +import { Droppable } from './Droppable'; + +const snapToTopLeft: Modifier = ({ + activatorEvent, + draggingNodeRect, + transform, +}) => { + if (draggingNodeRect && activatorEvent) { + const activatorCoordinates = getEventCoordinates(activatorEvent); + + if (!activatorCoordinates) { + return transform; + } + + const offsetX = activatorCoordinates.x - draggingNodeRect.left; + const offsetY = activatorCoordinates.y - draggingNodeRect.top; + + return { + ...transform, + x: transform.x + offsetX - 3, + y: transform.y + offsetY - 3, + }; + } + + return transform; +}; + +type DocGridContentListProps = { + docs: Doc[]; +}; + +export const DocGridContentList = ({ docs }: DocGridContentListProps) => { + const { mutate: handleMove, isError } = useMoveDoc(); + const queryClient = useQueryClient(); + const onDrag = (sourceDocumentId: string, targetDocumentId: string) => + handleMove( + { + sourceDocumentId, + targetDocumentId, + position: TreeViewMoveModeEnum.FIRST_CHILD, + }, + { + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [KEY_LIST_DOC], + }); + }, + }, + ); + + const { + selectedDoc, + canDrag, + canDrop, + sensors, + handleDragStart, + handleDragEnd, + updateCanDrop, + } = useDragAndDrop(onDrag); + + const { t } = useTranslation(); + + const overlayText = useMemo(() => { + if (!canDrag) { + return t('You must be the owner to move the document'); + } + if (!canDrop) { + return t('You must be at least the editor of the target document'); + } + + return selectedDoc?.title || t('Unnamed document'); + }, [canDrag, canDrop, selectedDoc, t]); + + const overlayBgColor = useMemo(() => { + if (!canDrag) { + return 'var(--c--theme--colors--danger-600)'; + } + if (canDrop !== undefined && !canDrop) { + return 'var(--c--theme--colors--danger-600)'; + } + if (isError) { + return 'var(--c--theme--colors--danger-600)'; + } + + return '#5858D3'; + }, [canDrag, canDrop, isError]); + + if (docs.length === 0) { + return null; + } + + return ( + + {docs.map((doc) => ( + + ))} + + + + {overlayText} + + + + + ); +}; + +interface DocGridItemProps { + doc: Doc; + dragMode: boolean; + canDrag: boolean; + updateCanDrop: (canDrop: boolean, isOver: boolean) => void; +} + +export const DraggableDocGridItem = ({ + doc, + dragMode, + canDrag, + updateCanDrop, +}: DocGridItemProps) => { + const userRole = doc.user_role; + const canDropItem = + userRole === Role.ADMIN || + userRole === Role.OWNER || + userRole === Role.EDITOR; + + return ( + updateCanDrop(canDropItem, isOver)} + id={doc.id} + data={doc} + > + + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx index 534252102..fc0dba2ff 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx @@ -9,7 +9,7 @@ import { useResponsiveStore } from '@/stores'; import { useResponsiveDocGrid } from '../hooks/useResponsiveDocGrid'; -import { DocsGridItem } from './DocsGridItem'; +import { DocGridContentList } from './DocGridContentList'; import { DocsGridLoader } from './DocsGridLoader'; type DocsGridProps = { @@ -37,6 +37,9 @@ export const DocsGrid = ({ is_creator_me: target === DocDefaultFilter.MY_DOCS, }), }); + + const docs = data?.pages.flatMap((page) => page.results) ?? []; + const loading = isFetching || isLoading; const hasDocs = data?.pages.some((page) => page.results.length > 0); const loadMore = (inView: boolean) => { @@ -115,11 +118,7 @@ export const DocsGrid = ({ )}
- {data?.pages.map((currentPage) => { - return currentPage.results.map((doc) => ( - - )); - })} + {hasNextPage && !loading && ( { +export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => { const { t } = useTranslation(); const { isDesktop } = useResponsiveStore(); const { flexLeft, flexRight } = useResponsiveDocGrid(); @@ -45,7 +46,9 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => { cursor: pointer; border-radius: 4px; &:hover { - background-color: var(--c--theme--colors--greyscale-100); + background-color: ${dragMode + ? 'none' + : 'var(--c--theme--colors--greyscale-100)'}; } `} className="--docs--doc-grid-item" @@ -79,25 +82,35 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => { : undefined } > - - {isPublic - ? t('Accessible to anyone') - : t('Accessible to authenticated users')} - - } - placement="top" - > -
- -
-
+ {dragMode && ( + + )} + {!dragMode && ( + + {isPublic + ? t('Accessible to anyone') + : t('Accessible to authenticated users')} + + } + placement="top" + > +
+ +
+
+ )}
)}
diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/Draggable.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/Draggable.tsx new file mode 100644 index 000000000..bafacf0b4 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/Draggable.tsx @@ -0,0 +1,26 @@ +import { Data, useDraggable } from '@dnd-kit/core'; + +type DraggableProps = { + id: string; + data?: Data; + children: React.ReactNode; +}; + +export const Draggable = (props: DraggableProps) => { + const { attributes, listeners, setNodeRef } = useDraggable({ + id: props.id, + data: props.data, + }); + + return ( +
+ {props.children} +
+ ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/Droppable.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/Droppable.tsx new file mode 100644 index 000000000..851bf6f6e --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/Droppable.tsx @@ -0,0 +1,53 @@ +import { Data, useDroppable } from '@dnd-kit/core'; +import { PropsWithChildren, useEffect } from 'react'; +import { css } from 'styled-components'; + +import { Box } from '@/components'; +import { Doc } from '@/docs/doc-management'; + +type DroppableProps = { + id: string; + onOver?: (isOver: boolean, data?: Data) => void; + data?: Data; + enabledDrop?: boolean; + canDrop?: boolean; +}; + +export const Droppable = ({ + onOver, + canDrop, + data, + children, + id, +}: PropsWithChildren) => { + const { isOver, setNodeRef } = useDroppable({ + id, + data, + }); + + const enableHover = canDrop && isOver; + + useEffect(() => { + onOver?.(isOver, data); + }, [isOver, data, onOver]); + + return ( + + {children} + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/SimpleDocItem.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/SimpleDocItem.tsx index 1a02d87a6..3cc8108bc 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/SimpleDocItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/SimpleDocItem.tsx @@ -34,13 +34,14 @@ export const SimpleDocItem = ({ const { t } = useTranslation(); const { spacingsTokens } = useCunninghamTheme(); const { isDesktop } = useResponsiveStore(); - const { untitledDocument } = useTrans(); + const { untitledDocument } = useTrans(doc); return ( void, +) { + const [selectedDoc, setSelectedDoc] = useState(); + const [canDrop, setCanDrop] = useState(); + + const canDrag = selectedDoc?.user_role === Role.OWNER; + + const mouseSensor = useSensor(MouseSensor, { activationConstraint }); + const touchSensor = useSensor(TouchSensor, { activationConstraint }); + const keyboardSensor = useSensor(KeyboardSensor, {}); + const sensors = useSensors(mouseSensor, touchSensor, keyboardSensor); + + const handleDragStart = (e: DragStartEvent) => { + document.body.style.cursor = 'grabbing'; + if (e.active.data.current) { + setSelectedDoc(e.active.data.current as Doc); + } + }; + + const handleDragEnd = (e: DragEndEvent) => { + setSelectedDoc(undefined); + setCanDrop(undefined); + document.body.style.cursor = 'default'; + if (!canDrag || !canDrop) { + return; + } + + const { active, over } = e; + + if (!over?.id || active.id === over?.id) { + return; + } + + onDrag(active.id as string, over.id as string); + }; + + const updateCanDrop = (docCanDrop: boolean, isOver: boolean) => { + if (isOver) { + setCanDrop(docCanDrop); + } + }; + + return { + selectedDoc, + canDrag, + canDrop, + sensors, + handleDragStart, + handleDragEnd, + updateCanDrop, + }; +} diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelDocContent.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelDocContent.tsx index 505735780..928af5ec3 100644 --- a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelDocContent.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelDocContent.tsx @@ -1,14 +1,15 @@ -import { css } from 'styled-components'; +import { useTreeContext } from '@gouvfr-lasuite/ui-kit'; -import { Box, SeparatedSection } from '@/components'; -import { useCunninghamTheme } from '@/cunningham'; -import { useDocStore } from '@/docs/doc-management'; -import { SimpleDocItem } from '@/docs/docs-grid'; +import { Box } from '@/components'; +import { Doc, useDocStore } from '@/docs/doc-management'; +import { DocTree } from '@/features/docs/doc-tree/components/DocTree'; export const LeftPanelDocContent = () => { const { currentDoc } = useDocStore(); - const { spacingsTokens } = useCunninghamTheme(); - if (!currentDoc) { + + const tree = useTreeContext(); + + if (!currentDoc || !tree) { return null; } @@ -19,19 +20,9 @@ export const LeftPanelDocContent = () => { $css="width: 100%; overflow-y: auto; overflow-x: hidden;" className="--docs--left-panel-doc-content" > - - - - - - - + {tree.initialTargetId && ( + + )} ); }; diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx index 7177bc2c7..b13f690bc 100644 --- a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx @@ -3,7 +3,7 @@ import { t } from 'i18next'; import { useRouter } from 'next/navigation'; import { PropsWithChildren, useCallback, useState } from 'react'; -import { Box, Icon, SeparatedSection } from '@/components'; +import { Box, DropdownMenu, Icon, SeparatedSection } from '@/components'; import { useCreateDoc } from '@/docs/doc-management'; import { DocSearchModal } from '@/docs/doc-search'; import { useAuth } from '@/features/auth'; @@ -14,6 +14,8 @@ import { useLeftPanelStore } from '../stores'; export const LeftPanelHeader = ({ children }: PropsWithChildren) => { const router = useRouter(); const { authenticated } = useAuth(); + const isDoc = router.pathname === '/docs/[id]'; + const [isSearchModalOpen, setIsSearchModalOpen] = useState(false); const openSearchModal = useCallback(() => { @@ -49,6 +51,16 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => { createDoc(); }; + const handleImportFilesystem = () => { + // TODO: Implement filesystem import + }; + + const handleImportNotion = () => { + const baseApiUrl = process.env.NEXT_PUBLIC_API_ORIGIN; + const notionAuthUrl = `${baseApiUrl}/api/v1.0/notion_import/redirect`; + window.location.href = notionAuthUrl; + }; + return ( <> @@ -81,16 +93,50 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => { )} {authenticated && ( - + + + )} {children}
{isSearchModalOpen && ( - + )} ); diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeaderButton.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeaderButton.tsx new file mode 100644 index 000000000..7589f9554 --- /dev/null +++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeaderButton.tsx @@ -0,0 +1,73 @@ +import { useTreeContext } from '@gouvfr-lasuite/ui-kit'; +import { Button } from '@openfun/cunningham-react'; +import { useRouter } from 'next/router'; +import { useTranslation } from 'react-i18next'; + +import { Doc, useCreateDoc, useDocStore } from '@/features/docs'; +import { useCreateChildrenDoc } from '@/features/docs/doc-tree/api/useCreateChildren'; +import { isOwnerOrAdmin } from '@/features/docs/doc-tree/utils'; + +import { useLeftPanelStore } from '../stores'; + +export const LeftPanelHeaderButton = () => { + const router = useRouter(); + const isDoc = router.pathname === '/docs/[id]'; + + if (isDoc) { + return ; + } + + return ; +}; + +export const LeftPanelHeaderHomeButton = () => { + const router = useRouter(); + const { t } = useTranslation(); + const { togglePanel } = useLeftPanelStore(); + const { mutate: createDoc } = useCreateDoc({ + onSuccess: (doc) => { + void router.push(`/docs/${doc.id}`); + togglePanel(); + }, + }); + return ( + + ); +}; + +export const LeftPanelHeaderDocButton = () => { + const router = useRouter(); + const { currentDoc } = useDocStore(); + const { t } = useTranslation(); + const { togglePanel } = useLeftPanelStore(); + const treeContext = useTreeContext(); + const tree = treeContext?.treeData; + const { mutate: createChildrenDoc } = useCreateChildrenDoc({ + onSuccess: (doc) => { + tree?.addRootNode(doc); + tree?.selectNodeById(doc.id); + void router.push(`/docs/${doc.id}`); + togglePanel(); + }, + }); + + const onCreateDoc = () => { + if (treeContext && treeContext.root) { + createChildrenDoc({ + parentId: treeContext.root.id, + }); + } + }; + + return ( + + ); +}; diff --git a/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts index 7a03f291d..ebb88f40c 100644 --- a/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts +++ b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts @@ -1,7 +1,7 @@ import { WorkboxPlugin } from 'workbox-core'; import { Doc, DocsResponse } from '@/docs/doc-management'; -import { LinkReach, LinkRole } from '@/docs/doc-management/types'; +import { LinkReach, LinkRole, Role } from '@/docs/doc-management/types'; import { DBRequest, DocsDB } from '../DocsDB'; import { RequestSerializer } from '../RequestSerializer'; @@ -172,9 +172,11 @@ export class ApiPlugin implements WorkboxPlugin { content: '', created_at: new Date().toISOString(), creator: 'dummy-id', + depth: 1, is_favorite: false, nb_accesses_direct: 1, nb_accesses_ancestors: 1, + numchild: 0, updated_at: new Date().toISOString(), abilities: { accesses_manage: true, @@ -198,9 +200,21 @@ export class ApiPlugin implements WorkboxPlugin { versions_destroy: true, versions_list: true, versions_retrieve: true, + link_select_options: { + public: [LinkRole.READER, LinkRole.EDITOR], + authenticated: [LinkRole.READER, LinkRole.EDITOR], + restricted: undefined, + }, }, link_reach: LinkReach.RESTRICTED, link_role: LinkRole.READER, + user_roles: [Role.OWNER], + user_role: Role.OWNER, + path: '', + computed_link_reach: LinkReach.RESTRICTED, + computed_link_role: LinkRole.READER, + ancestors_link_reach: LinkReach.RESTRICTED, + ancestors_link_role: undefined, }; await DocsDB.cacheResponse( diff --git a/src/frontend/apps/impress/src/features/service-worker/service-worker.ts b/src/frontend/apps/impress/src/features/service-worker/service-worker.ts index b4db83f6d..42772df3d 100644 --- a/src/frontend/apps/impress/src/features/service-worker/service-worker.ts +++ b/src/frontend/apps/impress/src/features/service-worker/service-worker.ts @@ -112,6 +112,7 @@ const precacheResources = [ '/accessibility/', '/legal-notice/', '/personal-data-cookies/', + '/import-notion', FALLBACK.offline, FALLBACK.images, FALLBACK.docs, diff --git a/src/frontend/apps/impress/src/i18n/translations.json b/src/frontend/apps/impress/src/i18n/translations.json index fb906dea0..9860fae69 100644 --- a/src/frontend/apps/impress/src/i18n/translations.json +++ b/src/frontend/apps/impress/src/i18n/translations.json @@ -166,6 +166,7 @@ "No text selected": "Kein Text ausgewählt", "No versions": "Keine Versionen", "Nothing exceptional, no special privileges related to a .gouv.fr.": "Nichts Außergewöhnliches, keine besonderen Privilegien im Zusammenhang mit .gouv.fr.", + "Notion import in progress...": "Notion-Import in Arbeit...", "OK": "OK", "Offline ?!": "Offline?!", "Only invited people can access": "Nur eingeladene Personen haben Zugriff", @@ -182,6 +183,7 @@ "Pin document icon": "Pinne das Dokumentenlogo an", "Pinned documents": "Angepinnte Dokumente", "Please download it only if it comes from a trusted source.": "Bitte laden Sie es nur herunter, wenn es von einer vertrauenswürdigen Quelle stammt.", + "Please stay on this page and be patient": "Bitte bleiben Sie auf dieser Seite und haben Sie Geduld", "Private": "Privat", "Proconnect Login": "Proconnect-Anmeldung", "Public": "Öffentlich", @@ -399,6 +401,7 @@ "No text selected": "No hay texto seleccionado", "No versions": "No hay versiones", "Nothing exceptional, no special privileges related to a .gouv.fr.": "Nada excepcional, no hay privilegios especiales relacionados con un .gouv.fr.", + "Notion import in progress...": "Importación de Notion en curso...", "OK": "Ok", "Offline ?!": "¿¡Sin conexión!?", "Only invited people can access": "Solo las personas invitadas pueden acceder", @@ -415,6 +418,7 @@ "Pin document icon": "Icono para marcar el documento como favorito", "Pinned documents": "Documentos favoritos", "Please download it only if it comes from a trusted source.": "Por favor, descárguelo solo si viene de una fuente de confianza.", + "Please stay on this page and be patient": "Rimanete su questa pagina e siate pazienti", "Private": "Privado", "Proconnect Login": "Iniciar sesión ProConnect", "Public": "Público", @@ -624,6 +628,10 @@ "No text selected": "Aucun texte sélectionné", "No versions": "Aucune version", "Nothing exceptional, no special privileges related to a .gouv.fr.": "Rien d'exceptionnel, pas de privilèges spéciaux liés à un .gouv.fr.", + "Notion import in progress...": "Import Notion en cours...", + "Notion import fetched": "🔄 Page Notion récupérée", + "Notion import imported": "✅️ Importé", + "Notion import pending": "⏸️ En attente", "OK": "OK", "Offline ?!": "Hors-ligne ?!", "Only invited people can access": "Seules les personnes invitées peuvent accéder", @@ -640,6 +648,7 @@ "Pin document icon": "Icône épingler un document", "Pinned documents": "Documents épinglés", "Please download it only if it comes from a trusted source.": "Veuillez le télécharger uniquement s'il provient d'une source fiable.", + "Please stay on this page and be patient": "Merci de rester sur cette page et de patienter un peu", "Private": "Privé", "Proconnect Login": "Login Proconnect", "Public": "Public", @@ -828,6 +837,7 @@ "No text selected": "Non è stato selezionato nessun testo", "No versions": "Nessuna versione", "Nothing exceptional, no special privileges related to a .gouv.fr.": "Niente di eccezionale, nessun privilegio speciale legato a un .gouv.fr.", + "Notion import in progress...": "Importazione di nozioni in corso...", "OK": "OK", "Offline ?!": "Offline ?!", "Only invited people can access": "Solo le persone invitate possono accedere", @@ -844,6 +854,7 @@ "Pin document icon": "Icona \"fissa documento\"", "Pinned documents": "Documenti fissati", "Please download it only if it comes from a trusted source.": "Per favore scaricalo solo se proviene da una fonte attendibile", + "Please stay on this page and be patient": "Rimanete su questa pagina e siate pazienti", "Private": "Privato", "Public": "Pubblico", "Public document": "Documento pubblico", @@ -1033,6 +1044,7 @@ "No text selected": "Geen tekst geselecteerd", "No versions": "Geen versies", "Nothing exceptional, no special privileges related to a .gouv.fr.": "Niets uitzonderlijk, geen speciale privileges gerelateerd aan een .gouv.fr.", + "Notion import in progress...": "Notion import bezig...", "OK": "Ok", "Offline ?!": "Offline ?!", "Only invited people can access": "Alleen uitgenodigde gebruikers hebben toegang", @@ -1049,6 +1061,7 @@ "Pin document icon": "Document icoon vastzetten", "Pinned documents": "Vastgepinde documenten", "Please download it only if it comes from a trusted source.": "Alleen downloaden als het van een vertrouwde bron komt.", + "Please stay on this page and be patient": "Blijf op deze pagina en heb geduld", "Private": "Privé", "Proconnect Login": "Login", "Public": "Publiek", diff --git a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx index 295672436..f391ad9e5 100644 --- a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx +++ b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx @@ -1,3 +1,4 @@ +import { TreeProvider } from '@gouvfr-lasuite/ui-kit'; import { Loader } from '@openfun/cunningham-react'; import { useQueryClient } from '@tanstack/react-query'; import Head from 'next/head'; @@ -7,14 +8,15 @@ import { useTranslation } from 'react-i18next'; import { Box, Icon, TextErrors } from '@/components'; import { DocEditor } from '@/docs/doc-editor'; +import { KEY_AUTH, setAuthUrl } from '@/features/auth'; import { Doc, KEY_DOC, useCollaboration, useDoc, useDocStore, -} from '@/docs/doc-management/'; -import { KEY_AUTH, setAuthUrl } from '@/features/auth'; +} from '@/features/docs/doc-management/'; +import { getDocChildren, subPageToTree } from '@/features/docs/doc-tree/'; import { MainLayout } from '@/layouts'; import { useBroadcastStore } from '@/stores'; import { NextPageWithLayout } from '@/types/next'; @@ -34,9 +36,17 @@ export function DocLayout() { - - - + { + const doc = await getDocChildren({ docId }); + return subPageToTree(doc.results); + }} + > + + + + ); } @@ -84,6 +94,12 @@ const DocPage = ({ id }: DocProps) => { setCurrentDoc(docQuery); }, [docQuery, setCurrentDoc, isFetching]); + useEffect(() => { + return () => { + setCurrentDoc(undefined); + }; + }, [setCurrentDoc]); + /** * We add a broadcast task to reset the query cache * when the document visibility changes. diff --git a/src/frontend/apps/impress/src/pages/globals.css b/src/frontend/apps/impress/src/pages/globals.css index c6bb8ac7a..c3fbaabea 100644 --- a/src/frontend/apps/impress/src/pages/globals.css +++ b/src/frontend/apps/impress/src/pages/globals.css @@ -76,3 +76,25 @@ main ::-webkit-scrollbar-thumb:hover, nextjs-portal { display: none; } + +.skeleton { + background: linear-gradient( + 100deg, + var(--c--theme--colors--greyscale-050) 30%, + var(--c--theme--colors--greyscale-100) 50%, + var(--c--theme--colors--greyscale-050) 70% + ); + background-size: 200% 100%; + animation: shimmer 2.5s infinite; + border-radius: 4px; +} + +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + + 100% { + background-position: 200% 0; + } +} diff --git a/src/frontend/apps/impress/src/pages/import-notion/index.tsx b/src/frontend/apps/impress/src/pages/import-notion/index.tsx new file mode 100644 index 000000000..1685f1a8c --- /dev/null +++ b/src/frontend/apps/impress/src/pages/import-notion/index.tsx @@ -0,0 +1,47 @@ +import { Loader } from '@openfun/cunningham-react'; +import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Box, Text } from '@/components'; +import { useImportNotion } from '@/features/docs/doc-management/api/useImportNotion'; +import { MainLayout } from '@/layouts'; +import { NextPageWithLayout } from '@/types/next'; + +const Page: NextPageWithLayout = () => { + const { t } = useTranslation(); + + const { importState, percentageValue } = useImportNotion(); + + return ( + + + {t('Notion import in progress...')} + + + {t('Please stay on this page and be patient')} + + + + {percentageValue}% + + + {importState?.map((page) => ( + {`${page.title} - ${t(`Notion import ${page.status}`)}`} + ))} + + + ); +}; + +Page.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default Page; diff --git a/src/frontend/apps/impress/src/tests/utils.tsx b/src/frontend/apps/impress/src/tests/utils.tsx index b0d7c7ded..523fa01d5 100644 --- a/src/frontend/apps/impress/src/tests/utils.tsx +++ b/src/frontend/apps/impress/src/tests/utils.tsx @@ -1,3 +1,4 @@ +import { TreeProvider } from '@gouvfr-lasuite/ui-kit'; import { CunninghamProvider } from '@openfun/cunningham-react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { PropsWithChildren } from 'react'; @@ -14,8 +15,10 @@ export const AppWrapper = ({ children }: PropsWithChildren) => { }); return ( - - {children} - + + + {children} + + ); }; diff --git a/src/frontend/servers/y-provider/src/handlers/convertBlocksHandler.ts b/src/frontend/servers/y-provider/src/handlers/convertBlocksHandler.ts new file mode 100644 index 000000000..05665c3b6 --- /dev/null +++ b/src/frontend/servers/y-provider/src/handlers/convertBlocksHandler.ts @@ -0,0 +1,47 @@ +//import { PartialBlock } from '@blocknote/core'; +import { ServerBlockNoteEditor } from '@blocknote/server-util'; +import { Request, Response } from 'express'; +import * as Y from 'yjs'; + +import { logger, toBase64 } from '@/utils'; + +interface ConversionRequest { + blocks: any; // TODO: PartialBlock +} + +interface ConversionResponse { + content: string; +} + +interface ErrorResponse { + error: string; +} + +export const convertBlocksHandler = async ( + req: Request< + object, + ConversionResponse | ErrorResponse, + ConversionRequest, + object + >, + res: Response, +) => { + const blocks = req.body?.blocks; + if (!blocks) { + res.status(400).json({ error: 'Invalid request: missing content' }); + return; + } + + try { + const editor = ServerBlockNoteEditor.create(); + + // Create a Yjs Document from blocks, and encode it as a base64 string + const yDocument = editor.blocksToYDoc(blocks, 'document-store'); + const content = toBase64(Y.encodeStateAsUpdate(yDocument)); + + res.status(200).json({ content }); + } catch (e) { + logger('conversion failed:', e); + res.status(500).json({ error: String(e) }); + } +}; diff --git a/src/frontend/servers/y-provider/src/handlers/index.ts b/src/frontend/servers/y-provider/src/handlers/index.ts index 75bd7f7bb..167493a30 100644 --- a/src/frontend/servers/y-provider/src/handlers/index.ts +++ b/src/frontend/servers/y-provider/src/handlers/index.ts @@ -1,3 +1,4 @@ export * from './collaborationResetConnectionsHandler'; export * from './collaborationWSHandler'; export * from './convertMarkdownHandler'; +export * from './convertBlocksHandler'; diff --git a/src/frontend/servers/y-provider/src/routes.ts b/src/frontend/servers/y-provider/src/routes.ts index 98803b87f..7b8d289bb 100644 --- a/src/frontend/servers/y-provider/src/routes.ts +++ b/src/frontend/servers/y-provider/src/routes.ts @@ -2,4 +2,5 @@ export const routes = { COLLABORATION_WS: '/collaboration/ws/', COLLABORATION_RESET_CONNECTIONS: '/collaboration/api/reset-connections/', CONVERT_MARKDOWN: '/api/convert-markdown/', + CONVERT_BLOCKS: '/api/convert-blocks/', }; diff --git a/src/frontend/servers/y-provider/src/servers/appServer.ts b/src/frontend/servers/y-provider/src/servers/appServer.ts index 5c035db79..2f99db5b1 100644 --- a/src/frontend/servers/y-provider/src/servers/appServer.ts +++ b/src/frontend/servers/y-provider/src/servers/appServer.ts @@ -9,6 +9,7 @@ import { collaborationResetConnectionsHandler, collaborationWSHandler, convertMarkdownHandler, + convertBlocksHandler, } from '../handlers'; import { corsMiddleware, httpSecurity, wsSecurity } from '../middlewares'; import { routes } from '../routes'; @@ -51,6 +52,8 @@ export const initServer = () => { */ app.post(routes.CONVERT_MARKDOWN, httpSecurity, convertMarkdownHandler); + app.post(routes.CONVERT_BLOCKS, httpSecurity, convertBlocksHandler); + Sentry.setupExpressErrorHandler(app); app.get('/ping', (req, res) => {