From 6d49898fe345f8f22e665c3130d3db54270fd762 Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Thu, 30 Jul 2026 15:49:09 +0200 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8(backend)=20add=20a=20Drive=20API?= =?UTF-8?q?=20client=20with=20user=20impersonation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The document tree, sharing and trash are moving to Drive: Docs needs a client to read and mutate its mirror items there. Every call carries a server-to-server token plus the acting user's identity so Drive applies its own permission logic; no identity headers means anonymous, which is how public link reach flows back to logged-out visitors. The client also maps Drive abilities onto the Docs abilities shape consumed by the frontend and the collaboration server, with all sharing-management abilities disabled, and caches item fetches for a few seconds to soften per-request lookups. --- src/backend/core/services/drive_client.py | 245 ++++++++++++++++++++++ src/backend/impress/settings.py | 8 + 2 files changed, 253 insertions(+) create mode 100644 src/backend/core/services/drive_client.py diff --git a/src/backend/core/services/drive_client.py b/src/backend/core/services/drive_client.py new file mode 100644 index 0000000000..3e79ceaf69 --- /dev/null +++ b/src/backend/core/services/drive_client.py @@ -0,0 +1,245 @@ +""" +Client for the Drive API. + +The document tree and sharing are owned by Drive: documents are represented +there as items of type "file" carrying `metadata.external_app == "docs"`. +Every call impersonates the acting user through the server-to-server token and +the X-User-Sub / X-User-Email headers. +""" + +import logging + +from django.conf import settings +from django.core.cache import cache + +import requests +from rest_framework import exceptions as drf_exceptions + +logger = logging.getLogger(__name__) + +ITEM_CACHE_TIMEOUT = 10 # seconds + + +class DriveClientError(Exception): + """Raised when a call to the Drive API fails.""" + + def __init__(self, message, status_code=None): + super().__init__(message) + self.status_code = status_code + + +def _headers(user): + headers = {"Authorization": f"Bearer {settings.DRIVE_SERVER_TO_SERVER_TOKEN}"} + sub = str(getattr(user, "sub", "") or "") + email = str(getattr(user, "email", "") or "") + # No identity headers means Drive computes abilities for an anonymous + # visitor (public link reach only). + if sub: + headers["X-User-Sub"] = sub + if email: + headers["X-User-Email"] = email + return headers + + +def _request(method, path, user, **kwargs): + """Perform a request to the Drive API on behalf of a user.""" + if not settings.DRIVE_API_BASE_URL or not settings.DRIVE_SERVER_TO_SERVER_TOKEN: + raise DriveClientError("Drive integration is not configured.") + + url = f"{settings.DRIVE_API_BASE_URL}{path}" + try: + response = requests.request( + method, url, headers=_headers(user), timeout=10, **kwargs + ) + except requests.RequestException as exc: + raise DriveClientError(f"Could not reach Drive: {exc}") from exc + + if response.status_code >= 400: + raise DriveClientError( + f"Drive API call failed ({response.status_code}) on {path}: " + f"{response.text[:500]}", + status_code=response.status_code, + ) + + if response.status_code == 204: + return None + + return response.json() + + +def raise_as_drf(exc): + """Convert a DriveClientError to the closest DRF exception.""" + if exc.status_code in (401, 403): + raise drf_exceptions.PermissionDenied(str(exc)) from exc + if exc.status_code == 404: + raise drf_exceptions.NotFound(str(exc)) from exc + raise drf_exceptions.APIException(str(exc)) from exc + + +def create_doc_item(user, title, parent_id=None): + """Create the Drive item representing a document, root or child.""" + payload = { + "type": "file", + "title": title or "Untitled document", + "metadata": {"external_app": "docs"}, + } + if parent_id: + return _request("post", f"/items/{parent_id}/children/", user, json=payload) + return _request("post", "/items/", user, json=payload) + + +def get_item(item_id, user): + """Fetch a Drive item (including abilities) with a short per-user cache.""" + cache_key = f"drive_item:{item_id}:{getattr(user, 'sub', 'anonymous')}" + cached = cache.get(cache_key) + if cached is not None: + return cached + + item = _request("get", f"/items/{item_id}/", user) + cache.set(cache_key, item, ITEM_CACHE_TIMEOUT) + return item + + +def get_tree(item_id, user): + """Fetch the nested descendants tree of a document from Drive.""" + return _request("get", f"/items/{item_id}/tree-descendants/", user) + + +def list_root_docs(user, page=1): + """List the user's root items pointing to Docs documents.""" + params = {"external_app": "docs", "type": "file"} + if page and str(page) != "1": + params["page"] = page + return _request("get", "/items/", user, params=params) + + +def list_children(item_id, user): + """List the direct children of a document item.""" + return _request( + "get", f"/items/{item_id}/children/", user, params={"external_app": "docs"} + ) + + +def patch_title(item_id, user, title): + """Push a document rename to Drive.""" + return _request("patch", f"/items/{item_id}/", user, json={"title": title}) + + +def delete_item(item_id, user): + """Move the Drive item mirroring a document to Drive's trash.""" + return _request("delete", f"/items/{item_id}/", user) + + +def map_drive_abilities(drive_abilities): + """ + Map a Drive item's abilities onto the Docs document abilities dict. + + The dict shape must stay identical to Document.get_abilities as the frontend + and the collaboration server both consume it. All sharing-related abilities + are disabled: sharing is managed in Drive. + """ + a = drive_abilities or {} + r = a.get("retrieve", False) + u = a.get("update", False) + + return { + "accesses_manage": False, + "accesses_view": False, + "ai_proxy": u, + "ai_transform": u, + "ai_translate": u, + "attachment_upload": u, + "media_check": r, + "can_edit": u, + "children_list": a.get("children_list", r), + "children_create": a.get("children_create", False), + "collaboration_auth": r, + "comment": u, + "formatted_content": r, + "content_patch": u, + "content_retrieve": r, + "cors_proxy": r, + "descendants": r, + "destroy": a.get("destroy", False), + "duplicate": False, + "favorite": False, + "link_configuration": False, + "invite_owner": False, + "leave": False, + "move": False, + "partial_update": u, + "restore": False, + "retrieve": r, + "media_auth": r, + "link_select_options": {}, + "tree": r, + "update": u, + "versions_destroy": u, + "versions_list": r, + "versions_retrieve": r, + "search": r, + } + + +def no_abilities(): + """All-False abilities dict used when Drive cannot be reached.""" + mapped = map_drive_abilities({}) + mapped["link_select_options"] = {} + return mapped + + +def get_doc_context(document_id, user): + """ + Return (mapped_abilities, drive_user_role) for a document, sourced from + Drive. Falls back to no abilities when the user has no access or Drive + cannot be reached. + """ + try: + item = get_item(str(document_id), user) + except DriveClientError: + return no_abilities(), None + + return map_drive_abilities(item.get("abilities")), item.get("user_role") + + +def drive_item_to_doc_dict(item): + """ + Map a Drive item payload to the shape of Docs' ListDocumentSerializer, so + the frontend can consume Drive-served lists/trees transparently. + """ + path = str(item.get("path") or "") + depth = len(path.split(".")) if path else 1 + + return { + "id": item["id"], + "abilities": map_drive_abilities(item.get("abilities")), + # Link reach/role are managed in Drive and exposed read-only. + "ancestors_link_reach": item.get("ancestors_link_reach") or "restricted", + "ancestors_link_role": item.get("ancestors_link_role"), + "computed_link_reach": item.get("computed_link_reach") or "restricted", + "computed_link_role": item.get("computed_link_role"), + "created_at": item.get("created_at"), + "creator": (item.get("creator") or {}).get("id"), + "deleted_at": item.get("deleted_at"), + "depth": depth, + "excerpt": None, + "is_favorite": False, + "link_reach": item.get("link_reach") or "restricted", + "link_role": item.get("link_role"), + "nb_accesses_ancestors": item.get("nb_accesses", 1), + "nb_accesses_direct": item.get("nb_accesses", 1), + "numchild": item.get("numchild", 0), + "path": path, + "title": item.get("title"), + "updated_at": item.get("updated_at"), + "user_role": item.get("user_role"), + } + + +def drive_tree_to_doc_tree(node): + """Recursively map a Drive nested tree node to the Docs tree node shape.""" + mapped = drive_item_to_doc_dict(node) + mapped["children"] = [ + drive_tree_to_doc_tree(child) for child in node.get("children", []) + ] + return mapped diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 7c94fb9f53..31da3eb0d7 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -80,6 +80,14 @@ class Base(Configuration): SECRET_KEY = SecretFileValue(None) SERVER_TO_SERVER_API_TOKENS = values.ListValue([]) + # Drive integration: the document tree and sharing are delegated to Drive. + DRIVE_API_BASE_URL = values.Value( + None, environ_name="DRIVE_API_BASE_URL", environ_prefix=None + ) + DRIVE_SERVER_TO_SERVER_TOKEN = values.Value( + None, environ_name="DRIVE_SERVER_TO_SERVER_TOKEN", environ_prefix=None + ) + # Application definition ROOT_URLCONF = "impress.urls" WSGI_APPLICATION = "impress.wsgi.application" From 5f5bf1efa24d92eb078a81f1b33b5a938929c55c Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Thu, 30 Jul 2026 15:49:21 +0200 Subject: [PATCH 2/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F(backend)=20delegate=20tr?= =?UTF-8?q?ee,=20sharing=20and=20permissions=20to=20Drive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents live in Drive as pointer items sharing the document id, and Drive is the source of truth for hierarchy, sharing, link reach and trash. Keeping a local copy of any of it would mean syncing two authorities, so the Document model drops its tree structure (treebeard) and access rows entirely and becomes a thin wrapper: a non-persisted drive_item, fetched per request on behalf of the user, feeds abilities, user role, link fields and tree data. Fail closed when Drive is unreachable. Lists and trees are proxied from Drive; "locally known" documents (the ones the user created or visited) only back secondary views. Deleting from Docs pushes to Drive's trash, and idempotent server-to-server endpoints let Drive propagate delete, restore and purge for whole subtrees. Public link reach now applies to anonymous visitors too, including attachments. DocumentAccess and Invitation are removed (invitations only existed to become accesses); sharing endpoints are commented, not deleted, so they can come back when Drive implements AskForAccess. --- src/backend/core/admin.py | 64 +- src/backend/core/api/permissions.py | 208 +- src/backend/core/api/serializers.py | 718 +++--- src/backend/core/api/utils.py | 52 - src/backend/core/api/viewsets.py | 1940 ++++++----------- src/backend/core/external_api/viewsets.py | 72 +- src/backend/core/factories.py | 94 +- .../management/commands/clean_document.py | 20 +- .../0033_alter_document_options_and_more.py | 57 + ...ument_remove_invitation_issuer_and_more.py | 27 + src/backend/core/models.py | 933 ++------ .../core/services/collaboration_services.py | 19 +- src/backend/core/services/search_indexers.py | 73 +- src/backend/core/signals.py | 27 - src/backend/core/tasks/mail.py | 31 +- src/backend/core/tasks/search.py | 4 +- .../tests/test_api_utils_filter_root_paths.py | 94 - src/backend/core/tests/test_utils.py | 26 - .../test_utils_create_tree_node_with_retry.py | 89 - .../tests/test_utils_filter_descendants.py | 163 -- src/backend/core/urls.py | 63 +- src/backend/core/utils/paths.py | 63 - src/backend/core/utils/treebeard.py | 62 - src/backend/core/utils/users.py | 55 - .../demo/management/commands/create_demo.py | 108 +- 25 files changed, 1601 insertions(+), 3461 deletions(-) create mode 100644 src/backend/core/migrations/0033_alter_document_options_and_more.py create mode 100644 src/backend/core/migrations/0034_remove_invitation_document_remove_invitation_issuer_and_more.py delete mode 100644 src/backend/core/tests/test_api_utils_filter_root_paths.py delete mode 100644 src/backend/core/tests/test_utils_create_tree_node_with_retry.py delete mode 100644 src/backend/core/tests/test_utils_filter_descendants.py delete mode 100644 src/backend/core/utils/paths.py delete mode 100644 src/backend/core/utils/treebeard.py delete mode 100644 src/backend/core/utils/users.py diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index e38e37f6a4..bd6f4a00eb 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -8,7 +8,6 @@ from django.shortcuts import redirect from django.utils.translation import gettext_lazy as _ -from treebeard.admin import TreeAdmin from core import models from core.tasks.user_reconciliation import user_reconciliation_csv_import_job @@ -164,16 +163,8 @@ class UserReconciliationAdmin(admin.ModelAdmin): actions = [process_reconciliation] -class DocumentAccessInline(admin.TabularInline): - """Inline admin class for document accesses.""" - - autocomplete_fields = ["user"] - model = models.DocumentAccess - extra = 0 - - @admin.register(models.Document) -class DocumentAdmin(TreeAdmin): +class DocumentAdmin(admin.ModelAdmin): """Document admin interface declaration.""" fieldsets = ( @@ -183,76 +174,23 @@ class DocumentAdmin(TreeAdmin): "fields": ( "id", "title", - ) - }, - ), - ( - _("Permissions"), - { - "fields": ( "creator", - "link_reach", - "link_role", - ) - }, - ), - ( - _("Tree structure"), - { - "fields": ( - "path", - "depth", - "numchild", - "duplicated_from", "attachments", ) }, ), ) - inlines = (DocumentAccessInline,) list_display = ( "id", "title", - "link_reach", - "link_role", "created_at", "updated_at", ) readonly_fields = ( "attachments", "creator", - "depth", - "duplicated_from", "id", - "numchild", - "path", ) search_fields = ("id", "title") -@admin.register(models.Invitation) -class InvitationAdmin(admin.ModelAdmin): - """Admin interface to handle invitations.""" - - fields = ( - "email", - "document", - "role", - "created_at", - "issuer", - ) - readonly_fields = ( - "created_at", - "is_expired", - "issuer", - ) - list_display = ( - "email", - "document", - "created_at", - "is_expired", - ) - - def save_model(self, request, obj, form, change): - obj.issuer = request.user - obj.save() diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py index 4b92b711e6..7da7ef732e 100644 --- a/src/backend/core/api/permissions.py +++ b/src/backend/core/api/permissions.py @@ -7,7 +7,7 @@ from rest_framework import permissions from core import choices -from core.models import DocumentAccess, RoleChoices, get_trashbin_cutoff +from core.models import RoleChoices, get_trashbin_cutoff # noqa: F401 ACTION_FOR_METHOD_TO_PERMISSION = { "versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}, @@ -66,122 +66,144 @@ def has_object_permission(self, request, view, obj): return False -class CanCreateInvitationPermission(permissions.BasePermission): +# POC-DRIVE-SHARING: disabled until Drive implements AskForAccess — do not delete +# class CanCreateInvitationPermission(permissions.BasePermission): +# """ +# Custom permission class to handle permission checks for managing invitations. +# """ +# +# def has_permission(self, request, view): +# user = request.user +# +# # Ensure the user is authenticated +# if not (bool(request.auth) or request.user.is_authenticated): +# return False +# +# # Apply permission checks only for creation (POST requests) +# if view.action != "create": +# return True +# +# # Check if resource_id is passed in the context +# try: +# document_id = view.kwargs["resource_id"] +# except KeyError as exc: +# raise exceptions.ValidationError( +# "You must set a document ID in kwargs to manage document invitations." +# ) from exc +# +# # Check if the user has access to manage invitations (Owner/Admin roles) +# return DocumentAccess.objects.filter( +# Q(user=user) | Q(team__in=user.teams), +# document=document_id, +# role__in=[RoleChoices.OWNER, RoleChoices.ADMIN], +# ).exists() +# +# +# POC-DRIVE-SHARING: disabled until Drive implements AskForAccess — do not delete +# class ResourceWithAccessPermission(permissions.BasePermission): +# """A permission class for invitations.""" +# +# def has_permission(self, request, view): +# """check create permission.""" +# 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 +# return abilities.get(action, False) + + +class DriveDelegatedPermission(permissions.BasePermission): """ - Custom permission class to handle permission checks for managing invitations. + Delegate document permissions to Drive: abilities are computed from the + Drive item mirroring the document, fetched server-to-server on behalf of + the current user. """ def has_permission(self, request, view): - user = request.user - - # Ensure the user is authenticated - if not (bool(request.auth) or request.user.is_authenticated): - return False + """ + Let anonymous users through: link reach is owned by Drive, so the + Drive-derived abilities (all False unless the item is public) are the + actual gate, applied in has_object_permission. List-level actions are + safe: DB-backed lists return nothing for anonymous users and Drive + proxy calls fail closed with a 401/403 from Drive. + """ + return True - # Apply permission checks only for creation (POST requests) - if view.action != "create": - return True + def has_object_permission(self, request, view, obj): + """Check the action against Drive-derived abilities.""" + # Import here to avoid a circular import through core.api.serializers + from core.services import drive_client # pylint: disable=import-outside-toplevel - # Check if resource_id is passed in the context try: - document_id = view.kwargs["resource_id"] - except KeyError as exc: - raise exceptions.ValidationError( - "You must set a document ID in kwargs to manage document invitations." - ) from exc - - # Check if the user has access to manage invitations (Owner/Admin roles) - return DocumentAccess.objects.filter( - Q(user=user) | Q(team__in=user.teams), - document=document_id, - role__in=[RoleChoices.OWNER, RoleChoices.ADMIN], - ).exists() - + item = drive_client.get_item(obj.id, request.user) + except drive_client.DriveClientError as exc: + if exc.status_code in (403, 404): + return False + drive_client.raise_as_drf(exc) -class ResourceWithAccessPermission(permissions.BasePermission): - """A permission class for invitations.""" - - def has_permission(self, request, view): - """check create permission.""" - return request.user.is_authenticated or view.action != "create" + # The document is a wrapper around the Drive item: hydrate it so + # abilities and link data flow from the instance everywhere downstream. + obj.drive_item = item + abilities = drive_client.map_drive_abilities(item.get("abilities")) - def has_object_permission(self, request, view, obj): - """Check permission for a given object.""" - abilities = obj.get_abilities(request.user) - action = view.action - return abilities.get(action, False) - - -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 - - for which the trashbin cutoff is past - - for which the current user is not owner of the document or one of its ancestors - """ - if ( - deleted_at := obj.ancestors_deleted_at - ) and deleted_at < get_trashbin_cutoff(): - raise Http404 - - 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) + return abilities.get(action, False) - 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) +# POC-DRIVE-SHARING: disabled until Drive implements AskForAccess — do not delete +# 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) class CommentPermission(permissions.BasePermission): - """Permission class for comments.""" + """ + Permission class for comments. Abilities are delegated to Drive, which + owns document sharing. + """ def has_permission(self, request, view): """Check permission for a given object.""" if view.action in ["create", "list"]: - document_abilities = view.get_document_or_404().get_abilities(request.user) - return document_abilities["comment"] + # Import here to avoid a circular import through core.api.serializers + from core.services import ( # pylint: disable=import-outside-toplevel + drive_client, + ) + + document = view.get_document_or_404() + abilities, _role = drive_client.get_doc_context(document.id, request.user) + return abilities["comment"] return True diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 0b465f135b..cf0a1070c0 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -17,14 +17,13 @@ from rest_framework import serializers from core import choices, enums, models, validators -from core.services import mime_types +from core.services import drive_client, mime_types from core.services.ai_services.legacy import AI_ACTIONS from core.services.converter_services import ( ConversionError, Converter, ) from core.utils.analytics import PosthogEventName, posthog_capture -from core.utils.treebeard import create_tree_node_with_retry class UserSerializer(serializers.ModelSerializer): @@ -81,11 +80,17 @@ class ListDocumentSerializer(serializers.ModelSerializer): """Serialize documents with limited fields for display in lists.""" is_favorite = serializers.BooleanField(read_only=True) - nb_accesses_ancestors = serializers.IntegerField(read_only=True) - nb_accesses_direct = serializers.IntegerField(read_only=True) + nb_accesses_ancestors = serializers.SerializerMethodField(read_only=True) + nb_accesses_direct = serializers.SerializerMethodField(read_only=True) user_role = serializers.SerializerMethodField(read_only=True) abilities = serializers.SerializerMethodField(read_only=True) deleted_at = serializers.SerializerMethodField(read_only=True) + # The hierarchy and sharing are owned by Drive: these keys are kept for + # the payload shape and sourced from the document's drive_item. + path = serializers.SerializerMethodField(read_only=True) + depth = serializers.SerializerMethodField(read_only=True) + numchild = serializers.SerializerMethodField(read_only=True) + excerpt = serializers.SerializerMethodField(read_only=True) class Meta: model = models.Document @@ -136,19 +141,61 @@ class Meta: ] def to_representation(self, instance): - """Precompute once per instance""" - paths_links_mapping = self.context.get("paths_links_mapping") + """ + Hydrate the document's drive_item once so every field (abilities, link + reach/role properties, hierarchy data) reads from it. - 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 - ) + Note (POC): DB-backed lists (favorites, search) trigger one Drive + fetch per row, amortized by drive_client's short per-user cache. + """ + request = self.context.get("request") + if request is not None: + try: + instance.get_drive_item(request.user) + except drive_client.DriveClientError: + pass # fields fall back to safe defaults return super().to_representation(instance) + def _get_drive_item(self, instance): + """Return the document's hydrated Drive item, if available.""" + return instance.drive_item + + def get_path(self, instance): + """Return the Drive item path, or the document id as a degenerate path.""" + item = self._get_drive_item(instance) + return item["path"] if item and item.get("path") else str(instance.pk) + + def get_depth(self, instance): + """Return the depth in the Drive tree, defaulting to root depth.""" + item = self._get_drive_item(instance) + if item and item.get("path"): + return len(str(item["path"]).split(".")) + return 1 + + def get_numchild(self, instance): + """Return the number of children as known by Drive.""" + item = self._get_drive_item(instance) + return item.get("numchild", 0) if item else 0 + + def get_excerpt(self, _instance): + """Excerpts are not supported anymore.""" + return None + + def get_nb_accesses_direct(self, instance): + """Number of accesses, as known by Drive.""" + item = self._get_drive_item(instance) + return item.get("nb_accesses", 0) if item else 0 + + def get_nb_accesses_ancestors(self, instance): + """Number of accesses including ancestors, as known by Drive.""" + return self.get_nb_accesses_direct(instance) + def get_abilities(self, instance) -> dict: - """Return abilities of the logged-in user on the instance.""" + """ + Return abilities of the logged-in user on the instance, as delegated + to Drive (which owns the document tree and sharing). + """ request = self.context.get("request") if not request: return {} @@ -157,25 +204,36 @@ def get_abilities(self, instance) -> dict: def get_user_role(self, instance): """ - Return roles of the logged-in user for the current document, - taking into account ancestors. + Return the role of the logged-in user for the current document, as + known by Drive (which owns sharing). """ - request = self.context.get("request") - return instance.get_role(request.user) if request else None + item = self._get_drive_item(instance) + return item.get("user_role") if item else None def get_deleted_at(self, instance): """Return the deleted_at of the current document.""" - return instance.ancestors_deleted_at + return instance.deleted_at class DocumentLightSerializer(serializers.ModelSerializer): """Minial document serializer for nesting in document accesses.""" + path = serializers.SerializerMethodField(read_only=True) + depth = serializers.SerializerMethodField(read_only=True) + class Meta: model = models.Document fields = ["id", "path", "depth"] read_only_fields = ["id", "path", "depth"] + def get_path(self, instance): + """The hierarchy is owned by Drive: degenerate single-node path.""" + return str(instance.pk) + + def get_depth(self, _instance): + """The hierarchy is owned by Drive: all local documents are roots.""" + return 1 + class DocumentSerializer(ListDocumentSerializer): """Serialize documents with all fields for display in detail views.""" @@ -336,97 +394,99 @@ def create(self, validated_data): raise NotImplementedError("Create is not supported for this serializer.") -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", - ] +# POC-DRIVE-SHARING: disabled until Drive implements AskForAccess — do not delete +# 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) +# +# +# POC-DRIVE-SHARING: disabled until Drive implements AskForAccess — do not delete +# 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): @@ -443,8 +503,11 @@ class ServerCreateDocumentSerializer(serializers.Serializer): """ # Document + # Optional client-supplied id, used by Drive to make the document reuse + # the id of the item pointing to it. + id = serializers.UUIDField(required=False) title = serializers.CharField(required=True) - content = serializers.CharField(required=True) + content = serializers.CharField(required=False, allow_blank=True, default="") # User sub = serializers.CharField( required=True, validators=[validators.sub_validator], max_length=255 @@ -457,6 +520,14 @@ class ServerCreateDocumentSerializer(serializers.Serializer): message = serializers.CharField(required=False) subject = serializers.CharField(required=False) + def validate_id(self, value): + """Ensure the provided ID is not already taken.""" + if models.Document.objects.filter(id=value).exists(): + raise serializers.ValidationError( + "A document with this ID already exists. You cannot override it." + ) + return value + def create(self, validated_data): """Create the document and associate it with the user or send an invitation.""" language = validated_data.get("language", settings.LANGUAGE_CODE) @@ -475,20 +546,25 @@ def create(self, validated_data): email = user.email language = user.language or language - try: - document_content = Converter().convert( - validated_data["content"], mime_types.MARKDOWN, mime_types.YJS - ) - except ConversionError as err: - raise serializers.ValidationError( - {"content": ["Could not convert content"]} - ) from err + document_content = None + if validated_data.get("content"): + try: + document_content = Converter().convert( + validated_data["content"], mime_types.MARKDOWN, mime_types.YJS + ) + except ConversionError as err: + raise serializers.ValidationError( + {"content": ["Could not convert content"]} + ) from err - document = create_tree_node_with_retry( - lambda: models.Document.add_root( - title=validated_data["title"], - creator=user, - ) + extra_document_fields = {} + if validated_data.get("id"): + extra_document_fields["id"] = validated_data["id"] + + document = models.Document.objects.create( + title=validated_data["title"], + creator=user, + **extra_document_fields, ) posthog_capture(PosthogEventName.DOC_CREATED, user, {}, document=document) @@ -502,25 +578,18 @@ def create(self, validated_data): document=document, ) - if user: - # Associate the document with the pre-existing user - models.DocumentAccess.objects.create( - document=document, - role=models.RoleChoices.OWNER, - user=user, - ) - else: - # The user doesn't exist in our database: we need to invite him/her - models.Invitation.objects.create( - document=document, - email=email, - role=models.RoleChoices.OWNER, - ) + # Sharing is owned by Drive: no local access row is created. When the + # user is not known locally yet (creator=None), Drive still grants + # access and DriveDelegatedPermission lets them in on first visit. - document.content = document_content - document.save() + if document_content is not None: + document.content = document_content + document.save() - self._send_email_notification(document, validated_data, email, language) + # Back-channel creations from Drive (id provided) are silent: the user + # initiated the creation themselves from the Drive UI. + if not validated_data.get("id"): + self._send_email_notification(document, validated_data, email, language) return document def _send_email_notification(self, document, validated_data, email, language): @@ -542,74 +611,79 @@ def update(self, instance, validated_data): raise NotImplementedError("Update is not supported for this serializer.") -class LinkDocumentSerializer(serializers.ModelSerializer): - """ - Serialize link configuration for documents. - We expose it separately from document in order to simplify and secure access control. - """ - - link_reach = serializers.ChoiceField( - choices=models.LinkReachChoices.choices, required=True - ) - - class Meta: - model = models.Document - fields = [ - "link_role", - "link_reach", - ] - - def validate(self, attrs): - """Validate that link_role and link_reach are compatible using get_select_options.""" - link_reach = attrs.get("link_reach") - link_role = attrs.get("link_role") - - if not link_reach: - raise serializers.ValidationError( - {"link_reach": _("This field is required.")} - ) - - # Get available options based on ancestors' link definition - available_options = models.LinkReachChoices.get_select_options( - **self.instance.ancestors_link_definition - ) - - # Validate link_reach is allowed - if link_reach not in available_options: - msg = _( - "Link reach '%(link_reach)s' is not allowed based on parent document configuration." - ) - raise serializers.ValidationError( - {"link_reach": msg % {"link_reach": link_reach}} - ) - - # Validate link_role is compatible with link_reach - allowed_roles = available_options[link_reach] - - # Restricted reach: link_role must be None - if link_reach == models.LinkReachChoices.RESTRICTED: - if link_role is not None: - raise serializers.ValidationError( - { - "link_role": ( - "Cannot set link_role when link_reach is 'restricted'. " - "Link role must be null for restricted reach." - ) - } - ) - return attrs - # Non-restricted: link_role must be in allowed roles - if link_role not in allowed_roles: - allowed_roles_str = ", ".join(allowed_roles) if allowed_roles else "none" - raise serializers.ValidationError( - { - "link_role": ( - f"Link role '{link_role}' is not allowed for link reach '{link_reach}'. " - f"Allowed roles: {allowed_roles_str}" - ) - } - ) - return attrs +# POC-DRIVE-SHARING: disabled until Drive implements AskForAccess — do not delete +# class LinkDocumentSerializer(serializers.ModelSerializer): +# """ +# Serialize link configuration for documents. +# We expose it separately from document in order to simplify and secure access control. +# """ +# +# link_reach = serializers.ChoiceField( +# choices=models.LinkReachChoices.choices, required=True +# ) +# # Link configuration is owned by Drive; kept for payload compatibility only. +# link_role = serializers.ChoiceField( +# choices=models.LinkRoleChoices.choices, required=False, allow_null=True +# ) +# +# class Meta: +# model = models.Document +# fields = [ +# "link_role", +# "link_reach", +# ] +# +# def validate(self, attrs): +# """Validate that link_role and link_reach are compatible using get_select_options.""" +# link_reach = attrs.get("link_reach") +# link_role = attrs.get("link_role") +# +# if not link_reach: +# raise serializers.ValidationError( +# {"link_reach": _("This field is required.")} +# ) +# +# # Get available options based on ancestors' link definition +# available_options = models.LinkReachChoices.get_select_options( +# **self.instance.ancestors_link_definition +# ) +# +# # Validate link_reach is allowed +# if link_reach not in available_options: +# msg = _( +# "Link reach '%(link_reach)s' is not allowed based on parent document configuration." +# ) +# raise serializers.ValidationError( +# {"link_reach": msg % {"link_reach": link_reach}} +# ) +# +# # Validate link_role is compatible with link_reach +# allowed_roles = available_options[link_reach] +# +# # Restricted reach: link_role must be None +# if link_reach == models.LinkReachChoices.RESTRICTED: +# if link_role is not None: +# raise serializers.ValidationError( +# { +# "link_role": ( +# "Cannot set link_role when link_reach is 'restricted'. " +# "Link role must be null for restricted reach." +# ) +# } +# ) +# return attrs +# # Non-restricted: link_role must be in allowed roles +# if link_role not in allowed_roles: +# allowed_roles_str = ", ".join(allowed_roles) if allowed_roles else "none" +# raise serializers.ValidationError( +# { +# "link_role": ( +# f"Link role '{link_role}' is not allowed for link reach '{link_reach}'. " +# f"Allowed roles: {allowed_roles_str}" +# ) +# } +# ) +# return attrs class DocumentDuplicationSerializer(serializers.Serializer): @@ -695,119 +769,123 @@ def validate(self, attrs): return attrs -class InvitationSerializer(serializers.ModelSerializer): - """Serialize invitations.""" - - abilities = serializers.SerializerMethodField(read_only=True) - - class Meta: - model = models.Invitation - fields = [ - "id", - "abilities", - "created_at", - "email", - "document", - "role", - "issuer", - "is_expired", - ] - read_only_fields = [ - "id", - "abilities", - "created_at", - "document", - "issuer", - "is_expired", - ] - - def get_abilities(self, invitation) -> dict: - """Return abilities of the logged-in user on the instance.""" - request = self.context.get("request") - if request: - return invitation.get_abilities(request.user) - return {} - - def validate(self, attrs): - """Validate invitation data.""" - request = self.context.get("request") - user = getattr(request, "user", None) - - attrs["document_id"] = self.context["resource_id"] - - # Only set the issuer if the instance is being created - if self.instance is None: - attrs["issuer"] = user - - if attrs.get("email"): - attrs["email"] = attrs["email"].lower() - - return attrs - - def validate_role(self, role): - """Custom validation for the role field.""" - request = self.context.get("request") - user = getattr(request, "user", None) - document_id = self.context["resource_id"] - - # If the role is OWNER, check if the user has OWNER access - if role == models.RoleChoices.OWNER: - if not models.DocumentAccess.objects.filter( - Q(user=user) | Q(team__in=user.teams), - document=document_id, - role=models.RoleChoices.OWNER, - ).exists(): - raise serializers.ValidationError( - "Only owners of a document can invite other users as owners." - ) - - return role - - -class RoleSerializer(serializers.Serializer): - """Serializer validating role choices.""" - - role = serializers.ChoiceField( - choices=models.RoleChoices.choices, required=False, allow_null=True - ) - - -class DocumentAskForAccessCreateSerializer(serializers.Serializer): - """Serializer for creating a document ask for access.""" - - role = serializers.ChoiceField( - choices=[ - role for role in choices.RoleChoices if role != models.RoleChoices.OWNER - ], - required=False, - default=models.RoleChoices.READER, - ) - - -class DocumentAskForAccessSerializer(serializers.ModelSerializer): - """Serializer for document ask for access model""" - - abilities = serializers.SerializerMethodField(read_only=True) - user = UserSerializer(read_only=True) - - class Meta: - model = models.DocumentAskForAccess - fields = [ - "id", - "document", - "user", - "role", - "created_at", - "abilities", - ] - read_only_fields = ["id", "document", "user", "role", "created_at", "abilities"] - - def get_abilities(self, instance) -> dict: - """Return abilities of the logged-in user on the instance.""" - request = self.context.get("request") - if request: - return instance.get_abilities(request.user) - return {} +# POC-DRIVE-SHARING: disabled until Drive implements AskForAccess — do not delete +# class InvitationSerializer(serializers.ModelSerializer): +# """Serialize invitations.""" +# +# abilities = serializers.SerializerMethodField(read_only=True) +# +# class Meta: +# model = models.Invitation +# fields = [ +# "id", +# "abilities", +# "created_at", +# "email", +# "document", +# "role", +# "issuer", +# "is_expired", +# ] +# read_only_fields = [ +# "id", +# "abilities", +# "created_at", +# "document", +# "issuer", +# "is_expired", +# ] +# +# def get_abilities(self, invitation) -> dict: +# """Return abilities of the logged-in user on the instance.""" +# request = self.context.get("request") +# if request: +# return invitation.get_abilities(request.user) +# return {} +# +# def validate(self, attrs): +# """Validate invitation data.""" +# request = self.context.get("request") +# user = getattr(request, "user", None) +# +# attrs["document_id"] = self.context["resource_id"] +# +# # Only set the issuer if the instance is being created +# if self.instance is None: +# attrs["issuer"] = user +# +# if attrs.get("email"): +# attrs["email"] = attrs["email"].lower() +# +# return attrs +# +# def validate_role(self, role): +# """Custom validation for the role field.""" +# request = self.context.get("request") +# user = getattr(request, "user", None) +# document_id = self.context["resource_id"] +# +# # If the role is OWNER, check if the user has OWNER access +# if role == models.RoleChoices.OWNER: +# if not models.DocumentAccess.objects.filter( +# Q(user=user) | Q(team__in=user.teams), +# document=document_id, +# role=models.RoleChoices.OWNER, +# ).exists(): +# raise serializers.ValidationError( +# "Only owners of a document can invite other users as owners." +# ) +# +# return role +# +# +# POC-DRIVE-SHARING: disabled until Drive implements AskForAccess — do not delete +# class RoleSerializer(serializers.Serializer): +# """Serializer validating role choices.""" +# +# role = serializers.ChoiceField( +# choices=models.RoleChoices.choices, required=False, allow_null=True +# ) +# +# +# POC-DRIVE-SHARING: disabled until Drive implements AskForAccess — do not delete +# class DocumentAskForAccessCreateSerializer(serializers.Serializer): +# """Serializer for creating a document ask for access.""" +# +# role = serializers.ChoiceField( +# choices=[ +# role for role in choices.RoleChoices if role != models.RoleChoices.OWNER +# ], +# required=False, +# default=models.RoleChoices.READER, +# ) +# +# +# POC-DRIVE-SHARING: disabled until Drive implements AskForAccess — do not delete +# class DocumentAskForAccessSerializer(serializers.ModelSerializer): +# """Serializer for document ask for access model""" +# +# abilities = serializers.SerializerMethodField(read_only=True) +# user = UserSerializer(read_only=True) +# +# class Meta: +# model = models.DocumentAskForAccess +# fields = [ +# "id", +# "document", +# "user", +# "role", +# "created_at", +# "abilities", +# ] +# read_only_fields = ["id", "document", "user", "role", "created_at", "abilities"] +# +# def get_abilities(self, instance) -> dict: +# """Return abilities of the logged-in user on the instance.""" +# request = self.context.get("request") +# if request: +# return instance.get_abilities(request.user) +# return {} class VersionFilterSerializer(serializers.Serializer): diff --git a/src/backend/core/api/utils.py b/src/backend/core/api/utils.py index 19cb03f3eb..7ff5f9d6e3 100644 --- a/src/backend/core/api/utils.py +++ b/src/backend/core/api/utils.py @@ -14,58 +14,6 @@ from rest_framework.throttling import BaseThrottle -def nest_tree(flat_list, steplen): - """ - Convert a flat list of serialized documents into a nested tree making advantage - of the`path` field and its step length. - """ - node_dict = {} - roots = [] - - # Sort the flat list by path to ensure parent nodes are processed first - flat_list.sort(key=lambda x: x["path"]) - - for node in flat_list: - node["children"] = [] # Initialize children list - node_dict[node["path"]] = node - - # Determine parent path - parent_path = node["path"][:-steplen] - - if parent_path in node_dict: - node_dict[parent_path]["children"].append(node) - else: - roots.append(node) # Collect root nodes - - if len(roots) > 1: - raise ValueError("More than one root element detected.") - - return roots[0] if roots else None - - -def filter_root_paths(paths, skip_sorting=False): - """ - Filters root paths from a list of paths representing a tree structure. - A root path is defined as a path that is not a prefix of any other path. - - Args: - paths (list of str): The list of paths. - - Returns: - list of str: The filtered list of root paths. - """ - if not skip_sorting: - paths.sort() - - root_paths = [] - for path in paths: - # If the current path is not a prefix of the last added root path, add it - if not root_paths or not path.startswith(root_paths[-1]): - root_paths.append(path) - - return root_paths - - def generate_s3_authorization_headers(key): """ Generate authorization headers for an s3 object. diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 5d9991bcbf..7230c9f163 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -9,9 +9,8 @@ import logging import socket import uuid -from collections import defaultdict from io import BytesIO -from urllib.parse import unquote, urlencode, urlparse +from urllib.parse import parse_qs, unquote, urlencode, urlparse from django.conf import settings from django.contrib.postgres.aggregates import ArrayAgg @@ -23,7 +22,7 @@ from django.db import DatabaseError, connection, transaction from django.db import models as db from django.db.models.expressions import RawSQL -from django.db.models.functions import Greatest, Left, Length +from django.db.models.functions import Greatest from django.http import Http404, StreamingHttpResponse from django.urls import reverse from django.utils import timezone @@ -50,7 +49,7 @@ from core import authentication, choices, enums, models from core.api.filters import remove_accents -from core.services import mime_types +from core.services import drive_client, mime_types from core.services.ai_services.blocknote import AIService from core.services.ai_services.legacy import get_legacy_ai_service from core.services.collaboration_services import CollaborationService @@ -69,12 +68,10 @@ get_visited_document_ids_of, ) from core.tasks.access import reset_service_connections_in_cascade -from core.tasks.mail import send_ask_for_access_mail +# POC-DRIVE-SHARING: disabled until Drive implements AskForAccess — do not delete +# from core.tasks.mail import send_ask_for_access_mail from core.utils.analytics import PosthogEventName, posthog_capture -from core.utils.paths import filter_descendants from core.utils.s3_response_stream import content_stream -from core.utils.treebeard import create_tree_node_with_retry -from core.utils.users import users_sharing_documents_with from core.utils.yjs import extract_attachments from ..enums import FeatureFlag, SearchType @@ -241,13 +238,10 @@ def get_queryset(self): # index, then only calculate precise similarity scores for sorting purposes. # # Additionally results are reordered to prefer users "closer" to the current - # user: users they recently shared documents with, then same email domain. - # To achieve that without complex SQL, we build a proximity score in Python - # and return the top N results. - # For security results, users that match neither of these proximity criteria - # are not returned at all, to prevent email enumeration. + # user: same email domain first. Sharing is owned by Drive, so the + # "recently shared with" proximity criterion is gone; to limit email + # enumeration, only same-domain users are returned. current_user = self.request.user - shared_map = users_sharing_documents_with(current_user.id) user_email_domain = get_domain_from_email(current_user.email) or "" @@ -261,54 +255,14 @@ def get_queryset(self): .order_by("-similarity") ) - # Keep only users that either share documents with the current user - # or have an email with the same domain as the current user. - filtered_candidates = [] - for u in candidates: - candidate_domain = get_domain_from_email(u.email) or "" - if shared_map.get(u.id) or ( - user_email_domain and candidate_domain == user_email_domain - ): - filtered_candidates.append(u) - - candidates = filtered_candidates - - # Build ordering key for each candidate - def _sort_key(u): - # shared priority: most recent first - # Use shared_last_at timestamp numeric for secondary ordering when shared. - shared_last_at = shared_map.get(u.id) - if shared_last_at: - is_shared = 1 - shared_score = int(shared_last_at.timestamp()) - else: - is_shared = 0 - shared_score = 0 - - # domain proximity - candidate_email_domain = get_domain_from_email(u.email) or "" - - same_full_domain = ( - 1 - if candidate_email_domain - and candidate_email_domain == user_email_domain - else 0 - ) - - # similarity fallback - sim = getattr(u, "similarity", 0) or 0 - - return ( - is_shared, - shared_score, - same_full_domain, - sim, - ) - - # Sort candidates by the key descending and return top N as a queryset-like - # list. Keep return type consistent with previous behavior (QuerySet slice - # was returned) by returning a list of model instances. - candidates.sort(key=_sort_key, reverse=True) + # Keep only users that have an email with the same domain as the + # current user. + candidates = [ + u + for u in candidates + if user_email_domain + and (get_domain_from_email(u.email) or "") == user_email_domain + ] return candidates[: settings.API_USERS_LIST_LIMIT] @@ -549,7 +503,7 @@ class DocumentViewSet( ordering_fields = ["created_at", "updated_at", "title"] pagination_class = Pagination permission_classes = [ - permissions.DocumentPermission, + permissions.DriveDelegatedPermission, ] throttle_classes = [DocumentThrottle] throttle_scope = "document" @@ -576,23 +530,16 @@ def get_queryset(self): if not user.is_authenticated: return queryset.none() - queryset = queryset.filter(ancestors_deleted_at__isnull=True) - - # Filter documents to which the current user has access... - access_documents_ids = models.DocumentAccess.objects.filter( - db.Q(user=user) | db.Q(team__in=user.teams) - ).values_list("document_id", flat=True) - - # ...or that were previously accessed and are not restricted - traced_documents_ids = models.LinkTrace.objects.filter(user=user).values_list( - "document_id", flat=True - ) + queryset = queryset.filter(deleted_at__isnull=True) + # Sharing is owned by Drive: "locally known" documents are the ones the + # user created or already visited (a LinkTrace is written on retrieve). return queryset.filter( - db.Q(id__in=access_documents_ids) - | ( - db.Q(id__in=traced_documents_ids) - & ~db.Q(link_reach=models.LinkReachChoices.RESTRICTED) + db.Q(creator=user) + | db.Q( + id__in=models.LinkTrace.objects.filter(user=user).values_list( + "document_id", flat=True + ) ) ) @@ -601,7 +548,6 @@ def filter_queryset(self, queryset): queryset = super().filter_queryset(queryset) user = self.request.user queryset = queryset.annotate_is_favorite(user) - queryset = queryset.annotate_user_roles(user) queryset = queryset.annotate_user_has_link_trace(user) return queryset @@ -625,43 +571,37 @@ def list(self, request, *args, **kwargs): 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. """ - user = request.user - - # Not calling filter_queryset. We do our own cooking. - queryset = self.get_queryset() - - filterset = ListDocumentFilter(request.GET, queryset=queryset, request=request) - if not filterset.is_valid(): - raise drf.exceptions.ValidationError(filterset.errors) - filter_data = filterset.form.cleaned_data - - # Filter as early as possible on fields that are available on the model - for field in ["is_creator_me", "title", "q"]: - queryset = filterset.filters[field].filter(queryset, filter_data[field]) - - queryset = queryset.annotate_user_roles(user).annotate_user_has_link_trace(user) + # The document list is owned by Drive: fetch the user's root items + # pointing to Docs documents. Local filters (is_creator_me, favorites, + # search) are not applied in this POC. + try: + drive_page = drive_client.list_root_docs( + request.user, page=request.GET.get("page", 1) + ) + except drive_client.DriveClientError as exc: + drive_client.raise_as_drf(exc) - # 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. - root_paths = utils.filter_root_paths( - queryset.order_by("path").values_list("path", flat=True), - skip_sorting=True, - ) - queryset = queryset.filter(path__in=root_paths) + results = [ + drive_client.drive_item_to_doc_dict(item) + for item in drive_page.get("results", []) + ] - # Annotate favorite status and filter if applicable as late as possible - queryset = queryset.annotate_is_favorite(user) - queryset = filterset.filters["is_favorite"].filter( - queryset, filter_data["is_favorite"] - ) + def _page_url(drive_url): + """Rebuild a Docs pagination URL from Drive's next/previous URL.""" + if not drive_url: + return None + page = parse_qs(urlparse(drive_url).query).get("page", ["1"])[0] + return request.build_absolute_uri(f"{request.path}?page={page}") - # Apply ordering only now that everything is filtered and annotated - queryset = filters.OrderingFilter().filter_queryset( - self.request, queryset, self + return drf.response.Response( + { + "count": drive_page.get("count", len(results)), + "next": _page_url(drive_page.get("next")), + "previous": _page_url(drive_page.get("previous")), + "results": results, + } ) - return self.get_response_for_queryset(queryset) - def retrieve(self, request, *args, **kwargs): """ Add a trace that the document was accessed by a user. This is used to list documents @@ -728,29 +668,42 @@ def _apply_uploaded_file_conversion(self, serializer): ) from err def perform_create(self, serializer): - """Set the current user as creator and owner of the newly created object.""" + """ + Create the Drive item first (Drive owns the document tree), then create + the local document reusing the Drive item id. + """ self._apply_uploaded_file_conversion(serializer) - obj = create_tree_node_with_retry( - lambda: models.Document.add_root( - creator=self.request.user, - **serializer.validated_data, + try: + drive_item = drive_client.create_doc_item( + self.request.user, serializer.validated_data.get("title") ) + except drive_client.DriveClientError as exc: + drive_client.raise_as_drf(exc) + + serializer.validated_data["id"] = drive_item["id"] + + obj = models.Document.objects.create( + creator=self.request.user, + **serializer.validated_data, ) serializer.instance = obj - models.DocumentAccess.objects.create( - document=obj, - user=self.request.user, - role=models.RoleChoices.OWNER, - ) posthog_capture( PosthogEventName.DOC_CREATED, self.request.user, {}, document=obj ) def perform_destroy(self, instance): - """Override to implement a soft delete instead of dumping the record in database.""" + """ + Soft delete the document locally after moving the Drive item, which + owns the tree and the trash, to Drive's trashbin. + """ + try: + drive_client.delete_item(str(instance.pk), self.request.user) + except drive_client.DriveClientError as exc: + drive_client.raise_as_drf(exc) + instance.soft_delete() posthog_capture( @@ -813,7 +766,24 @@ def perform_update(self, serializer): "You are not allowed to edit this document." ) - return super().perform_update(serializer) + old_title = serializer.instance.title + result = super().perform_update(serializer) + + # Titles are owned by Drive: push renames so lists/trees stay in sync. + new_title = serializer.instance.title + if new_title and new_title != old_title: + try: + drive_client.patch_title( + str(serializer.instance.id), self.request.user, new_title + ) + except drive_client.DriveClientError as exc: + logger.warning( + "Could not push rename of document %s to Drive: %s", + serializer.instance.id, + exc, + ) + + return result @drf.decorators.action( detail=True, @@ -841,28 +811,14 @@ def favorite_list(self, request, *args, **kwargs): """Get list of favorite documents for the current user.""" user = request.user - queryset = self.get_queryset() - - # 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. - root_paths = utils.filter_root_paths( - queryset.order_by("path").values_list("path", flat=True), - skip_sorting=True, - ) - - path_list = db.Q() - for path in root_paths: - path_list |= db.Q(path__startswith=path) - favorite_documents_ids = models.DocumentFavorite.objects.filter( user=user ).values_list("document_id", flat=True) - queryset = self.queryset.filter(path_list) + queryset = self.get_queryset() queryset = queryset.filter(id__in=favorite_documents_ids) - queryset = queryset.filter(ancestors_deleted_at__isnull=True) queryset = queryset.order_by("-updated_at") - queryset = queryset.annotate_user_roles(user).annotate_user_has_link_trace(user) + queryset = queryset.annotate_user_has_link_trace(user) queryset = queryset.annotate( is_favorite=db.Value(True, output_field=db.BooleanField()) ) @@ -876,45 +832,10 @@ def favorite_list(self, request, *args, **kwargs): ) def trashbin(self, request, *args, **kwargs): """ - Retrieve soft-deleted documents for which the current user has the owner role. - - The selected documents are those deleted within the cutoff period defined in the - settings (see TRASHBIN_CUTOFF_DAYS), before they are considered permanently deleted. + Drive owns the document tree and its trash: the Drive trashbin is the + single source of truth for deleted documents. """ - - if not request.user.is_authenticated: - return self.get_response_for_queryset(self.queryset.none()) - - access_documents_paths = ( - models.DocumentAccess.objects.select_related("document") - .filter( - db.Q(user=self.request.user) | db.Q(team__in=self.request.user.teams), - role=models.RoleChoices.OWNER, - ) - .values_list("document__path", flat=True) - ) - - if not access_documents_paths: - return self.get_response_for_queryset(self.queryset.none()) - - children_clause = db.Q() - for path in access_documents_paths: - children_clause |= db.Q(path__startswith=path) - - queryset = self.queryset.filter( - children_clause, - deleted_at__isnull=False, - deleted_at__gte=models.get_trashbin_cutoff(), - ) - queryset = queryset.annotate_user_roles( - self.request.user - ).annotate_user_has_link_trace(self.request.user) - - queryset = filters.OrderingFilter().filter_queryset( - self.request, queryset, self - ) - - return self.get_response_for_queryset(queryset) + return self.get_response_for_queryset(self.queryset.none()) @drf.decorators.action( authentication_classes=[authentication.ServerToServerAuthentication], @@ -941,119 +862,110 @@ def create_for_owner(self, request): {"id": str(document.id)}, status=status.HTTP_201_CREATED ) - @drf.decorators.action(detail=True, methods=["post"]) - @transaction.atomic - def move(self, request, *args, **kwargs): - """ - Move a document to another location within the document tree. + def _validate_s2s_ids(self, request): + """Validate and return the list of document UUIDs of a batch S2S call.""" + ids = request.data.get("ids") + if not isinstance(ids, list) or not ids: + raise drf.exceptions.ValidationError({"ids": "A non-empty list is required."}) + try: + return [uuid.UUID(str(value)) for value in ids] + except (ValueError, TypeError) as exc: + raise drf.exceptions.ValidationError( + {"ids": "All values must be valid UUIDs."} + ) from exc - The user must be an administrator or owner of both the document being moved - and the target parent document. + @drf.decorators.action( + authentication_classes=[authentication.ServerToServerAuthentication], + detail=False, + methods=["post"], + permission_classes=[], + url_path="s2s-delete", + ) + def s2s_delete(self, request): """ - user = request.user - document = self.get_object() # including permission checks - - # Validate the input payload - serializer = serializers.MoveDocumentSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - validated_data = serializer.validated_data + Soft-delete documents whose Drive pointer items were trashed. + Idempotent: unknown or already deleted ids are skipped silently. + """ + ids = self._validate_s2s_ids(request) + deleted = 0 + for document in models.Document.objects.filter( + id__in=ids, deleted_at__isnull=True + ): + document.soft_delete() + deleted += 1 - target_document_id = validated_data["target_document_id"] - try: - target_document = models.Document.objects.get( - id=target_document_id, ancestors_deleted_at__isnull=True - ) - except models.Document.DoesNotExist: - return drf.response.Response( - {"target_document_id": "Target parent document does not exist."}, - status=status.HTTP_400_BAD_REQUEST, - ) + return drf_response.Response({"deleted": deleted}, status=status.HTTP_200_OK) - position = validated_data["position"] - message = None - owner_accesses = [] - if position in [ - enums.MoveNodePositionChoices.FIRST_CHILD, - enums.MoveNodePositionChoices.LAST_CHILD, - ]: - if not target_document.get_abilities(user).get("move"): - message = ( - "You do not have permission to move documents " - "as a child to this target document." - ) - elif target_document.is_root(): - owner_accesses = list( - 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( - {"target_document_id": message}, - status=status.HTTP_400_BAD_REQUEST, - ) + @drf.decorators.action( + authentication_classes=[authentication.ServerToServerAuthentication], + detail=False, + methods=["post"], + permission_classes=[], + url_path="s2s-restore", + ) + def s2s_restore(self, request): + """ + Restore documents whose Drive pointer items were restored from trash. + Drive's retention window is authoritative: local cutoff errors are + swallowed. Idempotent. + """ + ids = self._validate_s2s_ids(request) + restored = 0 + for document in models.Document.objects.filter( + id__in=ids, deleted_at__isnull=False + ): + try: + document.restore() + restored += 1 + except RuntimeError as exc: + logger.warning("Could not restore document %s: %s", document.id, exc) - try: - document.move(target_document, pos=position) - except InvalidMoveToDescendant: - return drf.response.Response( - {"target_document_id": "Cannot move a document to its own descendant."}, - status=status.HTTP_400_BAD_REQUEST, - ) + return drf_response.Response({"restored": restored}, status=status.HTTP_200_OK) - # A move changes the document's permission scope in any of these cases: - # - it is currently a root (it carries its own scope), - # - it is moving into a different tree (different current root than target's), - # - it is being promoted to root as a sibling of its own current root. - # In all these cases, direct accesses and pending invitations must be wiped so - # the document inherits the new scope. Deletions and the move share the same - # atomic transaction, so a failure rolls everything back. - becomes_sibling_root = ( - position - not in [ - enums.MoveNodePositionChoices.FIRST_CHILD, - enums.MoveNodePositionChoices.LAST_CHILD, - ] - and target_document.is_root() - ) - scope_changes = ( - document.is_root() - or becomes_sibling_root - or document.get_root() != target_document.get_root() - ) - if scope_changes: - document.accesses.all().delete() - document.invitations.all().delete() + @drf.decorators.action( + authentication_classes=[authentication.ServerToServerAuthentication], + detail=False, + methods=["post"], + permission_classes=[], + url_path="s2s-purge", + ) + def s2s_purge(self, request): + """ + Permanently destroy documents whose Drive pointer items were purged: + S3 content and attachments are deleted, then the row. Idempotent. + """ + ids = self._validate_s2s_ids(request) + purged = 0 + for document in models.Document.objects.filter(id__in=ids): + keys = [document.file_key, *(document.attachments or [])] + for key in keys: + try: + default_storage.delete(key) + except (ClientError, Exception): # noqa: BLE001 pylint: disable=broad-exception-caught + logger.warning( + "Could not delete storage key %s for document %s", + key, + document.id, + ) + document.delete() + purged += 1 - # 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({"purged": purged}, status=status.HTTP_200_OK) - posthog_capture( - PosthogEventName.DOC_MOVED, - user, - { - "position": position, - "targeted_document_id": str(target_document_id), - }, - document=document, - ) + @drf.decorators.action(detail=True, methods=["post"]) + @transaction.atomic + def move(self, request, *args, **kwargs): + """ + Move a document to another location within the document tree. - return drf.response.Response( - {"message": "Document moved successfully."}, status=status.HTTP_200_OK + The user must be an administrator or owner of both the document being moved + and the target parent document. + """ + # The document hierarchy is owned by Drive: moving documents from Docs + # is not supported (the "move" ability delegated by Drive is always + # False, so this action is unreachable through permissions anyway). + raise drf.exceptions.PermissionDenied( + "Documents are moved from Drive, which owns the document tree." ) @drf.decorators.action( @@ -1085,7 +997,8 @@ def children(self, request, *args, **kwargs): document = self.get_object() if request.method == "POST": - # Create a child document + # Create a child document: the hierarchy lives in Drive, so create + # the child item there and store the document locally as a root. serializer = serializers.DocumentSerializer( data=request.data, context=self.get_serializer_context() ) @@ -1093,11 +1006,20 @@ def children(self, request, *args, **kwargs): self._apply_uploaded_file_conversion(serializer) - child_document = create_tree_node_with_retry( - lambda: document.add_child( - creator=request.user, - **serializer.validated_data, + try: + drive_item = drive_client.create_doc_item( + request.user, + serializer.validated_data.get("title"), + parent_id=str(document.id), ) + except drive_client.DriveClientError as exc: + drive_client.raise_as_drf(exc) + + serializer.validated_data["id"] = drive_item["id"] + + child_document = models.Document.objects.create( + creator=request.user, + **serializer.validated_data, ) # Set the created instance to the serializer @@ -1115,30 +1037,23 @@ def children(self, request, *args, **kwargs): serializer.data, status=status.HTTP_201_CREATED, headers=headers ) - # GET: List children - queryset = ( - document.get_children() - .select_related("creator") - .filter(ancestors_deleted_at__isnull=True) - ) - queryset = self.filter_queryset(queryset) - - filterset = DocumentFilter(request.GET, queryset=queryset) - if not filterset.is_valid(): - raise drf.exceptions.ValidationError(filterset.errors) - - queryset = filterset.qs - - # 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() + # GET: List children from Drive, which owns the document tree. + try: + drive_children = drive_client.list_children(str(document.id), request.user) + except drive_client.DriveClientError as exc: + drive_client.raise_as_drf(exc) - return self.get_response_for_queryset( - queryset, - context={ - "request": request, - "paths_links_mapping": paths_links_mapping, - }, + results = [ + drive_client.drive_item_to_doc_dict(item) + for item in drive_children.get("results", []) + ] + return drf.response.Response( + { + "count": drive_children.get("count", len(results)), + "next": None, + "previous": None, + "results": results, + } ) @drf.decorators.action( @@ -1158,20 +1073,9 @@ def all(self, request, *args, **kwargs): user = self.request.user - accessible_documents = self.get_queryset() - accessible_paths = list(accessible_documents.values_list("path", flat=True)) - - if not accessible_paths: - return self.get_response_for_queryset(self.queryset.none()) - - # Build query to include all descendants using path prefix matching - descendants_clause = db.Q() - for path in accessible_paths: - descendants_clause |= db.Q(path__startswith=path) - - queryset = self.queryset.filter( - descendants_clause, ancestors_deleted_at__isnull=True - ) + # The hierarchy is owned by Drive: locally accessible documents are + # exactly the ones the user has an access row on. + queryset = self.get_queryset() # Apply existing filters filterset = ListDocumentFilter( @@ -1185,7 +1089,7 @@ def all(self, request, *args, **kwargs): for field in ["is_creator_me", "title", "q"]: queryset = filterset.filters[field].filter(queryset, filter_data[field]) - queryset = queryset.annotate_user_roles(user).annotate_user_has_link_trace(user) + queryset = queryset.annotate_user_has_link_trace(user) # Annotate favorite status and filter if applicable as late as possible queryset = queryset.annotate_is_favorite(user) @@ -1207,284 +1111,37 @@ def all(self, request, *args, **kwargs): ) 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 + Return the document tree as known by Drive, which owns the hierarchy. + Drive resolves the subtree root itself (topmost readable ancestor of the + requested item), so requesting any node returns the whole document tree. + """ try: - current_document = ( - self.queryset.select_related(None) - .only("depth", "path", "ancestors_deleted_at") - .get(pk=pk) - ) - except models.Document.DoesNotExist as excpt: - raise drf.exceptions.NotFound() from excpt - - is_deleted = current_document.ancestors_deleted_at is not None + drive_tree = drive_client.get_tree(str(pk), request.user) + except drive_client.DriveClientError as exc: + drive_client.raise_as_drf(exc) - if is_deleted: - if current_document.get_role(user) != models.RoleChoices.OWNER: - raise ( - drf.exceptions.PermissionDenied() - if request.user.is_authenticated - else drf.exceptions.NotAuthenticated() - ) - highest_readable = current_document - ancestors = self.queryset.select_related(None).filter(pk=pk) - else: - ancestors = ( - ( - current_document.get_ancestors() - | self.queryset.select_related(None).filter(pk=pk) - ) - .filter(ancestors_deleted_at__isnull=True) - .order_by("path") - ) - # Get the highest readable ancestor - highest_readable = ( - ancestors.select_related(None) - .readable_per_se(request.user) - .only("depth", "path") - .first() - ) - - if highest_readable is None: - raise ( - drf.exceptions.PermissionDenied() - if request.user.is_authenticated - else drf.exceptions.NotAuthenticated() - ) - paths_links_mapping = {} - ancestors_links = [] - children_clause = db.Q() - for ancestor in ancestors: - # Compute cache for ancestors links to avoid many queries while computing - # abilities for his documents in the tree! - ancestors_links.append( - {"link_reach": ancestor.link_reach, "link_role": ancestor.link_role} - ) - paths_links_mapping[ancestor.path] = ancestors_links.copy() - - 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.select_related("creator").filter( - depth__gte=highest_readable.depth - ) - | children - ) - queryset = queryset.order_by("path") - queryset = queryset.annotate_user_roles(user) - queryset = queryset.annotate_is_favorite(user) - queryset = queryset.annotate_user_has_link_trace(user) - - # 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, - many=True, - context={ - "request": request, - "paths_links_mapping": paths_links_mapping, - }, - ) - return drf.response.Response( - utils.nest_tree(serializer.data, self.queryset.model.steplen) - ) + return drf.response.Response(drive_client.drive_tree_to_doc_tree(drive_tree)) @drf.decorators.action( detail=True, methods=["post"], permission_classes=[ permissions.IsAuthenticated, - permissions.DocumentPermission, + permissions.DriveDelegatedPermission, ], url_path="duplicate", ) - @transaction.atomic def duplicate(self, request, *args, **kwargs): """ - Duplicate a document, alongside its descendants if requested. + Duplicating documents is not supported in the Drive-integrated POC: + the "duplicate" ability delegated by Drive is always False, so this + action is unreachable through permissions anyway. """ - # Get document while checking permissions - document_to_duplicate = self.get_object() - - serializer = serializers.DocumentDuplicationSerializer( - data=request.data, partial=True + self.get_object() # permission check, always denies + raise drf.exceptions.PermissionDenied( + "Duplicating documents is not supported." ) - serializer.is_valid(raise_exception=True) - user = request.user - - duplicated_document = self._duplicate_document( - document_to_duplicate=document_to_duplicate, - serializer=serializer, - user=user, - ) - - posthog_capture( - PosthogEventName.DOC_DUPLICATED, - user, - { - "duplicated_from": str(document_to_duplicate.id), - }, - document=duplicated_document, - ) - - return drf_response.Response( - {"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED - ) - - def _duplicate_document( - self, - document_to_duplicate, - serializer, - user, - new_parent=None, - ): - """ - Duplicate a document and store the links to attached files in the duplicated - document to allow cross-access. - - Optionally duplicates accesses if `with_accesses` is set to true - in the payload. - - Optionally duplicates sub-documents if `with_descendants` is set to true in - the payload. In this case, the whole subtree of the document will be duplicated, - and the links to attached files will be stored in all duplicated documents. - - The `with_accesses` option will also be applied to all duplicated documents - if `with_descendants` is set to true. - """ - with_accesses = serializer.validated_data.get("with_accesses", False) - with_descendants = serializer.validated_data.get("with_descendants", False) - - user_role = document_to_duplicate.get_role(user) - is_owner_or_admin = user_role in models.PRIVILEGED_ROLES - - base64_yjs_content = document_to_duplicate.content - - # Duplicate the document instance - link_kwargs = ( - { - "link_reach": document_to_duplicate.link_reach, - "link_role": document_to_duplicate.link_role, - } - if with_accesses - else {} - ) - extracted_attachments = set(extract_attachments(document_to_duplicate.content)) - attachments = list( - extracted_attachments & set(document_to_duplicate.attachments) - ) - title = capfirst(_("copy of {title}").format(title=document_to_duplicate.title)) - # If parent_duplicate is provided we must add the duplicated document as a child - if new_parent is not None: - duplicated_document = new_parent.add_child( - title=title, - content=base64_yjs_content, - attachments=attachments, - duplicated_from=document_to_duplicate, - creator=user, - **link_kwargs, - ) - - # Handle access duplication for this child - if with_accesses and is_owner_or_admin: - original_accesses = models.DocumentAccess.objects.filter( - document=document_to_duplicate - ).exclude(user=user) - - accesses_to_create = [ - models.DocumentAccess( - document=duplicated_document, - user_id=access.user_id, - team=access.team, - role=access.role, - ) - for access in original_accesses - ] - - if accesses_to_create: - models.DocumentAccess.objects.bulk_create(accesses_to_create) - - elif not document_to_duplicate.is_root() and choices.RoleChoices.get_priority( - user_role - ) < choices.RoleChoices.get_priority(models.RoleChoices.EDITOR): - duplicated_document = models.Document.add_root( - creator=user, - title=title, - content=base64_yjs_content, - attachments=attachments, - duplicated_from=document_to_duplicate, - **link_kwargs, - ) - models.DocumentAccess.objects.create( - document=duplicated_document, - user=user, - role=models.RoleChoices.OWNER, - ) - else: - duplicated_document = document_to_duplicate.add_sibling( - "last-sibling", - title=title, - content=base64_yjs_content, - attachments=attachments, - duplicated_from=document_to_duplicate, - creator=user, - **link_kwargs, - ) - - # Always add the logged-in user as OWNER for root documents - if document_to_duplicate.is_root(): - accesses_to_create = [ - models.DocumentAccess( - document=duplicated_document, - user=user, - role=models.RoleChoices.OWNER, - ) - ] - - # If accesses should be duplicated, - # add other users' accesses as per original document - if with_accesses and is_owner_or_admin: - original_accesses = models.DocumentAccess.objects.filter( - document=document_to_duplicate - ).exclude(user=user) - - accesses_to_create.extend( - models.DocumentAccess( - document=duplicated_document, - user_id=access.user_id, - team=access.team, - role=access.role, - ) - for access in original_accesses - ) - - # Bulk create all the duplicated accesses - models.DocumentAccess.objects.bulk_create(accesses_to_create) - - if with_descendants: - for child in document_to_duplicate.get_children().filter( - ancestors_deleted_at__isnull=True - ): - # When duplicating descendants, attach duplicates under the duplicated_document - self._duplicate_document( - document_to_duplicate=child, - serializer=serializer, - user=user, - new_parent=duplicated_document, - ) - - return duplicated_document @drf.decorators.action(detail=False, methods=["get"], url_path="search") @utils.conditional_refresh_oidc_token @@ -1543,17 +1200,9 @@ def _search_using_indexer(indexer, request, params, search_type): """ queryset = models.Document.objects.all() - # The indexer filters descendants by path prefix, so resolve the document - # id to its path before querying it. + # The hierarchy is owned by Drive: per-document scoping by path prefix + # is not supported locally anymore. path = None - document_id = params.validated_data.get("document") - if document_id: - try: - path = models.Document.objects.get(pk=document_id).values_list( - "path", flat=True - ) - except models.Document.DoesNotExist as exc: - raise drf.exceptions.NotFound("Document not found.") from exc results = indexer.search( q=params.validated_data["q"], @@ -1563,111 +1212,32 @@ def _search_using_indexer(indexer, request, params, search_type): visited=get_visited_document_ids_of(queryset, request.user), ) - return drf_response.Response( - { - "count": len(results), - "next": None, - "previous": None, - "results": results, - } - ) - - def _get_response_for_search_queryset( - self, queryset, candidate_parent_paths, resolve_parents - ): - """ - Paginate the search results and attach to each document its top parent. - - To avoid loading every accessible root, the top parents are resolved only - for the documents on the current page: we determine which candidate parent - paths the page actually references, then `resolve_parents` fetches just those. - - Args: - queryset: the search result queryset. - candidate_parent_paths: iterable of disjoint top-parent path prefixes a - result may descend from. - resolve_parents: callable taking the set of parent paths referenced by the - current page and returning a ``{path: Document}`` mapping. - """ - page = self.paginate_queryset(queryset) - documents = list(page if page else queryset) - - candidate_parent_paths = set(candidate_parent_paths) - # Candidate roots are disjoint prefixes, so at most one is a prefix of a - # given document path. We only need to test the few distinct prefix lengths. - prefix_lengths = sorted({len(path) for path in candidate_parent_paths}) - - document_parent_path = {} - referenced_paths = set() - for document in documents: - for length in prefix_lengths: - candidate = document.path[:length] - if candidate != document.path and candidate in candidate_parent_paths: - document_parent_path[document.path] = candidate - referenced_paths.add(candidate) - break - - parents_by_path = resolve_parents(referenced_paths) if referenced_paths else {} - - for document in documents: - document.parent = parents_by_path.get( - document_parent_path.get(document.path) - ) - - serializer = self.get_serializer(documents, many=True) - - if page is None: - return drf.response.Response(serializer.data) - - return self.get_paginated_response(serializer.data) + return drf_response.Response( + { + "count": len(results), + "next": None, + "previous": None, + "results": results, + } + ) def _search_using_database(self, request, validated_data, *args, **kwargs): """ Fallback search method when no indexer is configured. - Only searches in the title field of documents. + Only searches in the title field of the documents the user has a local + access on: the hierarchy is owned by Drive, so results are flat. """ - - if validated_data.get("document"): - return self._list_descendants(request, validated_data) - - top_level_documents = self.get_queryset() - queryset = self.queryset user = request.user - filterset = DocumentFilter(request.GET, queryset=queryset, request=request) - if not filterset.is_valid(): - raise drf.exceptions.ValidationError(filterset.errors) - - # 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. - root_paths = utils.filter_root_paths( - top_level_documents.order_by("path").values_list("path", flat=True), - skip_sorting=True, - ) - - if not root_paths: - return self.get_response_for_queryset(top_level_documents.none()) - - path_list = db.Q() - for top_level_document in root_paths: - path_list |= db.Q(path__startswith=top_level_document) - - # Lazy queryset used to fetch only the top parents referenced by the page. - parents_queryset = ( - queryset.filter(ancestors_deleted_at__isnull=True) - .annotate_user_roles(user) - .annotate_is_favorite(user) - .annotate_user_has_link_trace(user) - ) - queryset = ( - queryset.filter(path_list) - .filter(ancestors_deleted_at__isnull=True) - .annotate_user_roles(user) + self.get_queryset() .annotate_is_favorite(user) .annotate_user_has_link_trace(user) ) + filterset = DocumentFilter(request.GET, queryset=queryset, request=request) + if not filterset.is_valid(): + raise drf.exceptions.ValidationError(filterset.errors) queryset = filterset.filter_queryset(queryset) # Apply ordering only now that everything is filtered and annotated @@ -1675,60 +1245,17 @@ def _search_using_database(self, request, validated_data, *args, **kwargs): self.request, queryset, self ) - return self._get_response_for_search_queryset( - queryset, - root_paths, - lambda paths: { - doc.path: doc for doc in parents_queryset.filter(path__in=paths) - }, - ) - - def _list_descendants(self, request, validated_data): - """ - List all documents descending from the document identified by the provided - document id. Includes the parent document itself. - Used internally by the search endpoint when document filtering is requested. - """ - # Get parent document without access filtering - document_id = validated_data["document"] - user = request.user - try: - parent = ( - models.Document.objects.annotate_user_roles(user) - .annotate_is_favorite(user) - .annotate_user_has_link_trace(user) - .get(pk=document_id) - ) - except models.Document.DoesNotExist as exc: - raise drf.exceptions.NotFound("Document not found.") from exc - - abilities = parent.get_abilities(user) - if not abilities.get("search"): - raise drf.exceptions.PermissionDenied( - "You do not have permission to search within this document." - ) + page = self.paginate_queryset(queryset) + documents = list(page if page else queryset) + for document in documents: + document.parent = None - # Get descendants and include the parent, ordered by path - queryset = ( - parent.get_descendants(include_self=True) - .filter(ancestors_deleted_at__isnull=True) - .order_by("path") - ) - queryset = self.filter_queryset(queryset) + serializer = self.get_serializer(documents, many=True) - # filter by title - filterset = DocumentFilter(request.GET, queryset=queryset) - if not filterset.is_valid(): - raise drf.exceptions.ValidationError(filterset.errors) + if page is None: + return drf.response.Response(serializer.data) - queryset = filterset.qs - # Every descendant's top parent is the search root itself; reuse the already - # fetched (and annotated) parent object instead of querying it again. - return self._get_response_for_search_queryset( - queryset, - [parent.path], - lambda paths: {parent.path: parent}, - ) + return self.get_paginated_response(serializer.data) @drf.decorators.action(detail=True, methods=["get"], url_path="versions") def versions_list(self, request, *args, **kwargs): @@ -1746,12 +1273,15 @@ def versions_list(self, request, *args, **kwargs): document = self.get_object() - # Users should not see version history dating from before they gained access to the - # document. Filter to get the minimum access date for the logged-in user - access_queryset = models.DocumentAccess.objects.filter( - db.Q(user=user) | db.Q(team__in=user.teams), - document__path=Left(db.Value(document.path), Length("document__path")), + # Users should not see version history dating from before they gained + # access. Sharing is owned by Drive: use the user's first visit + # (LinkTrace) as access date, falling back to the document creation + # date for its creator. + access_queryset = models.LinkTrace.objects.filter( + user=user, document_id=document.pk ).aggregate(min_date=db.Min("created_at")) + if not access_queryset["min_date"] and document.creator_id == user.id: + access_queryset["min_date"] = document.created_at # Handle the case where the user has no accesses min_datetime = access_queryset["min_date"] @@ -1784,15 +1314,19 @@ def versions_detail(self, request, pk, version_id, *args, **kwargs): raise Http404 from err # Don't let users access versions that were created before they were given access - # to the document + # to the document. Sharing is owned by Drive: use the first visit. user = request.user - min_datetime = min( - access.created_at - for access in models.DocumentAccess.objects.filter( - db.Q(user=user) | db.Q(team__in=user.teams), - document__path=Left(db.Value(document.path), Length("document__path")), - ) + if not user.is_authenticated: + raise Http404 + min_datetime = ( + models.LinkTrace.objects.filter(user=user, document_id=document.pk) + .aggregate(min_date=db.Min("created_at"))["min_date"] ) + if min_datetime is None: + if document.creator_id == user.id: + min_datetime = document.created_at + else: + raise Http404 if response["LastModified"] < min_datetime: raise Http404 @@ -1811,24 +1345,25 @@ def versions_detail(self, request, pk, version_id, *args, **kwargs): } ) - @drf.decorators.action(detail=True, methods=["put"], url_path="link-configuration") - def link_configuration(self, request, *args, **kwargs): - """Update link configuration with specific rights (cf get_abilities).""" - # Check permissions first - document = self.get_object() - - # Deserialize and validate the data - serializer = serializers.LinkDocumentSerializer( - document, data=request.data, partial=True - ) - serializer.is_valid(raise_exception=True) - - serializer.save() - - # Notify collaboration server about the link updated - reset_service_connections_in_cascade.delay(str(document.id)) - - return drf.response.Response(serializer.data, status=drf.status.HTTP_200_OK) + # POC-DRIVE-SHARING: disabled until Drive implements AskForAccess — do not delete + # @drf.decorators.action(detail=True, methods=["put"], url_path="link-configuration") + # def link_configuration(self, request, *args, **kwargs): + # """Update link configuration with specific rights (cf get_abilities).""" + # # Check permissions first + # document = self.get_object() + # + # # Deserialize and validate the data + # serializer = serializers.LinkDocumentSerializer( + # document, data=request.data, partial=True + # ) + # serializer.is_valid(raise_exception=True) + # + # serializer.save() + # + # # Notify collaboration server about the link updated + # reset_service_connections_in_cascade.delay(str(document.id)) + # + # return drf.response.Response(serializer.data, status=drf.status.HTTP_200_OK) @drf.decorators.action(detail=True, methods=["post", "delete"], url_path="favorite") def favorite(self, request, *args, **kwargs): @@ -2003,27 +1538,32 @@ def media_auth(self, request, *args, **kwargs): user = request.user key = f"{url_params['pk']:s}/{url_params['attachment']:s}" - # Look for a document to which the user has access and that includes this attachment - # We must look into all descendants of any document to which the user has access per se - readable_per_se_paths = ( + # Look for a document to which the user has access and that includes this + # attachment. The hierarchy is owned by Drive: no descendant inheritance. + has_readable_attachment = ( self.queryset.readable_per_se(user) - .order_by("path") - .values_list("path", flat=True) - ) - - attachments_documents = ( - self.queryset.select_related(None) .filter(attachments__contains=[key]) - .only("path") - .order_by("path") - ) - readable_attachments_paths = filter_descendants( - [doc.path for doc in attachments_documents], - readable_per_se_paths, - skip_sorting=True, + .exists() ) - if not readable_attachments_paths: + if not has_readable_attachment: + # Link and share accesses are owned by Drive: a user (or anonymous + # visitor) without a local trace may still read the attachment if + # Drive grants access to a document carrying it (e.g. public link). + candidate_ids = models.Document.objects.filter( + deleted_at__isnull=True, attachments__contains=[key] + ).values_list("id", flat=True)[:5] + for document_id in candidate_ids: + try: + item = drive_client.get_item(str(document_id), user) + except drive_client.DriveClientError: + continue + abilities = drive_client.map_drive_abilities(item.get("abilities")) + if abilities.get("media_auth"): + has_readable_attachment = True + break + + if not has_readable_attachment: logger.debug("User '%s' lacks permission for attachment", user) raise drf.exceptions.PermissionDenied() @@ -2076,32 +1616,15 @@ def content(self, request, *args, **kwargs): existing_attachments = set(document.attachments or []) new_attachments = extracted_attachments - existing_attachments - # Ensure we update attachments the request user is allowed to read + # Ensure we update attachments the request user is allowed to read. The + # hierarchy is owned by Drive: only directly readable documents count. if new_attachments: - attachments_documents = ( - models.Document.objects.filter( - attachments__overlap=list(new_attachments) - ) - .only("path", "attachments") - .order_by("path") - ) - user = self.request.user - readable_per_se_paths = ( - models.Document.objects.readable_per_se(user) - .order_by("path") - .values_list("path", flat=True) - ) - readable_attachments_paths = filter_descendants( - [doc.path for doc in attachments_documents], - readable_per_se_paths, - skip_sorting=True, - ) - readable_attachments = set() + attachments_documents = models.Document.objects.readable_per_se( + user + ).filter(attachments__overlap=list(new_attachments)) for attachments_document in attachments_documents: - if attachments_document.path not in readable_attachments_paths: - continue readable_attachments.update( set(attachments_document.attachments) & new_attachments ) @@ -2626,11 +2149,8 @@ def leave(self, request, *args, **kwargs): try: with transaction.atomic(): - models.DocumentAccess.objects.filter( - document__path__startswith=document.path, user=request.user - ).delete() models.LinkTrace.objects.filter( - document__path__startswith=document.path, user=request.user + document_id=document.pk, user=request.user ).delete() except DatabaseError: logger.error( @@ -2645,421 +2165,385 @@ def leave(self, request, *args, **kwargs): return drf.response.Response(status=drf.status.HTTP_204_NO_CONTENT) -class DocumentAccessViewSet( - ResourceAccessViewsetMixin, - drf.mixins.CreateModelMixin, - drf.mixins.RetrieveModelMixin, - drf.mixins.UpdateModelMixin, - drf.mixins.DestroyModelMixin, - viewsets.GenericViewSet, -): - """ - API ViewSet for all interactions with document accesses. - - GET /api/v1.0/documents//accesses/: - Return list of all document accesses related to the logged-in user or one - document access if an id is provided. - - POST /api/v1.0/documents//accesses/ with expected data: - - user: str - - role: str [administrator|editor|reader] - Return newly created document access - - PUT /api/v1.0/documents//accesses// with expected data: - - role: str [owner|admin|editor|reader] - Return updated document access - - PATCH /api/v1.0/documents//accesses// with expected data: - - role: str [owner|admin|editor|reader] - Return partially updated document access - - DELETE /api/v1.0/documents//accesses// - Delete targeted document access - """ - - lookup_field = "pk" - 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", - "user__is_first_connection", - "document__id", - "document__path", - "document__depth", - ) - resource_field_name = "document" - throttle_scope = "document_access" - - @cached_property - def document(self): - """Get related document from resource ID in url and annotate user roles.""" - try: - return models.Document.objects.annotate_user_roles(self.request.user).get( - pk=self.kwargs["resource_id"] - ) - except models.Document.DoesNotExist as excpt: - raise drf.exceptions.NotFound() from excpt - - def get_serializer_class(self): - """Use light serializer for unprivileged users.""" - return ( - serializers.DocumentAccessSerializer - if self.document.get_role(self.request.user) in choices.PRIVILEGED_ROLES - else serializers.DocumentAccessLightSerializer - ) - - def list(self, request, *args, **kwargs): - """Return accesses for the current document with filters and annotations.""" - user = request.user - - role = self.document.get_role(user) - if not role: - return drf.response.Response([]) - - ancestors = ( - self.document.get_ancestors() - | models.Document.objects.filter(pk=self.document.pk) - ).filter(ancestors_deleted_at__isnull=True) - - queryset = self.get_queryset().filter(document__in=ancestors) - - if role not in choices.PRIVILEGED_ROLES: - queryset = queryset.filter(role__in=choices.PRIVILEGED_ROLES) - - accesses = list(queryset.order_by("document__path")) - - # Annotate more information on roles - 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"]) - - posthog_capture( - PosthogEventName.DOC_ACCESS_CREATED, - self.request.user, - { - "access_id": str(access.id), - "document_id": str(access.document_id), - "role": access.role, - "created_by": str(self.request.user.id), - "access_user_id": str(access.user_id) if access.user else None, - "team": access.team or None, - }, - ) - - 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.""" - access = serializer.save() - - access_user_id = None - if access.user: - access_user_id = str(access.user.id) - - # Notify collaboration server about the access change - reset_service_connections_in_cascade.delay( - str(access.document.id), access_user_id - ) - - def perform_destroy(self, instance): - """Delete an access to the document and notify the collaboration server.""" - # Snapshot the identifiers before deletion as Django resets the primary key - # on the instance once it is deleted. - access_id = str(instance.id) - document_id = str(instance.document_id) - user_id = str(instance.user.id) - - instance.delete() - - posthog_capture( - PosthogEventName.DOC_ACCESS_DELETED, - self.request.user, - {"access_id": access_id, "document_id": document_id}, - ) - - # Notify collaboration server about the access removed - reset_service_connections_in_cascade.delay(document_id, user_id) - - -class InvitationViewset( - drf.mixins.CreateModelMixin, - drf.mixins.ListModelMixin, - drf.mixins.RetrieveModelMixin, - drf.mixins.DestroyModelMixin, - drf.mixins.UpdateModelMixin, - viewsets.GenericViewSet, -): - """API ViewSet for user invitations to document. - - GET /api/v1.0/documents//invitations/:/ - Return list of invitations related to that document or one - document access if an id is provided. - - POST /api/v1.0/documents//invitations/ with expected data: - - email: str - - role: str [administrator|editor|reader] - Return newly created invitation (issuer and document are automatically set) - - PATCH /api/v1.0/documents//invitations/:/ with expected data: - - role: str [owner|admin|editor|reader] - Return partially updated document invitation - - DELETE /api/v1.0/documents//invitations// - Delete targeted invitation - """ - - lookup_field = "id" - pagination_class = Pagination - permission_classes = [ - permissions.CanCreateInvitationPermission, - permissions.ResourceWithAccessPermission, - ] - throttle_scope = "invitation" - queryset = ( - models.Invitation.objects.all() - .select_related("document") - .order_by("-created_at") - ) - serializer_class = serializers.InvitationSerializer - - def get_serializer_context(self): - """Extra context provided to the serializer class.""" - context = super().get_serializer_context() - 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(document=self.kwargs["resource_id"]) - - if self.action == "list": - user = self.request.user - teams = user.teams - - # Determine which role the logged-in user has in the document - user_roles_query = ( - models.DocumentAccess.objects.filter( - db.Q(user=user) | db.Q(team__in=teams), - document=self.kwargs["resource_id"], - ) - .values("document") - .annotate(roles_array=ArrayAgg("role")) - .values("roles_array") - ) - - queryset = ( - # The logged-in user should be administrator or owner to see its accesses - queryset.filter( - db.Q( - document__accesses__user=user, - document__accesses__role__in=choices.PRIVILEGED_ROLES, - ) - | db.Q( - document__accesses__team__in=teams, - document__accesses__role__in=choices.PRIVILEGED_ROLES, - ), - ) - # Abilities are computed based on logged-in user's role and - # the user role on each document access - .annotate(user_roles=db.Subquery(user_roles_query)) - .distinct() - ) - return queryset - - def perform_create(self, serializer): - """Save invitation to a document then send an email to the invited user.""" - invitation = serializer.save() - - invitation.document.send_invitation_email( - invitation.email, - invitation.role, - self.request.user, - self.request.user.language or settings.LANGUAGE_CODE, - ) - - -class DocumentAskForAccessViewSet( - drf.mixins.ListModelMixin, - drf.mixins.RetrieveModelMixin, - drf.mixins.DestroyModelMixin, - viewsets.GenericViewSet, -): - """API ViewSet for asking for access to a document.""" - - lookup_field = "id" - pagination_class = Pagination - permission_classes = [ - permissions.IsAuthenticated, - permissions.ResourceWithAccessPermission, - ] - throttle_scope = "document_ask_for_access" - queryset = models.DocumentAskForAccess.objects.all().order_by("updated_at") - serializer_class = serializers.DocumentAskForAccessSerializer - _document = None - - def get_document_or_404(self): - """Get the document related to the viewset or raise a 404 error.""" - if self._document is None: - try: - self._document = models.Document.objects.get( - pk=self.kwargs["resource_id"], - depth=1, - ) - except models.Document.DoesNotExist as e: - raise drf.exceptions.NotFound("Document not found.") from e - return self._document - - def get_queryset(self): - """Return the queryset according to the action.""" - document = self.get_document_or_404() - - queryset = super().get_queryset() - queryset = queryset.filter(document=document) - - is_owner_or_admin = ( - document.get_role(self.request.user) in models.PRIVILEGED_ROLES - ) - if not is_owner_or_admin: - queryset = queryset.filter(user=self.request.user) - - return queryset - - def create(self, request, *args, **kwargs): - """Create a document ask for access resource.""" - document = self.get_document_or_404() - - if document.get_role(request.user) in models.PRIVILEGED_ROLES: - return drf.response.Response( - {"detail": "You already have privileged access to this document."}, - status=drf.status.HTTP_400_BAD_REQUEST, - ) - - serializer = serializers.DocumentAskForAccessCreateSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - - queryset = self.get_queryset() - - if queryset.filter(user=request.user).exists(): - return drf.response.Response( - {"detail": "You already ask to access to this document."}, - status=drf.status.HTTP_400_BAD_REQUEST, - ) - - ask_for_access = models.DocumentAskForAccess.objects.create( - document=document, - user=request.user, - role=serializer.validated_data["role"], - ) - - send_ask_for_access_mail.delay(ask_for_access.id) - - return drf.response.Response(status=drf.status.HTTP_201_CREATED) - - @drf.decorators.action(detail=True, methods=["post"]) - def accept(self, request, *args, **kwargs): - """Accept a document ask for access resource.""" - document_ask_for_access = self.get_object() - - serializer = serializers.RoleSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - - target_role = serializer.validated_data.get( - "role", document_ask_for_access.role - ) - abilities = document_ask_for_access.get_abilities(request.user) - - if target_role not in abilities["set_role_to"]: - return drf.response.Response( - {"detail": "You cannot accept a role higher than your own."}, - status=drf.status.HTTP_400_BAD_REQUEST, - ) - - document_ask_for_access.accept(role=target_role) - return drf.response.Response(status=drf.status.HTTP_204_NO_CONTENT) +# POC-DRIVE-SHARING: disabled until Drive implements AskForAccess — do not delete +# class DocumentAccessViewSet( +# ResourceAccessViewsetMixin, +# drf.mixins.CreateModelMixin, +# drf.mixins.RetrieveModelMixin, +# drf.mixins.UpdateModelMixin, +# drf.mixins.DestroyModelMixin, +# viewsets.GenericViewSet, +# ): +# """ +# API ViewSet for all interactions with document accesses. +# +# GET /api/v1.0/documents//accesses/: +# Return list of all document accesses related to the logged-in user or one +# document access if an id is provided. +# +# POST /api/v1.0/documents//accesses/ with expected data: +# - user: str +# - role: str [administrator|editor|reader] +# Return newly created document access +# +# PUT /api/v1.0/documents//accesses// with expected data: +# - role: str [owner|admin|editor|reader] +# Return updated document access +# +# PATCH /api/v1.0/documents//accesses// with expected data: +# - role: str [owner|admin|editor|reader] +# Return partially updated document access +# +# DELETE /api/v1.0/documents//accesses// +# Delete targeted document access +# """ +# +# lookup_field = "pk" +# 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", +# "user__is_first_connection", +# "document__id", +# ) +# resource_field_name = "document" +# throttle_scope = "document_access" +# +# @cached_property +# def document(self): +# """Get related document from resource ID in url and annotate user roles.""" +# try: +# return models.Document.objects.annotate_user_roles(self.request.user).get( +# pk=self.kwargs["resource_id"] +# ) +# except models.Document.DoesNotExist as excpt: +# raise drf.exceptions.NotFound() from excpt +# +# def get_serializer_class(self): +# """Use light serializer for unprivileged users.""" +# return ( +# serializers.DocumentAccessSerializer +# if self.document.get_role(self.request.user) in choices.PRIVILEGED_ROLES +# else serializers.DocumentAccessLightSerializer +# ) +# +# def list(self, request, *args, **kwargs): +# """Return accesses for the current document with filters and annotations.""" +# user = request.user +# +# role = self.document.get_role(user) +# if not role: +# return drf.response.Response([]) +# +# # The hierarchy is owned by Drive: only direct accesses exist locally. +# queryset = self.get_queryset().filter(document_id=self.document.pk) +# +# if role not in choices.PRIVILEGED_ROLES: +# queryset = queryset.filter(role__in=choices.PRIVILEGED_ROLES) +# +# accesses = list(queryset.order_by("created_at")) +# +# user_role = choices.RoleChoices.max( +# *[ +# access.role +# for access in accesses +# if access.user_id == user.id or access.team in user.teams +# ] +# ) +# +# # serialize and return the response +# context = self.get_serializer_context() +# serializer_class = self.get_serializer_class() +# serialized_data = [] +# for access in accesses: +# access.max_ancestors_role = None +# access.set_user_roles_tuple(None, user_role) +# 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"]) +# +# posthog_capture( +# PosthogEventName.DOC_ACCESS_CREATED, +# self.request.user, +# { +# "access_id": str(access.id), +# "document_id": str(access.document_id), +# "role": access.role, +# "created_by": str(self.request.user.id), +# "access_user_id": str(access.user_id) if access.user else None, +# "team": access.team or None, +# }, +# ) +# +# 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.""" +# access = serializer.save() +# +# access_user_id = None +# if access.user: +# access_user_id = str(access.user.id) +# +# # Notify collaboration server about the access change +# reset_service_connections_in_cascade.delay( +# str(access.document.id), access_user_id +# ) +# +# def perform_destroy(self, instance): +# """Delete an access to the document and notify the collaboration server.""" +# # Snapshot the identifiers before deletion as Django resets the primary key +# # on the instance once it is deleted. +# access_id = str(instance.id) +# document_id = str(instance.document_id) +# user_id = str(instance.user.id) +# +# instance.delete() +# +# posthog_capture( +# PosthogEventName.DOC_ACCESS_DELETED, +# self.request.user, +# {"access_id": access_id, "document_id": document_id}, +# ) +# +# # Notify collaboration server about the access removed +# reset_service_connections_in_cascade.delay(document_id, user_id) +# +# +# POC-DRIVE-SHARING: disabled until Drive implements AskForAccess — do not delete +# class InvitationViewset( +# drf.mixins.CreateModelMixin, +# drf.mixins.ListModelMixin, +# drf.mixins.RetrieveModelMixin, +# drf.mixins.DestroyModelMixin, +# drf.mixins.UpdateModelMixin, +# viewsets.GenericViewSet, +# ): +# """API ViewSet for user invitations to document. +# +# GET /api/v1.0/documents//invitations/:/ +# Return list of invitations related to that document or one +# document access if an id is provided. +# +# POST /api/v1.0/documents//invitations/ with expected data: +# - email: str +# - role: str [administrator|editor|reader] +# Return newly created invitation (issuer and document are automatically set) +# +# PATCH /api/v1.0/documents//invitations/:/ with expected data: +# - role: str [owner|admin|editor|reader] +# Return partially updated document invitation +# +# DELETE /api/v1.0/documents//invitations// +# Delete targeted invitation +# """ +# +# lookup_field = "id" +# pagination_class = Pagination +# permission_classes = [ +# permissions.CanCreateInvitationPermission, +# permissions.ResourceWithAccessPermission, +# ] +# throttle_scope = "invitation" +# queryset = ( +# models.Invitation.objects.all() +# .select_related("document") +# .order_by("-created_at") +# ) +# serializer_class = serializers.InvitationSerializer +# +# def get_serializer_context(self): +# """Extra context provided to the serializer class.""" +# context = super().get_serializer_context() +# 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(document=self.kwargs["resource_id"]) +# +# if self.action == "list": +# user = self.request.user +# teams = user.teams +# +# # Determine which role the logged-in user has in the document +# user_roles_query = ( +# models.DocumentAccess.objects.filter( +# db.Q(user=user) | db.Q(team__in=teams), +# document=self.kwargs["resource_id"], +# ) +# .values("document") +# .annotate(roles_array=ArrayAgg("role")) +# .values("roles_array") +# ) +# +# queryset = ( +# # The logged-in user should be administrator or owner to see its accesses +# queryset.filter( +# db.Q( +# document__accesses__user=user, +# document__accesses__role__in=choices.PRIVILEGED_ROLES, +# ) +# | db.Q( +# document__accesses__team__in=teams, +# document__accesses__role__in=choices.PRIVILEGED_ROLES, +# ), +# ) +# # Abilities are computed based on logged-in user's role and +# # the user role on each document access +# .annotate(user_roles=db.Subquery(user_roles_query)) +# .distinct() +# ) +# return queryset +# +# def perform_create(self, serializer): +# """Save invitation to a document then send an email to the invited user.""" +# invitation = serializer.save() +# +# invitation.document.send_invitation_email( +# invitation.email, +# invitation.role, +# self.request.user, +# self.request.user.language or settings.LANGUAGE_CODE, +# ) +# +# +# POC-DRIVE-SHARING: disabled until Drive implements AskForAccess — do not delete +# class DocumentAskForAccessViewSet( +# drf.mixins.ListModelMixin, +# drf.mixins.RetrieveModelMixin, +# drf.mixins.DestroyModelMixin, +# viewsets.GenericViewSet, +# ): +# """API ViewSet for asking for access to a document.""" +# +# lookup_field = "id" +# pagination_class = Pagination +# permission_classes = [ +# permissions.IsAuthenticated, +# permissions.ResourceWithAccessPermission, +# ] +# throttle_scope = "document_ask_for_access" +# queryset = models.DocumentAskForAccess.objects.all().order_by("updated_at") +# serializer_class = serializers.DocumentAskForAccessSerializer +# _document = None +# +# def get_document_or_404(self): +# """Get the document related to the viewset or raise a 404 error.""" +# if self._document is None: +# try: +# self._document = models.Document.objects.get( +# pk=self.kwargs["resource_id"], +# depth=1, +# ) +# except models.Document.DoesNotExist as e: +# raise drf.exceptions.NotFound("Document not found.") from e +# return self._document +# +# def get_queryset(self): +# """Return the queryset according to the action.""" +# document = self.get_document_or_404() +# +# queryset = super().get_queryset() +# queryset = queryset.filter(document=document) +# +# is_owner_or_admin = ( +# document.get_role(self.request.user) in models.PRIVILEGED_ROLES +# ) +# if not is_owner_or_admin: +# queryset = queryset.filter(user=self.request.user) +# +# return queryset +# +# def create(self, request, *args, **kwargs): +# """Create a document ask for access resource.""" +# document = self.get_document_or_404() +# +# if document.get_role(request.user) in models.PRIVILEGED_ROLES: +# return drf.response.Response( +# {"detail": "You already have privileged access to this document."}, +# status=drf.status.HTTP_400_BAD_REQUEST, +# ) +# +# serializer = serializers.DocumentAskForAccessCreateSerializer(data=request.data) +# serializer.is_valid(raise_exception=True) +# +# queryset = self.get_queryset() +# +# if queryset.filter(user=request.user).exists(): +# return drf.response.Response( +# {"detail": "You already ask to access to this document."}, +# status=drf.status.HTTP_400_BAD_REQUEST, +# ) +# +# ask_for_access = models.DocumentAskForAccess.objects.create( +# document=document, +# user=request.user, +# role=serializer.validated_data["role"], +# ) +# +# send_ask_for_access_mail.delay(ask_for_access.id) +# +# return drf.response.Response(status=drf.status.HTTP_201_CREATED) +# +# @drf.decorators.action(detail=True, methods=["post"]) +# def accept(self, request, *args, **kwargs): +# """Accept a document ask for access resource.""" +# document_ask_for_access = self.get_object() +# +# serializer = serializers.RoleSerializer(data=request.data) +# serializer.is_valid(raise_exception=True) +# +# target_role = serializer.validated_data.get( +# "role", document_ask_for_access.role +# ) +# abilities = document_ask_for_access.get_abilities(request.user) +# +# if target_role not in abilities["set_role_to"]: +# return drf.response.Response( +# {"detail": "You cannot accept a role higher than your own."}, +# status=drf.status.HTTP_400_BAD_REQUEST, +# ) +# +# document_ask_for_access.accept(role=target_role) +# return drf.response.Response(status=drf.status.HTTP_204_NO_CONTENT) class ConfigView(drf.views.APIView): diff --git a/src/backend/core/external_api/viewsets.py b/src/backend/core/external_api/viewsets.py index 9a8bafcb85..bb404d8b01 100644 --- a/src/backend/core/external_api/viewsets.py +++ b/src/backend/core/external_api/viewsets.py @@ -5,15 +5,11 @@ from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication from core.api.permissions import ( - CanCreateInvitationPermission, - DocumentPermission, + DriveDelegatedPermission, IsSelf, - ResourceAccessPermission, ) from core.api.viewsets import ( - DocumentAccessViewSet, DocumentViewSet, - InvitationViewset, UserViewSet, ) from core.external_api.permissions import ResourceServerClientPermission @@ -34,11 +30,11 @@ def _get_resource_server_actions(self, resource_name): class ResourceServerDocumentViewSet(ResourceServerRestrictionMixin, DocumentViewSet): - """Resource Server Viewset for Documents.""" + """Resource Server Viewset for Documents, abilities delegated to Drive.""" authentication_classes = [ResourceServerAuthentication] - permission_classes = [ResourceServerClientPermission & DocumentPermission] # type: ignore + permission_classes = [ResourceServerClientPermission & DriveDelegatedPermission] # type: ignore @property def resource_server_actions(self): @@ -46,36 +42,38 @@ def resource_server_actions(self): return self._get_resource_server_actions("documents") -class ResourceServerDocumentAccessViewSet( - ResourceServerRestrictionMixin, DocumentAccessViewSet -): - """Resource Server Viewset for DocumentAccess.""" - - authentication_classes = [ResourceServerAuthentication] - - permission_classes = [ResourceServerClientPermission & ResourceAccessPermission] # type: ignore - - @property - def resource_server_actions(self): - """Get resource_server_actions from settings.""" - return self._get_resource_server_actions("document_access") - - -class ResourceServerInvitationViewSet( - ResourceServerRestrictionMixin, InvitationViewset -): - """Resource Server Viewset for Invitations.""" - - authentication_classes = [ResourceServerAuthentication] - - permission_classes = [ - ResourceServerClientPermission & CanCreateInvitationPermission - ] - - @property - def resource_server_actions(self): - """Get resource_server_actions from settings.""" - return self._get_resource_server_actions("document_invitation") +# POC-DRIVE-SHARING: disabled until Drive implements AskForAccess — do not delete +# class ResourceServerDocumentAccessViewSet( +# ResourceServerRestrictionMixin, DocumentAccessViewSet +# ): +# """Resource Server Viewset for DocumentAccess.""" +# +# authentication_classes = [ResourceServerAuthentication] +# +# permission_classes = [ResourceServerClientPermission & ResourceAccessPermission] +# +# @property +# def resource_server_actions(self): +# """Get resource_server_actions from settings.""" +# return self._get_resource_server_actions("document_access") + + +# POC-DRIVE-SHARING: disabled until Drive implements AskForAccess — do not delete +# class ResourceServerInvitationViewSet( +# ResourceServerRestrictionMixin, InvitationViewset +# ): +# """Resource Server Viewset for Invitations.""" +# +# authentication_classes = [ResourceServerAuthentication] +# +# permission_classes = [ +# ResourceServerClientPermission & CanCreateInvitationPermission +# ] +# +# @property +# def resource_server_actions(self): +# """Get resource_server_actions from settings.""" +# return self._get_resource_server_actions("document_invitation") class ResourceServerUserViewSet(ResourceServerRestrictionMixin, UserViewSet): diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index eeefa8f4b7..8cbdddc2f8 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -44,16 +44,6 @@ class Meta: language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES]) password = make_password("password") - @factory.post_generation - def with_owned_document(self, create, extracted, **kwargs): - """ - Create a document for which the user is owner to check - that there is no interference - """ - if create and (extracted is True): - UserDocumentAccessFactory(user=self, role="owner") - - class ParentNodeFactory(factory.declarations.ParameteredAttribute): """Custom factory attribute for setting the parent node.""" @@ -82,59 +72,15 @@ class Meta: parent = ParentNodeFactory() title = factory.Sequence(lambda n: f"document{n}") - excerpt = factory.Sequence(lambda n: f"excerpt{n}") content = YDOC_HELLO_WORLD_BASE64 creator = factory.SubFactory(UserFactory) deleted_at = None - link_reach = factory.fuzzy.FuzzyChoice( - [a[0] for a in models.LinkReachChoices.choices] - ) - link_role = factory.fuzzy.FuzzyChoice( - [r[0] for r in models.LinkRoleChoices.choices] - ) @classmethod def _create(cls, model_class, *args, **kwargs): - """ - Custom creation logic for the factory: creates a document as a child node if - a parent is provided; otherwise, creates it as a root node. - """ - parent = kwargs.pop("parent", None) - - if parent: - # Add as a child node - kwargs["ancestors_deleted_at"] = ( - kwargs.get("ancestors_deleted_at") or parent.ancestors_deleted_at - ) - return parent.add_child(instance=model_class(**kwargs)) - - # Add as a root node - return model_class.add_root(instance=model_class(**kwargs)) - - @factory.lazy_attribute - def ancestors_deleted_at(self): - """Should always be set when "deleted_at" is set.""" - return self.deleted_at - - @factory.post_generation - def users(self, create, extracted, **kwargs): - """Add users to document from a given list of users with or without roles.""" - if create and extracted: - for item in extracted: - if isinstance(item, models.User): - UserDocumentAccessFactory(document=self, user=item) - else: - UserDocumentAccessFactory(document=self, user=item[0], role=item[1]) - - @factory.post_generation - def teams(self, create, extracted, **kwargs): - """Add teams to document from a given list of teams with or without roles.""" - if create and extracted: - for item in extracted: - if isinstance(item, str): - TeamDocumentAccessFactory(document=self, team=item) - else: - TeamDocumentAccessFactory(document=self, team=item[0], role=item[1]) + """The hierarchy is owned by Drive: documents are plain rows.""" + kwargs.pop("parent", None) + return model_class.objects.create(**kwargs) @factory.post_generation def link_traces(self, create, extracted, **kwargs): @@ -151,28 +97,6 @@ def favorited_by(self, create, extracted, **kwargs): models.DocumentFavorite.objects.create(document=self, user=item) -class UserDocumentAccessFactory(factory.django.DjangoModelFactory): - """Create fake document user accesses for testing.""" - - class Meta: - model = models.DocumentAccess - - document = factory.SubFactory(DocumentFactory) - user = factory.SubFactory(UserFactory) - role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices]) - - -class TeamDocumentAccessFactory(factory.django.DjangoModelFactory): - """Create fake document team accesses for testing.""" - - class Meta: - model = models.DocumentAccess - - document = factory.SubFactory(DocumentFactory) - team = factory.Sequence(lambda n: f"team{n}") - role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices]) - - class DocumentAskForAccessFactory(factory.django.DjangoModelFactory): """Create fake document ask for access for testing.""" @@ -184,18 +108,6 @@ class Meta: role = factory.fuzzy.FuzzyChoice([r[0] for r in models.RoleChoices.choices]) -class InvitationFactory(factory.django.DjangoModelFactory): - """A factory to create invitations for a user""" - - class Meta: - model = models.Invitation - - email = factory.Faker("email") - document = factory.SubFactory(DocumentFactory) - role = factory.fuzzy.FuzzyChoice([role[0] for role in models.RoleChoices.choices]) - issuer = factory.SubFactory(UserFactory) - - class ThreadFactory(factory.django.DjangoModelFactory): """A factory to create threads for a document""" diff --git a/src/backend/core/management/commands/clean_document.py b/src/backend/core/management/commands/clean_document.py index e7a006ad51..59b5fb2e02 100644 --- a/src/backend/core/management/commands/clean_document.py +++ b/src/backend/core/management/commands/clean_document.py @@ -13,10 +13,8 @@ from core.choices import LinkReachChoices, LinkRoleChoices, RoleChoices from core.models import ( Document, - DocumentAccess, DocumentAskForAccess, DocumentFavorite, - Invitation, LinkTrace, Thread, ) @@ -80,9 +78,10 @@ def handle(self, *args, **options): except (Document.DoesNotExist, ValueError) as err: raise CommandError(f"Document {document_id} does not exist.") from err - descendants = list(document.get_descendants()) - descendant_ids = [doc.id for doc in descendants] - all_documents = [document, *descendants] + # The hierarchy is owned by Drive: no local descendants anymore. + descendants = [] + descendant_ids = [] + all_documents = [document] # Collect all attachment keys before the transaction clears them all_attachment_keys = [] @@ -101,11 +100,7 @@ def handle(self, *args, **options): # update() so the post_save signal fires (search re-indexation) and # `updated_at` is refreshed. All descendants are about to be deleted, # so the document can no longer have any deleted child either. - document.excerpt = None - document.link_reach = options["link_reach"] - document.link_role = options["link_role"] document.attachments = [] - document.has_deleted_children = False if options["title"] is not None: document.title = options["title"] document.save() @@ -150,13 +145,8 @@ def _clean_root_relations(self, document): owners), invitations, threads, favorites, link traces and pending access requests. """ - access_count, _ = DocumentAccess.objects.filter( - Q(document_id=document.id) & ~Q(role=RoleChoices.OWNER) - ).delete() - self.stdout.write(f"Deleted {access_count} access(es) on root document.") - + # Sharing is owned by Drive: no local accesses/invitations to clean. for model, label in ( - (Invitation, "invitation"), (Thread, "thread"), (DocumentFavorite, "favorite"), (LinkTrace, "link trace"), diff --git a/src/backend/core/migrations/0033_alter_document_options_and_more.py b/src/backend/core/migrations/0033_alter_document_options_and_more.py new file mode 100644 index 0000000000..44928f2ca2 --- /dev/null +++ b/src/backend/core/migrations/0033_alter_document_options_and_more.py @@ -0,0 +1,57 @@ +# Generated by Django 5.2.14 on 2026-07-24 20:45 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0032_remove_linktrace_is_masked'), + ] + + operations = [ + migrations.AlterModelOptions( + name='document', + options={'ordering': ('-created_at',), 'verbose_name': 'Document', 'verbose_name_plural': 'Documents'}, + ), + migrations.RemoveConstraint( + model_name='document', + name='check_deleted_at_matches_ancestors_deleted_at_when_set', + ), + migrations.RemoveField( + model_name='document', + name='ancestors_deleted_at', + ), + migrations.RemoveField( + model_name='document', + name='depth', + ), + migrations.RemoveField( + model_name='document', + name='duplicated_from', + ), + migrations.RemoveField( + model_name='document', + name='excerpt', + ), + migrations.RemoveField( + model_name='document', + name='has_deleted_children', + ), + migrations.RemoveField( + model_name='document', + name='link_reach', + ), + migrations.RemoveField( + model_name='document', + name='link_role', + ), + migrations.RemoveField( + model_name='document', + name='numchild', + ), + migrations.RemoveField( + model_name='document', + name='path', + ), + ] diff --git a/src/backend/core/migrations/0034_remove_invitation_document_remove_invitation_issuer_and_more.py b/src/backend/core/migrations/0034_remove_invitation_document_remove_invitation_issuer_and_more.py new file mode 100644 index 0000000000..d7859dca7c --- /dev/null +++ b/src/backend/core/migrations/0034_remove_invitation_document_remove_invitation_issuer_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.14 on 2026-07-27 13:30 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0033_alter_document_options_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='invitation', + name='document', + ), + migrations.RemoveField( + model_name='invitation', + name='issuer', + ), + migrations.DeleteModel( + name='DocumentAccess', + ), + migrations.DeleteModel( + name='Invitation', + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 26849520ad..411a00289d 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -21,7 +21,6 @@ from django.core.mail import send_mail from django.db import models, transaction from django.db.models import Count -from django.db.models.functions import Left, Length from django.template.loader import render_to_string from django.utils import timezone from django.utils.functional import cached_property @@ -31,16 +30,13 @@ from botocore.exceptions import ClientError from rest_framework.exceptions import ValidationError from timezone_field import TimeZoneField -from treebeard.mp_tree import MP_Node, MP_NodeManager, MP_NodeQuerySet from core.choices import ( PRIVILEGED_ROLES, LinkReachChoices, - LinkRoleChoices, + LinkRoleChoices, # noqa: F401 (re-exported, e.g. models.LinkRoleChoices) RoleChoices, - get_equivalent_link_definition, ) -from core.utils.treebeard import create_tree_node_with_retry from core.validators import sub_validator logger = getLogger(__name__) @@ -224,46 +220,22 @@ def save(self, *args, **kwargs): if is_adding: self._handle_onboarding_documents_access() self._duplicate_onboarding_sandbox_document() - self._convert_valid_invitations() def delete(self, using=None, keep_parents=False): """Completely delete a user and its relations.""" with transaction.atomic(): - self._delete_user_shared_documents_accesses() - self._delete_documents_single_owner() - self._clear_user_created_documents() + self._delete_created_documents() return super().delete(using=using, keep_parents=keep_parents) - def _delete_user_shared_documents_accesses(self): + def _delete_created_documents(self): """ - accesses to delete where there are more than one owner. - - Create first a subquery to filter all the the accesses having more than one - owner. Then use this subquery to filter the accesses belonging to this list of - documents and to the user to delete + Delete the documents created by the user. Sharing is owned by Drive: + local ownership is materialized by the creator field only. """ - docs_ids = ( - DocumentAccess.objects.filter(role=RoleChoices.OWNER) - .values("document_id") - .annotate(owner_count=Count("id")) - .filter(owner_count__gte=2) - .values("document_id") - ) - DocumentAccess.objects.filter(user=self, document_id__in=docs_ids).delete() - + Document.objects.filter(creator=self).delete() logger.info( - "user_delete: shared documents accesses for user %s have been deleted", - self.id, - ) - - def _delete_documents_single_owner(self): - """Delete the documents where the user is the single owner.""" - Document.objects.filter( - accesses__user=self, accesses__role=RoleChoices.OWNER - ).delete() - logger.info( - "user_delete: documents where the user %s is the sole owner deleted", + "user_delete: documents created by user %s deleted", self.id, ) @@ -325,53 +297,28 @@ def _duplicate_onboarding_sandbox_document(self): sandbox_id, ) return - with transaction.atomic(): - sandbox_document = create_tree_node_with_retry( - lambda: Document.add_root( - title=template_document.title, - content=template_document.content, - attachments=template_document.attachments, - duplicated_from=template_document, - creator=self, - ) - ) + # Import here to avoid a circular import through core.api.serializers + from core.services import ( # pylint: disable=import-outside-toplevel + drive_client, + ) - DocumentAccess.objects.create( - user=self, document=sandbox_document, role=RoleChoices.OWNER + try: + drive_item = drive_client.create_doc_item( + self, template_document.title ) + except drive_client.DriveClientError as exc: + logger.warning("Could not create sandbox document in Drive: %s", exc) + return - def _convert_valid_invitations(self): - """ - Convert valid invitations to document accesses. - Expired invitations are ignored. - """ - valid_invitations = Invitation.objects.filter( - email__iexact=self.email, - created_at__gte=( - timezone.now() - - timedelta(seconds=settings.INVITATION_VALIDITY_DURATION) - ), - ).select_related("document") - - if not valid_invitations.exists(): - return - - DocumentAccess.objects.bulk_create( - [ - DocumentAccess( - user=self, document=invitation.document, role=invitation.role + with transaction.atomic(): + sandbox_document = Document.objects.create( + id=drive_item["id"], + title=template_document.title, + content=template_document.content, + attachments=template_document.attachments, + creator=self, ) - for invitation in valid_invitations - ] - ) - - # Set creator of documents if not yet set (e.g. documents created via server-to-server API) - document_ids = [invitation.document_id for invitation in valid_invitations] - Document.objects.filter(id__in=document_ids, creator__isnull=True).update( - creator=self - ) - valid_invitations.delete() def send_email(self, subject, context=None, language=None): """Generate and send email to the user from a template.""" @@ -509,10 +456,8 @@ def process_reconciliation_request(self): - Update the reconciliation entry itself. """ - # Prepare the data to perform the reconciliation on - updated_accesses, removed_accesses = ( - self.prepare_documentaccess_reconciliation() - ) + # Prepare the data to perform the reconciliation on. Document accesses + # are owned by Drive and are not reconciled here anymore. updated_linktraces, removed_linktraces = self.prepare_linktrace_reconciliation() update_favorites, removed_favorites = ( self.prepare_document_favorite_reconciliation() @@ -525,12 +470,6 @@ def process_reconciliation_request(self): self.inactive_user.is_active = False # Actually perform the bulk operations - DocumentAccess.objects.bulk_update(updated_accesses, ["user", "role"]) - - if removed_accesses: - ids_to_delete = [entry.id for entry in removed_accesses] - DocumentAccess.objects.filter(id__in=ids_to_delete).delete() - DocumentFavorite.objects.bulk_update(update_favorites, ["user"]) if removed_favorites: ids_to_delete = [entry.id for entry in removed_favorites] @@ -566,47 +505,13 @@ def process_reconciliation_request(self): User.objects.bulk_update([self.active_user, self.inactive_user], ["is_active"]) # Wrap up the reconciliation entry - self.logs += f"""Requested update for {len(updated_accesses)} DocumentAccess items - and deletion for {len(removed_accesses)} DocumentAccess items.\n""" + self.logs += f"""Requested update for {len(updated_linktraces)} LinkTrace items + and deletion for {len(removed_linktraces)} LinkTrace items.\n""" self.status = "done" self.save() self.send_reconciliation_done_email() - def prepare_documentaccess_reconciliation(self): - """ - Prepare the reconciliation by transferring document accesses from the inactive user - to the active user. - """ - updated_accesses = [] - removed_accesses = [] - inactive_accesses = DocumentAccess.objects.filter(user=self.inactive_user) - - # Check documents where the active user already has access - inactive_accesses_documents = inactive_accesses.values_list( - "document", flat=True - ) - existing_accesses = DocumentAccess.objects.filter(user=self.active_user).filter( - document__in=inactive_accesses_documents - ) - existing_roles_per_doc = dict(existing_accesses.values_list("document", "role")) - - for entry in inactive_accesses: - if entry.document_id in existing_roles_per_doc: - # Update role if needed - existing_role = existing_roles_per_doc[entry.document_id] - max_role = RoleChoices.max(entry.role, existing_role) - if existing_role != max_role: - existing_access = existing_accesses.get(document=entry.document) - existing_access.role = max_role - updated_accesses.append(existing_access) - removed_accesses.append(entry) - else: - entry.user = self.active_user - updated_accesses.append(entry) - - return updated_accesses, removed_accesses - def prepare_document_favorite_reconciliation(self): """ Prepare the reconciliation by transferring document favorites from the inactive user @@ -825,25 +730,7 @@ def send_reconciliation_error_email( self.send_email(subject, emails, context, language) -class BaseAccess(BaseModel): - """Base model for accesses to handle resources.""" - - user = models.ForeignKey( - User, - on_delete=models.CASCADE, - null=True, - blank=True, - ) - team = models.CharField(max_length=100, blank=True) - role = models.CharField( - max_length=20, choices=RoleChoices.choices, default=RoleChoices.READER - ) - - class Meta: - abstract = True - - -class DocumentQuerySet(MP_NodeQuerySet): +class DocumentQuerySet(models.QuerySet): """ Custom queryset for the Document model, providing additional methods to filter documents based on user permissions. @@ -851,21 +738,21 @@ class DocumentQuerySet(MP_NodeQuerySet): def readable_per_se(self, user): """ - Filters the queryset to return documents on which the given user has - direct access, team access or link access. This will not return all the - documents that a user can read because it can be obtained via an ancestor. - :param user: The user for whom readable documents are to be fetched. - :return: A queryset of documents for which the user has direct access, - team access or link access. + Filters the queryset to return documents locally known to the given + user: the ones they created or already visited (a LinkTrace is written + on first retrieve). Sharing itself is managed by Drive. """ if user.is_authenticated: return self.filter( - models.Q(accesses__user=user) - | models.Q(accesses__team__in=user.teams) - | ~models.Q(link_reach=LinkReachChoices.RESTRICTED) + models.Q(creator=user) + | models.Q( + id__in=LinkTrace.objects.filter(user=user).values_list( + "document_id", flat=True + ) + ) ) - return self.filter(link_reach=LinkReachChoices.PUBLIC) + return self.none() def annotate_is_favorite(self, user): """ @@ -879,29 +766,6 @@ def annotate_is_favorite(self, user): 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), - ) - def annotate_user_has_link_trace(self, user): """ Annotate document queryset with a boolean to know if the current user @@ -919,31 +783,24 @@ def annotate_user_has_link_trace(self, user): return self.annotate(user_has_link_trace=models.Value(False)) -class DocumentManager(MP_NodeManager.from_queryset(DocumentQuerySet)): +class DocumentManager(models.Manager.from_queryset(DocumentQuerySet)): """ Custom manager for the Document model, enabling the use of the custom queryset methods directly from the model manager. """ - def get_queryset(self): - """Sets the custom queryset as the default.""" - 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.""" +class Document(BaseModel): + """ + Pad document carrying the content. + + The document hierarchy and sharing are owned by Drive: this model is a + thin wrapper around the Drive item bearing the same id, only holding what + Drive cannot (the collaborative content and its attachments). + """ title = models.CharField(_("title"), max_length=255, null=True, blank=True) - excerpt = models.TextField(_("excerpt"), max_length=300, null=True, blank=True) - link_reach = models.CharField( - max_length=20, - choices=LinkReachChoices.choices, - default=LinkReachChoices.RESTRICTED, - ) - link_role = models.CharField( - max_length=20, choices=LinkRoleChoices.choices, default=LinkRoleChoices.READER - ) creator = models.ForeignKey( User, on_delete=models.RESTRICT, @@ -952,16 +809,6 @@ class Document(MP_Node, BaseModel): null=True, ) 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, - related_name="duplicates", - editable=False, - blank=True, - null=True, - ) attachments = ArrayField( models.CharField(max_length=255), default=list, @@ -972,39 +819,17 @@ class Document(MP_Node, BaseModel): _content = None - # Tree structure - alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" - steplen = 7 # nb siblings max: 3,521,614,606,208 - node_order_by = [] # Manual ordering - - path = models.CharField(max_length=7 * 36, unique=True, db_collation="C") - objects = DocumentManager() class Meta: db_table = "impress_document" - ordering = ("path",) + ordering = ("-created_at",) verbose_name = _("Document") verbose_name_plural = _("Documents") - constraints = [ - models.CheckConstraint( - condition=( - models.Q(deleted_at__isnull=True) - | models.Q(deleted_at=models.F("ancestors_deleted_at")) - ), - name="check_deleted_at_matches_ancestors_deleted_at_when_set", - ), - ] 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) @@ -1038,12 +863,6 @@ def save_content(self, content): 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.""" @@ -1152,70 +971,6 @@ def delete_version(self, version_id): Bucket=default_storage.bucket_name, Key=self.file_key, VersionId=version_id ) - def get_nb_accesses_cache_key(self): - """Generate a unique cache key for each document.""" - return f"document_{self.id!s}_nb_accesses" - - def get_nb_accesses(self): - """ - Calculate the number of accesses: - - directly attached to the document - - attached to any of the document's ancestors - """ - cache_key = self.get_nb_accesses_cache_key() - nb_accesses = cache.get(cache_key) - - if nb_accesses is None: - nb_accesses = ( - DocumentAccess.objects.filter(document=self).count(), - DocumentAccess.objects.filter( - document__path=Left( - models.Value(self.path), Length("document__path") - ), - document__ancestors_deleted_at__isnull=True, - ).count(), - ) - cache.set(cache_key, nb_accesses) - - return nb_accesses - - @property - def nb_accesses_direct(self): - """Returns the number of accesses related to the document or one of its ancestors.""" - return self.get_nb_accesses()[0] - - @property - def nb_accesses_ancestors(self): - """Returns the number of accesses related to the document or one of its ancestors.""" - return self.get_nb_accesses()[1] - - def invalidate_nb_accesses_cache(self): - """ - Invalidate the cache for number of accesses, including on affected descendants. - Args: - path: can optionally be passed as argument (useful when invalidating cache for a - document we just deleted) - """ - - for document in Document.objects.filter(path__startswith=self.path).only("id"): - cache_key = document.get_nb_accesses_cache_key() - cache.delete(cache_key) - - def get_role(self, user): - """Return the roles a user has on a document.""" - if not user.is_authenticated: - return None - - try: - roles = self.user_roles or [] - except AttributeError: - 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 RoleChoices.max(*roles) - def has_link_trace(self, user): """Return if the user has a link trace on this document.""" @@ -1227,194 +982,87 @@ def has_link_trace(self, user): except AttributeError: return LinkTrace.objects.filter(document=self, user=user).exists() - def compute_ancestors_links_paths_mapping(self): + # The document is a thin wrapper around the Drive item bearing the same + # id. `drive_item` is a non-DB attribute holding the Drive payload for the + # current user, set by the permission layer or lazily fetched. + drive_item = None + + def get_drive_item(self, user): """ - Compute the ancestors links for the current document up to the highest readable ancestor. + Return the Drive item mirroring this document (abilities, link + definition, hierarchy data), fetched on behalf of the given user. + Memoized on the instance; drive_client has a short per-user cache. """ - ancestors = ( - (self.get_ancestors() | self._meta.model.objects.filter(pk=self.pk)) - .filter(ancestors_deleted_at__isnull=True) - .order_by("path") - ) - ancestors_links = [] - paths_links_mapping = {} - - for ancestor in ancestors: - ancestors_links.append( - {"link_reach": ancestor.link_reach, "link_role": ancestor.link_role} + if self.drive_item is None: + # Import here to avoid a circular import through core.api.serializers + from core.services import ( # pylint: disable=import-outside-toplevel + drive_client, ) - paths_links_mapping[ancestor.path] = ancestors_links.copy() - return paths_links_mapping + self.drive_item = drive_client.get_item(str(self.pk), user) - @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} + return self.drive_item - @property - def ancestors_link_definition(self): - """Link definition equivalent to all document's ancestors.""" - if getattr(self, "_ancestors_link_definition", None) is None: - if self.depth <= 1: - ancestors_links = [] - else: - mapping = self.compute_ancestors_links_paths_mapping() - ancestors_links = mapping.get(self.path[: -self.steplen], []) - self._ancestors_link_definition = get_equivalent_link_definition( - ancestors_links - ) + def get_abilities(self, user): + """ + Return abilities for a given user on the document, as delegated to + Drive which owns the document tree and sharing. Fails closed (all + False) when the user has no access or Drive cannot be reached. + """ + # Import here to avoid a circular import through core.api.serializers + from core.services import drive_client # pylint: disable=import-outside-toplevel - return self._ancestors_link_definition + try: + item = self.get_drive_item(user) + except drive_client.DriveClientError: + return drive_client.no_abilities() + + return drive_client.map_drive_abilities(item.get("abilities")) + + # Link reach/role are owned by Drive and exposed read-only through the + # drive_item payload when it has been loaded. + + @property + def link_reach(self): + """Link reach is managed in Drive.""" + if self.drive_item: + return self.drive_item.get("link_reach") or LinkReachChoices.RESTRICTED + return LinkReachChoices.RESTRICTED - @ancestors_link_definition.setter - def ancestors_link_definition(self, definition): - """Cache the ancestors_link_definition.""" - self._ancestors_link_definition = definition + @property + def link_role(self): + """Link role is managed in Drive.""" + return self.drive_item.get("link_role") if self.drive_item else None @property def ancestors_link_reach(self): """Link reach equivalent to all document's ancestors.""" - return self.ancestors_link_definition["link_reach"] + if self.drive_item: + return ( + self.drive_item.get("ancestors_link_reach") + or LinkReachChoices.RESTRICTED + ) + return LinkReachChoices.RESTRICTED @property def ancestors_link_role(self): """Link role equivalent to all document's ancestors.""" - return self.ancestors_link_definition["link_role"] - - @property - def computed_link_definition(self): - """ - Link reach/role on the document, combining inherited ancestors' link - definitions and the document's own link definition. - """ - if getattr(self, "_computed_link_definition", None) is None: - self._computed_link_definition = get_equivalent_link_definition( - [self.ancestors_link_definition, self.link_definition] - ) - return self._computed_link_definition + return self.drive_item.get("ancestors_link_role") if self.drive_item else None @property def computed_link_reach(self): """Actual link reach on the document.""" - return self.computed_link_definition["link_reach"] + if self.drive_item: + return ( + self.drive_item.get("computed_link_reach") + or LinkReachChoices.RESTRICTED + ) + return LinkReachChoices.RESTRICTED @property def computed_link_role(self): """Actual link role on the document.""" - return self.computed_link_definition["link_role"] - - def get_abilities(self, user): # pylint: disable=too-many-locals - """ - 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 = role == RoleChoices.OWNER - is_deleted = self.ancestors_deleted_at - 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(role) and not is_deleted - can_update_from_access = ( - is_owner_or_admin or role == RoleChoices.EDITOR - ) and not is_deleted - - # compute can_leave - # An authenticated user can leave a document if it has a non - # privileged role on the document or access to it with a link_trace - can_leave = ( - user.is_authenticated - and not is_deleted - and ( - (has_access_role and not is_owner_or_admin) - or (not has_access_role and self.has_link_trace(user)) - ) - ) - - 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}, - ] - ) - - 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 - retrieve = can_get or is_owner - can_update = ( - is_owner_or_admin or role == RoleChoices.EDITOR - ) and not is_deleted - can_comment = (can_update or role == RoleChoices.COMMENTER) and not is_deleted - can_create_children = can_update and user.is_authenticated - can_destroy = ( - is_owner - if self.is_root() - else (is_owner_or_admin or (user.is_authenticated and self.creator == user)) - ) and not is_deleted - - ai_allow_reach_from = settings.AI_ALLOW_REACH_FROM - ai_access = any( - [ - ai_allow_reach_from == LinkReachChoices.PUBLIC and can_update, - ai_allow_reach_from == LinkReachChoices.AUTHENTICATED - and user.is_authenticated - and can_update, - ai_allow_reach_from == LinkReachChoices.RESTRICTED - and can_update_from_access, - ] - ) - - return { - "accesses_manage": is_owner_or_admin, - "accesses_view": has_access_role, - "ai_proxy": ai_access, - "ai_transform": ai_access, - "ai_translate": ai_access, - "attachment_upload": can_update, - "media_check": can_get, - "can_edit": can_update, - "children_list": can_get, - "children_create": can_create_children, - "collaboration_auth": can_get, - "comment": can_comment, - "formatted_content": can_get, - "content_patch": can_update, - "content_retrieve": retrieve, - "cors_proxy": can_get, - "descendants": can_get, - "destroy": can_destroy, - "duplicate": can_get and user.is_authenticated, - "favorite": can_get and user.is_authenticated, - "link_configuration": is_owner_or_admin, - "invite_owner": is_owner and not is_deleted, - "leave": can_leave, - "move": is_owner_or_admin and not is_deleted, - "partial_update": can_update, - "restore": is_owner and bool(self.deleted_at), - "retrieve": retrieve, - "media_auth": can_get, - "link_select_options": link_select_options, - "tree": retrieve, - "update": can_update, - "versions_destroy": is_owner_or_admin, - "versions_list": has_access_role, - "versions_retrieve": has_access_role, - "search": can_get, - } + return self.drive_item.get("computed_link_role") if self.drive_item else None def send_email(self, subject, emails, context=None, language=None): """Generate and send email from a template.""" @@ -1480,47 +1128,20 @@ def send_invitation_email(self, email, role, sender, language=None): self.send_email(subject, [email], context, language) - @transaction.atomic def soft_delete(self): """ - Soft delete the document, marking the deletion on descendants. - We still keep the .delete() method untouched for programmatic purposes. + Soft delete the document. The hierarchy is owned by Drive so there is + no descendant propagation: this only hides the local row. """ - if ( - self._meta.model.objects.filter( - models.Q(deleted_at__isnull=False) - | models.Q(ancestors_deleted_at__isnull=False), - pk=self.pk, - ).exists() - or self.get_ancestors().filter(deleted_at__isnull=False).exists() - ): - raise RuntimeError( - "This document is already deleted or has deleted ancestors." - ) + if self.deleted_at is not None: + raise RuntimeError("This document is already deleted.") - self.ancestors_deleted_at = self.deleted_at = timezone.now() - self.save() - self.invalidate_nb_accesses_cache() - - if self.depth > 1: - self._meta.model.objects.filter(pk=self.get_parent().pk).update( - numchild=models.F("numchild") - 1, - has_deleted_children=True, - ) + self.deleted_at = timezone.now() + self.save(update_fields=["deleted_at", "updated_at"]) - # Mark all descendants as soft deleted - self.get_descendants().filter(ancestors_deleted_at__isnull=True).update( - ancestors_deleted_at=self.ancestors_deleted_at, - updated_at=self.updated_at, - ) - - @transaction.atomic def restore(self): """Cancelling a soft delete with checks.""" - # This should not happen - if self._meta.model.objects.filter( - pk=self.pk, deleted_at__isnull=True - ).exists(): + if self.deleted_at is None: raise RuntimeError("This document is not deleted.") if self.deleted_at < get_trashbin_cutoff(): @@ -1528,33 +1149,8 @@ def restore(self): "This document was permanently deleted and cannot be restored." ) - # save the current deleted_at value to exclude it from the descendants update - current_deleted_at = self.deleted_at - - # Restore the current document self.deleted_at = None - - # Calculate the minimum `deleted_at` among all ancestors - ancestors_deleted_at = ( - self.get_ancestors() - .filter(deleted_at__isnull=False) - .order_by("deleted_at") - .values_list("deleted_at", flat=True) - .first() - ) - self.ancestors_deleted_at = ancestors_deleted_at - self.save(update_fields=["deleted_at", "ancestors_deleted_at"]) - self.invalidate_nb_accesses_cache() - - self.get_descendants().exclude( - models.Q(deleted_at__isnull=False) - | models.Q(ancestors_deleted_at__lt=current_deleted_at) - ).update(ancestors_deleted_at=self.ancestors_deleted_at) - - if self.depth > 1: - self._meta.model.objects.filter(pk=self.get_parent().pk).update( - numchild=models.F("numchild") + 1 - ) + self.save(update_fields=["deleted_at", "updated_at"]) class LinkTrace(BaseModel): @@ -1620,172 +1216,6 @@ def __str__(self): return f"{self.user!s} favorite on document {self.document!s}" -class DocumentAccess(BaseAccess): - """Relation model to give access to a document for a user or a team with a role.""" - - document = models.ForeignKey( - Document, - on_delete=models.CASCADE, - related_name="accesses", - ) - - class Meta: - db_table = "impress_document_access" - ordering = ("-created_at",) - verbose_name = _("Document/user relation") - verbose_name_plural = _("Document/user relations") - constraints = [ - models.UniqueConstraint( - fields=["user", "document"], - condition=models.Q(user__isnull=False), # Exclude null users - name="unique_document_user", - violation_error_message=_("This user is already in this document."), - ), - models.UniqueConstraint( - fields=["team", "document"], - condition=models.Q(team__gt=""), # Exclude empty string teams - name="unique_document_team", - violation_error_message=_("This team is already in this document."), - ), - models.CheckConstraint( - condition=models.Q(user__isnull=False, team="") - | models.Q(user__isnull=True, team__gt=""), - name="check_document_access_either_user_or_team", - violation_error_message=_("Either user or team must be set, not both."), - ), - ] - - def __str__(self): - return f"{self.user!s} is {self.role:s} in document {self.document!s}" - - def save(self, *args, **kwargs): - """Override save to clear the document's cache for number of accesses.""" - super().save(*args, **kwargs) - self.document.invalidate_nb_accesses_cache() - - @property - def target_key(self): - """Get a unique key for the actor targeted by the access, without possible conflict.""" - return f"user:{self.user_id!s}" if self.user_id else f"team:{self.team:s}" - - def delete(self, *args, **kwargs): - """Override delete to clear the document's cache for number of accesses.""" - super().delete(*args, **kwargs) - self.document.invalidate_nb_accesses_cache() - - def set_user_roles_tuple(self, ancestors_role, current_role): - """ - Set a precomputed (ancestor_role, current_role) tuple for this instance. - - This avoids querying the database in `get_roles_tuple()` and is useful - when roles are already known, such as in bulk serialization. - - Args: - ancestor_role (str | None): Highest role on any ancestor document. - current_role (str | None): Role on the current document. - """ - # 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. - """ - 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 = 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 is_owner_or_admin: - set_role_to.extend( - [ - RoleChoices.READER, - RoleChoices.COMMENTER, - RoleChoices.EDITOR, - RoleChoices.ADMIN, - ] - ) - if role == RoleChoices.OWNER: - set_role_to.append(RoleChoices.OWNER) - - # 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, - "set_role_to": set_role_to, - } - - class DocumentAskForAccess(BaseModel): """Relation model to ask for access to a document.""" @@ -1839,16 +1269,18 @@ def get_abilities(self, user): def accept(self, role=None): """Accept a document ask for access resource.""" - if role is None: - role = self.role - - DocumentAccess.objects.update_or_create( - document=self.document, - user=self.user, - defaults={"role": role}, - create_defaults={"role": role}, - ) - self.delete() + # POC-DRIVE-SHARING: disabled until Drive implements AskForAccess — do not delete + # if role is None: + # role = self.role + # + # DocumentAccess.objects.update_or_create( + # document=self.document, + # user=self.user, + # defaults={"role": role}, + # create_defaults={"role": role}, + # ) + # self.delete() + raise NotImplementedError("Sharing is managed in Drive.") def send_ask_for_access_email(self, email, language=None): """ @@ -1926,11 +1358,16 @@ def __str__(self): return f"Thread by {author!s} on {self.document!s}" def get_abilities(self, user): - """Compute and return abilities for a given user (mirrors comment logic).""" - role = self.document.get_role(user) - doc_abilities = self.document.get_abilities(user) + """ + Compute and return abilities for a given user (mirrors comment logic). + Sharing is owned by Drive, so abilities and roles come from there. + """ + # Import here to avoid a circular import through core.api.serializers + from core.services import drive_client # pylint: disable=import-outside-toplevel + + doc_abilities, drive_role = drive_client.get_doc_context(self.document_id, user) read_access = doc_abilities.get("comment", False) - write_access = self.creator == user or role in [ + write_access = (user.is_authenticated and self.creator == user) or drive_role in [ RoleChoices.OWNER, RoleChoices.ADMIN, ] @@ -1979,13 +1416,20 @@ def __str__(self): return f"Comment by {author!s} on thread {self.thread_id}" def get_abilities(self, user): - """Return the abilities of the comment.""" - role = self.thread.document.get_role(user) - doc_abilities = self.thread.document.get_abilities(user) + """ + Return the abilities of the comment. Sharing is owned by Drive, so + abilities and roles come from there. + """ + # Import here to avoid a circular import through core.api.serializers + from core.services import drive_client # pylint: disable=import-outside-toplevel + + doc_abilities, drive_role = drive_client.get_doc_context( + self.thread.document_id, user + ) read_access = doc_abilities.get("comment", False) can_react = read_access and user.is_authenticated - is_author = self.user == user - can_moderate = is_author or role in [ + is_author = user.is_authenticated and self.user == user + can_moderate = is_author or drive_role in [ RoleChoices.OWNER, RoleChoices.ADMIN, ] @@ -2031,86 +1475,3 @@ class Meta: def __str__(self): """Return the string representation of the reaction.""" return f"Reaction {self.emoji} on comment {self.comment.id}" - - -class Invitation(BaseModel): - """User invitation to a document.""" - - email = models.EmailField(_("email address"), null=False, blank=False) - document = models.ForeignKey( - Document, - on_delete=models.CASCADE, - related_name="invitations", - ) - role = models.CharField( - max_length=20, choices=RoleChoices.choices, default=RoleChoices.READER - ) - issuer = models.ForeignKey( - User, - on_delete=models.CASCADE, - related_name="invitations", - blank=True, - null=True, - ) - - class Meta: - db_table = "impress_invitation" - verbose_name = _("Document invitation") - verbose_name_plural = _("Document invitations") - constraints = [ - models.UniqueConstraint( - fields=["email", "document"], name="email_and_document_unique_together" - ) - ] - - def __str__(self): - return f"{self.email} invited to {self.document}" - - def clean(self): - """Validate fields.""" - super().clean() - - # Check if an identity already exists for the provided email - if ( - User.objects.filter(email__iexact=self.email).exists() - and not settings.OIDC_ALLOW_DUPLICATE_EMAILS - ): - raise ValidationError( - {"email": [_("This email is already associated to a registered user.")]} - ) - - @property - def is_expired(self): - """Calculate if invitation is still valid or has expired.""" - if not self.created_at: - return None - - validity_duration = timedelta(seconds=settings.INVITATION_VALIDITY_DURATION) - return timezone.now() > (self.created_at + validity_duration) - - def get_abilities(self, user): - """Compute and return abilities for a given user.""" - roles = [] - - if user.is_authenticated: - teams = user.teams - try: - roles = self.user_roles or [] - except AttributeError: - try: - roles = self.document.accesses.filter( - models.Q(user=user) | models.Q(team__in=teams), - ).values_list("role", flat=True) - except (self._meta.model.DoesNotExist, IndexError): - roles = [] - - is_admin_or_owner = bool( - set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN}) - ) - - return { - "destroy": is_admin_or_owner, - "update": is_admin_or_owner, - "partial_update": is_admin_or_owner, - "retrieve": is_admin_or_owner, - } diff --git a/src/backend/core/services/collaboration_services.py b/src/backend/core/services/collaboration_services.py index fa1e1e867a..b24ea119ac 100644 --- a/src/backend/core/services/collaboration_services.py +++ b/src/backend/core/services/collaboration_services.py @@ -22,8 +22,8 @@ def __init__(self): def reset_connections(self, document_id, user_id=None): """ - Reset the connections of a document and all its descendants in the - collaboration server. + Reset the connections of a document in the collaboration server. The + hierarchy is owned by Drive, so there is no descendant fan-out. Resetting a connection means that the user will be disconnected and will have to reconnect to the collaboration server, with updated rights. @@ -34,15 +34,12 @@ def reset_connections(self, document_id, user_id=None): logger.error("Document %s does not exists anymore", document_id) return - documents = models.Document.objects.filter( - path__startswith=document.path, depth__gte=document.depth - ).order_by("path") - - for doc in documents: - try: - self._reset_connection(doc.id, user_id) - except requests.HTTPError: - logger.error("impossible to reset connections for document %s", doc.id) + try: + self._reset_connection(document.id, user_id) + except requests.HTTPError: + logger.error( + "impossible to reset connections for document %s", document.id + ) def _reset_connection(self, room, user_id=None): """ diff --git a/src/backend/core/services/search_indexers.py b/src/backend/core/services/search_indexers.py index 2aa56d9fc0..1b80355faa 100644 --- a/src/backend/core/services/search_indexers.py +++ b/src/backend/core/services/search_indexers.py @@ -15,7 +15,6 @@ from core import models from core.enums import SearchType from core.utils.dicts import get_value_by_pattern -from core.utils.paths import get_ancestor_to_descendants_map from core.utils.yjs import base64_yjs_to_text logger = logging.getLogger(__name__) @@ -42,34 +41,33 @@ def get_document_indexer(): return None -def get_batch_accesses_by_users_and_teams(paths): +def get_batch_accesses_by_users_and_teams(document_ids): """ - Get accesses related to a list of document paths, - grouped by users and teams, including all ancestor paths. + Get read access candidates for a list of document ids, grouped by users + and teams. Sharing is owned by Drive: locally we only know the creator + and the users who already visited the document (LinkTrace). Drive still + gates actual document opens. """ - ancestor_map = get_ancestor_to_descendants_map( - paths, steplen=models.Document.steplen - ) - ancestor_paths = list(ancestor_map.keys()) - - access_qs = models.DocumentAccess.objects.filter( - document__path__in=ancestor_paths - ).values("document__path", "user__sub", "team") - - access_by_document_path = defaultdict(lambda: {"users": set(), "teams": set()}) - - for access in access_qs: - ancestor_path = access["document__path"] - user_sub = access["user__sub"] - team = access["team"] + access_by_document_id = defaultdict(lambda: {"users": set(), "teams": set()}) + + creators_qs = models.Document.objects.filter( + id__in=document_ids, creator__isnull=False + ).values("id", "creator__sub") + for entry in creators_qs: + if entry["creator__sub"]: + access_by_document_id[str(entry["id"])]["users"].add( + str(entry["creator__sub"]) + ) - for descendant_path in ancestor_map.get(ancestor_path, []): - if user_sub: - access_by_document_path[descendant_path]["users"].add(str(user_sub)) - if team: - access_by_document_path[descendant_path]["teams"].add(team) + traces_qs = models.LinkTrace.objects.filter( + document_id__in=document_ids, user__sub__isnull=False + ).values("document_id", "user__sub") + for entry in traces_qs: + access_by_document_id[str(entry["document_id"])]["users"].add( + str(entry["user__sub"]) + ) - return dict(access_by_document_path) + return dict(access_by_document_id) def get_visited_document_ids_of(queryset, user) -> tuple[str, ...]: @@ -86,10 +84,9 @@ def get_visited_document_ids_of(queryset, user) -> tuple[str, ...]: ) docs = ( - queryset.exclude(accesses__user=user) + queryset.exclude(creator=user) .filter( deleted_at__isnull=True, - ancestors_deleted_at__isnull=True, ) .filter(pk__in=visited_ids) .order_by("pk") @@ -153,12 +150,12 @@ def index(self, queryset=None, batch_size=None): if not documents_batch: break - doc_paths = [doc.path for doc in documents_batch] + doc_ids = [doc.id for doc in documents_batch] last_id = documents_batch[-1].id - accesses_by_document_path = get_batch_accesses_by_users_and_teams(doc_paths) + accesses_by_document_id = get_batch_accesses_by_users_and_teams(doc_ids) serialized_batch = [ - self.serialize_document(document, accesses_by_document_path) + self.serialize_document(document, accesses_by_document_id) for document in documents_batch if document.content or document.title ] @@ -319,24 +316,24 @@ def serialize_document(self, document, accesses): Returns: dict: A JSON-serializable dictionary. """ - doc_path = document.path + doc_id = str(document.id) doc_content = document.content text_content = base64_yjs_to_text(doc_content) if doc_content else "" return { - "id": str(document.id), + "id": doc_id, "title": document.title or "", "content": text_content, - "depth": document.depth, - "path": document.path, - "numchild": document.numchild, + "depth": 1, + "path": doc_id, + "numchild": 0, "created_at": document.created_at.isoformat(), "updated_at": document.updated_at.isoformat(), - "users": list(accesses.get(doc_path, {}).get("users", set())), - "groups": list(accesses.get(doc_path, {}).get("teams", set())), + "users": list(accesses.get(doc_id, {}).get("users", set())), + "groups": list(accesses.get(doc_id, {}).get("teams", set())), "reach": document.computed_link_reach, "size": len(text_content.encode("utf-8")), - "is_active": not bool(document.ancestors_deleted_at), + "is_active": document.deleted_at is None, } def search_query(self, data, token) -> requests.Response: diff --git a/src/backend/core/signals.py b/src/backend/core/signals.py index 03faa666b0..0f80ce3551 100644 --- a/src/backend/core/signals.py +++ b/src/backend/core/signals.py @@ -4,14 +4,12 @@ from functools import partial -from django.core.cache import cache from django.db import transaction from django.db.models import signals from django.dispatch import receiver from core import models from core.tasks.search import trigger_batch_document_indexer -from core.utils.users import get_users_sharing_documents_with_cache_key @receiver(signals.post_save, sender=models.Document) @@ -22,28 +20,3 @@ def document_post_save(sender, instance, **kwargs): # pylint: disable=unused-ar error. """ transaction.on_commit(partial(trigger_batch_document_indexer, instance)) - - -@receiver(signals.post_save, sender=models.DocumentAccess) -def document_access_post_save(sender, instance, created, **kwargs): # pylint: disable=unused-argument - """ - Asynchronous call to the document indexer at the end of the transaction. - Clear cache for the affected user. - """ - if not created: - transaction.on_commit( - partial(trigger_batch_document_indexer, instance.document) - ) - - # Invalidate cache for the user - cache_key = get_users_sharing_documents_with_cache_key(instance.user_id) - cache.delete(cache_key) - - -@receiver(signals.post_delete, sender=models.DocumentAccess) -def document_access_post_delete(sender, instance, **kwargs): # pylint: disable=unused-argument - """ - Clear cache for the affected user when document access is deleted. - """ - cache_key = get_users_sharing_documents_with_cache_key(instance.user_id) - cache.delete(cache_key) diff --git a/src/backend/core/tasks/mail.py b/src/backend/core/tasks/mail.py index 483c961486..cd67ccad70 100644 --- a/src/backend/core/tasks/mail.py +++ b/src/backend/core/tasks/mail.py @@ -7,18 +7,19 @@ from impress.celery_app import app -@app.task -def send_ask_for_access_mail(ask_for_access_id): - """Send mail using celery task.""" - # Send email to document owners/admins - ask_for_access = models.DocumentAskForAccess.objects.get(id=ask_for_access_id) - owner_admin_accesses = models.DocumentAccess.objects.filter( - document=ask_for_access.document, role__in=models.PRIVILEGED_ROLES - ).select_related("user") - - for access in owner_admin_accesses: - if access.user and access.user.email: - ask_for_access.send_ask_for_access_email( - access.user.email, - access.user.language or settings.LANGUAGE_CODE, - ) +# POC-DRIVE-SHARING: disabled until Drive implements AskForAccess — do not delete +# @app.task +# def send_ask_for_access_mail(ask_for_access_id): +# """Send mail using celery task.""" +# # Send email to document owners/admins +# ask_for_access = models.DocumentAskForAccess.objects.get(id=ask_for_access_id) +# owner_admin_accesses = models.DocumentAccess.objects.filter( +# document=ask_for_access.document, role__in=models.PRIVILEGED_ROLES +# ).select_related("user") +# +# for access in owner_admin_accesses: +# if access.user and access.user.email: +# ask_for_access.send_ask_for_access_email( +# access.user.email, +# access.user.language or settings.LANGUAGE_CODE, +# ) diff --git a/src/backend/core/tasks/search.py b/src/backend/core/tasks/search.py index e1c39e6bea..153298f744 100644 --- a/src/backend/core/tasks/search.py +++ b/src/backend/core/tasks/search.py @@ -54,9 +54,7 @@ def batch_document_indexer_task(timestamp): if indexer: queryset = models.Document.objects.filter( - Q(updated_at__gte=timestamp) - | Q(deleted_at__gte=timestamp) - | Q(ancestors_deleted_at__gte=timestamp) + Q(updated_at__gte=timestamp) | Q(deleted_at__gte=timestamp) ) count = indexer.index(queryset) diff --git a/src/backend/core/tests/test_api_utils_filter_root_paths.py b/src/backend/core/tests/test_api_utils_filter_root_paths.py deleted file mode 100644 index 1375d223b2..0000000000 --- a/src/backend/core/tests/test_api_utils_filter_root_paths.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -Unit tests for the filter_root_paths utility function. -""" - -from core.api.utils import filter_root_paths - - -def test_api_utils_filter_root_paths_success(): - """ - The `filter_root_paths` function should correctly identify root paths - from a given list of paths. - - This test uses a list of paths with missing intermediate paths to ensure that - only the minimal set of root paths is returned. - """ - paths = [ - "0001", - "00010001", - "000100010001", - "000100010002", - # missing 00010002 - "000100020001", - "000100020002", - "0002", - "00020001", - "00020002", - # missing 0003 - "00030001", - "000300010001", - "00030002", - # missing 0004 - # missing 00040001 - # missing 000400010001 - # missing 000400010002 - "000400010003", - "0004000100030001", - "000400010004", - ] - filtered_paths = filter_root_paths(paths, skip_sorting=True) - assert filtered_paths == [ - "0001", - "0002", - "00030001", - "00030002", - "000400010003", - "000400010004", - ] - - -def test_api_utils_filter_root_paths_sorting(): - """ - The `filter_root_paths` function should fail is sorting is skipped and paths are not sorted. - - This test verifies that when sorting is skipped, the function respects the input order, and - when sorting is enabled, the result is correctly ordered and minimal. - """ - paths = [ - "0001", - "00010001", - "000100010001", - "000100020002", - "000100010002", - "000100020001", - "00020001", - "0002", - "00020002", - "000300010001", - "00030001", - "00030002", - "0004000100030001", - "000400010003", - "000400010004", - ] - filtered_paths = filter_root_paths(paths, skip_sorting=True) - assert filtered_paths == [ - "0001", - "00020001", - "0002", - "000300010001", - "00030001", - "00030002", - "0004000100030001", - "000400010003", - "000400010004", - ] - filtered_paths = filter_root_paths(paths) - assert filtered_paths == [ - "0001", - "0002", - "00030001", - "00030002", - "000400010003", - "000400010004", - ] diff --git a/src/backend/core/tests/test_utils.py b/src/backend/core/tests/test_utils.py index 33c7c643e4..ddf5ce1bb7 100644 --- a/src/backend/core/tests/test_utils.py +++ b/src/backend/core/tests/test_utils.py @@ -10,7 +10,6 @@ from core import factories from core.utils.dicts import get_value_by_pattern -from core.utils.paths import get_ancestor_to_descendants_map from core.utils.users import ( get_users_sharing_documents_with_cache_key, users_sharing_documents_with, @@ -93,31 +92,6 @@ def test_utils_extract_attachments(): assert extract_attachments(base64_string) == [image_key1, image_key3] -def test_utils_get_ancestor_to_descendants_map_single_path(): - """Test ancestor mapping of a single path.""" - paths = ["000100020005"] - result = get_ancestor_to_descendants_map(paths, steplen=4) - - assert result == { - "0001": {"000100020005"}, - "00010002": {"000100020005"}, - "000100020005": {"000100020005"}, - } - - -def test_utils_get_ancestor_to_descendants_map_multiple_paths(): - """Test ancestor mapping of multiple paths with shared prefixes.""" - paths = ["000100020005", "00010003"] - result = get_ancestor_to_descendants_map(paths, steplen=4) - - assert result == { - "0001": {"000100020005", "00010003"}, - "00010002": {"000100020005"}, - "000100020005": {"000100020005"}, - "00010003": {"00010003"}, - } - - def test_utils_users_sharing_documents_with_cache_miss(): """Test cache miss: should query database and cache result.""" user1 = factories.UserFactory() diff --git a/src/backend/core/tests/test_utils_create_tree_node_with_retry.py b/src/backend/core/tests/test_utils_create_tree_node_with_retry.py deleted file mode 100644 index cc327b2804..0000000000 --- a/src/backend/core/tests/test_utils_create_tree_node_with_retry.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Tests for the create_tree_node_with_retry utils.""" - -from unittest import mock - -from django.core.exceptions import ValidationError as DjangoValidationError -from django.db import IntegrityError - -import pytest - -from core.factories import UserFactory -from core.models import Document -from core.utils.treebeard import _is_tree_path_collision, create_tree_node_with_retry - -pytestmark = pytest.mark.django_db - - -@pytest.mark.parametrize( - "exc", - [ - DjangoValidationError({"path": "not unique"}), - IntegrityError("impress_document_path_key"), - ], -) -def test_utils_create_tree_node_with_retry_exceed_max_attempts(settings, exc): - """Test exceeding the max attempts should reraise the exception.""" - - settings.TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS = 2 - - create_fn = mock.MagicMock() - create_fn.side_effect = exc - - with ( - pytest.raises(exc.__class__), - mock.patch( - "core.utils.treebeard._is_tree_path_collision" - ) as mock__is_tree_path_collision, - ): - mock__is_tree_path_collision.side_effect = _is_tree_path_collision - create_tree_node_with_retry(create_fn) - - mock__is_tree_path_collision.assert_called() - assert mock__is_tree_path_collision.call_count == 2 - assert create_fn.call_count == 2 - - -@pytest.mark.parametrize( - "exc", - [ - DjangoValidationError({"foo": "bar"}), - IntegrityError("not handled"), - ], -) -def test_utils_create_tree_node_with_retry_exceed_exception_not_handled(settings, exc): - """Test with an exception not handled should return reraise it immediately.""" - - settings.TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS = 2 - - create_fn = mock.MagicMock() - create_fn.side_effect = exc - - with ( - pytest.raises(exc.__class__), - mock.patch( - "core.utils.treebeard._is_tree_path_collision" - ) as mock__is_tree_path_collision, - ): - mock__is_tree_path_collision.side_effect = _is_tree_path_collision - create_tree_node_with_retry(create_fn) - - mock__is_tree_path_collision.assert_called() - assert mock__is_tree_path_collision.call_count == 1 - assert create_fn.call_count == 1 - - -def test_utils_create_tree_node_with_retry_success(): - """Test executing successfully the create_fn callback.""" - - user = UserFactory() - - document = create_tree_node_with_retry( - lambda: Document.add_root( - creator=user, - title="success", - ) - ) - - assert isinstance(document, Document) - assert document.title == "success" - assert document.path is not None diff --git a/src/backend/core/tests/test_utils_filter_descendants.py b/src/backend/core/tests/test_utils_filter_descendants.py deleted file mode 100644 index f5050fb1e9..0000000000 --- a/src/backend/core/tests/test_utils_filter_descendants.py +++ /dev/null @@ -1,163 +0,0 @@ -""" -Unit tests for the filter_root_paths utility function. -""" - -from core.utils.paths import filter_descendants - - -def test_utils_filter_descendants_success(): - """ - The `filter_descendants` function should correctly identify descendant paths - from a given list of paths and root paths. - - This test verifies that the function returns only the paths that have a prefix - matching one of the root paths. - """ - paths = [ - "0001", - "00010001", - "000100010001", - "000100010002", - "000100020001", - "000100020002", - "0002", - "00020001", - "00020002", - "00030001", - "000300010001", - "00030002", - "0004", - "000400010003", - "0004000100030001", - "000400010004", - ] - root_paths = [ - "0001", - "0002", - "000400010003", - ] - filtered_paths = filter_descendants(paths, root_paths, skip_sorting=True) - assert filtered_paths == [ - "0001", - "00010001", - "000100010001", - "000100010002", - "000100020001", - "000100020002", - "0002", - "00020001", - "00020002", - "000400010003", - "0004000100030001", - ] - - -def test_utils_filter_descendants_sorting(): - """ - The `filter_descendants` function should handle unsorted input when sorting is enabled. - - This test verifies that the function sorts the input if sorting is not skipped - and still correctly identifies accessible descendant paths. - """ - paths = [ - "000300010001", - "000100010002", - "0001", - "00010001", - "000100010001", - "000100020002", - "000100020001", - "0002", - "00020001", - "00020002", - "00030001", - "00030002", - "0004000100030001", - "0004", - "000400010003", - "000400010004", - ] - root_paths = [ - "0002", - "000400010003", - "0001", - ] - filtered_paths = filter_descendants(paths, root_paths) - assert filtered_paths == [ - "0001", - "00010001", - "000100010001", - "000100010002", - "000100020001", - "000100020002", - "0002", - "00020001", - "00020002", - "000400010003", - "0004000100030001", - ] - - filtered_paths = filter_descendants(paths, root_paths, skip_sorting=True) - assert filtered_paths == [ - "0001", - "00010001", - "000100010001", - "000100010002", - "000100020001", - "000100020002", - "0002", - "00020001", - "00020002", - "000400010003", - "0004000100030001", - ] - - -def test_utils_filter_descendants_empty(): - """ - The function should return an empty list if one or both inputs are empty. - """ - assert not filter_descendants([], ["0001"]) - assert not filter_descendants(["0001"], []) - assert not filter_descendants([], []) - - -def test_utils_filter_descendants_no_match(): - """ - The function should return an empty list if no path starts with any root path. - """ - paths = ["0001", "0002", "0003"] - root_paths = ["0004", "0005"] - assert not filter_descendants(paths, root_paths, skip_sorting=True) - - -def test_utils_filter_descendants_exact_match(): - """ - The function should include paths that exactly match a root path. - """ - paths = ["0001", "0002", "0003"] - root_paths = ["0001", "0002"] - assert filter_descendants(paths, root_paths, skip_sorting=True) == ["0001", "0002"] - - -def test_utils_filter_descendants_single_root_matches_all(): - """ - A single root path should match all its descendants. - """ - paths = ["0001", "00010001", "000100010001", "00010002"] - root_paths = ["0001"] - assert filter_descendants(paths, root_paths) == [ - "0001", - "00010001", - "000100010001", - "00010002", - ] - - -def test_utils_filter_descendants_path_shorter_than_root(): - """ - A path shorter than any root path should not match. - """ - paths = ["0001", "0002"] - root_paths = ["00010001"] - assert not filter_descendants(paths, root_paths) diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index e89618650b..9c72ff7922 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -17,26 +17,28 @@ # - Routes nested under a document document_related_router = DefaultRouter() -document_related_router.register( - "accesses", - viewsets.DocumentAccessViewSet, - basename="document_accesses", -) -document_related_router.register( - "invitations", - viewsets.InvitationViewset, - basename="invitations", -) +# POC-DRIVE-SHARING: disabled until Drive implements AskForAccess — do not delete +# document_related_router.register( +# "accesses", +# viewsets.DocumentAccessViewSet, +# basename="document_accesses", +# ) +# document_related_router.register( +# "invitations", +# viewsets.InvitationViewset, +# basename="invitations", +# ) document_related_router.register( "threads", viewsets.ThreadViewSet, basename="threads", ) -document_related_router.register( - "ask-for-access", - viewsets.DocumentAskForAccessViewSet, - basename="ask_for_access", -) +# POC-DRIVE-SHARING: disabled until Drive implements AskForAccess — do not delete +# document_related_router.register( +# "ask-for-access", +# viewsets.DocumentAskForAccessViewSet, +# basename="ask_for_access", +# ) thread_related_router = DefaultRouter() thread_related_router.register( @@ -88,21 +90,22 @@ # - Routes nested under a document in external API external_api_document_related_router = DefaultRouter() - document_access_config = settings.EXTERNAL_API.get("document_access", {}) - if document_access_config.get("enabled", False): - external_api_document_related_router.register( - "accesses", - external_api_viewsets.ResourceServerDocumentAccessViewSet, - basename="resource_server_document_accesses", - ) - - document_invitation_config = settings.EXTERNAL_API.get("document_invitation", {}) - if document_invitation_config.get("enabled", False): - external_api_document_related_router.register( - "invitations", - external_api_viewsets.ResourceServerInvitationViewSet, - basename="resource_server_document_invitations", - ) + # POC-DRIVE-SHARING: disabled until Drive implements AskForAccess — do not delete + # document_access_config = settings.EXTERNAL_API.get("document_access", {}) + # if document_access_config.get("enabled", False): + # external_api_document_related_router.register( + # "accesses", + # external_api_viewsets.ResourceServerDocumentAccessViewSet, + # basename="resource_server_document_accesses", + # ) + # + # document_invitation_config = settings.EXTERNAL_API.get("document_invitation", {}) + # if document_invitation_config.get("enabled", False): + # external_api_document_related_router.register( + # "invitations", + # external_api_viewsets.ResourceServerInvitationViewSet, + # basename="resource_server_document_invitations", + # ) urlpatterns.append( path( diff --git a/src/backend/core/utils/paths.py b/src/backend/core/utils/paths.py deleted file mode 100644 index fb0da42f43..0000000000 --- a/src/backend/core/utils/paths.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Path and tree structure utilities.""" - -from collections import defaultdict - - -def get_ancestor_to_descendants_map(paths, steplen): - """ - Given a list of document paths, return a mapping of ancestor_path -> set of descendant_paths. - - Each path is assumed to use materialized path format with fixed-length segments. - - Args: - paths (list of str): List of full document paths. - steplen (int): Length of each path segment. - - Returns: - dict[str, set[str]]: Mapping from ancestor path to its descendant paths (including itself). - """ - ancestor_map = defaultdict(set) - for path in paths: - for i in range(steplen, len(path) + 1, steplen): - ancestor = path[:i] - ancestor_map[ancestor].add(path) - return ancestor_map - - -def filter_descendants(paths, root_paths, skip_sorting=False): - """ - Filters paths to keep only those that are descendants of any path in root_paths. - - A path is considered a descendant of a root path if it starts with the root path. - If `skip_sorting` is not set to True, the function will sort both lists before - processing because both `paths` and `root_paths` need to be in lexicographic order - before going through the algorithm. - - Args: - paths (iterable of str): List of paths to be filtered. - root_paths (iterable of str): List of paths to check as potential prefixes. - skip_sorting (bool): If True, assumes both `paths` and `root_paths` are already sorted. - - Returns: - list of str: A list of sorted paths that are descendants of any path in `root_paths`. - """ - results = [] - i = 0 - n = len(root_paths) - - if not skip_sorting: - paths.sort() - root_paths.sort() - - for path in paths: - # Try to find a matching prefix in the sorted accessible paths - while i < n: - if path.startswith(root_paths[i]): - results.append(path) - break - if root_paths[i] < path: - i += 1 - else: - # If paths[i] > path, no need to keep searching - break - return results diff --git a/src/backend/core/utils/treebeard.py b/src/backend/core/utils/treebeard.py deleted file mode 100644 index 27f387ff64..0000000000 --- a/src/backend/core/utils/treebeard.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Treebeard path collision handling utilities.""" - -import logging -import time - -from django.conf import settings -from django.core.exceptions import ValidationError as DjangoValidationError -from django.db import IntegrityError, transaction - -logger = logging.getLogger(__name__) - - -def _is_tree_path_collision(exc): - """Return True when `exc` is caused by a Document.path uniqueness conflict. - - Treebeard computes the materialized path by reading the current siblings; - under concurrency two callers may compute the same value. Depending on - timing this surfaces either as: - - - `django.core.exceptions.ValidationError` raised by `full_clean()` / - `validate_unique()` before the INSERT (BaseModel.save calls full_clean), - with this message `{'path': ['Document with this Path already exists.']}` - - or `IntegrityError` from the database unique index when the validate - step misses the conflict. With this message: - duplicate key value violates unique constraint "impress_document_path_key" - DETAIL: Key (path)=(0000001) already exists. - - """ - if isinstance(exc, DjangoValidationError): - message_dict = getattr(exc, "message_dict", None) - if message_dict is not None: - return "path" in message_dict - return "path" in str(exc).lower() - - # search in the IntegrityError exception - return "impress_document_path_key" in str(exc).lower() - - -def create_tree_node_with_retry(create_fn): - """Run `create_fn` in a fresh atomic block, retrying on path collisions. - - The Document.path field carries a unique constraint, which is the source of - truth that prevents duplicate paths. On collision we let the failed - transaction roll back, and call `create_fn` again so treebeard recomputes - the path from the latest state. - """ - max_attempts = settings.TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS - for attempt in range(max_attempts): - try: - with transaction.atomic(): - return create_fn() - except (IntegrityError, DjangoValidationError) as exc: - if not _is_tree_path_collision(exc) or attempt == max_attempts - 1: - raise - logger.info( - "tree path collision on attempt %d/%d, retrying", - attempt + 1, - max_attempts, - ) - time.sleep(attempt * 0.1) - - raise RuntimeError("create_tree_node_with_retry exited without result") diff --git a/src/backend/core/utils/users.py b/src/backend/core/utils/users.py deleted file mode 100644 index 0130383ab5..0000000000 --- a/src/backend/core/utils/users.py +++ /dev/null @@ -1,55 +0,0 @@ -"""User sharing cache utilities.""" - -import logging -import time - -from django.core.cache import cache -from django.db import models as db -from django.db.models import Subquery - -from core import models - -logger = logging.getLogger(__name__) - - -def get_users_sharing_documents_with_cache_key(user_id): - """Generate a unique cache key for each user.""" - return f"users_sharing_documents_with_{user_id}" - - -def users_sharing_documents_with(user_id): - """ - Returns a map of users sharing documents with the given user, - sorted by last shared date. - """ - start_time = time.time() - cache_key = get_users_sharing_documents_with_cache_key(user_id) - cached_result = cache.get(cache_key) - - if cached_result is not None: - elapsed = time.time() - start_time - logger.info( - "users_sharing_documents_with cache hit for user %s (took %.3fs)", - user_id, - elapsed, - ) - return cached_result - - user_docs_qs = models.DocumentAccess.objects.filter(user__id=user_id).values_list( - "document_id", flat=True - ) - shared_qs = ( - models.DocumentAccess.objects.filter(document__id__in=Subquery(user_docs_qs)) - .exclude(user__id=user_id) - .values("user") - .annotate(last_shared=db.Max("created_at")) - ) - result = {item["user"]: item["last_shared"] for item in shared_qs} - cache.set(cache_key, result, 86400) # Cache for 1 day - elapsed = time.time() - start_time - logger.info( - "users_sharing_documents_with cache miss for user %s (took %.3fs)", - user_id, - elapsed, - ) - return result diff --git a/src/backend/demo/management/commands/create_demo.py b/src/backend/demo/management/commands/create_demo.py index e216edf945..e01235e752 100644 --- a/src/backend/demo/management/commands/create_demo.py +++ b/src/backend/demo/management/commands/create_demo.py @@ -118,106 +118,14 @@ def __exit__(self, exc_type, exc_value, exc_tb): def create_demo(stdout): - """ - Create a database with demo data for developers to work in a realistic environment. - The code is engineered to create a huge number of objects fast. - """ - - queue = BulkQueue(stdout) - - with Timeit(stdout, "Creating users"): - name_size = int(math.sqrt(defaults.NB_OBJECTS["users"])) - first_names = [fake.first_name() for _ in range(name_size)] - last_names = [fake.last_name() for _ in range(name_size)] - for i in range(defaults.NB_OBJECTS["users"]): - first_name = random.choice(first_names) - queue.push( - models.User( - admin_email=f"user.test{i:d}@example.com", - email=f"user.test{i:d}@example.com", - password="!", - is_superuser=False, - is_active=True, - is_first_connection=False, - is_staff=False, - short_name=first_name, - full_name=f"{first_name:s} {random.choice(last_names):s}", - language=random.choice(languages), - ) - ) - queue.flush() - - users_ids = list(models.User.objects.values_list("id", flat=True)) - - with Timeit(stdout, "Creating documents"): - for i in range(defaults.NB_OBJECTS["docs"]): - # pylint: disable=protected-access - key = models.Document._int2str(i) # noqa: SLF001 - padding = models.Document.alphabet[0] * (models.Document.steplen - len(key)) - title = fake.sentence(nb_words=4) - document = models.Document( - id=uuid4(), - depth=1, - path=f"{padding}{key}", - creator_id=random.choice(users_ids), - title=title, - link_reach=models.LinkReachChoices.AUTHENTICATED - if random_true_with_probability(0.5) - else random.choice(models.LinkReachChoices.values), - ) - document.save_content(get_ydoc_for_text(f"Content for {title:s}")) - queue.push(document) - - queue.flush() - - with Timeit(stdout, "Creating docs accesses"): - docs_ids = list(models.Document.objects.values_list("id", flat=True)) - for doc_id in docs_ids: - for user_id in random.sample( - users_ids, - random.randint(1, defaults.NB_OBJECTS["max_users_per_document"]), - ): - role = random.choice(models.RoleChoices.choices) - queue.push( - models.DocumentAccess( - document_id=doc_id, user_id=user_id, role=role[0] - ) - ) - queue.flush() - - with Timeit(stdout, "Creating development users"): - for dev_user in defaults.DEV_USERS: - queue.push( - models.User( - admin_email=dev_user["email"], - email=dev_user["email"], - sub=dev_user["email"], - password="!", - is_superuser=False, - is_active=True, - is_first_connection=False, - is_staff=False, - language=dev_user["language"] or random.choice(languages), - ) - ) - - queue.flush() - - with Timeit(stdout, "Creating docs accesses on development users"): - for dev_user in defaults.DEV_USERS: - docs_ids = list(models.Document.objects.values_list("id", flat=True)) - user_id = models.User.objects.get(email=dev_user["email"]).id - - for doc_id in docs_ids: - role = random.choice(models.RoleChoices.choices) - queue.push( - models.DocumentAccess( - document_id=doc_id, user_id=user_id, role=role[0] - ) - ) - - queue.flush() - + """Not supported with the Drive integration.""" + # The document tree, sharing and listing are owned by Drive: demo documents + # created locally would have no Drive item and would be invisible in the + # app. Seed data through the Drive app instead. + raise CommandError( + "create_demo is not supported with the Drive integration: documents " + "must be created through Drive." + ) class Command(BaseCommand): """A management command to create a demo database.""" From 0a6cfa3a3305dc977dcc483c61f981e2cc8bbc59 Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Thu, 30 Jul 2026 15:49:29 +0200 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=94=A5(frontend)=20remove=20the=20sha?= =?UTF-8?q?ring=20management=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharing is managed in Drive now: keeping the share modal, invitations and role management in Docs would offer two competing places to do the same thing on the same items. The whole doc-share feature goes away; only read-only visibility badges remain so users still see at a glance that a document is public or shared. The 403 page points users to the document owner instead of an in-app access request, and the shared button in the grid becomes a passive indicator. --- .../doc-header/components/DocFloatingBar.tsx | 2 - .../docs/doc-header/components/DocToolBox.tsx | 66 --- .../doc-management/components/DocPage403.tsx | 52 +-- .../src/features/docs/doc-share/api/index.ts | 10 - .../docs/doc-share/api/useCreateDocAccess.tsx | 71 ---- .../doc-share/api/useCreateDocInvitation.tsx | 53 --- .../docs/doc-share/api/useDeleteDocAccess.ts | 70 --- .../doc-share/api/useDeleteDocInvitation.ts | 70 --- .../doc-share/api/useDocAccessRequest.tsx | 236 ----------- .../docs/doc-share/api/useDocAccesses.tsx | 44 -- .../docs/doc-share/api/useDocInvitations.tsx | 75 ---- .../docs/doc-share/api/useLeaveDoc.ts | 45 -- .../docs/doc-share/api/useUpdateDocAccess.ts | 68 --- .../doc-share/api/useUpdateDocInvitation.ts | 79 ---- .../docs/doc-share/api/useUpdateDocLink.tsx | 60 --- .../features/docs/doc-share/api/useUsers.tsx | 43 -- .../docs/doc-share/assets/desynchro.svg | 15 - .../features/docs/doc-share/assets/undo.svg | 15 - .../components/AlertModalRequestAccess.tsx | 89 ---- .../components/ConfirmationLeaveModal.tsx | 257 ----------- .../components/DocDesynchronized.tsx | 74 ---- .../components/DocInheritedShareContent.tsx | 66 --- .../doc-share/components/DocRoleDropdown.tsx | 156 ------- .../components/DocShareAccessRequest.tsx | 255 ----------- .../components/DocShareAddMemberList.tsx | 171 -------- .../components/DocShareAddMemberListItem.tsx | 55 --- .../doc-share/components/DocShareButton.tsx | 89 ---- .../components/DocShareInvitation.tsx | 187 -------- .../doc-share/components/DocShareMember.tsx | 135 ------ .../doc-share/components/DocShareModal.tsx | 398 ------------------ .../components/DocShareModalFooter.tsx | 50 --- .../doc-share/components/DocVisibility.tsx | 250 ----------- .../doc-share/components/SearchUserRow.tsx | 69 --- .../docs/doc-share/components/index.ts | 3 - .../features/docs/doc-share/hooks/index.ts | 2 - .../hooks/useTranslatedShareSettings.tsx | 52 --- .../docs/doc-share/hooks/useWhoAmI.tsx | 28 -- .../src/features/docs/doc-share/index.ts | 4 - .../src/features/docs/doc-share/types.tsx | 51 --- .../docs-grid/components/DocMoveModal.tsx | 23 - .../docs-grid/components/DocsGridActions.tsx | 56 --- .../components/DocsGridItemSharedButton.tsx | 93 ++-- .../apps/impress/src/pages/docs/new/index.tsx | 46 +- 43 files changed, 50 insertions(+), 3683 deletions(-) delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/api/index.ts delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/api/useCreateDocAccess.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/api/useCreateDocInvitation.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/api/useDeleteDocAccess.ts delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/api/useDeleteDocInvitation.ts delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccessRequest.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccesses.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/api/useDocInvitations.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/api/useLeaveDoc.ts delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/api/useUpdateDocAccess.ts delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/api/useUpdateDocInvitation.ts delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/api/useUpdateDocLink.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/api/useUsers.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/assets/desynchro.svg delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/assets/undo.svg delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/components/AlertModalRequestAccess.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/components/ConfirmationLeaveModal.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/components/DocDesynchronized.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/components/DocInheritedShareContent.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/components/DocRoleDropdown.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAccessRequest.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberListItem.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareButton.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitation.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMember.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModalFooter.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/components/DocVisibility.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/components/SearchUserRow.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/components/index.ts delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/hooks/index.ts delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/hooks/useTranslatedShareSettings.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/hooks/useWhoAmI.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/index.ts delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/types.tsx diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocFloatingBar.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocFloatingBar.tsx index 648caefed2..b250216d7e 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocFloatingBar.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocFloatingBar.tsx @@ -1,7 +1,6 @@ import { Box } from '@/components'; import { CardFloatingBar, FloatingBar } from '@/components/FloatingBar'; import { useDocStore } from '@/docs/doc-management/stores/useDocStore'; -import { DocShareButton } from '@/features/docs/doc-share/components/DocShareButton'; import { RightPanelCollapseButton } from '@/features/right-panel/components/RightPanelCollapseButton'; import { DocLeftPanelCollapseButton } from './DocLeftPanelCollapseButton'; @@ -15,7 +14,6 @@ export const DocFloatingBar = () => { - {!isDeletedDoc && currentDoc && } {!isDeletedDoc && currentDoc && } 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 8ad47dda48..e4eb982f97 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 @@ -14,11 +14,9 @@ import AddLinkSVG from '@/assets/icons/ui-kit/add_link.svg'; import ContentCopySVG from '@/assets/icons/ui-kit/content_copy.svg'; import DeleteSVG from '@/assets/icons/ui-kit/delete.svg'; import DownloadSVG from '@/assets/icons/ui-kit/download.svg'; -import SharedSVG from '@/assets/icons/ui-kit/group.svg'; import HistorySVG from '@/assets/icons/ui-kit/history.svg'; import KeepSVG from '@/assets/icons/ui-kit/keep.svg'; import KeepOffSVG from '@/assets/icons/ui-kit/keep_off.svg'; -import LeaveSVG from '@/assets/icons/ui-kit/leave.svg'; import MarkdownCopySVG from '@/assets/icons/ui-kit/markdown_copy.svg'; import MoreSVG from '@/assets/icons/ui-kit/more_horiz.svg'; import { @@ -29,11 +27,9 @@ import { useCopyDocLink, useCreateFavoriteDoc, useDeleteFavoriteDoc, - useDocUtils, useDuplicateDoc, } from '@/docs/doc-management'; import { usePresenterStore } from '@/docs/doc-presenter/stores'; -import { useAuth } from '@/features/auth'; import { useFocusStore, useResponsiveStore } from '@/stores'; import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard'; @@ -54,24 +50,6 @@ const ModalSelectVersion = dynamic( { ssr: false }, ); -const DocShareModal = dynamic( - () => - import('@/docs/doc-share/components/DocShareModal').then((mod) => ({ - default: mod.DocShareModal, - })), - { ssr: false }, -); - -const ConfirmationLeaveModal = dynamic( - () => - import('@/docs/doc-share/components/ConfirmationLeaveModal').then( - (mod) => ({ - default: mod.ConfirmationLeaveModal, - }), - ), - { ssr: false }, -); - const ModalExport = process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false' ? dynamic( @@ -91,16 +69,12 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { const { t } = useTranslation(); const treeContext = useTreeContext(); const router = useRouter(); - const { isTopRoot } = useDocUtils(doc); const isTopParent = doc.id === treeContext?.root?.id; // it can be a child but not for the current user - const { authenticated } = useAuth(); const copyCurrentEditorToClipboard = useCopyCurrentEditorToClipboard(); const [openDropdown, setOpenDropdown] = useState(false); const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false); const [isModalExportOpen, setIsModalExportOpen] = useState(false); - const [isModalShareOpen, setIsModalShareOpen] = useState(false); const [isModalHistoryOpen, setIsModalHistoryOpen] = useState(false); - const [isModalLeaveOpen, setIsModalLeaveOpen] = useState(false); const { restoreFocus, addLastFocus } = useFocusStore(); const { isMobile } = useResponsiveStore(); @@ -153,14 +127,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { icon: diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/api/index.ts b/src/frontend/apps/impress/src/features/docs/doc-share/api/index.ts deleted file mode 100644 index ff9c7b4181..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/api/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export * from './useCreateDocAccess'; -export * from './useCreateDocInvitation'; -export * from './useDeleteDocAccess'; -export * from './useDeleteDocInvitation'; -export * from './useDocAccesses'; -export * from './useDocAccessRequest'; -export * from './useDocInvitations'; -export * from './useUpdateDocAccess'; -export * from './useUpdateDocInvitation'; -export * from './useUsers'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/api/useCreateDocAccess.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/api/useCreateDocAccess.tsx deleted file mode 100644 index 23cd2616b7..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/api/useCreateDocAccess.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; - -import { APIError, errorCauses, fetchAPI } from '@/api'; -import { - Access, - Doc, - KEY_DOC, - KEY_LIST_DOC, - Role, -} from '@/docs/doc-management'; -import { User } from '@/features/auth'; -import { useBroadcastStore } from '@/stores/useBroadcastStore'; - -import { OptionType } from '../types'; - -import { KEY_LIST_DOC_ACCESSES } from './useDocAccesses'; -import { KEY_LIST_USER } from './useUsers'; - -interface CreateDocAccessParams { - role: Role; - docId: Doc['id']; - memberId: User['id']; -} - -export const createDocAccess = async ({ - memberId, - role, - docId, -}: CreateDocAccessParams): Promise => { - const response = await fetchAPI(`documents/${docId}/accesses/`, { - method: 'POST', - body: JSON.stringify({ - user_id: memberId, - role, - }), - }); - - if (!response.ok) { - throw new APIError( - `Failed to add the member in the doc.`, - await errorCauses(response, { - type: OptionType.NEW_MEMBER, - }), - ); - } - - return response.json() as Promise; -}; - -export function useCreateDocAccess() { - const queryClient = useQueryClient(); - const { broadcast } = useBroadcastStore(); - - return useMutation({ - mutationFn: createDocAccess, - onSuccess: (_data, variable) => { - void queryClient.resetQueries({ - queryKey: [KEY_LIST_DOC], - }); - void queryClient.resetQueries({ - queryKey: [KEY_LIST_USER], - }); - void queryClient.resetQueries({ - queryKey: [KEY_LIST_DOC_ACCESSES], - }); - - // Broadcast to every user connected to the document - broadcast(`${KEY_DOC}-${variable.docId}`); - }, - }); -} diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/api/useCreateDocInvitation.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/api/useCreateDocInvitation.tsx deleted file mode 100644 index a13e423576..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/api/useCreateDocInvitation.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; - -import { APIError, errorCauses, fetchAPI } from '@/api'; -import { Doc, Role } from '@/docs/doc-management'; -import { User } from '@/features/auth'; - -import { Invitation, OptionType } from '../types'; - -import { KEY_LIST_DOC_INVITATIONS } from './useDocInvitations'; - -interface CreateDocInvitationParams { - email: User['email']; - role: Role; - docId: Doc['id']; -} - -export const createDocInvitation = async ({ - email, - role, - docId, -}: CreateDocInvitationParams): Promise => { - const response = await fetchAPI(`documents/${docId}/invitations/`, { - method: 'POST', - body: JSON.stringify({ - email, - role, - }), - }); - - if (!response.ok) { - throw new APIError( - `Failed to create the invitation for ${email}`, - await errorCauses(response, { - value: email, - type: OptionType.INVITATION, - }), - ); - } - - return response.json() as Promise; -}; - -export function useCreateDocInvitation() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: createDocInvitation, - onSuccess: () => { - void queryClient.invalidateQueries({ - queryKey: [KEY_LIST_DOC_INVITATIONS], - }); - }, - }); -} diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/api/useDeleteDocAccess.ts b/src/frontend/apps/impress/src/features/docs/doc-share/api/useDeleteDocAccess.ts deleted file mode 100644 index 0acae97a74..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/api/useDeleteDocAccess.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { - UseMutationOptions, - useMutation, - useQueryClient, -} from '@tanstack/react-query'; - -import { APIError, errorCauses, fetchAPI } from '@/api'; -import { KEY_DOC, KEY_LIST_DOC } from '@/docs/doc-management'; -import { useBroadcastStore } from '@/stores/useBroadcastStore'; - -import { KEY_LIST_DOC_ACCESSES } from './useDocAccesses'; -import { KEY_LIST_USER } from './useUsers'; - -interface DeleteDocAccessProps { - docId: string; - accessId: string; -} - -export const deleteDocAccess = async ({ - docId, - accessId, -}: DeleteDocAccessProps): Promise => { - const response = await fetchAPI(`documents/${docId}/accesses/${accessId}/`, { - method: 'DELETE', - }); - - if (!response.ok) { - throw new APIError( - 'Failed to delete the member', - await errorCauses(response), - ); - } -}; - -type UseDeleteDocAccessOptions = UseMutationOptions< - void, - APIError, - DeleteDocAccessProps ->; - -export const useDeleteDocAccess = (options?: UseDeleteDocAccessOptions) => { - const queryClient = useQueryClient(); - const { broadcast } = useBroadcastStore(); - - return useMutation({ - mutationFn: deleteDocAccess, - ...options, - onSuccess: (data, variables, onMutateResult, context) => { - void queryClient.invalidateQueries({ - queryKey: [KEY_LIST_DOC_ACCESSES], - }); - void queryClient.invalidateQueries({ - queryKey: [KEY_DOC], - }); - - // Broadcast to every user connected to the document - broadcast(`${KEY_DOC}-${variables.docId}`); - - void queryClient.resetQueries({ - queryKey: [KEY_LIST_DOC], - }); - void queryClient.invalidateQueries({ - queryKey: [KEY_LIST_USER], - }); - if (options?.onSuccess) { - void options.onSuccess(data, variables, onMutateResult, context); - } - }, - }); -}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/api/useDeleteDocInvitation.ts b/src/frontend/apps/impress/src/features/docs/doc-share/api/useDeleteDocInvitation.ts deleted file mode 100644 index 3ba722ae90..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/api/useDeleteDocInvitation.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { - UseMutationOptions, - useMutation, - useQueryClient, -} from '@tanstack/react-query'; - -import { APIError, errorCauses, fetchAPI } from '@/api'; - -import { KEY_LIST_DOC_INVITATIONS } from './useDocInvitations'; - -interface DeleteDocInvitationProps { - docId: string; - invitationId: string; -} - -type RemoveDocInvitationError = { - role?: string[]; -}; - -export const deleteDocInvitation = async ({ - docId, - invitationId, -}: DeleteDocInvitationProps): Promise => { - const response = await fetchAPI( - `documents/${docId}/invitations/${invitationId}/`, - { - method: 'DELETE', - }, - ); - - if (!response.ok) { - throw new APIError( - 'Failed to delete the invitation', - await errorCauses(response), - ); - } -}; - -type UseDeleteDocInvitationOptions = UseMutationOptions< - void, - APIError, - DeleteDocInvitationProps ->; - -export const useDeleteDocInvitation = ( - options?: UseDeleteDocInvitationOptions, -) => { - const queryClient = useQueryClient(); - return useMutation< - void, - APIError, - DeleteDocInvitationProps - >({ - mutationFn: deleteDocInvitation, - ...options, - onSuccess: (data, variables, onMutateResult, context) => { - void queryClient.invalidateQueries({ - queryKey: [KEY_LIST_DOC_INVITATIONS], - }); - if (options?.onSuccess) { - void options.onSuccess(data, variables, onMutateResult, context); - } - }, - onError: (error, variables, onMutateResult, context) => { - if (options?.onError) { - void options.onError(error, variables, onMutateResult, context); - } - }, - }); -}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccessRequest.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccessRequest.tsx deleted file mode 100644 index 9648a81f8d..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccessRequest.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { - UseMutationOptions, - UseQueryOptions, - useMutation, - useQuery, - useQueryClient, -} from '@tanstack/react-query'; - -import { - APIError, - APIList, - errorCauses, - fetchAPI, - useAPIInfiniteQuery, -} from '@/api'; -import { AccessRequest, Doc, Role } from '@/docs/doc-management'; - -import { OptionType } from '../types'; - -import { KEY_LIST_DOC_ACCESSES } from './useDocAccesses'; - -interface CreateDocAccessRequestParams { - docId: Doc['id']; - role?: Role; -} - -export const createDocAccessRequest = async ({ - docId, - role, -}: CreateDocAccessRequestParams): Promise => { - const response = await fetchAPI(`documents/${docId}/ask-for-access/`, { - method: 'POST', - body: JSON.stringify({ - role, - }), - }); - - if (!response.ok) { - throw new APIError( - `Failed to create a request to access to the doc.`, - await errorCauses(response, { - type: OptionType.NEW_MEMBER, - }), - ); - } -}; - -type UseCreateDocAccessRequestOptions = UseMutationOptions< - void, - APIError, - CreateDocAccessRequestParams ->; - -export function useCreateDocAccessRequest( - options?: UseCreateDocAccessRequestOptions, -) { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: createDocAccessRequest, - ...options, - onSuccess: (data, variables, onMutateResult, context) => { - void queryClient.resetQueries({ - queryKey: [KEY_LIST_DOC_ACCESS_REQUESTS], - }); - - void options?.onSuccess?.(data, variables, onMutateResult, context); - }, - }); -} - -type AccessRequestResponse = APIList; - -interface DocAccessRequestsParams { - docId: Doc['id']; -} - -export type DocAccessRequestsAPIParams = DocAccessRequestsParams & { - page: number; -}; - -export const getDocAccessRequests = async ({ - docId, - page, -}: DocAccessRequestsAPIParams): Promise => { - const response = await fetchAPI( - `documents/${docId}/ask-for-access/?page=${page}`, - ); - - if (!response.ok) { - throw new APIError( - 'Failed to get the doc access requests', - await errorCauses(response), - ); - } - - return response.json() as Promise; -}; - -export const KEY_LIST_DOC_ACCESS_REQUESTS = 'docs-access-requests'; - -export function useDocAccessRequests( - params: DocAccessRequestsAPIParams, - queryConfig?: UseQueryOptions< - AccessRequestResponse, - APIError, - AccessRequestResponse - >, -) { - return useQuery({ - queryKey: [KEY_LIST_DOC_ACCESS_REQUESTS, params], - queryFn: () => getDocAccessRequests(params), - ...queryConfig, - }); -} - -export const useDocAccessRequestsInfinite = ( - params: DocAccessRequestsParams, -) => { - return useAPIInfiniteQuery( - KEY_LIST_DOC_ACCESS_REQUESTS, - getDocAccessRequests, - params, - ); -}; - -interface acceptDocAccessRequestsParams { - docId: string; - accessRequestId: string; - role: Role; -} - -export const acceptDocAccessRequests = async ({ - docId, - accessRequestId, - role, -}: acceptDocAccessRequestsParams): Promise => { - const response = await fetchAPI( - `documents/${docId}/ask-for-access/${accessRequestId}/accept/`, - { - method: 'POST', - body: JSON.stringify({ - role, - }), - }, - ); - - if (!response.ok) { - throw new APIError( - 'Failed to accept the access request', - await errorCauses(response), - ); - } -}; - -type UseAcceptDocAccessRequests = Partial; - -type UseAcceptDocAccessRequestsOptions = UseMutationOptions< - void, - APIError, - UseAcceptDocAccessRequests ->; - -export const useAcceptDocAccessRequest = ( - options?: UseAcceptDocAccessRequestsOptions, -) => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: acceptDocAccessRequests, - ...options, - onSuccess: (data, variables, onMutateResult, context) => { - void queryClient.invalidateQueries({ - queryKey: [KEY_LIST_DOC_ACCESSES], - }); - - void queryClient.invalidateQueries({ - queryKey: [KEY_LIST_DOC_ACCESS_REQUESTS], - }); - - if (options?.onSuccess) { - void options.onSuccess(data, variables, onMutateResult, context); - } - }, - }); -}; - -interface DeleteDocAccessRequestParams { - docId: string; - accessRequestId: string; -} - -export const deleteDocAccessRequest = async ({ - docId, - accessRequestId, -}: DeleteDocAccessRequestParams): Promise => { - const response = await fetchAPI( - `documents/${docId}/ask-for-access/${accessRequestId}/`, - { - method: 'DELETE', - }, - ); - - if (!response.ok) { - throw new APIError( - 'Failed to delete the access request', - await errorCauses(response), - ); - } -}; - -type UseDeleteDocAccessRequestOptions = UseMutationOptions< - void, - APIError, - DeleteDocAccessRequestParams ->; - -export const useDeleteDocAccessRequest = ( - options?: UseDeleteDocAccessRequestOptions, -) => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: deleteDocAccessRequest, - ...options, - onSuccess: (data, variables, onMutateResult, context) => { - void queryClient.invalidateQueries({ - queryKey: [KEY_LIST_DOC_ACCESS_REQUESTS], - }); - - if (options?.onSuccess) { - void options.onSuccess(data, variables, onMutateResult, context); - } - }, - }); -}; 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 deleted file mode 100644 index 87b7d55453..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccesses.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { UseQueryOptions, useQuery } from '@tanstack/react-query'; - -import { APIError, errorCauses, fetchAPI } from '@/api'; -import { Access } from '@/docs/doc-management'; - -export type DocAccessesParams = { - docId: string; - ordering?: string; -}; - -export const getDocAccesses = async ({ - docId, - ordering, -}: DocAccessesParams): Promise => { - let url = `documents/${docId}/accesses/`; - - if (ordering) { - url += '&ordering=' + ordering; - } - - const response = await fetchAPI(url); - - if (!response.ok) { - throw new APIError( - 'Failed to get the doc accesses', - await errorCauses(response), - ); - } - - return (await response.json()) as Access[]; -}; - -export const KEY_LIST_DOC_ACCESSES = 'docs-accesses'; - -export function useDocAccesses( - params: DocAccessesParams, - queryConfig?: UseQueryOptions, -) { - return useQuery({ - queryKey: [KEY_LIST_DOC_ACCESSES, params], - queryFn: () => getDocAccesses(params), - ...queryConfig, - }); -} diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocInvitations.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocInvitations.tsx deleted file mode 100644 index dbea614b9e..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocInvitations.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { UseQueryOptions, useQuery } from '@tanstack/react-query'; - -import { - APIError, - APIList, - errorCauses, - fetchAPI, - useAPIInfiniteQuery, -} from '@/api'; - -import { Invitation } from '../types'; - -export type DocInvitationsParams = { - docId: string; - ordering?: string; -}; - -export type DocInvitationsAPIParams = DocInvitationsParams & { - page: number; -}; - -type DocInvitationsResponse = APIList; - -export const getDocInvitations = async ({ - page, - docId, - ordering, -}: DocInvitationsAPIParams): Promise => { - let url = `documents/${docId}/invitations/?page=${page}`; - - if (ordering) { - url += '&ordering=' + ordering; - } - - const response = await fetchAPI(url); - - if (!response.ok) { - throw new APIError( - 'Failed to get the doc accesses', - await errorCauses(response), - ); - } - - return response.json() as Promise; -}; - -export const KEY_LIST_DOC_INVITATIONS = 'docs-invitations'; - -export function useDocInvitations( - params: DocInvitationsAPIParams, - queryConfig?: UseQueryOptions< - DocInvitationsResponse, - APIError, - DocInvitationsResponse - >, -) { - return useQuery({ - queryKey: [KEY_LIST_DOC_INVITATIONS, params], - queryFn: () => getDocInvitations(params), - ...queryConfig, - }); -} - -/** - * @param param Used for infinite scroll pagination - * @param queryConfig - * @returns - */ -export function useDocInvitationsInfinite(params: DocInvitationsParams) { - return useAPIInfiniteQuery( - KEY_LIST_DOC_INVITATIONS, - getDocInvitations, - params, - ); -} diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/api/useLeaveDoc.ts b/src/frontend/apps/impress/src/features/docs/doc-share/api/useLeaveDoc.ts deleted file mode 100644 index 3e012bf525..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/api/useLeaveDoc.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - UseMutationOptions, - useMutation, - useQueryClient, -} from '@tanstack/react-query'; - -import { APIError, errorCauses, fetchAPI } from '@/api'; -import { KEY_LIST_DOC } from '@/docs/doc-management/api'; - -interface LeaveDocProps { - docId: string; -} - -export const leaveDoc = async ({ docId }: LeaveDocProps): Promise => { - const response = await fetchAPI(`documents/${docId}/leave/`, { - method: 'POST', - }); - - if (!response.ok) { - throw new APIError( - 'Failed to leave the document', - await errorCauses(response), - ); - } -}; - -type UseLeaveDocOptions = UseMutationOptions; - -export const useLeaveDoc = (options?: UseLeaveDocOptions) => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: leaveDoc, - ...options, - onSuccess: (data, variables, onMutateResult, context) => { - void queryClient.invalidateQueries({ - queryKey: [KEY_LIST_DOC], - }); - - if (options?.onSuccess) { - void options.onSuccess(data, variables, onMutateResult, context); - } - }, - }); -}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/api/useUpdateDocAccess.ts b/src/frontend/apps/impress/src/features/docs/doc-share/api/useUpdateDocAccess.ts deleted file mode 100644 index 3f54b7fe63..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/api/useUpdateDocAccess.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { - UseMutationOptions, - useMutation, - useQueryClient, -} from '@tanstack/react-query'; - -import { APIError, errorCauses, fetchAPI } from '@/api'; -import { Access, KEY_DOC, KEY_LIST_DOC, Role } from '@/docs/doc-management'; - -import { KEY_LIST_DOC_ACCESSES } from './useDocAccesses'; - -interface UpdateDocAccessProps { - docId: string; - accessId: string; - role: Role; -} - -export const updateDocAccess = async ({ - docId, - accessId, - role, -}: UpdateDocAccessProps): Promise => { - const response = await fetchAPI(`documents/${docId}/accesses/${accessId}/`, { - method: 'PATCH', - body: JSON.stringify({ - role, - }), - }); - - if (!response.ok) { - throw new APIError('Failed to update role', await errorCauses(response)); - } - - return response.json() as Promise; -}; - -type UseUpdateDocAccess = Partial; - -type UseUpdateDocAccessOptions = UseMutationOptions< - Access, - APIError, - UseUpdateDocAccess ->; - -export const useUpdateDocAccess = (options?: UseUpdateDocAccessOptions) => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: updateDocAccess, - ...options, - onSuccess: (data, variables, onMutateResult, context) => { - void queryClient.invalidateQueries({ - queryKey: [KEY_LIST_DOC_ACCESSES], - }); - void queryClient.invalidateQueries({ - queryKey: [KEY_DOC], - }); - - void queryClient.invalidateQueries({ - queryKey: [KEY_LIST_DOC], - }); - - if (options?.onSuccess) { - void options.onSuccess(data, variables, onMutateResult, context); - } - }, - }); -}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/api/useUpdateDocInvitation.ts b/src/frontend/apps/impress/src/features/docs/doc-share/api/useUpdateDocInvitation.ts deleted file mode 100644 index a7170ec0e3..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/api/useUpdateDocInvitation.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { - UseMutationOptions, - useMutation, - useQueryClient, -} from '@tanstack/react-query'; - -import { APIError, errorCauses, fetchAPI } from '@/api'; -import { Role } from '@/docs/doc-management'; - -import { Invitation } from '../types'; - -import { KEY_LIST_DOC_INVITATIONS } from './useDocInvitations'; - -interface UpdateDocInvitationProps { - docId: string; - invitationId: string; - role: Role; -} - -type UpdateDocInvitationError = { - role?: string[]; -}; - -export const updateDocInvitation = async ({ - docId, - invitationId, - role, -}: UpdateDocInvitationProps): Promise => { - const response = await fetchAPI( - `documents/${docId}/invitations/${invitationId}/`, - { - method: 'PATCH', - body: JSON.stringify({ - role, - }), - }, - ); - - if (!response.ok) { - throw new APIError('Failed to update role', await errorCauses(response)); - } - - return response.json() as Promise; -}; - -type UseUpdateDocInvitation = Partial; - -type UseUpdateDocInvitationOptions = UseMutationOptions< - Invitation, - APIError, - UseUpdateDocInvitation ->; - -export const useUpdateDocInvitation = ( - options?: UseUpdateDocInvitationOptions, -) => { - const queryClient = useQueryClient(); - return useMutation< - Invitation, - APIError, - UpdateDocInvitationProps - >({ - mutationFn: updateDocInvitation, - ...options, - onSuccess: (data, variables, onMutateResult, context) => { - void queryClient.invalidateQueries({ - queryKey: [KEY_LIST_DOC_INVITATIONS], - }); - if (options?.onSuccess) { - void options.onSuccess(data, variables, onMutateResult, context); - } - }, - onError: (error, variables, onMutateResult, context) => { - if (options?.onError) { - void options.onError(error, variables, onMutateResult, context); - } - }, - }); -}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/api/useUpdateDocLink.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/api/useUpdateDocLink.tsx deleted file mode 100644 index 5a950d0e8e..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/api/useUpdateDocLink.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { - UseMutationOptions, - useMutation, - useQueryClient, -} from '@tanstack/react-query'; - -import { APIError, errorCauses, fetchAPI } from '@/api'; -import { Doc, LinkReach, LinkRole } from '@/docs/doc-management'; - -export type UpdateDocLinkParams = Pick & - Partial>; - -type UpdateDocLinkResponse = { link_role: LinkRole; link_reach: LinkReach }; - -export const updateDocLink = async ({ - id, - ...params -}: UpdateDocLinkParams): Promise => { - const response = await fetchAPI(`documents/${id}/link-configuration/`, { - method: 'PUT', - body: JSON.stringify({ - ...params, - }), - }); - - if (!response.ok) { - throw new APIError( - 'Failed to update the doc link', - await errorCauses(response), - ); - } - - return response.json() as Promise; -}; - -type UseUpdateDocLinkOptions = UseMutationOptions< - UpdateDocLinkResponse, - APIError, - UpdateDocLinkParams -> & { - listInvalidQueries?: string[]; -}; - -export function useUpdateDocLink(options?: UseUpdateDocLinkOptions) { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: updateDocLink, - ...options, - onSuccess: (data, variables, onMutateResult, context) => { - options?.listInvalidQueries?.forEach((queryKey) => { - void queryClient.invalidateQueries({ - queryKey: [queryKey], - }); - }); - - options?.onSuccess?.(data, variables, onMutateResult, context); - }, - }); -} diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/api/useUsers.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/api/useUsers.tsx deleted file mode 100644 index ed029222ef..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/api/useUsers.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { UseQueryOptions, useQuery } from '@tanstack/react-query'; - -import { APIError, errorCauses, fetchAPI } from '@/api'; -import { Doc } from '@/docs/doc-management'; -import { User } from '@/features/auth'; - -export type UsersParams = { - query: string; - docId: Doc['id']; -}; - -type UsersResponse = User[]; - -export const getUsers = async ({ - query, - docId, -}: UsersParams): Promise => { - const queriesParams = []; - queriesParams.push(query ? `q=${encodeURIComponent(query)}` : ''); - queriesParams.push(docId ? `document_id=${docId}` : ''); - const queryParams = queriesParams.filter(Boolean).join('&'); - - const response = await fetchAPI(`users/?${queryParams}`); - - if (!response.ok) { - throw new APIError('Failed to get the users', await errorCauses(response)); - } - - return response.json() as Promise; -}; - -export const KEY_LIST_USER = 'users'; - -export function useUsers( - param: UsersParams, - queryConfig?: UseQueryOptions, -) { - return useQuery({ - queryKey: [KEY_LIST_USER, param], - queryFn: () => getUsers(param), - ...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 deleted file mode 100644 index d682fe52b8..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/assets/desynchro.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - 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 deleted file mode 100644 index 139ce42dd3..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/assets/undo.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/AlertModalRequestAccess.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/AlertModalRequestAccess.tsx deleted file mode 100644 index 6060b0a6b4..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/AlertModalRequestAccess.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { Button } from '@gouvfr-lasuite/cunningham-react'; -import { Trans, useTranslation } from 'react-i18next'; - -import { AlertModal, Box, Icon, Text } from '@/components'; - -import { useDocAccessRequests } from '../api/useDocAccessRequest'; - -import { ButtonAccessRequest } from './DocShareAccessRequest'; - -interface AlertModalRequestAccessProps { - docId: string; - isOpen: boolean; - onClose: () => void; - onConfirm: () => void; - targetDocumentTitle: string; - title: string; -} - -export const AlertModalRequestAccess = ({ - docId, - isOpen, - onClose, - onConfirm, - targetDocumentTitle, - title, -}: AlertModalRequestAccessProps) => { - const { t } = useTranslation(); - const { data: requests } = useDocAccessRequests({ - docId, - page: 1, - }); - - const hasRequested = !!( - requests && requests?.results.find((request) => request.document === docId) - ); - - return ( - - - }} - /> - - {hasRequested && ( - - - {t('You have already requested access to this document.')} - - )} - - } - confirmLabel={t('Request access')} - onConfirm={onConfirm} - rightActions={ - - - - - } - /> - ); -}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/ConfirmationLeaveModal.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/ConfirmationLeaveModal.tsx deleted file mode 100644 index 9052fc9386..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/ConfirmationLeaveModal.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; -import { useRouter } from 'next/router'; -import { useMemo } from 'react'; -import { Trans, useTranslation } from 'react-i18next'; -import { createGlobalStyle, css } from 'styled-components'; - -import { Box, ButtonCloseModal, Text } from '@/components'; -import { useAuth } from '@/features/auth'; - -import { Doc } from '../../doc-management'; -import { useDeleteDocAccess, useDocAccesses } from '../api'; -import { useLeaveDoc } from '../api/useLeaveDoc'; -import { useWhoAmI } from '../hooks/useWhoAmI'; - -const ModalStyle = createGlobalStyle` - .c__modal__footer { - margin-top: 0; - } -`; - -interface ConfirmationLeaveModalProps { - doc: Doc; - onClose: () => void; -} - -export const ConfirmationLeaveModal = ({ - doc, - onClose, -}: ConfirmationLeaveModalProps) => { - const { t } = useTranslation(); - const router = useRouter(); - const { mutate: leaveDoc, isPending: isLeavePending } = useLeaveDoc({ - onSuccess: () => { - if (router.pathname !== `/`) { - void router.push('/'); - } else { - onClose(); - } - }, - }); - - return ( - - {doc.abilities.leave ? ( - leaveDoc({ docId: doc.id })} - isPending={isLeavePending} - onClose={onClose} - /> - ) : ( - leaveDoc({ docId: doc.id })} - isLeavePending={isLeavePending} - onClose={onClose} - /> - )} - - } - size={ModalSize.MEDIUM} - title={ - <> - - {t('Leave a doc')} - - - - - - } - > - - - {doc.abilities.leave ? ( - - ) : ( - - )} - - - ); -}; - -const TextModal = () => { - const { t } = useTranslation(); - return ( - - This document and all the sub-documents will no longer be - visible in your document list and in your search results. The rights that - were given to you on this document will be removed. - - ); -}; - -const TextModalMember = ({ docId }: { docId: string }) => { - const { t } = useTranslation(); - const { access, isLoading, isError } = useGetMyAccess(docId); - const { isLastOwner } = useWhoAmI(access); - - if (isLoading) { - return null; - } - - if (isError) { - return ( - - Unable to verify your permissions on this document.{' '} - Leaving has been disabled until your access level can - be confirmed. Please try again later. - - ); - } - - if (isLastOwner) { - return ( - - You cannot leave this document{' '} - because you are the unique owner. Add another user with - the owner role to ensure you can transfer ownership before leaving the - document. - - ); - } - - return ; -}; - -/** - * Simple button to leave a doc - * The user is not a member of the doc, he can just leave the doc - */ -const ButtonsLeaveDoc = ({ - leave, - isPending, - onClose, -}: { - leave: () => void; - isPending: boolean; - onClose: () => void; -}) => { - const { t } = useTranslation(); - - return ( - <> - - - - ); -}; - -/** - * The user is a member of the doc, he can leave the doc but - * if he is the last owner, he need to transfer the ownership - * before leaving the doc - */ -const ButtonsLeaveMemberDoc = ({ - doc, - leave, - isLeavePending, - onClose, -}: { - doc: Doc; - leave: () => void; - isLeavePending: boolean; - onClose: () => void; -}) => { - const { mutateAsync: deleteDocAccess, isPending: isDeletePending } = - useDeleteDocAccess(); - const { access, isLoading, isError } = useGetMyAccess(doc.id); - const { isLastOwner } = useWhoAmI(access); - - /** - * If the user is the last owner, or ownership cannot be verified (loading or - * error), we don't display the leave button to avoid failing open. - */ - if (isLastOwner || isLoading || isError) { - return null; - } - - return ( - { - if (access) { - await deleteDocAccess({ docId: doc.id, accessId: access.id }); - leave(); - } else { - leave(); - } - }} - isPending={isDeletePending || isLeavePending} - onClose={onClose} - /> - ); -}; - -const useGetMyAccess = (docId: string) => { - const { user } = useAuth(); - const { - data: accesses, - isLoading, - isError, - } = useDocAccesses({ - docId, - }); - const access = useMemo(() => { - return accesses?.find((access) => access.user.id === user?.id); - }, [accesses, user]); - - return { access, isLoading, isError }; -}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocDesynchronized.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocDesynchronized.tsx deleted file mode 100644 index a92480c09e..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocDesynchronized.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { - Button, - VariantType, - useToastProvider, -} from '@gouvfr-lasuite/cunningham-react'; -import { useTranslation } from 'react-i18next'; - -import { Box, Card, Text } from '@/components'; -import { useCunninghamTheme } from '@/cunningham'; -import { Doc, KEY_DOC, KEY_LIST_DOC } from '@/docs/doc-management'; - -import { useUpdateDocLink } from '../api/useUpdateDocLink'; - -import Desync from './../assets/desynchro.svg'; -import Undo from './../assets/undo.svg'; - -interface DocDesynchronizedProps { - doc: Doc; -} - -export const DocDesynchronized = ({ doc }: DocDesynchronizedProps) => { - const { t } = useTranslation(); - const { spacingsTokens } = useCunninghamTheme(); - const { toast } = useToastProvider(); - - const { mutate: updateDocLink } = useUpdateDocLink({ - listInvalidQueries: [KEY_LIST_DOC, KEY_DOC], - onSuccess: () => { - toast(t('The document visibility restored.'), VariantType.SUCCESS, { - duration: 2000, - }); - }, - }); - - return ( - - - - - {t('The link sharing rules differ from the parent document')} - - - {doc.abilities.accesses_manage && ( - - )} - - ); -}; 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 deleted file mode 100644 index 0e0b98fd73..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocInheritedShareContent.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { Button } from '@gouvfr-lasuite/cunningham-react'; -import { Fragment } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { Box, HorizontalSeparator, Icon, StyledLink, Text } from '@/components'; -import { useCunninghamTheme } from '@/cunningham'; - -import { Access, useDocStore } from '../../doc-management'; - -import { DocShareMemberItem } from './DocShareMember'; - -type DocInheritedShareContentProps = { - rawAccesses: Access[]; -}; - -export const DocInheritedShareContent = ({ - rawAccesses, -}: DocInheritedShareContentProps) => { - const { t } = useTranslation(); - const { spacingsTokens } = useCunninghamTheme(); - const { currentDoc } = useDocStore(); - - // Check if accesses map is empty - const hasAccesses = rawAccesses.length > 0; - - if (!hasAccesses) { - return null; - } - - return ( - - - - - - {t('People with access via the parent document')} - - - - - - {doc.abilities.accesses_manage && ( - - removeDocAccess({ - accessRequestId: accessRequest.id, - docId: doc.id, - }) - } - aria-label={t('Close the access request modal')} - > - - - )} - - } - /> - - ); -}; - -interface QuickSearchGroupAccessRequestProps { - doc: Doc; -} - -export const QuickSearchGroupAccessRequest = ({ - doc, -}: QuickSearchGroupAccessRequestProps) => { - const { t } = useTranslation(); - const accessRequestQuery = useDocAccessRequestsInfinite({ docId: doc.id }); - - const accessRequestsData: QuickSearchData = useMemo(() => { - const accessRequests = - accessRequestQuery.data?.pages.flatMap((page) => page.results) || []; - - return { - groupName: t('Access Requests'), - elements: accessRequests, - endActions: accessRequestQuery.hasNextPage - ? [ - { - content: , - onSelect: () => void accessRequestQuery.fetchNextPage(), - }, - ] - : undefined, - }; - }, [accessRequestQuery, t]); - - if (!accessRequestsData.elements.length) { - return null; - } - - return ( - <> - - - ( - - )} - /> - - - - ); -}; - -type ButtonAccessRequestProps = { - docId: Doc['id']; -} & Omit & { - onClick?: MouseEventHandler; - }; - -export const ButtonAccessRequest = ({ - docId, - onClick, - ...buttonProps -}: ButtonAccessRequestProps) => { - const { authenticated } = useAuth(); - const { - data: requests, - error: docAccessError, - isLoading, - } = useDocAccessRequests({ - docId, - page: 1, - }); - const { t } = useTranslation(); - const { toast } = useToastProvider(); - const { mutate: createRequest } = useCreateDocAccessRequest({ - onSuccess: () => { - toast(t('Access request sent successfully.'), VariantType.SUCCESS, { - duration: 3000, - }); - }, - }); - - if (!authenticated) { - return null; - } - - if (docAccessError?.status === 404) { - return ( - - {t( - 'As this is a sub-document, please request access to the parent document to enable these features.', - )} - - ); - } - - if (isLoading) { - return ; - } - - const hasRequested = !!( - requests && requests?.results.find((request) => request.document === docId) - ); - - return ( - - ); -}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx deleted file mode 100644 index 0db52f351d..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { - Button, - VariantType, - useToastProvider, -} from '@gouvfr-lasuite/cunningham-react'; -import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { APIError } from '@/api'; -import { Box, Card } from '@/components'; -import { useCunninghamTheme } from '@/cunningham'; -import { Doc, Role } from '@/docs/doc-management'; -import { User } from '@/features/auth'; -import { useResponsiveStore } from '@/stores'; - -import { useCreateDocAccess, useCreateDocInvitation } from '../api'; -import { OptionType } from '../types'; - -import { DocRoleDropdown } from './DocRoleDropdown'; -import { DocShareAddMemberListItem } from './DocShareAddMemberListItem'; - -type APIErrorUser = APIError<{ - value: string; - type: OptionType; -}>; - -type Props = { - doc: Doc; - selectedUsers: User[]; - onRemoveUser?: (user: User) => void; - onSubmit?: (selectedUsers: User[], role: Role) => void; - afterInvite?: () => void; -}; -export const DocShareAddMemberList = ({ - doc, - selectedUsers, - onRemoveUser, - afterInvite, -}: Props) => { - const { t } = useTranslation(); - const { toast } = useToastProvider(); - const { isSmallMobile } = useResponsiveStore(); - const [isLoading, setIsLoading] = useState(false); - const { spacingsTokens } = useCunninghamTheme(); - const [invitationRole, setInvitationRole] = useState(Role.EDITOR); - const canShare = doc.abilities.accesses_manage; - const { mutateAsync: createInvitation } = useCreateDocInvitation(); - const { mutateAsync: createDocAccess } = useCreateDocAccess(); - - const onError = (dataError: APIErrorUser) => { - let messageError = - dataError['data']?.type === OptionType.INVITATION - ? t(`Failed to create the invitation for {{email}}.`, { - email: dataError['data']?.value, - }) - : t(`Failed to add the member in the document.`); - - if ( - dataError.cause?.[0] === - 'Document invitation with this Email address and Document already exists.' - ) { - messageError = t('"{{email}}" is already invited to the document.', { - email: dataError['data']?.value, - }); - } - - if ( - dataError.cause?.[0] === - 'This email is already associated to a registered user.' - ) { - messageError = t('"{{email}}" is already member of the document.', { - email: dataError['data']?.value, - }); - } - - toast(messageError, VariantType.ERROR, { - duration: 4000, - }); - }; - - const onInvite = async () => { - setIsLoading(true); - const promises = selectedUsers.map((user) => { - const isInvitationMode = user.id === user.email; - - const payload = { - role: invitationRole, - docId: doc.id, - }; - - return isInvitationMode - ? createInvitation({ - ...payload, - email: user.email.toLowerCase(), - }) - : createDocAccess({ - ...payload, - memberId: user.id, - }); - }); - - const settledPromises = await Promise.allSettled(promises); - settledPromises.forEach((settledPromise) => { - if (settledPromise.status === 'rejected') { - onError(settledPromise.reason as APIErrorUser); - } - }); - afterInvite?.(); - setIsLoading(false); - }; - const inviteLabel = - selectedUsers.length === 1 - ? t('Invite {{name}}', { - name: selectedUsers[0].full_name || selectedUsers[0].email, - }) - : t('Invite {{count}} members', { count: selectedUsers.length }); - - return ( - - - {selectedUsers.map((user) => ( - - ))} - - - - - - - ); -}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberListItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberListItem.tsx deleted file mode 100644 index a973acd73f..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberListItem.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { css } from 'styled-components'; - -import { Box, BoxButton, Icon, Text } from '@/components'; -import { useCunninghamTheme } from '@/cunningham'; -import { User } from '@/features/auth'; - -type Props = { - user: User; - onRemoveUser?: (user: User) => void; -}; -export const DocShareAddMemberListItem = ({ user, onRemoveUser }: Props) => { - const { t } = useTranslation(); - const { spacingsTokens } = useCunninghamTheme(); - - return ( - - - {user.full_name || user.email} - - onRemoveUser?.(user)} - aria-label={t('Remove {{name}} from the invite list', { - name: user.full_name || user.email, - })} - $withThemeInherited - > - - - - ); -}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareButton.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareButton.tsx deleted file mode 100644 index bdba2a761a..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareButton.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { Button, useModal } from '@gouvfr-lasuite/cunningham-react'; -import { useTreeContext } from '@gouvfr-lasuite/ui-kit'; -import dynamic from 'next/dynamic'; -import { useTranslation } from 'react-i18next'; - -import SharedSVG from '@/assets/icons/ui-kit/shared.svg'; -import { CardFloatingBar } from '@/components/FloatingBar'; -import { Doc } from '@/docs/doc-management/types'; -import { useAuth } from '@/features/auth'; -import { useFocusStore } from '@/stores/useFocusStore'; - -import { KEY_LIST_DOC_ACCESSES, useDocAccesses } from '../api'; - -const DocShareModal = dynamic( - () => - import('./DocShareModal').then((mod) => ({ - default: mod.DocShareModal, - })), - { ssr: false }, -); - -interface DocShareButtonProps { - doc: Doc; - isDisabled?: boolean; - isHidden?: boolean; -} - -export const DocShareButton = ({ - doc, - isDisabled, - isHidden, -}: DocShareButtonProps) => { - const { t } = useTranslation(); - const { addLastFocus, restoreFocus } = useFocusStore(); - const treeContext = useTreeContext(); - const modalShare = useModal(); - const { data: accesses } = useDocAccesses( - { - docId: doc.id, - }, - { - enabled: doc.abilities.accesses_view, - queryKey: [KEY_LIST_DOC_ACCESSES, doc.id], - }, - ); - const { authenticated } = useAuth(); - - const hasAccesses = !!accesses && accesses.length > 1; // more than the current user - - if (isHidden || !authenticated) { - return null; - } - - return ( - <> - - - - {modalShare.isOpen && ( - { - modalShare.close(); - restoreFocus(); - }} - doc={doc} - isRootDoc={treeContext?.root?.id === doc.id} - /> - )} - - ); -}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitation.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitation.tsx deleted file mode 100644 index d7f72df127..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitation.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import { - VariantType, - useToastProvider, -} from '@gouvfr-lasuite/cunningham-react'; -import { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { css } from 'styled-components'; - -import { - Box, - BoxButton, - HorizontalSeparator, - Icon, - LoadMoreText, - Text, -} from '@/components'; -import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search'; -import { useCunninghamTheme } from '@/cunningham'; -import { Doc, Role } from '@/docs/doc-management'; -import { User } from '@/features/auth'; - -import { useDocInvitationsInfinite, useUpdateDocInvitation } from '../api'; -import { Invitation } from '../types'; - -import { DocRoleDropdown } from './DocRoleDropdown'; -import { SearchUserRow } from './SearchUserRow'; - -type DocShareInvitationItemProps = { - doc: Doc; - invitation: Invitation; -}; - -export const DocShareInvitationItem = ({ - doc, - invitation, -}: DocShareInvitationItemProps) => { - const { t } = useTranslation(); - const { spacingsTokens } = useCunninghamTheme(); - const invitedUser: User = { - id: invitation.email, - full_name: invitation.email, - email: invitation.email, - short_name: invitation.email, - language: 'en-us', - is_first_connection: false, - }; - - const { toast } = useToastProvider(); - const canUpdate = doc.abilities.accesses_manage; - - const { mutate: updateDocInvitation } = useUpdateDocInvitation({ - onError: (error) => { - toast( - error?.data?.role?.[0] ?? t('Error during update invitation'), - VariantType.ERROR, - { - duration: 4000, - }, - ); - }, - }); - - const onUpdate = (newRole: Role) => { - updateDocInvitation({ - docId: doc.id, - role: newRole, - invitationId: invitation.id, - }); - }; - - return ( - - - - - } - /> - - ); -}; - -type DocShareModalInviteUserRowProps = { - user: User; -}; -export const DocShareModalInviteUserRow = ({ - user, -}: DocShareModalInviteUserRowProps) => { - const { t } = useTranslation(); - return ( - - - - {t('Add')} - - - - } - /> - - ); -}; - -interface QuickSearchGroupInvitationProps { - doc: Doc; -} - -export const QuickSearchGroupInvitation = ({ - doc, -}: QuickSearchGroupInvitationProps) => { - const { t } = useTranslation(); - const { data, hasNextPage, fetchNextPage } = useDocInvitationsInfinite({ - docId: doc.id, - }); - - const invitationsData: QuickSearchData = useMemo(() => { - const invitations = data?.pages.flatMap((page) => page.results) || []; - - return { - groupName: t('Pending invitations'), - elements: invitations, - endActions: hasNextPage - ? [ - { - content: , - onSelect: () => void fetchNextPage(), - }, - ] - : undefined, - }; - }, [data?.pages, fetchNextPage, hasNextPage, t]); - - if (!invitationsData.elements.length) { - return null; - } - - return ( - <> - - ( - - )} - /> - - - - ); -}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMember.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMember.tsx deleted file mode 100644 index bb8ccbb399..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMember.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { - VariantType, - useToastProvider, -} from '@gouvfr-lasuite/cunningham-react'; -import { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { Box } from '@/components'; -import { QuickSearchData } from '@/components/quick-search'; -import { QuickSearchGroup } from '@/components/quick-search/QuickSearchGroup'; -import { useCunninghamTheme } from '@/cunningham'; -import { Access, Doc, Role } from '@/docs/doc-management/'; - -import { useDocAccesses, useUpdateDocAccess } from '../api'; -import { useWhoAmI } from '../hooks/'; - -import { DocRoleDropdown } from './DocRoleDropdown'; -import { SearchUserRow } from './SearchUserRow'; - -type Props = { - doc?: Doc; - access: Access; - isInherited?: boolean; -}; -export const DocShareMemberItem = ({ - doc, - access, - isInherited = false, -}: Props) => { - const { t } = useTranslation(); - const { isLastOwner } = useWhoAmI(access); - const { toast } = useToastProvider(); - const { spacingsTokens } = useCunninghamTheme(); - - const message = isLastOwner - ? t( - 'You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.', - ) - : undefined; - - const { mutate: updateDocAccess } = useUpdateDocAccess({ - onError: () => { - toast(t('Error while updating the member role.'), VariantType.ERROR, { - duration: 4000, - }); - }, - }); - - const onUpdate = (newRole: Role) => { - if (!doc) { - return; - } - updateDocAccess({ - docId: doc.id, - role: newRole, - accessId: access.id, - }); - }; - - const canUpdate = isInherited ? false : !!doc?.abilities.accesses_manage; - - return ( - - - - - } - /> - - ); -}; - -interface QuickSearchGroupMemberProps { - doc: Doc; -} - -export const QuickSearchGroupMember = ({ - doc, -}: QuickSearchGroupMemberProps) => { - const { t } = useTranslation(); - const membersQuery = useDocAccesses({ - docId: doc.id, - }); - - const membersData: QuickSearchData = useMemo(() => { - const members = membersQuery.data || []; - - const count = members.length; - - return { - groupName: - count === 1 - ? t('Document owner') - : t('Share with {{count}} users', { - count: count, - }), - elements: members, - endActions: undefined, - }; - }, [membersQuery.data, t]); - - return ( - - ( - - )} - /> - - ); -}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx deleted file mode 100644 index 01f1c729a1..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx +++ /dev/null @@ -1,398 +0,0 @@ -import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; -import { announce } from '@react-aria/live-announcer'; -import { useQueryClient } from '@tanstack/react-query'; -import { useEffect, useMemo, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { createGlobalStyle, css } from 'styled-components'; -import { useDebouncedCallback } from 'use-debounce'; - -import { Box, ButtonCloseModal, HorizontalSeparator, Text } from '@/components'; -import { - QuickSearch, - QuickSearchData, - QuickSearchGroup, -} from '@/components/quick-search/'; -import { useConfig } from '@/core'; -import { Doc } from '@/docs/doc-management'; -import { User } from '@/features/auth'; -import { useResponsiveStore } from '@/stores'; -import { isValidEmail } from '@/utils'; - -import { - KEY_LIST_DOC_ACCESSES, - KEY_LIST_DOC_ACCESS_REQUESTS, - KEY_LIST_DOC_INVITATIONS, - KEY_LIST_USER, - useDocAccesses, - useUsers, -} from '../api'; - -import { DocInheritedShareContent } from './DocInheritedShareContent'; -import { - ButtonAccessRequest, - QuickSearchGroupAccessRequest, -} from './DocShareAccessRequest'; -import { DocShareAddMemberList } from './DocShareAddMemberList'; -import { - DocShareModalInviteUserRow, - QuickSearchGroupInvitation, -} from './DocShareInvitation'; -import { QuickSearchGroupMember } from './DocShareMember'; -import { DocShareModalFooter } from './DocShareModalFooter'; - -const ShareModalStyle = createGlobalStyle` - .--docs--doc-share-modal [cmdk-item] { - cursor: auto; - } - .c__modal__title { - padding-bottom: 0 !important; - } -`; - -type Props = { - doc: Doc; - isRootDoc?: boolean; - onClose: () => void; -}; - -export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => { - const { t } = useTranslation(); - const selectedUsersRef = useRef(null); - const queryClient = useQueryClient(); - const { data: config } = useConfig(); - const API_USERS_SEARCH_QUERY_MIN_LENGTH = - config?.API_USERS_SEARCH_QUERY_MIN_LENGTH || 5; - - const { isLargeScreen } = useResponsiveStore(); - - /** - * The modal content height is calculated based on the viewport height. - * The formula is: - * 100dvh - 2em - 12px - 34px - * - 34px is the height of the modal title in mobile - * - 2em is the padding of the modal content - * - 12px is the padding of the modal footer - * - 690px is the height of the content in desktop - * This ensures that the modal content is always visible and does not overflow. - */ - const modalContentHeight = isLargeScreen - ? 'min(690px, calc(100dvh - 2em - 12px - 34px))' - : `calc(100dvh - 34px)`; - const [selectedUsers, setSelectedUsers] = useState([]); - const [userQuery, setUserQuery] = useState(''); - const [inputValue, setInputValue] = useState(''); - - const [listHeight, setListHeight] = useState('400px'); - const canShare = doc.abilities.accesses_manage && isRootDoc; - const canViewAccesses = doc.abilities.accesses_view; - const showMemberSection = inputValue === '' && selectedUsers.length === 0; - const showFooter = selectedUsers.length === 0 && !inputValue; - - const onSelect = (user: User) => { - setSelectedUsers((prev) => [...prev, user]); - setUserQuery(''); - setInputValue(''); - - const userName = user.full_name || user.email; - announce( - t( - '{{name}} added to invite list. Add more members or press Tab to select role and invite.', - { - name: userName, - }, - ), - 'polite', - ); - }; - - const { data: membersQuery } = useDocAccesses({ - docId: doc.id, - }); - - const searchUsersQuery = useUsers( - { query: userQuery, docId: doc.id }, - { - enabled: userQuery?.length >= API_USERS_SEARCH_QUERY_MIN_LENGTH, - queryKey: [KEY_LIST_USER, { query: userQuery }], - }, - ); - - const onFilter = useDebouncedCallback((str: string) => { - setUserQuery(str); - }, 300); - - const onRemoveUser = (row: User) => { - setSelectedUsers((prevState) => { - const index = prevState.findIndex((value) => value.id === row.id); - if (index < 0) { - return prevState; - } - const newArray = [...prevState]; - newArray.splice(index, 1); - - const userName = row.full_name || row.email; - announce( - t('{{name}} removed from invite list', { - name: userName, - }), - 'polite', - ); - - return newArray; - }); - }; - - const handleRef = (node: HTMLDivElement) => { - const inputHeight = canShare ? 70 : 0; - const marginTop = 11; - const footerHeight = node?.clientHeight ?? 0; - const selectedUsersHeight = selectedUsersRef.current?.clientHeight ?? 0; - const height = `calc(${modalContentHeight} - ${footerHeight}px - ${selectedUsersHeight}px - ${inputHeight}px - ${marginTop}px)`; - - setListHeight(height); - }; - - const inheritedAccesses = useMemo(() => { - return ( - membersQuery?.filter((access) => access.document.id !== doc.id) ?? [] - ); - }, [membersQuery, doc.id]); - - const showInheritedShareContent = - inheritedAccesses.length > 0 && showMemberSection && !isRootDoc; - - // Invalidate relevant queries to ensure fresh data on modal open - useEffect(() => { - [ - KEY_LIST_DOC_INVITATIONS, - KEY_LIST_DOC_ACCESS_REQUESTS, - KEY_LIST_DOC_ACCESSES, - ].forEach((key) => { - void queryClient.invalidateQueries({ - queryKey: [key], - }); - }); - }, [queryClient]); - - return ( - <> - - - {t('Share the document')} - - - - } - hideCloseButton - > - - - - - {canShare && selectedUsers.length > 0 && ( - - { - setUserQuery(''); - setInputValue(''); - setSelectedUsers([]); - }} - /> - - )} - {!canViewAccesses && ( - - )} - - - - {!canViewAccesses && ( - - - {t( - 'You can view this document but need additional access to see its members or modify settings.', - )} - - - - )} - {canViewAccesses && ( - { - setInputValue(str); - onFilter(str); - }} - inputValue={inputValue} - showInput={canShare} - loading={searchUsersQuery.isLoading} - placeholder={t('Type a name or email')} - > - {showInheritedShareContent && ( - access.document.id !== doc.id, - ) ?? [] - } - /> - )} - {showMemberSection && isRootDoc && ( - - - - - - )} - - {!showMemberSection && canShare && ( - - )} - - )} - - - - - {showFooter && } - - - - - ); -}; - -interface QuickSearchInviteInputSectionProps { - onSelect: (usr: User) => void; - searchUsersRawData: User[] | undefined; - userQuery: string; - minLength: number; -} - -const QuickSearchInviteInputSection = ({ - onSelect, - searchUsersRawData, - userQuery, - minLength, -}: QuickSearchInviteInputSectionProps) => { - const { t } = useTranslation(); - const hint = useMemo(() => { - if (userQuery.length < minLength) { - return t('Type at least {{minLength}} characters to display user names', { - minLength, - }); - } - if (isValidEmail(userQuery)) { - return t('Choose the email'); - } - if (!searchUsersRawData?.length) { - return t('No results. Type a full email address to invite someone.'); - } - - return t('Choose a user'); - }, [minLength, searchUsersRawData?.length, t, userQuery]); - - useEffect(() => { - announce(hint, 'polite'); - }, [hint]); - - const searchUserData: QuickSearchData = useMemo(() => { - const users = searchUsersRawData || []; - const isEmail = isValidEmail(userQuery); - const newUser: User = { - id: userQuery, - full_name: '', - email: userQuery, - short_name: '', - language: '', - is_first_connection: false, - }; - - const hasEmailInUsers = users.some( - (user) => user.email.toLowerCase() === userQuery.toLowerCase(), - ); - - return { - groupName: hint, - elements: users, - endActions: - isEmail && !hasEmailInUsers - ? [ - { - content: , - onSelect: () => void onSelect(newUser), - }, - ] - : undefined, - }; - }, [searchUsersRawData, userQuery, hint, onSelect]); - - return ( - - } - /> - - ); -}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModalFooter.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModalFooter.tsx deleted file mode 100644 index fee14913c2..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModalFooter.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Button } from '@gouvfr-lasuite/cunningham-react'; -import { useTranslation } from 'react-i18next'; -import { css } from 'styled-components'; - -import { Box, HorizontalSeparator, Icon } from '@/components'; -import { Doc, useCopyDocLink } from '@/docs/doc-management'; - -import { DocVisibility } from './DocVisibility'; - -type DocShareModalFooterProps = { - doc: Doc; - onClose: () => void; -}; - -export const DocShareModalFooter = ({ - doc, - onClose, -}: DocShareModalFooterProps) => { - const copyDocLink = useCopyDocLink(doc.id); - const { t } = useTranslation(); - return ( - - - - - - - - - - - - ); -}; 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 deleted file mode 100644 index 3a8d2ef83a..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocVisibility.tsx +++ /dev/null @@ -1,250 +0,0 @@ -import { - VariantType, - useToastProvider, -} from '@gouvfr-lasuite/cunningham-react'; -import { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { css } from 'styled-components'; - -import { - Box, - DropdownMenu, - DropdownMenuOption, - Icon, - Text, -} from '@/components'; -import { useCunninghamTheme } from '@/cunningham'; -import { - Doc, - KEY_DOC, - KEY_LIST_DOC, - LinkReach, - LinkRole, - getDocLinkReach, - getDocLinkRole, - useDocUtils, -} from '@/docs/doc-management'; -import { useResponsiveStore } from '@/stores'; - -import { useUpdateDocLink } from '../api/useUpdateDocLink'; -import { useTranslatedShareSettings } from '../hooks/'; - -import { DocDesynchronized } from './DocDesynchronized'; - -interface DocVisibilityProps { - doc: Doc; -} - -export const DocVisibility = ({ doc }: DocVisibilityProps) => { - const { t } = useTranslation(); - const { isDesktop } = useResponsiveStore(); - const { spacingsTokens } = useCunninghamTheme(); - const canManage = doc.abilities.accesses_manage; - const docLinkReach = getDocLinkReach(doc); - const docLinkRole = getDocLinkRole(doc); - const { isDesynchronized } = useDocUtils(doc); - const { linkModeTranslations, linkReachChoices, linkReachTranslations } = - useTranslatedShareSettings(); - const { toast } = useToastProvider(); - - const description = - docLinkRole === LinkRole.READER - ? linkReachChoices[docLinkReach].descriptionReadOnly - : linkReachChoices[docLinkReach].descriptionEdit; - - const { mutate: updateDocLink } = useUpdateDocLink({ - listInvalidQueries: [KEY_LIST_DOC, KEY_DOC], - onSuccess: () => { - toast( - t('The document visibility has been updated.'), - VariantType.SUCCESS, - { - duration: 2000, - }, - ); - }, - }); - - const linkReachOptions: DropdownMenuOption[] = useMemo(() => { - return Object.values(LinkReach).map((key) => { - const isDisabled = doc.abilities.link_select_options[key] === undefined; - let linkRole = undefined; - if (key !== LinkReach.RESTRICTED) { - linkRole = docLinkRole; - } - - return { - label: linkReachTranslations[key], - callback: () => - updateDocLink({ - id: doc.id, - link_reach: key, - link_role: linkRole, - }), - isSelected: docLinkReach === key, - disabled: isDisabled, - }; - }); - }, [ - doc.abilities.link_select_options, - doc.id, - docLinkReach, - docLinkRole, - linkReachTranslations, - updateDocLink, - ]); - - const haveDisabledOptions = linkReachOptions.some( - (option) => option.disabled, - ); - - const showLinkRoleOptions = - docLinkReach !== LinkReach.RESTRICTED && docLinkRole; - - const linkRoleOptions: DropdownMenuOption[] = useMemo(() => { - const options = doc.abilities.link_select_options[docLinkReach] ?? []; - return Object.values(LinkRole).map((key) => { - const isDisabled = !options.includes(key); - return { - label: linkModeTranslations[key], - callback: () => - updateDocLink({ - id: doc.id, - link_role: key, - link_reach: docLinkReach, - }), - isSelected: docLinkRole === key, - disabled: isDisabled, - }; - }); - }, [ - doc.abilities.link_select_options, - doc.id, - docLinkReach, - docLinkRole, - linkModeTranslations, - updateDocLink, - ]); - - const haveDisabledLinkRoleOptions = linkRoleOptions.some( - (option) => option.disabled, - ); - - return ( - - - {t('Link settings')} - - {isDesynchronized && } - - - - - - - {linkReachChoices[docLinkReach].label} - - - - {isDesktop && ( - - {description} - - )} - - {showLinkRoleOptions && ( - - - - {linkModeTranslations[docLinkRole]} - - - - )} - - {!isDesktop && ( - - {description} - - )} - - ); -}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/SearchUserRow.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/SearchUserRow.tsx deleted file mode 100644 index f908dee3f1..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/SearchUserRow.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { css } from 'styled-components'; - -import { Box, Text } from '@/components'; -import { - QuickSearchItemContent, - QuickSearchItemContentProps, -} from '@/components/quick-search'; -import { useCunninghamTheme } from '@/cunningham'; -import { User, UserAvatar } from '@/features/auth'; - -type Props = { - user: User; - alwaysShowRight?: boolean; - right?: QuickSearchItemContentProps['right']; - isInvitation?: boolean; -}; - -export const SearchUserRow = ({ - user, - right, - alwaysShowRight = false, - isInvitation = false, -}: Props) => { - const hasFullName = !!user.full_name; - const { spacingsTokens, colorsTokens } = useCunninghamTheme(); - - return ( - - - - - {hasFullName ? user.full_name : user.email} - - {hasFullName && ( - - {user.email} - - )} - - - } - /> - ); -}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/index.ts b/src/frontend/apps/impress/src/features/docs/doc-share/components/index.ts deleted file mode 100644 index da615fb611..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './AlertModalRequestAccess'; -export * from './DocShareModal'; -export * from './DocShareAccessRequest'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/hooks/index.ts b/src/frontend/apps/impress/src/features/docs/doc-share/hooks/index.ts deleted file mode 100644 index 6b41a97702..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/hooks/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './useTranslatedShareSettings'; -export * from './useWhoAmI'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/hooks/useTranslatedShareSettings.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/hooks/useTranslatedShareSettings.tsx deleted file mode 100644 index d214609989..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/hooks/useTranslatedShareSettings.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useTranslation } from 'react-i18next'; - -import { LinkReach, LinkRole } from '@/docs/doc-management/types'; - -export const useTranslatedShareSettings = () => { - const { t } = useTranslation(); - - const linkReachTranslations = { - [LinkReach.RESTRICTED]: t('Private'), - [LinkReach.AUTHENTICATED]: t('Connected'), - [LinkReach.PUBLIC]: t('Public'), - }; - - const linkModeTranslations = { - [LinkRole.READER]: t('Reading'), - [LinkRole.EDITOR]: t('Editing'), - }; - - const linkReachChoices = { - [LinkReach.RESTRICTED]: { - label: linkReachTranslations[LinkReach.RESTRICTED], - icon: 'lock', - value: LinkReach.RESTRICTED, - descriptionReadOnly: t('Only invited people can access'), - descriptionEdit: t('Only invited people can access'), - }, - [LinkReach.AUTHENTICATED]: { - label: linkReachTranslations[LinkReach.AUTHENTICATED], - icon: 'vpn_lock', - value: LinkReach.AUTHENTICATED, - descriptionReadOnly: t( - 'Anyone with the link can view the document if they are logged in', - ), - descriptionEdit: t( - 'Anyone with the link can edit the document if they are logged in', - ), - }, - [LinkReach.PUBLIC]: { - label: linkReachTranslations[LinkReach.PUBLIC], - icon: 'public', - value: LinkReach.PUBLIC, - descriptionReadOnly: t('Anyone with the link can see the document'), - descriptionEdit: t('Anyone with the link can edit the document'), - }, - }; - - return { - linkReachTranslations, - linkModeTranslations, - linkReachChoices, - }; -}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/hooks/useWhoAmI.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/hooks/useWhoAmI.tsx deleted file mode 100644 index e418987df0..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/hooks/useWhoAmI.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Access, Role } from '@/docs/doc-management'; -import { useAuth } from '@/features/auth'; - -export const useWhoAmI = (access?: Access) => { - const { user } = useAuth(); - - if (!access) { - return { - isLastOwner: false, - isOtherOwner: false, - isMyself: false, - }; - } - - const isMyself = user?.id === access.user.id; - const rolesAllowed = access.abilities.set_role_to; - - const isLastOwner = - !rolesAllowed.length && access.role === Role.OWNER && isMyself; - - const isOtherOwner = access.role === Role.OWNER && user?.id && !isMyself; - - return { - isLastOwner, - isOtherOwner, - isMyself, - }; -}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/index.ts b/src/frontend/apps/impress/src/features/docs/doc-share/index.ts deleted file mode 100644 index 14fa9fe259..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './api'; -export * from './components'; -export * from './hooks'; -export * from './types'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/types.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/types.tsx deleted file mode 100644 index aab9980690..0000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-share/types.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Role } from '@/docs/doc-management'; -import { User } from '@/features/auth'; - -export interface Invitation { - id: string; - role: Role; - document: string; - created_at: string; - is_expired: boolean; - issuer: string; - email: string; - abilities: { - destroy: boolean; - retrieve: boolean; - partial_update: boolean; - update: boolean; - }; -} - -/** - * Type guard to check if an object is an Invitation - * Invitation has unique properties: email, issuer, is_expired, and document as a string - */ -export const isInvitation = (obj: unknown): obj is Invitation => { - return obj !== null && typeof obj === 'object' && 'issuer' in obj; -}; - -export enum OptionType { - INVITATION = 'invitation', - NEW_MEMBER = 'new_member', -} - -export const isOptionNewMember = ( - data: OptionSelect, -): data is OptionNewMember => { - return 'id' in data.value; -}; - -export interface OptionInvitation { - value: { email: string }; - label: string; - type: OptionType.INVITATION; -} - -export interface OptionNewMember { - value: User; - label: string; - type: OptionType.NEW_MEMBER; -} - -export type OptionSelect = OptionNewMember | OptionInvitation; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocMoveModal.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocMoveModal.tsx index f43aff36f4..add32e3db6 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocMoveModal.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocMoveModal.tsx @@ -21,14 +21,6 @@ import { useResponsiveStore } from '@/stores'; import { DocsGridItemDate, DocsGridItemTitle } from './DocsGridItem'; -const AlertModalRequestAccess = dynamic( - () => - import('@/docs/doc-share/components/AlertModalRequestAccess').then( - (mod) => ({ default: mod.AlertModalRequestAccess }), - ), - { ssr: false }, -); - const ModalConfirmationMoveDoc = dynamic( () => import('./ModalConfimationMoveDoc').then((mod) => ({ @@ -96,7 +88,6 @@ export const DocMoveModal = ({ const { untitledDocument } = useTrans(); const docTargetTitle = docSelected?.title || untitledDocument; const modalConfirmation = useModal(); - const modalRequest = useModal(); const { mutateAsync: moveDoc } = useMoveDoc(); const [search, setSearch] = useState(''); const { isDesktop, isTablet, isMobile } = useResponsiveStore(); @@ -155,7 +146,6 @@ export const DocMoveModal = ({ fullWidth onClick={() => { if (!docSelected?.abilities.move) { - modalRequest.open(); return; } @@ -292,19 +282,6 @@ export const DocMoveModal = ({ targetDocumentTitle={docTargetTitle} /> )} - {modalRequest.isOpen && docSelected?.id && ( - { - modalRequest.onClose(); - onClose(); - }} - targetDocumentTitle={docTargetTitle} - title={t('Move document')} - /> - )} ); }; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx index 430df47114..87bba20271 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx @@ -7,10 +7,8 @@ import { useTranslation } from 'react-i18next'; import ContentCopySVG from '@/assets/icons/ui-kit/content_copy.svg'; import DeleteSVG from '@/assets/icons/ui-kit/delete.svg'; import DocMoveInSVG from '@/assets/icons/ui-kit/doc-move-in.svg'; -import GroupSVG from '@/assets/icons/ui-kit/group.svg'; import KeepSVG from '@/assets/icons/ui-kit/keep.svg'; import KeepOffSVG from '@/assets/icons/ui-kit/keep_off.svg'; -import LeaveSVG from '@/assets/icons/ui-kit/leave.svg'; import MoreSVG from '@/assets/icons/ui-kit/more_horiz.svg'; import { Doc, @@ -26,14 +24,6 @@ import { useFocusStore } from '@/stores'; import { DocMoveModal } from './DocMoveModal'; -const DocShareModal = dynamic( - () => - import('@/docs/doc-share/components/DocShareModal').then((mod) => ({ - default: mod.DocShareModal, - })), - { ssr: false }, -); - const ModalRemoveDoc = dynamic( () => import('@/docs/doc-management/components/ModalRemoveDoc').then((mod) => ({ @@ -42,16 +32,6 @@ const ModalRemoveDoc = dynamic( { ssr: false }, ); -const ConfirmationLeaveModal = dynamic( - () => - import('@/docs/doc-share/components/ConfirmationLeaveModal').then( - (mod) => ({ - default: mod.ConfirmationLeaveModal, - }), - ), - { ssr: false }, -); - interface DocsGridActionsProps { doc: Doc; } @@ -61,8 +41,6 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => { const { restoreFocus, addLastFocus } = useFocusStore(); const [openDropdown, setOpenDropdown] = useState(false); const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false); - const [isModalLeaveOpen, setIsModalLeaveOpen] = useState(false); - const [isModalShareOpen, setIsModalShareOpen] = useState(false); const [isModalMoveOpen, setIsModalMoveOpen] = useState(false); const { untitledDocument } = useTrans(); @@ -99,15 +77,6 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => { testId: `docs-grid-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`, showSeparator: true, }, - { - label: t('Share'), - icon: