🔥(backend) remove content in document responses

The content was always loaded in the document reponse. We remove this
behavior in order to not make an http call to the s3 storage. To get the
document content it is needed now to use the new endpoint dedicated to
retrive the document content.
This commit is contained in:
Manuel Raynaud
2026-04-27 15:06:57 +02:00
parent d7a186a98b
commit 51d4746435
5 changed files with 23 additions and 219 deletions
+1
View File
@@ -31,6 +31,7 @@ and this project adheres to
### Removed
- 🔥(backend) remove deprecated descendants endpoint #2243
- 🔥(backend) remove content in document responses
## [v4.8.6] - 2026-04-08
-68
View File
@@ -178,7 +178,6 @@ class DocumentLightSerializer(serializers.ModelSerializer):
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)
file = serializers.FileField(
required=False, write_only=True, allow_null=True, max_length=255
@@ -193,7 +192,6 @@ class DocumentSerializer(ListDocumentSerializer):
"ancestors_link_role",
"computed_link_reach",
"computed_link_role",
"content",
"created_at",
"creator",
"deleted_at",
@@ -242,13 +240,6 @@ class DocumentSerializer(ListDocumentSerializer):
if request:
if request.method == "POST":
fields["id"].read_only = False
if (
serializers.BooleanField().to_internal_value(
request.query_params.get("without_content", False)
)
is True
):
del fields["content"]
return fields
@@ -265,18 +256,6 @@ class DocumentSerializer(ListDocumentSerializer):
return value
def validate_content(self, value):
"""Validate the content field."""
if not value:
return None
try:
b64decode(value, validate=True)
except binascii.Error as err:
raise serializers.ValidationError("Invalid base64 content.") from err
return value
def validate_file(self, file):
"""Add file size and type constraints as defined in settings."""
if not file:
@@ -310,53 +289,6 @@ class DocumentSerializer(ListDocumentSerializer):
return instance # No data provided, skip the update
return super().update(instance, validated_data)
def save(self, **kwargs):
"""
Process the content field to extract attachment keys and update the document's
"attachments" field for access control.
"""
content = self.validated_data.get("content", "")
extracted_attachments = set(utils.extract_attachments(content))
existing_attachments = (
set(self.instance.attachments or []) if self.instance else set()
)
new_attachments = extracted_attachments - existing_attachments
if new_attachments:
attachments_documents = (
models.Document.objects.filter(
attachments__overlap=list(new_attachments)
)
.only("path", "attachments")
.order_by("path")
)
user = self.context["request"].user
readable_per_se_paths = (
models.Document.objects.readable_per_se(user)
.order_by("path")
.values_list("path", flat=True)
)
readable_attachments_paths = utils.filter_descendants(
[doc.path for doc in attachments_documents],
readable_per_se_paths,
skip_sorting=True,
)
readable_attachments = set()
for document in attachments_documents:
if document.path not in readable_attachments_paths:
continue
readable_attachments.update(set(document.attachments) & new_attachments)
# Update attachments with readable keys
self.validated_data["attachments"] = list(
existing_attachments | readable_attachments
)
return super().save(**kwargs)
class DocumentContentSerializer(serializers.Serializer):
"""Serializer for updating only the raw content of a document stored in S3."""
@@ -70,7 +70,6 @@ def test_api_document_favorite_list_authenticated_with_favorite():
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"deleted_at": None,
"content": document.content,
"depth": document.depth,
"excerpt": document.excerpt,
"id": str(document.id),
@@ -71,7 +71,6 @@ def test_api_documents_retrieve_anonymous_public_standalone():
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"deleted_at": None,
@@ -150,7 +149,6 @@ def test_api_documents_retrieve_anonymous_public_parent():
"ancestors_link_role": grand_parent.link_role,
"computed_link_reach": "public",
"computed_link_role": grand_parent.link_role,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"deleted_at": None,
@@ -262,7 +260,6 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 1,
@@ -348,7 +345,6 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea
"ancestors_link_role": grand_parent.link_role,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"depth": 3,
@@ -463,7 +459,6 @@ def test_api_documents_retrieve_authenticated_related_direct():
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"content": document.content,
"creator": str(document.creator.id),
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"deleted_at": None,
@@ -549,7 +544,6 @@ def test_api_documents_retrieve_authenticated_related_parent():
"ancestors_link_role": None,
"computed_link_reach": "restricted",
"computed_link_role": None,
"content": document.content,
"creator": str(document.creator.id),
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"depth": 3,
@@ -706,7 +700,6 @@ def test_api_documents_retrieve_authenticated_related_team_members(
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"deleted_at": None,
@@ -773,7 +766,6 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"deleted_at": None,
@@ -840,7 +832,6 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
"ancestors_link_role": None,
"computed_link_reach": document.computed_link_reach,
"computed_link_role": document.computed_link_role,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"creator": str(document.creator.id),
"deleted_at": None,
@@ -1072,48 +1063,3 @@ def test_api_documents_retrieve_permanently_deleted_related(role, depth):
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_api_documents_retrieve_without_content():
"""
Test retrieve using without_content query string should remove the content in the response
"""
user = factories.UserFactory()
document = factories.DocumentFactory(creator=user, users=[(user, "owner")])
client = APIClient()
client.force_login(user)
with mock.patch("core.models.Document.content") as mock_document_content:
response = client.get(
f"/api/v1.0/documents/{document.id!s}/?without_content=true"
)
assert response.status_code == 200
payload = response.json()
assert "content" not in payload
mock_document_content.assert_not_called()
def test_api_documents_retrieve_without_content_invalid_value():
"""
Test retrieve using without_content query string but an invalid value
should return a 400
"""
user = factories.UserFactory()
document = factories.DocumentFactory(creator=user, users=[(user, "owner")])
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/documents/{document.id!s}/?without_content=invalid-value"
)
assert response.status_code == 400
assert response.json() == ["Must be a valid boolean."]
@@ -19,25 +19,6 @@ from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
# A valid Yjs document derived from YDOC_HELLO_WORLD_BASE64 with "Hello" replaced by "World",
# used in PATCH tests to guarantee a real content change distinct from what DocumentFactory
# produces.
YDOC_UPDATED_CONTENT_BASE64 = (
"AR717vLVDgAHAQ5kb2N1bWVudC1zdG9yZQMKYmxvY2tHcm91cAcA9e7y1Q4AAw5ibG9ja0NvbnRh"
"aW5lcgcA9e7y1Q4BAwdoZWFkaW5nBwD17vLVDgIGBgD17vLVDgMGaXRhbGljAnt9hPXu8tUOBAVX"
"b3JsZIb17vLVDgkGaXRhbGljBG51bGwoAPXu8tUOAg10ZXh0QWxpZ25tZW50AXcEbGVmdCgA9e7y"
"1Q4CBWxldmVsAX0BKAD17vLVDgECaWQBdyQwNGQ2MjM0MS04MzI2LTQyMzYtYTA4My00ODdlMjZm"
"YWQyMzAoAPXu8tUOAQl0ZXh0Q29sb3IBdwdkZWZhdWx0KAD17vLVDgEPYmFja2dyb3VuZENvbG9y"
"AXcHZGVmYXVsdIf17vLVDgEDDmJsb2NrQ29udGFpbmVyBwD17vLVDhADDmJ1bGxldExpc3RJdGVt"
"BwD17vLVDhEGBAD17vLVDhIBd4b17vLVDhMEYm9sZAJ7fYT17vLVDhQCb3KG9e7y1Q4WBGJvbGQE"
"bnVsbIT17vLVDhcCbGQoAPXu8tUOEQ10ZXh0QWxpZ25tZW50AXcEbGVmdCgA9e7y1Q4QAmlkAXck"
"ZDM1MWUwNjgtM2U1NS00MjI2LThlYTUtYWJiMjYzMTk4ZTJhKAD17vLVDhAJdGV4dENvbG9yAXcH"
"ZGVmYXVsdCgA9e7y1Q4QD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHSH9e7y1Q4QAw5ibG9ja0Nv"
"bnRhaW5lcgcA9e7y1Q4eAwlwYXJhZ3JhcGgoAPXu8tUOHw10ZXh0QWxpZ25tZW50AXcEbGVmdCgA"
"9e7y1Q4eAmlkAXckODk3MDBjMDctZTBlMS00ZmUwLWFjYTItODQ5MzIwOWE3ZTQyKAD17vLVDh4J"
"dGV4dENvbG9yAXcHZGVmYXVsdCgA9e7y1Q4eD2JhY2tncm91bmRDb2xvcgF3B2RlZmF1bHQA"
)
@pytest.mark.parametrize("via_parent", [True, False])
@pytest.mark.parametrize(
@@ -736,25 +717,6 @@ def test_api_documents_update_administrator_or_owner_of_another(via, mock_user_t
assert other_document_values == old_document_values
def test_api_documents_update_invalid_content():
"""
Updating a document with a non base64 encoded content should raise a validation error.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[[user, "owner"]])
response = client.put(
f"/api/v1.0/documents/{document.id!s}/",
{"content": "invalid content"},
format="json",
)
assert response.status_code == 400
assert response.json() == {"content": ["Invalid base64 content."]}
# =============================================================================
# PATCH tests
# =============================================================================
@@ -784,11 +746,10 @@ def test_api_documents_patch_anonymous_forbidden(reach, role, via_parent):
document = factories.DocumentFactory(link_reach=reach, link_role=role)
old_document_values = serializers.DocumentSerializer(instance=document).data
new_content = YDOC_UPDATED_CONTENT_BASE64
response = APIClient().patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
{"title": "new title"},
format="json",
)
assert response.status_code == 401
@@ -828,11 +789,10 @@ def test_api_documents_patch_authenticated_unrelated_forbidden(reach, role, via_
document = factories.DocumentFactory(link_reach=reach, link_role=role)
old_document_values = serializers.DocumentSerializer(instance=document).data
new_content = YDOC_UPDATED_CONTENT_BASE64
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
{"title": "new title"},
format="json",
)
@@ -876,11 +836,10 @@ def test_api_documents_patch_anonymous_or_authenticated_unrelated(
old_document_values = serializers.DocumentSerializer(instance=document).data
old_path = document.path
new_content = YDOC_UPDATED_CONTENT_BASE64
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content, "websocket": True},
{"title": "new title", "websocket": True},
format="json",
)
assert response.status_code == 200
@@ -889,11 +848,10 @@ def test_api_documents_patch_anonymous_or_authenticated_unrelated(
# Force reloading it by fetching the document in the database.
document = models.Document.objects.get(id=document.id)
assert document.path == old_path
assert document.content == new_content
assert document.title == "new title"
document_values = serializers.DocumentSerializer(instance=document).data
for key in [
"id",
"title",
"link_reach",
"link_role",
"creator",
@@ -933,11 +891,10 @@ def test_api_documents_patch_authenticated_reader(via, via_parent, mock_user_tea
)
old_document_values = serializers.DocumentSerializer(instance=document).data
new_content = YDOC_UPDATED_CONTENT_BASE64
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
{"title": "new title"},
format="json",
)
@@ -983,11 +940,10 @@ def test_api_documents_patch_authenticated_editor_administrator_or_owner(
old_document_values = serializers.DocumentSerializer(instance=document).data
old_path = document.path
new_content = YDOC_UPDATED_CONTENT_BASE64
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content, "websocket": True},
{"title": "new title", "websocket": True},
format="json",
)
assert response.status_code == 200
@@ -996,11 +952,10 @@ def test_api_documents_patch_authenticated_editor_administrator_or_owner(
# Force reloading it by fetching the document in the database.
document = models.Document.objects.get(id=document.id)
assert document.path == old_path
assert document.content == new_content
assert document.title == "new title"
document_values = serializers.DocumentSerializer(instance=document).data
for key in [
"id",
"title",
"link_reach",
"link_role",
"creator",
@@ -1025,7 +980,6 @@ def test_api_documents_patch_authenticated_no_websocket(settings):
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_content = YDOC_UPDATED_CONTENT_BASE64
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
@@ -1041,7 +995,7 @@ def test_api_documents_patch_authenticated_no_websocket(settings):
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
{"title": "new title"},
format="json",
)
assert response.status_code == 200
@@ -1050,7 +1004,7 @@ def test_api_documents_patch_authenticated_no_websocket(settings):
# Force reloading it by fetching the document from the database.
document = models.Document.objects.get(id=document.id)
assert document.path == old_path
assert document.content == new_content
assert document.title == "new title"
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
assert ws_resp.call_count == 1
@@ -1067,7 +1021,6 @@ def test_api_documents_patch_authenticated_no_websocket_user_already_editing(set
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_content = YDOC_UPDATED_CONTENT_BASE64
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
@@ -1082,7 +1035,7 @@ def test_api_documents_patch_authenticated_no_websocket_user_already_editing(set
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
{"title": "new title"},
format="json",
)
assert response.status_code == 403
@@ -1103,7 +1056,6 @@ def test_api_documents_patch_no_websocket_other_user_connected_to_websocket(sett
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_content = YDOC_UPDATED_CONTENT_BASE64
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
@@ -1118,7 +1070,7 @@ def test_api_documents_patch_no_websocket_other_user_connected_to_websocket(sett
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
{"title": "new title"},
format="json",
)
assert response.status_code == 403
@@ -1139,7 +1091,6 @@ def test_api_documents_patch_user_connected_to_websocket(settings):
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_content = YDOC_UPDATED_CONTENT_BASE64
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
@@ -1155,7 +1106,7 @@ def test_api_documents_patch_user_connected_to_websocket(settings):
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
{"title": "new title"},
format="json",
)
assert response.status_code == 200
@@ -1164,7 +1115,7 @@ def test_api_documents_patch_user_connected_to_websocket(settings):
# Force reloading it by fetching the document in the database.
document = models.Document.objects.get(id=document.id)
assert document.path == old_path
assert document.content == new_content
assert document.title == "new title"
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 1
@@ -1183,7 +1134,6 @@ def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websock
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_content = YDOC_UPDATED_CONTENT_BASE64
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
@@ -1199,7 +1149,7 @@ def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websock
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
{"title": "new title"},
format="json",
)
assert response.status_code == 200
@@ -1208,7 +1158,7 @@ def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websock
# Force reloading it by fetching the document from the database.
document = models.Document.objects.get(id=document.id)
assert document.path == old_path
assert document.content == new_content
assert document.title == "new title"
assert cache.get(f"docs:no-websocket:{document.id}") == session_key
assert ws_resp.call_count == 1
@@ -1227,7 +1177,6 @@ def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websock
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_content = YDOC_UPDATED_CONTENT_BASE64
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
@@ -1242,7 +1191,7 @@ def test_api_documents_patch_websocket_server_unreachable_fallback_to_no_websock
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
{"title": "new title"},
format="json",
)
assert response.status_code == 403
@@ -1265,7 +1214,6 @@ def test_api_documents_patch_websocket_server_room_not_found_fallback_to_no_webs
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_content = YDOC_UPDATED_CONTENT_BASE64
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
@@ -1280,7 +1228,7 @@ def test_api_documents_patch_websocket_server_room_not_found_fallback_to_no_webs
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
{"title": "new title"},
format="json",
)
assert response.status_code == 403
@@ -1300,7 +1248,6 @@ def test_api_documents_patch_force_websocket_param_to_true(settings):
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_content = YDOC_UPDATED_CONTENT_BASE64
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
@@ -1315,7 +1262,7 @@ def test_api_documents_patch_force_websocket_param_to_true(settings):
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content, "websocket": True},
{"title": "new title", "websocket": True},
format="json",
)
assert response.status_code == 200
@@ -1324,7 +1271,7 @@ def test_api_documents_patch_force_websocket_param_to_true(settings):
# Force reloading it by fetching the document from the database.
document = models.Document.objects.get(id=document.id)
assert document.path == old_path
assert document.content == new_content
assert document.title == "new title"
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 0
@@ -1340,7 +1287,6 @@ def test_api_documents_patch_feature_flag_disabled(settings):
session_key = client.session.session_key
document = factories.DocumentFactory(users=[(user, "editor")])
new_content = YDOC_UPDATED_CONTENT_BASE64
settings.COLLABORATION_API_URL = "http://example.com/"
settings.COLLABORATION_SERVER_SECRET = "secret-token"
@@ -1356,7 +1302,7 @@ def test_api_documents_patch_feature_flag_disabled(settings):
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": new_content},
{"title": "new title"},
format="json",
)
assert response.status_code == 200
@@ -1365,7 +1311,7 @@ def test_api_documents_patch_feature_flag_disabled(settings):
# Force reloading it by fetching the document from the database.
document = models.Document.objects.get(id=document.id)
assert document.path == old_path
assert document.content == new_content
assert document.title == "new title"
assert cache.get(f"docs:no-websocket:{document.id}") is None
assert ws_resp.call_count == 0
@@ -1396,11 +1342,10 @@ def test_api_documents_patch_administrator_or_owner_of_another(via, mock_user_te
other_document = factories.DocumentFactory(title="Old title", link_role="reader")
old_document_values = serializers.DocumentSerializer(instance=other_document).data
new_content = YDOC_UPDATED_CONTENT_BASE64
response = client.patch(
f"/api/v1.0/documents/{other_document.id!s}/",
{"content": new_content},
{"title": "new title"},
format="json",
)
@@ -1413,25 +1358,6 @@ def test_api_documents_patch_administrator_or_owner_of_another(via, mock_user_te
)
def test_api_documents_patch_invalid_content():
"""
Patching a document with a non base64 encoded content should raise a validation error.
"""
user = factories.UserFactory(with_owned_document=True)
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[[user, "owner"]])
response = client.patch(
f"/api/v1.0/documents/{document.id!s}/",
{"content": "invalid content"},
format="json",
)
assert response.status_code == 400
assert response.json() == {"content": ["Invalid base64 content."]}
@responses.activate
def test_api_documents_patch_empty_body(settings):
"""