From 319383d8ff170b9871dfc75f3cb7f9130efed9bb Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Wed, 23 Sep 2026 11:35:03 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A9(backend)=20add=20DUPLICATE=5FCHILD?= =?UTF-8?q?REN=5FFEATURE=5FENABLED=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We want to introduce a feature flag that allows duplicating documents along with their children when enabled. We will be able to control not only frontend side but also backend behavior regarding document with children duplication. --- documentation/env.md | 1 + src/backend/core/api/viewsets.py | 6 ++- .../documents/test_api_documents_duplicate.py | 49 +++++++++++++++++++ src/backend/core/tests/test_api_config.py | 2 + src/backend/impress/settings.py | 7 +++ .../e2e/__tests__/app-impress/utils-common.ts | 1 + .../impress/src/core/config/api/useConfig.tsx | 1 + .../doc-management/components/DocToolBox.tsx | 6 ++- .../components/__tests__/DocToolBox.spec.tsx | 42 ++++++++++++++-- 9 files changed, 110 insertions(+), 5 deletions(-) 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 });