🚩(backend) add DUPLICATE_CHILDREN_FEATURE_ENABLED flag

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.
This commit is contained in:
Anthony LC
2026-09-25 14:06:23 +02:00
parent f0d718fa3e
commit 319383d8ff
9 changed files with 110 additions and 5 deletions
+1
View File
@@ -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 |
+5 -1
View File
@@ -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",
@@ -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)
@@ -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,
+7
View File
@@ -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(
@@ -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,
@@ -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;
@@ -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);
@@ -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('<DocToolBox /> - 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(<DocToolBox doc={doc} isCurrentDoc />, { wrapper: AppWrapper });
@@ -117,8 +125,11 @@ describe('<DocToolBox /> - 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(<DocToolBox doc={doc} isCurrentDoc />, { wrapper: AppWrapper });
@@ -136,8 +147,33 @@ describe('<DocToolBox /> - 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(<DocToolBox doc={doc} isCurrentDoc />, { 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(<DocToolBox doc={doc} isCurrentDoc />, { wrapper: AppWrapper });