Skip to content

Commit 0acab47

Browse files
committed
🚧(back) new endpoint document can_edit
1 parent 651f2d1 commit 0acab47

File tree

7 files changed

+288
-31
lines changed

7 files changed

+288
-31
lines changed

src/backend/core/api/viewsets.py

Lines changed: 51 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -635,54 +635,76 @@ def perform_destroy(self, instance):
635635
"""Override to implement a soft delete instead of dumping the record in database."""
636636
instance.soft_delete()
637637

638-
def perform_update(self, serializer):
639-
"""Check rules about collaboration."""
640-
if serializer.validated_data.get("websocket"):
641-
return super().perform_update(serializer)
642-
638+
def _can_user_edit_document(self, document_id, set_cache=False):
639+
"""Check if the user can edit the document."""
643640
try:
644-
connection_info = CollaborationService().get_document_connection_info(
645-
serializer.instance.id,
641+
count, exists = CollaborationService().get_document_connection_info(
642+
document_id,
646643
self.request.session.session_key,
647644
)
648645
except requests.HTTPError as e:
649-
capture_exception(e)
650-
connection_info = {
651-
"count": 0,
652-
"exists": False,
653-
}
646+
logger.exception("Failed to call collaboration server: %s", e)
647+
count = 0
648+
exists = False
654649

655-
if connection_info["count"] == 0:
656-
# No websocket mode
650+
if count == 0:
651+
# Nobody is connected to the websocket server
657652
logger.debug("update without connection found in the websocket server")
658-
cache_key = f"docs:no-websocket:{serializer.instance.id}"
653+
cache_key = f"docs:no-websocket:{document_id}"
659654
current_editor = cache.get(cache_key)
655+
660656
if not current_editor:
661-
cache.set(
662-
cache_key,
663-
self.request.session.session_key,
664-
settings.NO_WEBSOCKET_CACHE_TIMEOUT,
665-
)
666-
elif current_editor != self.request.session.session_key:
667-
raise drf.exceptions.PermissionDenied(
668-
"You are not allowed to edit this document."
669-
)
670-
cache.touch(cache_key, settings.NO_WEBSOCKET_CACHE_TIMEOUT)
671-
return super().perform_update(serializer)
657+
if set_cache:
658+
cache.set(
659+
cache_key,
660+
self.request.session.session_key,
661+
settings.NO_WEBSOCKET_CACHE_TIMEOUT,
662+
)
663+
return True
664+
665+
if current_editor != self.request.session.session_key:
666+
return False
667+
668+
if set_cache:
669+
cache.touch(cache_key, settings.NO_WEBSOCKET_CACHE_TIMEOUT)
670+
return True
672671

673-
if connection_info["exists"]:
674-
# Websocket mode
672+
if exists:
673+
# Current user is connected to the websocket server
675674
logger.debug("session key found in the websocket server")
676-
return super().perform_update(serializer)
675+
return True
677676

678677
logger.debug(
679678
"Users connected to the websocket but current editor not connected to it. Can not edit."
680679
)
681680

681+
return False
682+
683+
def perform_update(self, serializer):
684+
"""Check rules about collaboration."""
685+
if serializer.validated_data.get("websocket", False):
686+
return super().perform_update(serializer)
687+
688+
if self._can_user_edit_document(serializer.instance.id, set_cache=True):
689+
return super().perform_update(serializer)
690+
682691
raise drf.exceptions.PermissionDenied(
683692
"You are not allowed to edit this document."
684693
)
685694

695+
@drf.decorators.action(
696+
detail=True,
697+
methods=["get"],
698+
url_path="can-edit",
699+
)
700+
def can_edit(self, request, *args, **kwargs):
701+
"""Check if the current user can edit the document."""
702+
document = self.get_object()
703+
704+
return drf.response.Response(
705+
{"can_edit": self._can_user_edit_document(document.id)}
706+
)
707+
686708
@drf.decorators.action(
687709
detail=False,
688710
methods=["get"],

src/backend/core/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -836,6 +836,7 @@ def get_abilities(self, user, ancestors_links=None):
836836
"ai_translate": ai_access,
837837
"attachment_upload": can_update,
838838
"media_check": can_get,
839+
"can_edit": can_update,
839840
"children_list": can_get,
840841
"children_create": can_update and user.is_authenticated,
841842
"collaboration_auth": can_get,

src/backend/core/services/collaboration_services.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,5 +67,5 @@ def get_document_connection_info(self, room, session_key):
6767
f"Failed to get document connection info. Status code: {response.status_code}, "
6868
f"Response: {response.text}"
6969
)
70-
71-
return response.json()
70+
result = response.json()
71+
return result.get("count", 0), result.get("exists", False)
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
"""Test the can_edit endpoint in the viewset DocumentViewSet."""
2+
3+
from django.core.cache import cache
4+
5+
import pytest
6+
import responses
7+
from rest_framework.test import APIClient
8+
9+
from core import factories
10+
11+
pytestmark = pytest.mark.django_db
12+
13+
14+
def test_api_documents_can_edit_anonymous():
15+
"""Anonymous users can not edit documents."""
16+
document = factories.DocumentFactory()
17+
client = APIClient()
18+
response = client.get(f"/api/v1.0/documents/{document.id!s}/can-edit/")
19+
assert response.status_code == 401
20+
21+
22+
@responses.activate
23+
def test_api_documents_can_edit_authenticated_no_websocket(settings):
24+
"""
25+
A user not connected to the websocket and no other user have already updated the document,
26+
the document can be updated.
27+
"""
28+
user = factories.UserFactory(with_owned_document=True)
29+
client = APIClient()
30+
client.force_login(user)
31+
session_key = client.session.session_key
32+
33+
document = factories.DocumentFactory(users=[(user, "editor")])
34+
35+
settings.COLLABORATION_API_URL = "http://example.com/"
36+
settings.COLLABORATION_SERVER_SECRET = "secret-token"
37+
endpoint_url = (
38+
f"{settings.COLLABORATION_API_URL}get-connections/"
39+
f"?room={document.id}&sessionKey={session_key}"
40+
)
41+
42+
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
43+
44+
assert cache.get(f"docs:no-websocket:{document.id}") is None
45+
46+
response = client.get(
47+
f"/api/v1.0/documents/{document.id!s}/can-edit/",
48+
)
49+
assert response.status_code == 200
50+
51+
assert response.json() == {"can_edit": True}
52+
assert ws_resp.call_count == 1
53+
54+
55+
@responses.activate
56+
def test_api_documents_can_edit_authenticated_no_websocket_user_already_editing(
57+
settings,
58+
):
59+
"""
60+
A user not connected to the websocket and another user have already updated the document,
61+
the document can not be updated.
62+
"""
63+
user = factories.UserFactory(with_owned_document=True)
64+
client = APIClient()
65+
client.force_login(user)
66+
session_key = client.session.session_key
67+
68+
document = factories.DocumentFactory(users=[(user, "editor")])
69+
70+
settings.COLLABORATION_API_URL = "http://example.com/"
71+
settings.COLLABORATION_SERVER_SECRET = "secret-token"
72+
endpoint_url = (
73+
f"{settings.COLLABORATION_API_URL}get-connections/"
74+
f"?room={document.id}&sessionKey={session_key}"
75+
)
76+
ws_resp = responses.get(endpoint_url, json={"count": 0, "exists": False})
77+
78+
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
79+
80+
response = client.get(
81+
f"/api/v1.0/documents/{document.id!s}/can-edit/",
82+
)
83+
assert response.status_code == 200
84+
assert response.json() == {"can_edit": False}
85+
86+
assert ws_resp.call_count == 1
87+
88+
89+
@responses.activate
90+
def test_api_documents_can_edit_no_websocket_other_user_connected_to_websocket(
91+
settings,
92+
):
93+
"""
94+
A user not connected to the websocket and another user is connected to the websocket,
95+
the document can not be updated.
96+
"""
97+
user = factories.UserFactory(with_owned_document=True)
98+
client = APIClient()
99+
client.force_login(user)
100+
session_key = client.session.session_key
101+
102+
document = factories.DocumentFactory(users=[(user, "editor")])
103+
104+
settings.COLLABORATION_API_URL = "http://example.com/"
105+
settings.COLLABORATION_SERVER_SECRET = "secret-token"
106+
endpoint_url = (
107+
f"{settings.COLLABORATION_API_URL}get-connections/"
108+
f"?room={document.id}&sessionKey={session_key}"
109+
)
110+
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": False})
111+
112+
assert cache.get(f"docs:no-websocket:{document.id}") is None
113+
114+
response = client.get(
115+
f"/api/v1.0/documents/{document.id!s}/can-edit/",
116+
)
117+
assert response.status_code == 200
118+
assert response.json() == {"can_edit": False}
119+
assert cache.get(f"docs:no-websocket:{document.id}") is None
120+
assert ws_resp.call_count == 1
121+
122+
123+
@responses.activate
124+
def test_api_documents_can_edit_user_connected_to_websocket(settings):
125+
"""
126+
A user connected to the websocket, the document can be updated.
127+
"""
128+
user = factories.UserFactory(with_owned_document=True)
129+
client = APIClient()
130+
client.force_login(user)
131+
session_key = client.session.session_key
132+
133+
document = factories.DocumentFactory(users=[(user, "editor")])
134+
135+
settings.COLLABORATION_API_URL = "http://example.com/"
136+
settings.COLLABORATION_SERVER_SECRET = "secret-token"
137+
endpoint_url = (
138+
f"{settings.COLLABORATION_API_URL}get-connections/"
139+
f"?room={document.id}&sessionKey={session_key}"
140+
)
141+
ws_resp = responses.get(endpoint_url, json={"count": 3, "exists": True})
142+
143+
assert cache.get(f"docs:no-websocket:{document.id}") is None
144+
145+
response = client.get(
146+
f"/api/v1.0/documents/{document.id!s}/can-edit/",
147+
)
148+
assert response.status_code == 200
149+
assert response.json() == {"can_edit": True}
150+
assert cache.get(f"docs:no-websocket:{document.id}") is None
151+
assert ws_resp.call_count == 1
152+
153+
154+
@responses.activate
155+
def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_websocket(
156+
settings,
157+
):
158+
"""
159+
When the websocket server is unreachable, the document can be updated like if the user was
160+
not connected to the websocket.
161+
"""
162+
user = factories.UserFactory(with_owned_document=True)
163+
client = APIClient()
164+
client.force_login(user)
165+
session_key = client.session.session_key
166+
167+
document = factories.DocumentFactory(users=[(user, "editor")])
168+
169+
settings.COLLABORATION_API_URL = "http://example.com/"
170+
settings.COLLABORATION_SERVER_SECRET = "secret-token"
171+
endpoint_url = (
172+
f"{settings.COLLABORATION_API_URL}get-connections/"
173+
f"?room={document.id}&sessionKey={session_key}"
174+
)
175+
ws_resp = responses.get(endpoint_url, status=500)
176+
177+
assert cache.get(f"docs:no-websocket:{document.id}") is None
178+
179+
response = client.get(
180+
f"/api/v1.0/documents/{document.id!s}/can-edit/",
181+
)
182+
assert response.status_code == 200
183+
assert response.json() == {"can_edit": True}
184+
185+
assert ws_resp.call_count == 1
186+
187+
188+
@responses.activate
189+
def test_api_documents_can_edit_websocket_server_unreachable_fallback_to_no_websocket_other_users(
190+
settings,
191+
):
192+
"""
193+
When the websocket server is unreachable, the behavior fallback to the no websocket one.
194+
If an other user is already editing, the document can not be updated.
195+
"""
196+
user = factories.UserFactory(with_owned_document=True)
197+
client = APIClient()
198+
client.force_login(user)
199+
session_key = client.session.session_key
200+
201+
document = factories.DocumentFactory(users=[(user, "editor")])
202+
203+
settings.COLLABORATION_API_URL = "http://example.com/"
204+
settings.COLLABORATION_SERVER_SECRET = "secret-token"
205+
endpoint_url = (
206+
f"{settings.COLLABORATION_API_URL}get-connections/"
207+
f"?room={document.id}&sessionKey={session_key}"
208+
)
209+
ws_resp = responses.get(endpoint_url, status=500)
210+
211+
cache.set(f"docs:no-websocket:{document.id}", "other_session_key")
212+
213+
response = client.get(
214+
f"/api/v1.0/documents/{document.id!s}/can-edit/",
215+
)
216+
assert response.status_code == 200
217+
assert response.json() == {"can_edit": False}
218+
219+
assert cache.get(f"docs:no-websocket:{document.id}") == "other_session_key"
220+
assert ws_resp.call_count == 1

src/backend/core/tests/documents/test_api_documents_retrieve.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ def test_api_documents_retrieve_anonymous_public_standalone():
3131
"ai_transform": False,
3232
"ai_translate": False,
3333
"attachment_upload": document.link_role == "editor",
34+
"can_edit": document.link_role == "editor",
3435
"children_create": False,
3536
"children_list": True,
3637
"collaboration_auth": True,
@@ -99,6 +100,7 @@ def test_api_documents_retrieve_anonymous_public_parent():
99100
"ai_transform": False,
100101
"ai_translate": False,
101102
"attachment_upload": grand_parent.link_role == "editor",
103+
"can_edit": grand_parent.link_role == "editor",
102104
"children_create": False,
103105
"children_list": True,
104106
"collaboration_auth": True,
@@ -196,6 +198,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
196198
"ai_transform": document.link_role == "editor",
197199
"ai_translate": document.link_role == "editor",
198200
"attachment_upload": document.link_role == "editor",
201+
"can_edit": document.link_role == "editor",
199202
"children_create": document.link_role == "editor",
200203
"children_list": True,
201204
"collaboration_auth": True,
@@ -271,6 +274,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
271274
"ai_transform": grand_parent.link_role == "editor",
272275
"ai_translate": grand_parent.link_role == "editor",
273276
"attachment_upload": grand_parent.link_role == "editor",
277+
"can_edit": grand_parent.link_role == "editor",
274278
"children_create": grand_parent.link_role == "editor",
275279
"children_list": True,
276280
"collaboration_auth": True,
@@ -452,6 +456,7 @@ def test_api_documents_retrieve_authenticated_related_parent():
452456
"ai_transform": access.role != "reader",
453457
"ai_translate": access.role != "reader",
454458
"attachment_upload": access.role != "reader",
459+
"can_edit": access.role != "reader",
455460
"children_create": access.role != "reader",
456461
"children_list": True,
457462
"collaboration_auth": True,

src/backend/core/tests/documents/test_api_documents_trashbin.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ def test_api_documents_trashbin_format():
7575
"ai_transform": True,
7676
"ai_translate": True,
7777
"attachment_upload": True,
78+
"can_edit": True,
7879
"children_create": True,
7980
"children_list": True,
8081
"collaboration_auth": True,

0 commit comments

Comments
 (0)