From 5f5bf1efa24d92eb078a81f1b33b5a938929c55c Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Thu, 30 Jul 2026 15:49:21 +0200 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F(backend)=20delegate=20tree,?= =?UTF-8?q?=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 | 210 +- src/backend/core/api/serializers.py | 718 +++--- src/backend/core/api/utils.py | 52 - src/backend/core/api/viewsets.py | 1928 ++++++----------- src/backend/core/external_api/viewsets.py | 68 +- 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 | 943 ++------ .../core/services/collaboration_services.py | 19 +- src/backend/core/services/search_indexers.py | 71 +- 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, 1598 insertions(+), 3458 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 e38e37f6a..bd6f4a00e 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -8,7 +8,6 @@ from django.db import transaction 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 4b92b711e..7da7ef732 100644 --- a/src/backend/core/api/permissions.py +++ b/src/backend/core/api/permissions.py @@ -7,7 +7,7 @@ from django.http import Http404 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 @@ class IsOwnedOrPublic(IsAuthenticated): 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 + """ + 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 - # Ensure the user is authenticated - if not (bool(request.auth) or request.user.is_authenticated): - return False + 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 - # 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 + 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) - # 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() + # 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")) - -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 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) - - 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 0b465f135..cf0a1070c 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -17,14 +17,13 @@ import magic 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 ListDocumentSerializer(serializers.ModelSerializer): ] 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 @@ class ListDocumentSerializer(serializers.ModelSerializer): 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 @@ class DocumentContentSerializer(serializers.Serializer): 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 @@ class ServerCreateDocumentSerializer(serializers.Serializer): 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 @@ class ServerCreateDocumentSerializer(serializers.Serializer): 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 @@ class ServerCreateDocumentSerializer(serializers.Serializer): 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 @@ class FileUploadSerializer(serializers.Serializer): 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 19cb03f3e..7ff5f9d6e 100644 --- a/src/backend/core/api/utils.py +++ b/src/backend/core/api/utils.py @@ -14,58 +14,6 @@ from lasuite.oidc_login.decorators import refresh_oidc_access_token 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 5d9991bcb..7230c9f16 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -9,9 +9,8 @@ import json 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.core.validators import URLValidator 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 treebeard.exceptions import InvalidMoveToDescendant 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 @@ from core.services.search_indexers import ( 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 @@ class UserViewSet( # 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 @@ class UserViewSet( .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 @@ class DocumentViewSet( 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 @@ class DocumentViewSet( 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,42 +571,36 @@ class DocumentViewSet( 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 + # 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) - # Not calling filter_queryset. We do our own cooking. - queryset = self.get_queryset() + results = [ + drive_client.drive_item_to_doc_dict(item) + for item in drive_page.get("results", []) + ] - 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 + 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}") - # 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) - - # 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, + 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, + } ) - queryset = queryset.filter(path__in=root_paths) - - # 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"] - ) - - # Apply ordering only now that everything is filtered and annotated - queryset = filters.OrderingFilter().filter_queryset( - self.request, queryset, self - ) - - return self.get_response_for_queryset(queryset) def retrieve(self, request, *args, **kwargs): """ @@ -728,29 +668,42 @@ class DocumentViewSet( ) 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 @@ class DocumentViewSet( "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 @@ class DocumentViewSet( """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 @@ class DocumentViewSet( ) 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,6 +862,96 @@ class DocumentViewSet( {"id": str(document.id)}, status=status.HTTP_201_CREATED ) + 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 + + @drf.decorators.action( + authentication_classes=[authentication.ServerToServerAuthentication], + detail=False, + methods=["post"], + permission_classes=[], + url_path="s2s-delete", + ) + def s2s_delete(self, request): + """ + 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 + + return drf_response.Response({"deleted": deleted}, status=status.HTTP_200_OK) + + @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) + + return drf_response.Response({"restored": restored}, status=status.HTTP_200_OK) + + @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 + + return drf_response.Response({"purged": purged}, status=status.HTTP_200_OK) + @drf.decorators.action(detail=True, methods=["post"]) @transaction.atomic def move(self, request, *args, **kwargs): @@ -950,110 +961,11 @@ class DocumentViewSet( The user must be an administrator or owner of both the document being moved and the target parent document. """ - 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 - - 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, - ) - - 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, - ) - - 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, - ) - - # 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() - - # 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}, - ) - - posthog_capture( - PosthogEventName.DOC_MOVED, - user, - { - "position": position, - "targeted_document_id": str(target_document_id), - }, - document=document, - ) - - return drf.response.Response( - {"message": "Document moved successfully."}, status=status.HTTP_200_OK + # 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 @@ class DocumentViewSet( 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 @@ class DocumentViewSet( 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 @@ class DocumentViewSet( 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) + # 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) - 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() - - 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 @@ class DocumentViewSet( 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 @@ class DocumentViewSet( 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 @@ class DocumentViewSet( ) 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. + 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. """ - user = self.request.user - 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 + drive_tree = drive_client.get_tree(str(pk), request.user) + except drive_client.DriveClientError as exc: + drive_client.raise_as_drf(exc) - is_deleted = current_document.ancestors_deleted_at is not None - - 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 @@ class DocumentViewSet( """ 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"], @@ -1572,102 +1221,23 @@ class DocumentViewSet( } ) - 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) - 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 + queryset = ( + 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) - - # 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) - .annotate_is_favorite(user) - .annotate_user_has_link_trace(user) - ) - queryset = filterset.filter_queryset(queryset) # Apply ordering only now that everything is filtered and annotated @@ -1675,60 +1245,17 @@ class DocumentViewSet( 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) - }, - ) + page = self.paginate_queryset(queryset) + documents = list(page if page else queryset) + for document in documents: + document.parent = None - 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 + serializer = self.get_serializer(documents, many=True) - abilities = parent.get_abilities(user) - if not abilities.get("search"): - raise drf.exceptions.PermissionDenied( - "You do not have permission to search within this document." - ) + if page is None: + return drf.response.Response(serializer.data) - # 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) - - # filter by title - filterset = DocumentFilter(request.GET, queryset=queryset) - if not filterset.is_valid(): - raise drf.exceptions.ValidationError(filterset.errors) - - 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 @@ class DocumentViewSet( 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 @@ class DocumentViewSet( 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 @@ class DocumentViewSet( } ) - @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 @@ class DocumentViewSet( 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 @@ class DocumentViewSet( 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 @@ class DocumentViewSet( 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 @@ class DocumentViewSet( 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 9a8bafcb8..bb404d8b0 100644 --- a/src/backend/core/external_api/viewsets.py +++ b/src/backend/core/external_api/viewsets.py @@ -5,15 +5,11 @@ from django.conf import settings 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 @@ class ResourceServerRestrictionMixin: 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 @@ class ResourceServerDocumentViewSet(ResourceServerRestrictionMixin, DocumentView 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") +# 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") -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 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 eeefa8f4b..8cbdddc2f 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -44,16 +44,6 @@ class UserFactory(factory.django.DjangoModelFactory): 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 DocumentFactory(factory.django.DjangoModelFactory): 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 @@ class DocumentFactory(factory.django.DjangoModelFactory): 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 DocumentAskForAccessFactory(factory.django.DjangoModelFactory): 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 e7a006ad5..59b5fb2e0 100644 --- a/src/backend/core/management/commands/clean_document.py +++ b/src/backend/core/management/commands/clean_document.py @@ -13,10 +13,8 @@ from botocore.exceptions import ClientError from core.choices import LinkReachChoices, LinkRoleChoices, RoleChoices from core.models import ( Document, - DocumentAccess, DocumentAskForAccess, DocumentFavorite, - Invitation, LinkTrace, Thread, ) @@ -80,9 +78,10 @@ class Command(BaseCommand): 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 @@ class Command(BaseCommand): # 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 @@ class Command(BaseCommand): 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 000000000..44928f2ca --- /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 000000000..d7859dca7 --- /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 26849520a..411a00289 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -21,7 +21,6 @@ from django.core.files.storage import default_storage 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 django.utils.translation import gettext_lazy as _ 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 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin): 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 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin): sandbox_id, ) return + # Import here to avoid a circular import through core.api.serializers + from core.services import ( # pylint: disable=import-outside-toplevel + drive_client, + ) + + 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 + 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, - ) + sandbox_document = Document.objects.create( + id=drive_item["id"], + title=template_document.title, + content=template_document.content, + attachments=template_document.attachments, + creator=self, ) - DocumentAccess.objects.create( - user=self, document=sandbox_document, role=RoleChoices.OWNER - ) - - 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 - ) - 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 @@ class UserReconciliation(BaseModel): - 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 @@ class UserReconciliation(BaseModel): 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 @@ class UserReconciliation(BaseModel): 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 @@ class UserReconciliationCsvImport(BaseModel): 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 @@ class DocumentQuerySet(MP_NodeQuerySet): 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 @@ class DocumentQuerySet(MP_NodeQuerySet): 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 @@ class Document(MP_Node, BaseModel): 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 @@ class Document(MP_Node, BaseModel): 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 @@ class Document(MP_Node, BaseModel): 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} - ) - paths_links_mapping[ancestor.path] = ancestors_links.copy() - - return paths_links_mapping - - @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} - - @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 + 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, ) - return self._ancestors_link_definition + self.drive_item = drive_client.get_item(str(self.pk), user) - @ancestors_link_definition.setter - def ancestors_link_definition(self, definition): - """Cache the ancestors_link_definition.""" - self._ancestors_link_definition = definition + return self.drive_item + + 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 + + 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 + + @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 @@ class Document(MP_Node, BaseModel): 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() + self.deleted_at = timezone.now() + self.save(update_fields=["deleted_at", "updated_at"]) - if self.depth > 1: - self._meta.model.objects.filter(pk=self.get_parent().pk).update( - numchild=models.F("numchild") - 1, - has_deleted_children=True, - ) - - # 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 @@ class Document(MP_Node, BaseModel): "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 @@ class DocumentFavorite(BaseModel): 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 @@ class DocumentAskForAccess(BaseModel): 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 @@ class Thread(BaseModel): 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 @@ class Comment(BaseModel): 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 Reaction(BaseModel): 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 fa1e1e867..b24ea119a 100644 --- a/src/backend/core/services/collaboration_services.py +++ b/src/backend/core/services/collaboration_services.py @@ -22,8 +22,8 @@ class CollaborationService: 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 @@ class CollaborationService: 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 2aa56d9fc..1b80355fa 100644 --- a/src/backend/core/services/search_indexers.py +++ b/src/backend/core/services/search_indexers.py @@ -15,7 +15,6 @@ import requests 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_by_document_id = defaultdict(lambda: {"users": set(), "teams": set()}) - access_qs = models.DocumentAccess.objects.filter( - document__path__in=ancestor_paths - ).values("document__path", "user__sub", "team") + 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"]) + ) - access_by_document_path = defaultdict(lambda: {"users": set(), "teams": set()}) + 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"]) + ) - for access in access_qs: - ancestor_path = access["document__path"] - user_sub = access["user__sub"] - team = access["team"] - - 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) - - 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 @@ class BaseDocumentIndexer(ABC): 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 @@ class FindDocumentIndexer(BaseDocumentIndexer): 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 03faa666b..0f80ce355 100644 --- a/src/backend/core/signals.py +++ b/src/backend/core/signals.py @@ -4,14 +4,12 @@ Declare and configure the signals for the impress core application 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 483c96148..cd67ccad7 100644 --- a/src/backend/core/tasks/mail.py +++ b/src/backend/core/tasks/mail.py @@ -7,18 +7,19 @@ from core import models 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 e1c39e6be..153298f74 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 1375d223b..000000000 --- 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 33c7c643e..ddf5ce1bb 100644 --- a/src/backend/core/tests/test_utils.py +++ b/src/backend/core/tests/test_utils.py @@ -10,7 +10,6 @@ import pytest 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 cc327b280..000000000 --- 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 f5050fb1e..000000000 --- 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 e89618650..9c72ff792 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -17,26 +17,28 @@ router.register("users", viewsets.UserViewSet, basename="users") # - 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 @@ if settings.OIDC_RESOURCE_SERVER_ENABLED: # - 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 fb0da42f4..000000000 --- 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 27f387ff6..000000000 --- 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 0130383ab..000000000 --- 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 e216edf94..e01235e75 100644 --- a/src/backend/demo/management/commands/create_demo.py +++ b/src/backend/demo/management/commands/create_demo.py @@ -118,106 +118,14 @@ class Timeit: 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."""