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