diff --git a/documentation/env.md b/documentation/env.md index 85befe7d6..c66e84a7b 100644 --- a/documentation/env.md +++ b/documentation/env.md @@ -86,6 +86,7 @@ These are the environment variables you can set for the `impress-backend` contai | DOCUMENT_IMAGE_MAX_SIZE | Maximum size of document in bytes | 10485760 | | DOCUMENT_ALL_ENDPOINT_ENABLED | Enable or not the endpoint /api/v1.0/documents/all/ | true | | DOCUMENT_NB_ACCESSES_CACHE_TIMEOUT | Time, in seconds, the number of accesses for a document stay in cache. | 600 | +| DUPLICATE_CHILDREN_FEATURE_ENABLED | Allow duplicating a document together with its children | true | | FRONTEND_CSS_URL | To add a external css file to the app | | | FRONTEND_JS_URL | To add a external js file to the app | | | FRONTEND_HOMEPAGE_FEATURE_ENABLED | Frontend feature flag to display the homepage | false | diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 8a452936e..6ec050c4f 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1429,7 +1429,10 @@ class DocumentViewSet( if `with_descendants` is set to true. """ with_accesses = serializer.validated_data.get("with_accesses", False) - with_descendants = serializer.validated_data.get("with_descendants", False) + with_descendants = ( + serializer.validated_data.get("with_descendants", False) + and settings.DUPLICATE_CHILDREN_FEATURE_ENABLED + ) user_role = document_to_duplicate.get_role(user) is_owner_or_admin = user_role in models.PRIVILEGED_ROLES @@ -2977,6 +2980,7 @@ class ConfigView(drf.views.APIView): "CONVERSION_FILE_MAX_SIZE", "CONVERSION_UPLOAD_ENABLED", "DOCUMENT_IMAGE_MAX_SIZE", + "DUPLICATE_CHILDREN_FEATURE_ENABLED", "ENVIRONMENT", "FRONTEND_CSS_URL", "FRONTEND_HOMEPAGE_FEATURE_ENABLED", diff --git a/src/backend/core/tests/documents/test_api_documents_duplicate.py b/src/backend/core/tests/documents/test_api_documents_duplicate.py index 26c5582eb..b950044f7 100644 --- a/src/backend/core/tests/documents/test_api_documents_duplicate.py +++ b/src/backend/core/tests/documents/test_api_documents_duplicate.py @@ -810,6 +810,55 @@ def test_api_documents_duplicate_without_descendants_should_not_duplicate_childr assert duplicated_root.get_children().count() == 0 +def test_api_documents_duplicate_with_descendants_disabled_by_feature_flag(settings): + """ + When DUPLICATE_CHILDREN_FEATURE_ENABLED is off, requesting with_descendants=True + should be ignored server-side and children should not be duplicated, regardless + of what the client sends. + """ + settings.DUPLICATE_CHILDREN_FEATURE_ENABLED = False + + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # Create document tree + root = factories.DocumentFactory( + users=[(user, "owner")], + title="Root", + ) + # child + factories.DocumentFactory( + parent=root, + title="Child", + ) + + initial_count = models.Document.objects.count() + assert initial_count == 2 + + # Duplicate requesting descendants while the feature is disabled + with mock.patch("core.api.viewsets.posthog_capture") as mock_capture: + response = client.post( + f"/api/v1.0/documents/{root.id!s}/duplicate/", + {"with_descendants": True}, + format="json", + ) + + assert response.status_code == 201 + duplicated_root = models.Document.objects.get(id=response.json()["id"]) + + mock_capture.assert_called_once_with( + "doc_duplicated", + user, + {"duplicated_from": str(root.id)}, + document=duplicated_root, + ) + + # Only root should be duplicated, not children + assert models.Document.objects.count() == 3 + assert duplicated_root.get_children().count() == 0 + + def test_api_documents_duplicate_with_descendants_preserves_link_configuration(): """ Duplicating with descendants should preserve link configuration (link_reach, link_role) diff --git a/src/backend/core/tests/test_api_config.py b/src/backend/core/tests/test_api_config.py index 40011477f..17f5320cb 100644 --- a/src/backend/core/tests/test_api_config.py +++ b/src/backend/core/tests/test_api_config.py @@ -29,6 +29,7 @@ pytestmark = pytest.mark.django_db COLLABORATION_WS_URL="http://testcollab/", COLLABORATION_WS_INACTIVITY_TIMEOUT=300, CONVERSION_UPLOAD_ENABLED=False, + DUPLICATE_CHILDREN_FEATURE_ENABLED=False, FRONTEND_CSS_URL="http://testcss/", FRONTEND_JS_URL="http://testjs/", FRONTEND_THEME="test-theme", @@ -64,6 +65,7 @@ def test_api_config(is_authenticated): "CONVERSION_FILE_MAX_SIZE": 20971520, "CONVERSION_UPLOAD_ENABLED": False, "DOCUMENT_IMAGE_MAX_SIZE": 10485760, + "DUPLICATE_CHILDREN_FEATURE_ENABLED": False, "ENVIRONMENT": "test", "FRONTEND_CSS_URL": "http://testcss/", "FRONTEND_HOMEPAGE_FEATURE_ENABLED": True, diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index bb78a41c2..7b2c9cf6a 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -211,6 +211,13 @@ class Base(Configuration): environ_prefix=None, ) + # Allow duplicating a document together with its children + DUPLICATE_CHILDREN_FEATURE_ENABLED = values.BooleanValue( + default=True, + environ_name="DUPLICATE_CHILDREN_FEATURE_ENABLED", + environ_prefix=None, + ) + DATA_UPLOAD_MAX_MEMORY_SIZE = values.IntegerValue(20 * MB) # 20 MB REACTIONS_MAX_PER_COMMENT = values.IntegerValue( diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts index d487b07ed..1aa3f624f 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts @@ -26,6 +26,7 @@ export const CONFIG = { CONVERSION_FILE_EXTENSIONS_ALLOWED: ['.docx', '.md'], CONVERSION_FILE_MAX_SIZE: 20971520, DOCUMENT_IMAGE_MAX_SIZE: 10485760, + DUPLICATE_CHILDREN_FEATURE_ENABLED: true, ENVIRONMENT: 'development', FRONTEND_CSS_URL: null, FRONTEND_JS_URL: null, diff --git a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx index 944d74e80..8f5bf9f59 100644 --- a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx +++ b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx @@ -56,6 +56,7 @@ export interface ConfigResponse { CONVERSION_FILE_MAX_SIZE: number; CONVERSION_UPLOAD_ENABLED?: boolean; DOCUMENT_IMAGE_MAX_SIZE?: number; + DUPLICATE_CHILDREN_FEATURE_ENABLED?: boolean; ENVIRONMENT: string; FRONTEND_CSS_URL?: string; FRONTEND_HOMEPAGE_FEATURE_ENABLED?: boolean; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/DocToolBox.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/DocToolBox.tsx index 7d1057041..9b597fa2d 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/components/DocToolBox.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/DocToolBox.tsx @@ -15,6 +15,7 @@ import { useTranslation } from 'react-i18next'; import { Box } from '@/components/Box'; import { Text } from '@/components/Text'; +import { useConfig } from '@/core/config/api'; import { useEditorStore } from '@/docs/doc-editor/stores/useEditorStore'; import { getWordCount } from '@/docs/doc-editor/utils'; import { printDocumentWithStyles } from '@/docs/doc-export/utils_print'; @@ -158,8 +159,11 @@ const DocToolBoxComponent = ({ const [isModalMoveOpen, setIsModalMoveOpen] = useState(false); const { onClick: onButtonClick, ...buttonPropsLeft } = buttonProps || {}; const { isFeatureFlagActivated } = useAnalytics(); + const { data: conf } = useConfig(); const duplicateWithChildrenAllowed = !!( - isFeatureFlagActivated(DUPLICATE_WITH_CHILDREN_FEATURE_FLAG) && doc.numchild + isFeatureFlagActivated(DUPLICATE_WITH_CHILDREN_FEATURE_FLAG) && + conf?.DUPLICATE_CHILDREN_FEATURE_ENABLED && + doc.numchild ); const editor = useEditorStore((state) => state.editor); diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/__tests__/DocToolBox.spec.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/__tests__/DocToolBox.spec.tsx index f6948580a..3ee1cdca7 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/components/__tests__/DocToolBox.spec.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/__tests__/DocToolBox.spec.tsx @@ -27,6 +27,11 @@ vi.mock('@/libs/Analytics', () => ({ }), })); +const useConfigMock = vi.fn(); +vi.mock('@/core/config/api', () => ({ + useConfig: () => useConfigMock(), +})); + const duplicateDocMock = vi.fn(); vi.mock('@/docs/doc-management/components/ConfirmationDuplicateModal', () => ({ useDuplicatedDoc: () => ({ @@ -101,8 +106,11 @@ const openDuplicateOption = async () => { }; describe(' - duplicate with children', () => { - test('opens the confirmation modal when the feature flag is active and the doc has children', async () => { + test('opens the confirmation modal when the posthog flag and the backend setting are both active and the doc has children', async () => { isFeatureFlagActivatedMock.mockReturnValue(true); + useConfigMock.mockReturnValue({ + data: { DUPLICATE_CHILDREN_FEATURE_ENABLED: true }, + }); const doc = createDoc({ numchild: 3 }); render(, { wrapper: AppWrapper }); @@ -117,8 +125,11 @@ describe(' - duplicate with children', () => { expect(duplicateDocMock).not.toHaveBeenCalled(); }); - test('duplicates directly when the feature flag is inactive, even if the doc has children', async () => { + test('duplicates directly when the posthog flag is inactive, even if the backend setting is active and the doc has children', async () => { isFeatureFlagActivatedMock.mockReturnValue(false); + useConfigMock.mockReturnValue({ + data: { DUPLICATE_CHILDREN_FEATURE_ENABLED: true }, + }); const doc = createDoc({ numchild: 3 }); render(, { wrapper: AppWrapper }); @@ -136,8 +147,33 @@ describe(' - duplicate with children', () => { ).not.toBeInTheDocument(); }); - test('duplicates directly when the feature flag is active but the doc has no children', async () => { + test('duplicates directly when the backend setting is inactive, even if the posthog flag is active and the doc has children', async () => { isFeatureFlagActivatedMock.mockReturnValue(true); + useConfigMock.mockReturnValue({ + data: { DUPLICATE_CHILDREN_FEATURE_ENABLED: false }, + }); + const doc = createDoc({ numchild: 3 }); + + render(, { wrapper: AppWrapper }); + + await openDuplicateOption(); + + await waitFor(() => { + expect(duplicateDocMock).toHaveBeenCalledWith({ + docId: doc.id, + canSave: doc.abilities.partial_update, + }); + }); + expect( + screen.queryByTestId('confirmation-duplicate-modal'), + ).not.toBeInTheDocument(); + }); + + test('duplicates directly when both flags are active but the doc has no children', async () => { + isFeatureFlagActivatedMock.mockReturnValue(true); + useConfigMock.mockReturnValue({ + data: { DUPLICATE_CHILDREN_FEATURE_ENABLED: true }, + }); const doc = createDoc({ numchild: 0 }); render(, { wrapper: AppWrapper });