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."""