(back) new endpoint document can_edit

The endpoint can_edit is added to the DocumentViewset, it will give the
information to the frontend application id the current user can edit the
Docs based on the no-websocket rules.
This commit is contained in:
Manuel Raynaud
2025-06-26 07:17:00 +02:00
parent 651f2d1d75
commit 118804e810
7 changed files with 289 additions and 32 deletions

View File

@@ -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"],

View File

@@ -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,

View File

@@ -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)

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,