diff --git a/CHANGELOG.md b/CHANGELOG.md index 536cf53bd..aaa498e95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index cb5b10e03..d8a2efce4 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -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.""" diff --git a/src/backend/core/tests/documents/test_api_documents_favorite_list.py b/src/backend/core/tests/documents/test_api_documents_favorite_list.py index eb9bdefce..e28e0f777 100644 --- a/src/backend/core/tests/documents/test_api_documents_favorite_list.py +++ b/src/backend/core/tests/documents/test_api_documents_favorite_list.py @@ -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), 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 48c6911ff..5698b37ae 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -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."] diff --git a/src/backend/core/tests/documents/test_api_documents_update.py b/src/backend/core/tests/documents/test_api_documents_update.py index 1e25ac520..27ac8cc3c 100644 --- a/src/backend/core/tests/documents/test_api_documents_update.py +++ b/src/backend/core/tests/documents/test_api_documents_update.py @@ -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): """