From 963d5f9d2176d819583c52aa6c85a56509517781 Mon Sep 17 00:00:00 2001 From: Manuel Raynaud Date: Wed, 25 Jun 2025 17:25:46 +0200 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8(y-provider)=20add=20endpoint=20re?= =?UTF-8?q?turning=20document=20connection=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We need a new endpoint in the y-provider server allowing the backend to retrieve the number of active connections on a document and if a session key exists. --- .../getDocumentConnectionInfoHandler.test.ts | 174 ++++++++++++++++++ .../getDocumentConnectionInfoHandler.ts | 46 +++++ .../servers/y-provider/src/handlers/index.ts | 1 + .../servers/y-provider/src/middlewares.ts | 1 + src/frontend/servers/y-provider/src/routes.ts | 1 + .../y-provider/src/servers/appServer.ts | 7 + .../src/servers/hocusPocusServer.ts | 8 + 7 files changed, 238 insertions(+) create mode 100644 src/frontend/servers/y-provider/__tests__/getDocumentConnectionInfoHandler.test.ts create mode 100644 src/frontend/servers/y-provider/src/handlers/getDocumentConnectionInfoHandler.ts diff --git a/src/frontend/servers/y-provider/__tests__/getDocumentConnectionInfoHandler.test.ts b/src/frontend/servers/y-provider/__tests__/getDocumentConnectionInfoHandler.test.ts new file mode 100644 index 0000000000..754dd87322 --- /dev/null +++ b/src/frontend/servers/y-provider/__tests__/getDocumentConnectionInfoHandler.test.ts @@ -0,0 +1,174 @@ +import request from 'supertest'; +import { v4 as uuid } from 'uuid'; + +const port = 5555; +const origin = 'http://localhost:3000'; + +jest.mock('../src/env', () => { + return { + PORT: port, + COLLABORATION_SERVER_ORIGIN: origin, + COLLABORATION_SERVER_SECRET: 'test-secret-api-key', + }; +}); + +console.error = jest.fn(); + +import { hocusPocusServer } from '@/servers/hocusPocusServer'; + +import { initServer } from '../src/servers/appServer'; + +const { app, server } = initServer(); +const apiEndpoint = '/collaboration/api/get-connections/'; + +describe('Server Tests', () => { + afterAll(() => { + server.close(); + }); + + test('POST /collaboration/api/get-connections?room=[ROOM_ID] with incorrect API key should return 403', async () => { + const response = await request(app as any) + .get(`${apiEndpoint}?room=test-room`) + .set('Origin', origin) + .set('Authorization', 'wrong-api-key'); + + expect(response.status).toBe(403); + expect(response.body.error).toBe('Forbidden: Invalid API Key'); + }); + + test('POST /collaboration/api/get-connections?room=[ROOM_ID] failed if room not indicated', async () => { + const response = await request(app as any) + .get(`${apiEndpoint}`) + .set('Origin', origin) + .set('Authorization', 'test-secret-api-key') + .send({ document_id: 'test-document' }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Room name not provided'); + }); + + test('POST /collaboration/api/get-connections?room=[ROOM_ID] failed if session key not indicated', async () => { + const response = await request(app as any) + .get(`${apiEndpoint}?room=test-room`) + .set('Origin', origin) + .set('Authorization', 'test-secret-api-key') + .send({ document_id: 'test-document' }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Session key not provided'); + }); + + test('POST /collaboration/api/get-connections?room=[ROOM_ID] return a 404 if room not found', async () => { + const response = await request(app as any) + .get(`${apiEndpoint}?room=test-room&sessionKey=test-session-key`) + .set('Origin', origin) + .set('Authorization', 'test-secret-api-key'); + + expect(response.status).toBe(404); + expect(response.body.error).toBe('Room not found'); + }); + + test('POST /collaboration/api/get-connections?room=[ROOM_ID] returns connection info, session key existing', async () => { + const document = await hocusPocusServer.createDocument( + 'test-room', + {}, + uuid(), + { isAuthenticated: true, readOnly: false, requiresAuthentication: true }, + {}, + ); + + document.addConnection({ + webSocket: 1, + context: { sessionKey: 'test-session-key' }, + document: document, + pongReceived: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + document.addConnection({ + webSocket: 2, + context: { sessionKey: 'other-session-key' }, + document: document, + pongReceived: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + document.addConnection({ + webSocket: 3, + context: { sessionKey: 'last-session-key' }, + document: document, + pongReceived: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + + const response = await request(app as any) + .get(`${apiEndpoint}?room=test-room&sessionKey=test-session-key`) + .set('Origin', origin) + .set('Authorization', 'test-secret-api-key'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + count: 3, + exists: true, + }); + }); + + test('POST /collaboration/api/get-connections?room=[ROOM_ID] returns connection info, session key not existing', async () => { + const document = await hocusPocusServer.createDocument( + 'test-room', + {}, + uuid(), + { isAuthenticated: true, readOnly: false, requiresAuthentication: true }, + {}, + ); + + document.addConnection({ + webSocket: 1, + context: { sessionKey: 'test-session-key' }, + document: document, + pongReceived: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + document.addConnection({ + webSocket: 2, + context: { sessionKey: 'other-session-key' }, + document: document, + pongReceived: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + document.addConnection({ + webSocket: 3, + context: { sessionKey: 'last-session-key' }, + document: document, + pongReceived: false, + request: null, + timeout: 0, + socketId: uuid(), + lock: null, + } as any); + + const response = await request(app as any) + .get(`${apiEndpoint}?room=test-room&sessionKey=non-existing-session-key`) + .set('Origin', origin) + .set('Authorization', 'test-secret-api-key'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + count: 3, + exists: false, + }); + }); +}); diff --git a/src/frontend/servers/y-provider/src/handlers/getDocumentConnectionInfoHandler.ts b/src/frontend/servers/y-provider/src/handlers/getDocumentConnectionInfoHandler.ts new file mode 100644 index 0000000000..32f05e3a18 --- /dev/null +++ b/src/frontend/servers/y-provider/src/handlers/getDocumentConnectionInfoHandler.ts @@ -0,0 +1,46 @@ +import { Request, Response } from 'express'; + +import { hocusPocusServer } from '@/servers/hocusPocusServer'; +import { logger } from '@/utils'; + +type getDocumentConnectionInfoRequestQuery = { + room?: string; + sessionKey?: string; +}; + +export const getDocumentConnectionInfoHandler = ( + req: Request, + res: Response, +) => { + const room = req.query.room; + const sessionKey = req.query.sessionKey; + + if (!room) { + res.status(400).json({ error: 'Room name not provided' }); + return; + } + + if (!req.query.sessionKey) { + res.status(400).json({ error: 'Session key not provided' }); + return; + } + + logger('Getting document connection info for room:', room); + + const roomInfo = hocusPocusServer.documents.get(room); + + if (!roomInfo) { + logger('Room not found:', room); + res.status(404).json({ error: 'Room not found' }); + return; + } + const connections = roomInfo.getConnections(); + + res.status(200).json({ + count: connections.length, + exists: connections.some( + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (connection) => connection.context.sessionKey === sessionKey, + ), + }); +}; diff --git a/src/frontend/servers/y-provider/src/handlers/index.ts b/src/frontend/servers/y-provider/src/handlers/index.ts index 75bd7f7bbf..6b18a86fea 100644 --- a/src/frontend/servers/y-provider/src/handlers/index.ts +++ b/src/frontend/servers/y-provider/src/handlers/index.ts @@ -1,3 +1,4 @@ export * from './collaborationResetConnectionsHandler'; export * from './collaborationWSHandler'; export * from './convertMarkdownHandler'; +export * from './getDocumentConnectionInfoHandler'; diff --git a/src/frontend/servers/y-provider/src/middlewares.ts b/src/frontend/servers/y-provider/src/middlewares.ts index 2769e3a868..99570af3d3 100644 --- a/src/frontend/servers/y-provider/src/middlewares.ts +++ b/src/frontend/servers/y-provider/src/middlewares.ts @@ -28,6 +28,7 @@ export const httpSecurity = ( // Note: Changing this header to Bearer token format will break backend compatibility with this microservice. const apiKey = req.headers['authorization']; if (!apiKey || !VALID_API_KEYS.includes(apiKey)) { + logger('Forbidden: Invalid API Key', apiKey); res.status(403).json({ error: 'Forbidden: Invalid API Key' }); return; } diff --git a/src/frontend/servers/y-provider/src/routes.ts b/src/frontend/servers/y-provider/src/routes.ts index 98803b87f6..e5c9dda010 100644 --- a/src/frontend/servers/y-provider/src/routes.ts +++ b/src/frontend/servers/y-provider/src/routes.ts @@ -1,5 +1,6 @@ export const routes = { COLLABORATION_WS: '/collaboration/ws/', COLLABORATION_RESET_CONNECTIONS: '/collaboration/api/reset-connections/', + COLLABORATION_GET_CONNECTIONS: '/collaboration/api/get-connections/', CONVERT_MARKDOWN: '/api/convert-markdown/', }; diff --git a/src/frontend/servers/y-provider/src/servers/appServer.ts b/src/frontend/servers/y-provider/src/servers/appServer.ts index 5c035db799..8baf37a6f9 100644 --- a/src/frontend/servers/y-provider/src/servers/appServer.ts +++ b/src/frontend/servers/y-provider/src/servers/appServer.ts @@ -9,6 +9,7 @@ import { collaborationResetConnectionsHandler, collaborationWSHandler, convertMarkdownHandler, + getDocumentConnectionInfoHandler, } from '../handlers'; import { corsMiddleware, httpSecurity, wsSecurity } from '../middlewares'; import { routes } from '../routes'; @@ -46,6 +47,12 @@ export const initServer = () => { collaborationResetConnectionsHandler, ); + app.get( + routes.COLLABORATION_GET_CONNECTIONS, + httpSecurity, + getDocumentConnectionInfoHandler, + ); + /** * Route to convert markdown */ diff --git a/src/frontend/servers/y-provider/src/servers/hocusPocusServer.ts b/src/frontend/servers/y-provider/src/servers/hocusPocusServer.ts index 0fdefa7eac..2854d8b865 100644 --- a/src/frontend/servers/y-provider/src/servers/hocusPocusServer.ts +++ b/src/frontend/servers/y-provider/src/servers/hocusPocusServer.ts @@ -61,6 +61,14 @@ export const hocusPocusServer = Server.configure({ connection.readOnly = !can_edit; + const session = requestHeaders['cookie'] + ?.split('; ') + .find((cookie) => cookie.startsWith('docs_sessionid=')); + if (session) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + context.sessionKey = session.split('=')[1]; + } + /* * Unauthenticated users can be allowed to connect * so we flag only authenticated users From e1409ae216fcf99dae757b3a11992ecaf416f2f0 Mon Sep 17 00:00:00 2001 From: Manuel Raynaud Date: Wed, 25 Jun 2025 17:30:33 +0200 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=A8(back)=20check=20on=20document=20u?= =?UTF-8?q?pdate=20if=20user=20can=20save=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a document is updated, users not connected to the collaboration server can override work made by other people connected to the collaboration server. To avoid this, the priority is given to user connected to the collaboration server. If the websocket property in the request payload is missing or set to False, the backend fetch the collaboration server to now if the user can save or not. If users are already connected, the user can't save. Also, only one user without websocket can save a connect, the first user saving acquire a lock and all other users can't save. To implement this behavior, we need to track all users, connected and not, so a session is created for every user in the ForceSessionMiddleware. --- CHANGELOG.md | 4 +- src/backend/core/api/serializers.py | 2 + src/backend/core/api/viewsets.py | 49 +++ src/backend/core/middleware.py | 21 ++ .../core/services/collaboration_services.py | 28 ++ .../documents/test_api_documents_update.py | 280 +++++++++++++++++- ...pi_documents_update_extract_attachments.py | 4 +- src/backend/impress/settings.py | 14 +- 8 files changed, 391 insertions(+), 11 deletions(-) create mode 100644 src/backend/core/middleware.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c77e00834..2b750b31f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,9 @@ and this project adheres to - 📝(project) add troubleshoot doc #1066 - 📝(project) add system-requirement doc #1066 - 🔧(front) configure x-frame-options to DENY in nginx conf #1084 -- (doc) add documentation to install with compose #855 +- ✨(doc) add documentation to install with compose #855 +- ✨ Give priority to users connected to collaboration server + (aka no websocket feature) #1093 ### Changed diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index e86288bb34..d0b0edbc8d 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -239,6 +239,7 @@ class DocumentSerializer(ListDocumentSerializer): """Serialize documents with all fields for display in detail views.""" content = serializers.CharField(required=False) + websocket = serializers.BooleanField(required=False, write_only=True) class Meta: model = models.Document @@ -260,6 +261,7 @@ class Meta: "title", "updated_at", "user_roles", + "websocket", ] read_only_fields = [ "id", diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 2250c91aa4..57bff8896f 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -30,6 +30,7 @@ from rest_framework import response as drf_response from rest_framework.permissions import AllowAny from rest_framework.throttling import UserRateThrottle +from sentry_sdk import capture_exception from core import authentication, enums, models from core.services.ai_services import AIService @@ -631,6 +632,54 @@ def perform_destroy(self, instance): """Override to implement a soft delete instead of dumping the record in database.""" instance.soft_delete() + def perform_update(self, serializer): + """Check rules about collaboration.""" + if serializer.validated_data.get("websocket"): + return super().perform_update(serializer) + + try: + connection_info = CollaborationService().get_document_connection_info( + serializer.instance.id, + self.request.session.session_key, + ) + except requests.HTTPError as e: + capture_exception(e) + connection_info = { + "count": 0, + "exists": False, + } + + if connection_info["count"] == 0: + # No websocket mode + logger.debug("update without connection found in the websocket server") + cache_key = f"docs:no-websocket:{serializer.instance.id}" + current_editor = cache.get(cache_key) + if not current_editor: + cache.set( + cache_key, + self.request.session.session_key, + settings.NO_WEBSOCKET_CACHE_TIMEOUT, + ) + elif current_editor != self.request.session.session_key: + raise drf.exceptions.PermissionDenied( + "You are not allowed to edit this document." + ) + cache.touch(cache_key, settings.NO_WEBSOCKET_CACHE_TIMEOUT) + return super().perform_update(serializer) + + if connection_info["exists"]: + # Websocket mode + logger.debug("session key found in the websocket server") + return super().perform_update(serializer) + + logger.debug( + "Users connected to the websocket but current editor not connected to it. Can not edit." + ) + + raise drf.exceptions.PermissionDenied( + "You are not allowed to edit this document." + ) + @drf.decorators.action( detail=False, methods=["get"], diff --git a/src/backend/core/middleware.py b/src/backend/core/middleware.py new file mode 100644 index 0000000000..a46cb3eb84 --- /dev/null +++ b/src/backend/core/middleware.py @@ -0,0 +1,21 @@ +"""Force session creation for all requests.""" + + +class ForceSessionMiddleware: + """ + Force session creation for unauthenticated users. + Must be used after Authentication middleware. + """ + + def __init__(self, get_response): + """Initialize the middleware.""" + self.get_response = get_response + + def __call__(self, request): + """Force session creation for unauthenticated users.""" + + if not request.user.is_authenticated and request.session.session_key is None: + request.session.save() + + response = self.get_response(request) + return response diff --git a/src/backend/core/services/collaboration_services.py b/src/backend/core/services/collaboration_services.py index dac16fa6db..fe6229c573 100644 --- a/src/backend/core/services/collaboration_services.py +++ b/src/backend/core/services/collaboration_services.py @@ -41,3 +41,31 @@ def reset_connections(self, room, user_id=None): f"Failed to notify WebSocket server. Status code: {response.status_code}, " f"Response: {response.text}" ) + + def get_document_connection_info(self, room, session_key): + """ + Get the connection info for a document. + """ + endpoint = "get-connections" + querystring = { + "room": room, + "sessionKey": session_key, + } + endpoint_url = f"{settings.COLLABORATION_API_URL}{endpoint}/" + + headers = {"Authorization": settings.COLLABORATION_SERVER_SECRET} + + try: + response = requests.get( + endpoint_url, headers=headers, params=querystring, timeout=10 + ) + except requests.RequestException as e: + raise requests.HTTPError("Failed to get document connection info.") from e + + if response.status_code != 200: + raise requests.HTTPError( + f"Failed to get document connection info. Status code: {response.status_code}, " + f"Response: {response.text}" + ) + + return response.json() diff --git a/src/backend/core/tests/documents/test_api_documents_update.py b/src/backend/core/tests/documents/test_api_documents_update.py index 1c583bc950..b24b8e7346 100644 --- a/src/backend/core/tests/documents/test_api_documents_update.py +++ b/src/backend/core/tests/documents/test_api_documents_update.py @@ -5,8 +5,10 @@ import random from django.contrib.auth.models import AnonymousUser +from django.core.cache import cache import pytest +import responses from rest_framework.test import APIClient from core import factories, models @@ -44,6 +46,7 @@ def test_api_documents_update_anonymous_forbidden(reach, role, via_parent): new_document_values = serializers.DocumentSerializer( instance=factories.DocumentFactory() ).data + new_document_values["websocket"] = True response = APIClient().put( f"/api/v1.0/documents/{document.id!s}/", new_document_values, @@ -90,8 +93,9 @@ def test_api_documents_update_authenticated_unrelated_forbidden( old_document_values = serializers.DocumentSerializer(instance=document).data new_document_values = serializers.DocumentSerializer( - instance=factories.DocumentFactory() + instance=factories.DocumentFactory(), ).data + new_document_values["websocket"] = True response = client.put( f"/api/v1.0/documents/{document.id!s}/", new_document_values, @@ -141,8 +145,9 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated( old_document_values = serializers.DocumentSerializer(instance=document).data new_document_values = serializers.DocumentSerializer( - instance=factories.DocumentFactory() + instance=factories.DocumentFactory(), ).data + new_document_values["websocket"] = True response = client.put( f"/api/v1.0/documents/{document.id!s}/", new_document_values, @@ -206,6 +211,7 @@ def test_api_documents_update_authenticated_reader(via, via_parent, mock_user_te new_document_values = serializers.DocumentSerializer( instance=factories.DocumentFactory() ).data + new_document_values["websocket"] = True response = client.put( f"/api/v1.0/documents/{document.id!s}/", new_document_values, @@ -258,6 +264,7 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner( new_document_values = serializers.DocumentSerializer( instance=factories.DocumentFactory() ).data + new_document_values["websocket"] = True response = client.put( f"/api/v1.0/documents/{document.id!s}/", new_document_values, @@ -287,6 +294,274 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner( assert value == new_document_values[key] +@responses.activate +def test_api_documents_update_authenticated_no_websocket(settings): + """ + When a user updates the document, not connected to the websocket and is the first to update, + the document should be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + new_document_values["websocket"] = False + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + + ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False}) + + assert cache.get(f"docs:no-websocket:{document.id}") is None + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + assert response.status_code == 200 + + assert cache.get(f"docs:no-websocket:{document.id}") == session_key + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_update_authenticated_no_websocket_user_already_editing(settings): + """ + When a user updates the document, not connected to the websocket and is not the first to update, + the document should not be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + new_document_values["websocket"] = False + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False}) + + cache.set(f"docs:no-websocket:{document.id}", "other_session_key") + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + assert response.status_code == 403 + assert response.json() == {"detail": "You are not allowed to edit this document."} + + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_update_no_websocket_other_user_connected_to_websocket(settings): + """ + When a user updates the document, not connected to the websocket and another user is connected + to the websocket, the document should not be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + new_document_values["websocket"] = False + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": False}) + + assert cache.get(f"docs:no-websocket:{document.id}") is None + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + assert response.status_code == 403 + assert response.json() == {"detail": "You are not allowed to edit this document."} + assert cache.get(f"docs:no-websocket:{document.id}") is None + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_update_user_connected_to_websocket(settings): + """ + When a user updates the document, connected to the websocket, the document should be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + new_document_values["websocket"] = False + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True}) + + assert cache.get(f"docs:no-websocket:{document.id}") is None + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + assert response.status_code == 200 + assert cache.get(f"docs:no-websocket:{document.id}") is None + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websocket( + settings, +): + """ + When the websocket server is unreachable, the document should be updated like if the user was + not connected to the websocket. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + new_document_values["websocket"] = False + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, status=500) + + assert cache.get(f"docs:no-websocket:{document.id}") is None + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + assert response.status_code == 200 + + assert cache.get(f"docs:no-websocket:{document.id}") == session_key + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_update_websocket_server_unreachable_fallback_to_no_websocket_other_users( + settings, +): + """ + When the websocket server is unreachable, the behavior fallback to the no websocket one. + If an other user is already editing, the document should not be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + new_document_values["websocket"] = False + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, status=500) + + cache.set(f"docs:no-websocket:{document.id}", "other_session_key") + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + assert response.status_code == 403 + + assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key" + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_update_force_websocket_param_to_true(settings): + """ + When the websocket parameter is set to true, the document should be updated without any check. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + new_document_values = serializers.DocumentSerializer( + instance=factories.DocumentFactory() + ).data + new_document_values["websocket"] = True + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, status=500) + + assert cache.get(f"docs:no-websocket:{document.id}") is None + + response = client.put( + f"/api/v1.0/documents/{document.id!s}/", + new_document_values, + format="json", + ) + assert response.status_code == 200 + + assert cache.get(f"docs:no-websocket:{document.id}") is None + assert ws_resp.call_count == 0 + + @pytest.mark.parametrize("via", VIA) def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_teams): """ @@ -317,6 +592,7 @@ def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_t new_document_values = serializers.DocumentSerializer( instance=factories.DocumentFactory() ).data + new_document_values["websocket"] = True response = client.put( f"/api/v1.0/documents/{other_document.id!s}/", new_document_values, diff --git a/src/backend/core/tests/documents/test_api_documents_update_extract_attachments.py b/src/backend/core/tests/documents/test_api_documents_update_extract_attachments.py index cf30b5e66c..5623749fdd 100644 --- a/src/backend/core/tests/documents/test_api_documents_update_extract_attachments.py +++ b/src/backend/core/tests/documents/test_api_documents_update_extract_attachments.py @@ -50,7 +50,7 @@ def test_api_documents_update_new_attachment_keys_anonymous(django_assert_num_qu with django_assert_num_queries(11): response = APIClient().put( f"/api/v1.0/documents/{document.id!s}/", - {"content": get_ydoc_with_mages(image_keys)}, + {"content": get_ydoc_with_mages(image_keys), "websocket": True}, format="json", ) assert response.status_code == 200 @@ -63,7 +63,7 @@ def test_api_documents_update_new_attachment_keys_anonymous(django_assert_num_qu with django_assert_num_queries(7): response = APIClient().put( f"/api/v1.0/documents/{document.id!s}/", - {"content": get_ydoc_with_mages(image_keys[:2])}, + {"content": get_ydoc_with_mages(image_keys[:2]), "websocket": True}, format="json", ) assert response.status_code == 200 diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 5d16e165ba..a60048aabb 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -283,6 +283,7 @@ class Base(Configuration): "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "core.middleware.ForceSessionMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "dockerflow.django.middleware.DockerflowMiddleware", ] @@ -470,6 +471,7 @@ class Base(Configuration): SESSION_COOKIE_AGE = values.PositiveIntegerValue( default=60 * 60 * 12, environ_name="SESSION_COOKIE_AGE", environ_prefix=None ) + SESSION_COOKIE_NAME = "docs_sessionid" # OIDC - Authorization Code Flow OIDC_CREATE_USER = values.BooleanValue( @@ -649,6 +651,12 @@ class Base(Configuration): environ_prefix=None, ) + NO_WEBSOCKET_CACHE_TIMEOUT = values.Value( + default=120, + environ_name="NO_WEBSOCKET_CACHE_TIMEOUT", + environ_prefix=None, + ) + # Logging # We want to make it easy to log to console but by default we log production # to Sentry and don't want to log to console. @@ -811,15 +819,9 @@ class Development(Base): CSRF_TRUSTED_ORIGINS = ["http://localhost:8072", "http://localhost:3000"] DEBUG = True - SESSION_COOKIE_NAME = "impress_sessionid" - USE_SWAGGER = True - SESSION_CACHE_ALIAS = "session" CACHES = { "default": { - "BACKEND": "django.core.cache.backends.dummy.DummyCache", - }, - "session": { "BACKEND": "django_redis.cache.RedisCache", "LOCATION": values.Value( "redis://redis:6379/2", From 88d27abe04df63e867d4b08d2b6e4a18a0edae2a Mon Sep 17 00:00:00 2001 From: Manuel Raynaud Date: Thu, 26 Jun 2025 07:17:00 +0200 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=9A=A7(back)=20new=20endpoint=20docum?= =?UTF-8?q?ent=20can=5Fedit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/backend/core/api/viewsets.py | 64 ++++-- src/backend/core/models.py | 1 + .../documents/test_api_documents_can_edit.py | 212 ++++++++++++++++++ 3 files changed, 258 insertions(+), 19 deletions(-) create mode 100644 src/backend/core/tests/documents/test_api_documents_can_edit.py diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 57bff8896f..c4d1589ff2 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -632,14 +632,15 @@ def perform_destroy(self, instance): """Override to implement a soft delete instead of dumping the record in database.""" instance.soft_delete() - def perform_update(self, serializer): - """Check rules about collaboration.""" - if serializer.validated_data.get("websocket"): - return super().perform_update(serializer) + def _compute_no_websocket_cache_key(self, document_id): + """Compute the cache key for the no websocket cache.""" + return f"docs:no-websocket:{document_id}" + def _can_user_edit_document(self, document_id, set_cache=False): + """Check if the user can edit the document.""" try: connection_info = CollaborationService().get_document_connection_info( - serializer.instance.id, + document_id, self.request.session.session_key, ) except requests.HTTPError as e: @@ -650,36 +651,61 @@ def perform_update(self, serializer): } if connection_info["count"] == 0: - # No websocket mode + # Nobody is connected to the websocket server logger.debug("update without connection found in the websocket server") - cache_key = f"docs:no-websocket:{serializer.instance.id}" + cache_key = self._compute_no_websocket_cache_key(document_id) current_editor = cache.get(cache_key) + if not current_editor: - cache.set( - cache_key, - self.request.session.session_key, - settings.NO_WEBSOCKET_CACHE_TIMEOUT, - ) + if set_cache: + cache.set( + cache_key, + self.request.session.session_key, + settings.NO_WEBSOCKET_CACHE_TIMEOUT, + ) + return True elif current_editor != self.request.session.session_key: - raise drf.exceptions.PermissionDenied( - "You are not allowed to edit this document." - ) - cache.touch(cache_key, settings.NO_WEBSOCKET_CACHE_TIMEOUT) - return super().perform_update(serializer) + return False + + if set_cache: + cache.touch(cache_key, settings.NO_WEBSOCKET_CACHE_TIMEOUT) + return True if connection_info["exists"]: - # Websocket mode + # Current user is connected to the websocket server logger.debug("session key found in the websocket server") - return super().perform_update(serializer) + return True logger.debug( "Users connected to the websocket but current editor not connected to it. Can not edit." ) + return False + + def perform_update(self, serializer): + """Check rules about collaboration.""" + if serializer.validated_data.get("websocket"): + return super().perform_update(serializer) + + if self._can_user_edit_document(serializer.instance.id, set_cache=True): + return super().perform_update(serializer) + raise drf.exceptions.PermissionDenied( "You are not allowed to edit this document." ) + @drf.decorators.action( + detail=True, + methods=["get"], + url_path="can-edit", + ) + def can_edit(self, request, *args, **kwargs): + document = self.get_object() + + return drf.response.Response( + {"can_edit": self._can_user_edit_document(document.id)} + ) + @drf.decorators.action( detail=False, methods=["get"], diff --git a/src/backend/core/models.py b/src/backend/core/models.py index a2599edfb2..5dcb09ca74 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -836,6 +836,7 @@ def get_abilities(self, user, ancestors_links=None): "ai_translate": ai_access, "attachment_upload": can_update, "media_check": can_get, + "can_edit": can_update, "children_list": can_get, "children_create": can_update and user.is_authenticated, "collaboration_auth": can_get, diff --git a/src/backend/core/tests/documents/test_api_documents_can_edit.py b/src/backend/core/tests/documents/test_api_documents_can_edit.py new file mode 100644 index 0000000000..5a21afdcdf --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_can_edit.py @@ -0,0 +1,212 @@ +"""Test the can_edit endpoint in the viewset DocumentViewSet.""" + +from django.core.cache import cache + +import pytest +import responses +from rest_framework.test import APIClient + +from core import factories + +pytestmark = pytest.mark.django_db + + +@responses.activate +def test_api_documents_can_edit_authenticated_no_websocket(settings): + """ + A user not connected to the websocket and no other user have already updated the document, + the document can be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + + ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False}) + + assert cache.get(f"docs:no-websocket:{document.id}") is None + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/can-edit/", + ) + assert response.status_code == 200 + + assert response.json() == {"can_edit": True} + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_can_edit_authenticated_no_websocket_user_already_editing( + settings, +): + """ + A user not connected to the websocket and another user have already updated the document, + the document can not be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False}) + + cache.set(f"docs:no-websocket:{document.id}", "other_session_key") + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/can-edit/", + ) + assert response.status_code == 200 + assert response.json() == {"can_edit": False} + + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_can_edit_no_websocket_other_user_connected_to_websocket( + settings, +): + """ + A user not connected to the websocket and another user is connected to the websocket, + the document can not be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": False}) + + assert cache.get(f"docs:no-websocket:{document.id}") is None + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/can-edit/", + ) + assert response.status_code == 200 + assert response.json() == {"can_edit": False} + assert cache.get(f"docs:no-websocket:{document.id}") is None + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_can_edit_user_connected_to_websocket(settings): + """ + A user connected to the websocket, the document can be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True}) + + assert cache.get(f"docs:no-websocket:{document.id}") is None + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/can-edit/", + ) + assert response.status_code == 200 + assert response.json() == {"can_edit": True} + assert cache.get(f"docs:no-websocket:{document.id}") is None + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_websocket( + settings, +): + """ + When the websocket server is unreachable, the document can be updated like if the user was + not connected to the websocket. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, status=500) + + assert cache.get(f"docs:no-websocket:{document.id}") is None + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/can-edit/", + ) + assert response.status_code == 200 + assert response.json() == {"can_edit": True} + + assert ws_resp.call_count == 1 + + +@responses.activate +def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_websocket_other_users( + settings, +): + """ + When the websocket server is unreachable, the behavior fallback to the no websocket one. + If an other user is already editing, the document can not be updated. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + session_key = client.session.session_key + + document = factories.DocumentFactory(users=[(user, "editor")]) + + settings.COLLABORATION_API_URL = "http://example.com/" + settings.COLLABORATION_SERVER_SECRET = "secret-token" + endpoint_url = ( + f"{settings.COLLABORATION_API_URL}get-connections/" + f"?room={document.id}&sessionKey={session_key}" + ) + ws_resp = responses.get(endpoint_url, status=500) + + cache.set(f"docs:no-websocket:{document.id}", "other_session_key") + + response = client.get( + f"/api/v1.0/documents/{document.id!s}/can-edit/", + ) + assert response.status_code == 200 + assert response.json() == {"can_edit": False} + + assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key" + assert ws_resp.call_count == 1 From 1728b27f56e4a06467a3bed971ccce35cae655f7 Mon Sep 17 00:00:00 2001 From: Manuel Raynaud Date: Thu, 26 Jun 2025 10:26:55 +0200 Subject: [PATCH 4/4] =?UTF-8?q?fixup!=20=F0=9F=9A=A7(back)=20new=20endpoin?= =?UTF-8?q?t=20document=20can=5Fedit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/backend/core/api/viewsets.py | 4 +++- .../core/tests/documents/test_api_documents_can_edit.py | 7 +++++++ .../core/tests/documents/test_api_documents_retrieve.py | 5 +++++ .../core/tests/documents/test_api_documents_trashbin.py | 1 + src/backend/core/tests/test_models_documents.py | 8 ++++++++ 5 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index c4d1589ff2..07c76a1a3c 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -664,7 +664,8 @@ def _can_user_edit_document(self, document_id, set_cache=False): settings.NO_WEBSOCKET_CACHE_TIMEOUT, ) return True - elif current_editor != self.request.session.session_key: + + if current_editor != self.request.session.session_key: return False if set_cache: @@ -700,6 +701,7 @@ def perform_update(self, serializer): url_path="can-edit", ) def can_edit(self, request, *args, **kwargs): + """Check if the current user can edit the document.""" document = self.get_object() return drf.response.Response( diff --git a/src/backend/core/tests/documents/test_api_documents_can_edit.py b/src/backend/core/tests/documents/test_api_documents_can_edit.py index 5a21afdcdf..ac3b835e91 100644 --- a/src/backend/core/tests/documents/test_api_documents_can_edit.py +++ b/src/backend/core/tests/documents/test_api_documents_can_edit.py @@ -11,6 +11,13 @@ pytestmark = pytest.mark.django_db +def test_api_documents_can_edit_anonymous(): + """Anonymous users can not edit documents.""" + document = factories.DocumentFactory() + client = APIClient() + response = client.get(f"/api/v1.0/documents/{document.id!s}/can-edit/") + assert response.status_code == 401 + @responses.activate def test_api_documents_can_edit_authenticated_no_websocket(settings): """ diff --git a/src/backend/core/tests/documents/test_api_documents_retrieve.py b/src/backend/core/tests/documents/test_api_documents_retrieve.py index 91e6ca0e52..f30a2ee557 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -31,6 +31,7 @@ def test_api_documents_retrieve_anonymous_public_standalone(): "ai_transform": False, "ai_translate": False, "attachment_upload": document.link_role == "editor", + "can_edit": document.link_role == "editor", "children_create": False, "children_list": True, "collaboration_auth": True, @@ -99,6 +100,7 @@ def test_api_documents_retrieve_anonymous_public_parent(): "ai_transform": False, "ai_translate": False, "attachment_upload": grand_parent.link_role == "editor", + "can_edit": grand_parent.link_role == "editor", "children_create": False, "children_list": True, "collaboration_auth": True, @@ -196,6 +198,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "ai_transform": document.link_role == "editor", "ai_translate": document.link_role == "editor", "attachment_upload": document.link_role == "editor", + "can_edit": document.link_role == "editor", "children_create": document.link_role == "editor", "children_list": True, "collaboration_auth": True, @@ -271,6 +274,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea "ai_transform": grand_parent.link_role == "editor", "ai_translate": grand_parent.link_role == "editor", "attachment_upload": grand_parent.link_role == "editor", + "can_edit": grand_parent.link_role == "editor", "children_create": grand_parent.link_role == "editor", "children_list": True, "collaboration_auth": True, @@ -452,6 +456,7 @@ def test_api_documents_retrieve_authenticated_related_parent(): "ai_transform": access.role != "reader", "ai_translate": access.role != "reader", "attachment_upload": access.role != "reader", + "can_edit": access.role != "reader", "children_create": access.role != "reader", "children_list": True, "collaboration_auth": True, diff --git a/src/backend/core/tests/documents/test_api_documents_trashbin.py b/src/backend/core/tests/documents/test_api_documents_trashbin.py index 4e4eb27698..61ccc02145 100644 --- a/src/backend/core/tests/documents/test_api_documents_trashbin.py +++ b/src/backend/core/tests/documents/test_api_documents_trashbin.py @@ -75,6 +75,7 @@ def test_api_documents_trashbin_format(): "ai_transform": True, "ai_translate": True, "attachment_upload": True, + "can_edit": True, "children_create": True, "children_list": True, "collaboration_auth": True, diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index ae10fb55ba..ba0d31ebf2 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -155,6 +155,7 @@ def test_models_documents_get_abilities_forbidden( "ai_transform": False, "ai_translate": False, "attachment_upload": False, + "can_edit": False, "children_create": False, "children_list": False, "collaboration_auth": False, @@ -216,6 +217,7 @@ def test_models_documents_get_abilities_reader( "ai_transform": False, "ai_translate": False, "attachment_upload": False, + "can_edit": False, "children_create": False, "children_list": True, "collaboration_auth": True, @@ -279,6 +281,7 @@ def test_models_documents_get_abilities_editor( "ai_transform": is_authenticated, "ai_translate": is_authenticated, "attachment_upload": True, + "can_edit": True, "children_create": is_authenticated, "children_list": True, "collaboration_auth": True, @@ -331,6 +334,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries): "ai_transform": True, "ai_translate": True, "attachment_upload": True, + "can_edit": True, "children_create": True, "children_list": True, "collaboration_auth": True, @@ -380,6 +384,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries) "ai_transform": True, "ai_translate": True, "attachment_upload": True, + "can_edit": True, "children_create": True, "children_list": True, "collaboration_auth": True, @@ -432,6 +437,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries): "ai_transform": True, "ai_translate": True, "attachment_upload": True, + "can_edit": True, "children_create": True, "children_list": True, "collaboration_auth": True, @@ -491,6 +497,7 @@ def test_models_documents_get_abilities_reader_user( "ai_transform": access_from_link and ai_access_setting != "restricted", "ai_translate": access_from_link and ai_access_setting != "restricted", "attachment_upload": access_from_link, + "can_edit": access_from_link, "children_create": access_from_link, "children_list": True, "collaboration_auth": True, @@ -548,6 +555,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries): "ai_transform": False, "ai_translate": False, "attachment_upload": False, + "can_edit": False, "children_create": False, "children_list": True, "collaboration_auth": True,