diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 1d66ffcc..03777ad6 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -635,54 +635,76 @@ class DocumentViewSet( """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 _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, + count, exists = CollaborationService().get_document_connection_info( + document_id, self.request.session.session_key, ) except requests.HTTPError as e: - capture_exception(e) - connection_info = { - "count": 0, - "exists": False, - } + logger.exception("Failed to call collaboration server: %s", e) + count = 0 + exists = False - if connection_info["count"] == 0: - # No websocket mode + if count == 0: + # 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 = f"docs:no-websocket:{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, - ) - 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 + if not current_editor: + if set_cache: + cache.set( + cache_key, + self.request.session.session_key, + settings.NO_WEBSOCKET_CACHE_TIMEOUT, + ) + return True + + if current_editor != self.request.session.session_key: + return False + + if set_cache: + cache.touch(cache_key, settings.NO_WEBSOCKET_CACHE_TIMEOUT) + return True + + if exists: + # 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", False): + 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): + """Check if the current user can edit the document.""" + 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 9d8d2db5..ead7161e 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -836,6 +836,7 @@ class Document(MP_Node, BaseModel): "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/services/collaboration_services.py b/src/backend/core/services/collaboration_services.py index fe6229c5..ae4df1d5 100644 --- a/src/backend/core/services/collaboration_services.py +++ b/src/backend/core/services/collaboration_services.py @@ -67,5 +67,5 @@ class CollaborationService: f"Failed to get document connection info. Status code: {response.status_code}, " f"Response: {response.text}" ) - - return response.json() + result = response.json() + return result.get("count", 0), result.get("exists", False) 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 00000000..83695873 --- /dev/null +++ b/src/backend/core/tests/documents/test_api_documents_can_edit.py @@ -0,0 +1,220 @@ +"""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 + + +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): + """ + 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 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 80b135d3..fbecf2f5 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 4e4eb276..61ccc021 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 1e81e83c..a791f3d8 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,