diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 9253a1cfb..1cd693a3c 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -492,6 +492,15 @@ class ServerCreateDocumentSerializer(serializers.Serializer): ) posthog_capture(PosthogEventName.DOC_CREATED, user, {}, document=document) + posthog_capture( + PosthogEventName.DOC_IMPORTED, + user, + { + "content_type": mime_types.MARKDOWN, + "create_for_owner": True, + }, + document=document, + ) if user: # Associate the document with the pre-existing user diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index ca884be6a..0f2963653 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -755,6 +755,12 @@ class DocumentViewSet( serializer.validated_data["content"] = converted_content serializer.validated_data["title"] = uploaded_file.name logger.info("conversion ended successfully") + + posthog_capture( + PosthogEventName.DOC_IMPORTED, + self.request.user, + {"content_type": uploaded_file.content_type}, + ) except ConversionError as err: logger.error("could not convert file content with error: %s", err) raise drf.exceptions.ValidationError( diff --git a/src/backend/core/tests/documents/test_api_documents_children_create.py b/src/backend/core/tests/documents/test_api_documents_children_create.py index 9d98b5472..355e9236c 100644 --- a/src/backend/core/tests/documents/test_api_documents_children_create.py +++ b/src/backend/core/tests/documents/test_api_documents_children_create.py @@ -330,13 +330,14 @@ def test_api_documents_children_create_with_docx_file_success(mock_convert, sett parent = factories.DocumentFactory(creator=user, users=[(user, "owner")]) - response = client.post( - f"/api/v1.0/documents/{parent.id}/children/", - { - "file": file, - }, - format="multipart", - ) + with mock.patch("core.api.viewsets.posthog_capture") as mock_capture: + response = client.post( + f"/api/v1.0/documents/{parent.id}/children/", + { + "file": file, + }, + format="multipart", + ) assert response.status_code == 201 assert Document.objects.count() == 2 @@ -351,6 +352,13 @@ def test_api_documents_children_create_with_docx_file_success(mock_convert, sett accept=mime_types.YJS, ) + # The successful conversion should be tracked in PostHog + mock_capture.assert_any_call( + PosthogEventName.DOC_IMPORTED, + user, + {"content_type": mime_types.DOCX}, + ) + @patch("core.services.converter_services.Converter.convert") def test_api_documents_children_create_with_docx_file_disabled(mock_convert, settings): @@ -370,13 +378,14 @@ def test_api_documents_children_create_with_docx_file_disabled(mock_convert, set parent = factories.DocumentFactory(creator=user, users=[(user, "owner")]) - response = client.post( - f"/api/v1.0/documents/{parent.id}/children/", - { - "file": file, - }, - format="multipart", - ) + with mock.patch("core.api.viewsets.posthog_capture") as mock_capture: + response = client.post( + f"/api/v1.0/documents/{parent.id}/children/", + { + "file": file, + }, + format="multipart", + ) assert response.status_code == 400 assert response.json() == {"file": ["file upload is not allowed"]} @@ -384,6 +393,9 @@ def test_api_documents_children_create_with_docx_file_disabled(mock_convert, set # Verify the converter was not called mock_convert.assert_not_called() + # No event should be tracked since the upload is rejected + mock_capture.assert_not_called() + def test_api_documents_children_create_with_file_max_size_exceeded(settings): """ diff --git a/src/backend/core/tests/documents/test_api_documents_create_for_owner.py b/src/backend/core/tests/documents/test_api_documents_create_for_owner.py index 09d021ce7..cf02674c2 100644 --- a/src/backend/core/tests/documents/test_api_documents_create_for_owner.py +++ b/src/backend/core/tests/documents/test_api_documents_create_for_owner.py @@ -207,12 +207,23 @@ def test_api_documents_create_for_owner_existing(mock_convert_md): assert document.creator == user assert document.accesses.filter(user=user, role="owner").exists() - mock_capture.assert_called_once_with( + mock_capture.assert_any_call( PosthogEventName.DOC_CREATED, user, {}, document=document, ) + mock_capture.assert_any_call( + PosthogEventName.DOC_IMPORTED, + user, + { + "content_type": mime_types.MARKDOWN, + "create_for_owner": True, + }, + document=document, + ) + + assert mock_capture.call_count == 2 assert Invitation.objects.exists() is False @@ -262,12 +273,23 @@ def test_api_documents_create_for_owner_new_user(mock_convert_md): assert document.creator is None assert document.accesses.exists() is False - mock_capture.assert_called_once_with( + mock_capture.assert_any_call( PosthogEventName.DOC_CREATED, None, {}, document=document, ) + mock_capture.assert_any_call( + PosthogEventName.DOC_IMPORTED, + None, + { + "content_type": mime_types.MARKDOWN, + "create_for_owner": True, + }, + document=document, + ) + + assert mock_capture.call_count == 2 invitation = Invitation.objects.get() assert invitation.email == "john.doe@example.com" diff --git a/src/backend/core/tests/documents/test_api_documents_create_with_file.py b/src/backend/core/tests/documents/test_api_documents_create_with_file.py index 185bc2620..ecafeb028 100644 --- a/src/backend/core/tests/documents/test_api_documents_create_with_file.py +++ b/src/backend/core/tests/documents/test_api_documents_create_with_file.py @@ -16,6 +16,7 @@ from core.services.converter_services import ( ConversionError, ServiceUnavailableError, ) +from core.utils.analytics import PosthogEventName pytestmark = pytest.mark.django_db @@ -60,13 +61,14 @@ def test_api_documents_create_with_docx_file_success(mock_convert, settings): file = BytesIO(file_content) file.name = "My Important Document.docx" - response = client.post( - "/api/v1.0/documents/", - { - "file": file, - }, - format="multipart", - ) + with patch("core.api.viewsets.posthog_capture") as mock_capture: + response = client.post( + "/api/v1.0/documents/", + { + "file": file, + }, + format="multipart", + ) assert response.status_code == 201 document = Document.objects.get() @@ -81,6 +83,21 @@ def test_api_documents_create_with_docx_file_success(mock_convert, settings): accept=mime_types.YJS, ) + # The successful conversion should be tracked in PostHog + mock_capture.assert_any_call( + PosthogEventName.DOC_IMPORTED, + user, + {"content_type": mime_types.DOCX}, + ) + mock_capture.assert_any_call( + PosthogEventName.DOC_CREATED, + user, + {}, + document=document, + ) + + assert mock_capture.call_count == 2 + @patch("core.services.converter_services.Converter.convert") def test_api_documents_create_with_docx_file_disabled(mock_convert, settings): @@ -98,13 +115,14 @@ def test_api_documents_create_with_docx_file_disabled(mock_convert, settings): file = BytesIO(file_content) file.name = "My Important Document.docx" - response = client.post( - "/api/v1.0/documents/", - { - "file": file, - }, - format="multipart", - ) + with patch("core.api.viewsets.posthog_capture") as mock_capture: + response = client.post( + "/api/v1.0/documents/", + { + "file": file, + }, + format="multipart", + ) assert response.status_code == 400 assert response.json() == {"file": ["file upload is not allowed"]} @@ -112,6 +130,9 @@ def test_api_documents_create_with_docx_file_disabled(mock_convert, settings): # Verify the converter was not called mock_convert.assert_not_called() + # No event should be tracked since the upload is rejected + mock_capture.assert_not_called() + @patch("core.services.converter_services.Converter.convert") def test_api_documents_create_with_markdown_file_success(mock_convert, settings): @@ -133,13 +154,14 @@ def test_api_documents_create_with_markdown_file_success(mock_convert, settings) file = BytesIO(file_content) file.name = "readme.md" - response = client.post( - "/api/v1.0/documents/", - { - "file": file, - }, - format="multipart", - ) + with patch("core.api.viewsets.posthog_capture") as mock_capture: + response = client.post( + "/api/v1.0/documents/", + { + "file": file, + }, + format="multipart", + ) assert response.status_code == 201 document = Document.objects.get() @@ -154,6 +176,21 @@ def test_api_documents_create_with_markdown_file_success(mock_convert, settings) accept=mime_types.YJS, ) + # The successful conversion should be tracked in PostHog + mock_capture.assert_any_call( + PosthogEventName.DOC_IMPORTED, + user, + {"content_type": mime_types.MARKDOWN}, + ) + mock_capture.assert_any_call( + PosthogEventName.DOC_CREATED, + user, + {}, + document=document, + ) + + assert mock_capture.call_count == 2 + @patch("core.services.converter_services.Converter.convert") def test_api_documents_create_with_file_and_explicit_title(mock_convert, settings): @@ -175,20 +212,36 @@ def test_api_documents_create_with_file_and_explicit_title(mock_convert, setting file = BytesIO(file_content) file.name = "Uploaded Document.docx" - response = client.post( - "/api/v1.0/documents/", - { - "file": file, - "title": "This should be overridden", - }, - format="multipart", - ) + with patch("core.api.viewsets.posthog_capture") as mock_capture: + response = client.post( + "/api/v1.0/documents/", + { + "file": file, + "title": "This should be overridden", + }, + format="multipart", + ) assert response.status_code == 201 document = Document.objects.get() # The filename should take precedence assert document.title == "Uploaded Document.docx" + # The successful conversion should be tracked in PostHog + mock_capture.assert_any_call( + PosthogEventName.DOC_IMPORTED, + user, + {"content_type": mime_types.DOCX}, + ) + mock_capture.assert_any_call( + PosthogEventName.DOC_CREATED, + user, + {}, + document=document, + ) + + assert mock_capture.call_count == 2 + def test_api_documents_create_with_empty_file(settings): """ @@ -204,18 +257,21 @@ def test_api_documents_create_with_empty_file(settings): file = BytesIO(b"") file.name = "empty.docx" - response = client.post( - "/api/v1.0/documents/", - { - "file": file, - }, - format="multipart", - ) + with patch("core.api.viewsets.posthog_capture") as mock_capture: + response = client.post( + "/api/v1.0/documents/", + { + "file": file, + }, + format="multipart", + ) assert response.status_code == 400 assert response.json() == {"file": ["The submitted file is empty."]} assert not Document.objects.exists() + mock_capture.assert_not_called() + @patch("core.services.converter_services.Converter.convert") def test_api_documents_create_with_file_conversion_error(mock_convert, settings): @@ -236,18 +292,22 @@ def test_api_documents_create_with_file_conversion_error(mock_convert, settings) file = BytesIO(file_content) file.name = "corrupted.docx" - response = client.post( - "/api/v1.0/documents/", - { - "file": file, - }, - format="multipart", - ) + with patch("core.api.viewsets.posthog_capture") as mock_capture: + response = client.post( + "/api/v1.0/documents/", + { + "file": file, + }, + format="multipart", + ) assert response.status_code == 400 assert response.json() == {"file": ["Could not convert file content"]} assert not Document.objects.exists() + # No event should be tracked when the conversion fails + mock_capture.assert_not_called() + @patch("core.services.converter_services.Converter.convert") def test_api_documents_create_with_file_service_unavailable(mock_convert, settings): @@ -270,18 +330,22 @@ def test_api_documents_create_with_file_service_unavailable(mock_convert, settin file = BytesIO(file_content) file.name = "document.docx" - response = client.post( - "/api/v1.0/documents/", - { - "file": file, - }, - format="multipart", - ) + with patch("core.api.viewsets.posthog_capture") as mock_capture: + response = client.post( + "/api/v1.0/documents/", + { + "file": file, + }, + format="multipart", + ) assert response.status_code == 400 assert response.json() == {"file": ["Could not convert file content"]} assert not Document.objects.exists() + # No event should be tracked when the conversion service is unavailable + mock_capture.assert_not_called() + def test_api_documents_create_without_file_still_works(): """ @@ -291,13 +355,14 @@ def test_api_documents_create_without_file_still_works(): client = APIClient() client.force_login(user) - response = client.post( - "/api/v1.0/documents/", - { - "title": "Regular document without file", - }, - format="json", - ) + with patch("core.api.viewsets.posthog_capture") as mock_capture: + response = client.post( + "/api/v1.0/documents/", + { + "title": "Regular document without file", + }, + format="json", + ) assert response.status_code == 201 document = Document.objects.get() @@ -305,6 +370,13 @@ def test_api_documents_create_without_file_still_works(): assert document.content is None assert document.accesses.filter(role="owner", user=user).exists() + mock_capture.assert_called_once_with( + PosthogEventName.DOC_CREATED, + user, + {}, + document=document, + ) + @patch("core.services.converter_services.Converter.convert") def test_api_documents_create_with_file_null_value(mock_convert, settings): @@ -317,20 +389,27 @@ def test_api_documents_create_with_file_null_value(mock_convert, settings): settings.CONVERSION_UPLOAD_ENABLED = True - response = client.post( - "/api/v1.0/documents/", - { - "title": "Document with null file", - "file": None, - }, - format="json", - ) + with patch("core.api.viewsets.posthog_capture") as mock_capture: + response = client.post( + "/api/v1.0/documents/", + { + "title": "Document with null file", + "file": None, + }, + format="json", + ) assert response.status_code == 201 document = Document.objects.get() assert document.title == "Document with null file" # Converter should not have been called mock_convert.assert_not_called() + mock_capture.assert_called_once_with( + PosthogEventName.DOC_CREATED, + user, + {}, + document=document, + ) @patch("core.services.converter_services.Converter.convert") @@ -355,13 +434,14 @@ def test_api_documents_create_with_file_preserves_content_format( file = BytesIO(file_content) file.name = "complex_document.docx" - response = client.post( - "/api/v1.0/documents/", - { - "file": file, - }, - format="multipart", - ) + with patch("core.api.viewsets.posthog_capture") as mock_capture: + response = client.post( + "/api/v1.0/documents/", + { + "file": file, + }, + format="multipart", + ) assert response.status_code == 201 document = Document.objects.get() @@ -369,6 +449,21 @@ def test_api_documents_create_with_file_preserves_content_format( # Verify the content is stored as returned by the converter assert document.content == converted_yjs + # The successful conversion should be tracked in PostHog + mock_capture.assert_any_call( + PosthogEventName.DOC_IMPORTED, + user, + {"content_type": mime_types.DOCX}, + ) + mock_capture.assert_any_call( + PosthogEventName.DOC_CREATED, + user, + {}, + document=document, + ) + + assert mock_capture.call_count == 2 + # Verify it's valid base64 (can be decoded) try: b64decode(converted_yjs) @@ -396,18 +491,34 @@ def test_api_documents_create_with_file_unicode_filename(mock_convert, settings) file = BytesIO(file_content) file.name = "文档-télécharger-документ.docx" - response = client.post( - "/api/v1.0/documents/", - { - "file": file, - }, - format="multipart", - ) + with patch("core.api.viewsets.posthog_capture") as mock_capture: + response = client.post( + "/api/v1.0/documents/", + { + "file": file, + }, + format="multipart", + ) assert response.status_code == 201 document = Document.objects.get() assert document.title == "文档-télécharger-документ.docx" + # The successful conversion should be tracked in PostHog + mock_capture.assert_any_call( + PosthogEventName.DOC_IMPORTED, + user, + {"content_type": mime_types.DOCX}, + ) + mock_capture.assert_any_call( + PosthogEventName.DOC_CREATED, + user, + {}, + document=document, + ) + + assert mock_capture.call_count == 2 + def test_api_documents_create_with_file_max_size_exceeded(settings): """ @@ -423,17 +534,19 @@ def test_api_documents_create_with_file_max_size_exceeded(settings): file = BytesIO(b"a" * (10)) file.name = "test.docx" - response = client.post( - "/api/v1.0/documents/", - { - "file": file, - }, - format="multipart", - ) + with patch("core.api.viewsets.posthog_capture") as mock_capture: + response = client.post( + "/api/v1.0/documents/", + { + "file": file, + }, + format="multipart", + ) assert response.status_code == 400 assert response.json() == {"file": ["File size exceeds the maximum limit of 0 MB."]} + mock_capture.assert_not_called() def test_api_documents_create_with_file_extension_not_allowed(settings): @@ -450,13 +563,14 @@ def test_api_documents_create_with_file_extension_not_allowed(settings): file = BytesIO(b"fake docx content") file.name = "test.md" - response = client.post( - "/api/v1.0/documents/", - { - "file": file, - }, - format="multipart", - ) + with patch("core.api.viewsets.posthog_capture") as mock_capture: + response = client.post( + "/api/v1.0/documents/", + { + "file": file, + }, + format="multipart", + ) assert response.status_code == 400 assert response.json() == { @@ -464,3 +578,5 @@ def test_api_documents_create_with_file_extension_not_allowed(settings): "File extension .md is not allowed. Allowed extensions are: ['.docx']." ] } + + mock_capture.assert_not_called() diff --git a/src/backend/core/tests/external_api/test_external_api_documents.py b/src/backend/core/tests/external_api/test_external_api_documents.py index 036a87c81..66e0bbe7f 100644 --- a/src/backend/core/tests/external_api/test_external_api_documents.py +++ b/src/backend/core/tests/external_api/test_external_api_documents.py @@ -18,6 +18,7 @@ from rest_framework.test import APIClient from core import factories, models from core.services import mime_types +from core.utils.analytics import PosthogEventName pytestmark = pytest.mark.django_db @@ -283,13 +284,14 @@ def test_external_api_documents_create_with_markdown_file_success( file = BytesIO(file_content) file.name = "readme.md" - response = client.post( - "/external_api/v1.0/documents/", - { - "file": file, - }, - format="multipart", - ) + with patch("core.api.viewsets.posthog_capture") as mock_capture: + response = client.post( + "/external_api/v1.0/documents/", + { + "file": file, + }, + format="multipart", + ) assert response.status_code == 201 @@ -307,6 +309,13 @@ def test_external_api_documents_create_with_markdown_file_success( accept=mime_types.YJS, ) + # The successful conversion should be tracked in PostHog + mock_capture.assert_any_call( + PosthogEventName.DOC_IMPORTED, + user_specific_sub, + {"content_type": mime_types.MARKDOWN}, + ) + def test_external_api_documents_list_with_multiple_roles( user_token, resource_server_backend, user_specific_sub diff --git a/src/backend/core/utils/analytics.py b/src/backend/core/utils/analytics.py index f40b59ddf..577369506 100644 --- a/src/backend/core/utils/analytics.py +++ b/src/backend/core/utils/analytics.py @@ -17,6 +17,7 @@ class PosthogEventName(StrEnum): DOC_CREATED = "doc_created" DOC_DELETED = "doc_deleted" DOC_DUPLICATED = "doc_duplicated" + DOC_IMPORTED = "doc_imported" USER_LOGIN = "user_login"