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 });