(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.""" """Override to implement a soft delete instead of dumping the record in database."""
instance.soft_delete() instance.soft_delete()
def perform_update(self, serializer): def _can_user_edit_document(self, document_id, set_cache=False):
"""Check rules about collaboration.""" """Check if the user can edit the document."""
if serializer.validated_data.get("websocket"):
return super().perform_update(serializer)
try: try:
connection_info = CollaborationService().get_document_connection_info( count, exists = CollaborationService().get_document_connection_info(
serializer.instance.id, document_id,
self.request.session.session_key, self.request.session.session_key,
) )
except requests.HTTPError as e: except requests.HTTPError as e:
capture_exception(e) logger.exception("Failed to call collaboration server: %s", e)
connection_info = { count = 0
"count": 0, exists = False
"exists": False,
}
if connection_info["count"] == 0: if count == 0:
# No websocket mode # Nobody is connected to the websocket server
logger.debug("update without connection found in 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) 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"]: if not current_editor:
# Websocket mode 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") logger.debug("session key found in the websocket server")
return super().perform_update(serializer) return True
logger.debug( logger.debug(
"Users connected to the websocket but current editor not connected to it. Can not edit." "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( raise drf.exceptions.PermissionDenied(
"You are not allowed to edit this document." "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( @drf.decorators.action(
detail=False, detail=False,
methods=["get"], methods=["get"],

View File

@@ -836,6 +836,7 @@ class Document(MP_Node, BaseModel):
"ai_translate": ai_access, "ai_translate": ai_access,
"attachment_upload": can_update, "attachment_upload": can_update,
"media_check": can_get, "media_check": can_get,
"can_edit": can_update,
"children_list": can_get, "children_list": can_get,
"children_create": can_update and user.is_authenticated, "children_create": can_update and user.is_authenticated,
"collaboration_auth": can_get, "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"Failed to get document connection info. Status code: {response.status_code}, "
f"Response: {response.text}" f"Response: {response.text}"
) )
result = response.json()
return 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_transform": False,
"ai_translate": False, "ai_translate": False,
"attachment_upload": document.link_role == "editor", "attachment_upload": document.link_role == "editor",
"can_edit": document.link_role == "editor",
"children_create": False, "children_create": False,
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
@@ -99,6 +100,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
"ai_transform": False, "ai_transform": False,
"ai_translate": False, "ai_translate": False,
"attachment_upload": grand_parent.link_role == "editor", "attachment_upload": grand_parent.link_role == "editor",
"can_edit": grand_parent.link_role == "editor",
"children_create": False, "children_create": False,
"children_list": True, "children_list": True,
"collaboration_auth": 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_transform": document.link_role == "editor",
"ai_translate": document.link_role == "editor", "ai_translate": document.link_role == "editor",
"attachment_upload": document.link_role == "editor", "attachment_upload": document.link_role == "editor",
"can_edit": document.link_role == "editor",
"children_create": document.link_role == "editor", "children_create": document.link_role == "editor",
"children_list": True, "children_list": True,
"collaboration_auth": 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_transform": grand_parent.link_role == "editor",
"ai_translate": grand_parent.link_role == "editor", "ai_translate": grand_parent.link_role == "editor",
"attachment_upload": 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_create": grand_parent.link_role == "editor",
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
@@ -452,6 +456,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
"ai_transform": access.role != "reader", "ai_transform": access.role != "reader",
"ai_translate": access.role != "reader", "ai_translate": access.role != "reader",
"attachment_upload": access.role != "reader", "attachment_upload": access.role != "reader",
"can_edit": access.role != "reader",
"children_create": access.role != "reader", "children_create": access.role != "reader",
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,

View File

@@ -75,6 +75,7 @@ def test_api_documents_trashbin_format():
"ai_transform": True, "ai_transform": True,
"ai_translate": True, "ai_translate": True,
"attachment_upload": True, "attachment_upload": True,
"can_edit": True,
"children_create": True, "children_create": True,
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,

View File

@@ -155,6 +155,7 @@ def test_models_documents_get_abilities_forbidden(
"ai_transform": False, "ai_transform": False,
"ai_translate": False, "ai_translate": False,
"attachment_upload": False, "attachment_upload": False,
"can_edit": False,
"children_create": False, "children_create": False,
"children_list": False, "children_list": False,
"collaboration_auth": False, "collaboration_auth": False,
@@ -216,6 +217,7 @@ def test_models_documents_get_abilities_reader(
"ai_transform": False, "ai_transform": False,
"ai_translate": False, "ai_translate": False,
"attachment_upload": False, "attachment_upload": False,
"can_edit": False,
"children_create": False, "children_create": False,
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
@@ -279,6 +281,7 @@ def test_models_documents_get_abilities_editor(
"ai_transform": is_authenticated, "ai_transform": is_authenticated,
"ai_translate": is_authenticated, "ai_translate": is_authenticated,
"attachment_upload": True, "attachment_upload": True,
"can_edit": True,
"children_create": is_authenticated, "children_create": is_authenticated,
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
@@ -331,6 +334,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries):
"ai_transform": True, "ai_transform": True,
"ai_translate": True, "ai_translate": True,
"attachment_upload": True, "attachment_upload": True,
"can_edit": True,
"children_create": True, "children_create": True,
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
@@ -380,6 +384,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries)
"ai_transform": True, "ai_transform": True,
"ai_translate": True, "ai_translate": True,
"attachment_upload": True, "attachment_upload": True,
"can_edit": True,
"children_create": True, "children_create": True,
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
@@ -432,6 +437,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
"ai_transform": True, "ai_transform": True,
"ai_translate": True, "ai_translate": True,
"attachment_upload": True, "attachment_upload": True,
"can_edit": True,
"children_create": True, "children_create": True,
"children_list": True, "children_list": True,
"collaboration_auth": 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_transform": access_from_link and ai_access_setting != "restricted",
"ai_translate": access_from_link and ai_access_setting != "restricted", "ai_translate": access_from_link and ai_access_setting != "restricted",
"attachment_upload": access_from_link, "attachment_upload": access_from_link,
"can_edit": access_from_link,
"children_create": access_from_link, "children_create": access_from_link,
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,
@@ -548,6 +555,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
"ai_transform": False, "ai_transform": False,
"ai_translate": False, "ai_translate": False,
"attachment_upload": False, "attachment_upload": False,
"can_edit": False,
"children_create": False, "children_create": False,
"children_list": True, "children_list": True,
"collaboration_auth": True, "collaboration_auth": True,