From 5bc52373697c00460d3c5a58d495b5413173e17e Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Sun, 6 Apr 2025 21:12:34 +0200 Subject: [PATCH 001/104] =?UTF-8?q?=F0=9F=90=9B(backend)=20fix=20link=20de?= =?UTF-8?q?finition=20select=20options=20linked=20to=20ancestors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We were returning too many select options for the restricted link reach: - when the "restricted" reach is an option (key present in the returned dictionary), the possible values for link roles are now always None to make it clearer that they don't matter and no select box should be shown for roles. - Never propose "restricted" as option for link reach when the ancestors already offer a public access. Indeed, restricted/editor was shown when the ancestors had public/read access. The logic was to propose editor role on more restricted reaches... but this does not make sense for restricted since the role does is not taken into account for this reach. Roles are set by each access line assign to users/teams. --- CHANGELOG.md | 4 ++ src/backend/core/models.py | 52 ++++++++++++------- .../documents/test_api_documents_retrieve.py | 4 +- .../documents/test_api_documents_trashbin.py | 2 +- .../core/tests/test_models_documents.py | 29 +++++------ 5 files changed, 51 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b0a03a39..3cf2ad327 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,10 @@ and this project adheres to - 🐛(backend) race condition create doc #633 - 🐛(frontend) fix breaklines in custom blocks #908 +## Fixed + +- 🐛(backend) fix link definition select options linked to ancestors #846 + ## [3.1.0] - 2025-04-07 ## Added diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 2c5239ead..f4e649956 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -87,49 +87,61 @@ def get_select_options(cls, ancestors_links): """ Determines the valid select options for link reach and link role depending on the list of ancestors' link reach/role. - Args: ancestors_links: List of dictionaries, each with 'link_reach' and 'link_role' keys representing the reach and role of ancestors links. - Returns: Dictionary mapping possible reach levels to their corresponding possible roles. """ # If no ancestors, return all options if not ancestors_links: - return dict.fromkeys(cls.values, LinkRoleChoices.values) + return { + reach: LinkRoleChoices.values if reach != cls.RESTRICTED else None + for reach in cls.values + } # Initialize result with all possible reaches and role options as sets - result = {reach: set(LinkRoleChoices.values) for reach in cls.values} + result = { + reach: set(LinkRoleChoices.values) if reach != cls.RESTRICTED else None + for reach in cls.values + } # Group roles by reach level reach_roles = defaultdict(set) for link in ancestors_links: reach_roles[link["link_reach"]].add(link["link_role"]) - # Apply constraints based on ancestor links - if LinkRoleChoices.EDITOR in reach_roles[cls.RESTRICTED]: - result[cls.RESTRICTED].discard(LinkRoleChoices.READER) + # Rule 1: public/editor → override everything + if LinkRoleChoices.EDITOR in reach_roles.get(cls.PUBLIC, set()): + return {cls.PUBLIC: [LinkRoleChoices.EDITOR]} - if LinkRoleChoices.EDITOR in reach_roles[cls.AUTHENTICATED]: + # Rule 2: public/reader + if LinkRoleChoices.READER in reach_roles.get(cls.PUBLIC, set()): + result.get(cls.AUTHENTICATED, set()).discard(LinkRoleChoices.READER) + result.pop(cls.RESTRICTED, None) + + # Rule 3: authenticated/editor + if LinkRoleChoices.EDITOR in reach_roles.get(cls.AUTHENTICATED, set()): result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER) result.pop(cls.RESTRICTED, None) - elif LinkRoleChoices.READER in reach_roles[cls.AUTHENTICATED]: - result[cls.RESTRICTED].discard(LinkRoleChoices.READER) - if LinkRoleChoices.EDITOR in reach_roles[cls.PUBLIC]: - result[cls.PUBLIC].discard(LinkRoleChoices.READER) - result.pop(cls.AUTHENTICATED, None) + # Rule 4: authenticated/reader + if LinkRoleChoices.READER in reach_roles.get(cls.AUTHENTICATED, set()): result.pop(cls.RESTRICTED, None) - elif LinkRoleChoices.READER in reach_roles[cls.PUBLIC]: - result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER) - result.get(cls.RESTRICTED, set()).discard(LinkRoleChoices.READER) - # Convert roles sets to lists while maintaining the order from LinkRoleChoices - for reach, roles in result.items(): - result[reach] = [role for role in LinkRoleChoices.values if role in roles] + # Clean up: remove empty entries and convert sets to ordered lists + cleaned = {} + for reach in cls.values: + if reach in result: + if result[reach]: + cleaned[reach] = [ + r for r in LinkRoleChoices.values if r in result[reach] + ] + else: + # Could be [] or None (for RESTRICTED reach) + cleaned[reach] = result[reach] - return result + return cleaned class DuplicateEmailError(Exception): diff --git a/src/backend/core/tests/documents/test_api_documents_retrieve.py b/src/backend/core/tests/documents/test_api_documents_retrieve.py index 38d66cd46..4dcc288d1 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -45,7 +45,7 @@ def test_api_documents_retrieve_anonymous_public_standalone(): "link_select_options": { "authenticated": ["reader", "editor"], "public": ["reader", "editor"], - "restricted": ["reader", "editor"], + "restricted": None, }, "media_auth": True, "move": False, @@ -207,7 +207,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "link_select_options": { "authenticated": ["reader", "editor"], "public": ["reader", "editor"], - "restricted": ["reader", "editor"], + "restricted": None, }, "media_auth": True, "move": False, diff --git a/src/backend/core/tests/documents/test_api_documents_trashbin.py b/src/backend/core/tests/documents/test_api_documents_trashbin.py index 6db898eaf..60b8ac620 100644 --- a/src/backend/core/tests/documents/test_api_documents_trashbin.py +++ b/src/backend/core/tests/documents/test_api_documents_trashbin.py @@ -88,7 +88,7 @@ def test_api_documents_trashbin_format(): "link_select_options": { "authenticated": ["reader", "editor"], "public": ["reader", "editor"], - "restricted": ["reader", "editor"], + "restricted": None, }, "media_auth": True, "move": False, # Can't move a deleted document diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index 6599b737a..24b686d5c 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -170,7 +170,7 @@ def test_models_documents_get_abilities_forbidden( "link_select_options": { "authenticated": ["reader", "editor"], "public": ["reader", "editor"], - "restricted": ["reader", "editor"], + "restricted": None, }, "partial_update": False, "restore": False, @@ -228,7 +228,7 @@ def test_models_documents_get_abilities_reader( "link_select_options": { "authenticated": ["reader", "editor"], "public": ["reader", "editor"], - "restricted": ["reader", "editor"], + "restricted": None, }, "media_auth": True, "move": False, @@ -290,7 +290,7 @@ def test_models_documents_get_abilities_editor( "link_select_options": { "authenticated": ["reader", "editor"], "public": ["reader", "editor"], - "restricted": ["reader", "editor"], + "restricted": None, }, "media_auth": True, "move": False, @@ -341,7 +341,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries): "link_select_options": { "authenticated": ["reader", "editor"], "public": ["reader", "editor"], - "restricted": ["reader", "editor"], + "restricted": None, }, "media_auth": True, "move": True, @@ -389,7 +389,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries) "link_select_options": { "authenticated": ["reader", "editor"], "public": ["reader", "editor"], - "restricted": ["reader", "editor"], + "restricted": None, }, "media_auth": True, "move": True, @@ -440,7 +440,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries): "link_select_options": { "authenticated": ["reader", "editor"], "public": ["reader", "editor"], - "restricted": ["reader", "editor"], + "restricted": None, }, "media_auth": True, "move": False, @@ -498,7 +498,7 @@ def test_models_documents_get_abilities_reader_user( "link_select_options": { "authenticated": ["reader", "editor"], "public": ["reader", "editor"], - "restricted": ["reader", "editor"], + "restricted": None, }, "media_auth": True, "move": False, @@ -554,7 +554,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries): "link_select_options": { "authenticated": ["reader", "editor"], "public": ["reader", "editor"], - "restricted": ["reader", "editor"], + "restricted": None, }, "media_auth": True, "move": False, @@ -1174,7 +1174,6 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): ( [{"link_reach": "public", "link_role": "reader"}], { - "restricted": ["editor"], "authenticated": ["editor"], "public": ["reader", "editor"], }, @@ -1183,7 +1182,6 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): ( [{"link_reach": "authenticated", "link_role": "reader"}], { - "restricted": ["editor"], "authenticated": ["reader", "editor"], "public": ["reader", "editor"], }, @@ -1195,7 +1193,7 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): ( [{"link_reach": "restricted", "link_role": "reader"}], { - "restricted": ["reader", "editor"], + "restricted": None, "authenticated": ["reader", "editor"], "public": ["reader", "editor"], }, @@ -1203,7 +1201,7 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): ( [{"link_reach": "restricted", "link_role": "editor"}], { - "restricted": ["editor"], + "restricted": None, "authenticated": ["reader", "editor"], "public": ["reader", "editor"], }, @@ -1229,7 +1227,7 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): {"link_reach": "restricted", "link_role": "editor"}, ], { - "restricted": ["editor"], + "restricted": None, "authenticated": ["reader", "editor"], "public": ["reader", "editor"], }, @@ -1241,7 +1239,6 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): {"link_reach": "public", "link_role": "reader"}, ], { - "restricted": ["editor"], "authenticated": ["editor"], "public": ["reader", "editor"], }, @@ -1253,7 +1250,6 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): {"link_reach": "public", "link_role": "reader"}, ], { - "restricted": ["editor"], "authenticated": ["editor"], "public": ["reader", "editor"], }, @@ -1279,7 +1275,6 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): {"link_reach": "authenticated", "link_role": "reader"}, ], { - "restricted": ["editor"], "authenticated": ["reader", "editor"], "public": ["reader", "editor"], }, @@ -1297,7 +1292,7 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): { "public": ["reader", "editor"], "authenticated": ["reader", "editor"], - "restricted": ["reader", "editor"], + "restricted": None, }, ), ], From f94356f4874b3aec63742df332ad7a094e3e2493 Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Sun, 6 Apr 2025 21:20:04 +0200 Subject: [PATCH 002/104] =?UTF-8?q?=E2=9C=A8(backend)=20add=20ancestors=20?= =?UTF-8?q?links=20definitions=20to=20document=20abilities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frontend needs to display inherited link accesses when it displays possible selection options. We need to return this information to the client. --- CHANGELOG.md | 1 + src/backend/core/models.py | 34 +++++++++++++------ .../documents/test_api_documents_retrieve.py | 12 +++++++ .../documents/test_api_documents_trashbin.py | 1 + .../core/tests/test_models_documents.py | 18 +++++++--- 5 files changed, 50 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cf2ad327..acac89010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ and this project adheres to ## Added +- ✨(backend) add ancestors links definitions to document abilities #846 - 🚸(backend) make document search on title accent-insensitive #874 - 🚩 add homepage feature flag #861 - 📝(doc) update contributing policy (commit signatures are now mandatory) #895 diff --git a/src/backend/core/models.py b/src/backend/core/models.py index f4e649956..9c855a2b2 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -749,17 +749,16 @@ def get_roles(self, user): roles = [] return roles - def get_links_definitions(self, ancestors_links): - """Get links reach/role definitions for the current document and its ancestors.""" + def get_ancestors_links_definitions(self, ancestors_links): + """Get links reach/role definitions for ancestors of the current document.""" - links_definitions = defaultdict(set) - links_definitions[self.link_reach].add(self.link_role) - - # Merge ancestor link definitions + ancestors_links_definitions = defaultdict(set) for ancestor in ancestors_links: - links_definitions[ancestor["link_reach"]].add(ancestor["link_role"]) + ancestors_links_definitions[ancestor["link_reach"]].add( + ancestor["link_role"] + ) - return dict(links_definitions) # Convert defaultdict back to a normal dict + return ancestors_links_definitions def compute_ancestors_links(self, user): """ @@ -815,10 +814,20 @@ def get_abilities(self, user, ancestors_links=None): ) and not is_deleted # Add roles provided by the document link, taking into account its ancestors - links_definitions = self.get_links_definitions(ancestors_links) - public_roles = links_definitions.get(LinkReachChoices.PUBLIC, set()) + ancestors_links_definitions = self.get_ancestors_links_definitions( + ancestors_links + ) + + public_roles = ancestors_links_definitions.get( + LinkReachChoices.PUBLIC, set() + ) | ({self.link_role} if self.link_reach == LinkReachChoices.PUBLIC else set()) authenticated_roles = ( - links_definitions.get(LinkReachChoices.AUTHENTICATED, set()) + ancestors_links_definitions.get(LinkReachChoices.AUTHENTICATED, set()) + | ( + {self.link_role} + if self.link_reach == LinkReachChoices.AUTHENTICATED + else set() + ) if user.is_authenticated else set() ) @@ -862,6 +871,9 @@ def get_abilities(self, user, ancestors_links=None): "restore": is_owner, "retrieve": can_get, "media_auth": can_get, + "ancestors_links_definitions": { + k: list(v) for k, v in ancestors_links_definitions.items() + }, "link_select_options": LinkReachChoices.get_select_options(ancestors_links), "tree": can_get, "update": can_update, diff --git a/src/backend/core/tests/documents/test_api_documents_retrieve.py b/src/backend/core/tests/documents/test_api_documents_retrieve.py index 4dcc288d1..88507147a 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -42,6 +42,7 @@ def test_api_documents_retrieve_anonymous_public_standalone(): "favorite": False, "invite_owner": False, "link_configuration": False, + "ancestors_links_definitions": {}, "link_select_options": { "authenticated": ["reader", "editor"], "public": ["reader", "editor"], @@ -97,6 +98,10 @@ def test_api_documents_retrieve_anonymous_public_parent(): "accesses_view": False, "ai_transform": False, "ai_translate": False, + "ancestors_links_definitions": { + "public": [grand_parent.link_role], + parent.link_reach: [parent.link_role], + }, "attachment_upload": grand_parent.link_role == "editor", "children_create": False, "children_list": True, @@ -193,6 +198,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "accesses_view": False, "ai_transform": document.link_role == "editor", "ai_translate": document.link_role == "editor", + "ancestors_links_definitions": {}, "attachment_upload": document.link_role == "editor", "children_create": document.link_role == "editor", "children_list": True, @@ -267,6 +273,10 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea "accesses_view": False, "ai_transform": grand_parent.link_role == "editor", "ai_translate": grand_parent.link_role == "editor", + "ancestors_links_definitions": { + grand_parent.link_reach: [grand_parent.link_role], + "restricted": [parent.link_role], + }, "attachment_upload": grand_parent.link_role == "editor", "children_create": grand_parent.link_role == "editor", "children_list": True, @@ -440,6 +450,7 @@ def test_api_documents_retrieve_authenticated_related_parent(): ) assert response.status_code == 200 links = document.get_ancestors().values("link_reach", "link_role") + ancestors_roles = list({grand_parent.link_role, parent.link_role}) assert response.json() == { "id": str(document.id), "abilities": { @@ -447,6 +458,7 @@ def test_api_documents_retrieve_authenticated_related_parent(): "accesses_view": True, "ai_transform": access.role != "reader", "ai_translate": access.role != "reader", + "ancestors_links_definitions": {"restricted": ancestors_roles}, "attachment_upload": access.role != "reader", "children_create": access.role != "reader", "children_list": True, diff --git a/src/backend/core/tests/documents/test_api_documents_trashbin.py b/src/backend/core/tests/documents/test_api_documents_trashbin.py index 60b8ac620..b48ba252b 100644 --- a/src/backend/core/tests/documents/test_api_documents_trashbin.py +++ b/src/backend/core/tests/documents/test_api_documents_trashbin.py @@ -74,6 +74,7 @@ def test_api_documents_trashbin_format(): "accesses_view": True, "ai_transform": True, "ai_translate": True, + "ancestors_links_definitions": {}, "attachment_upload": True, "children_create": True, "children_list": True, diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index 24b686d5c..e96d6858f 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -154,6 +154,7 @@ def test_models_documents_get_abilities_forbidden( "accesses_view": False, "ai_transform": False, "ai_translate": False, + "ancestors_links_definitions": {}, "attachment_upload": False, "children_create": False, "children_list": False, @@ -214,6 +215,7 @@ def test_models_documents_get_abilities_reader( "accesses_view": False, "ai_transform": False, "ai_translate": False, + "ancestors_links_definitions": {}, "attachment_upload": False, "children_create": False, "children_list": True, @@ -250,7 +252,7 @@ def test_models_documents_get_abilities_reader( assert all( value is False for key, value in document.get_abilities(user).items() - if key != "link_select_options" + if key not in ["link_select_options", "ancestors_links_definitions"] ) @@ -276,6 +278,7 @@ def test_models_documents_get_abilities_editor( "accesses_view": False, "ai_transform": is_authenticated, "ai_translate": is_authenticated, + "ancestors_links_definitions": {}, "attachment_upload": True, "children_create": is_authenticated, "children_list": True, @@ -311,7 +314,7 @@ def test_models_documents_get_abilities_editor( assert all( value is False for key, value in document.get_abilities(user).items() - if key != "link_select_options" + if key not in ["link_select_options", "ancestors_links_definitions"] ) @@ -327,6 +330,7 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries): "accesses_view": True, "ai_transform": True, "ai_translate": True, + "ancestors_links_definitions": {}, "attachment_upload": True, "children_create": True, "children_list": True, @@ -375,6 +379,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries) "accesses_view": True, "ai_transform": True, "ai_translate": True, + "ancestors_links_definitions": {}, "attachment_upload": True, "children_create": True, "children_list": True, @@ -410,7 +415,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries) assert all( value is False for key, value in document.get_abilities(user).items() - if key != "link_select_options" + if key not in ["link_select_options", "ancestors_links_definitions"] ) @@ -426,6 +431,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries): "accesses_view": True, "ai_transform": True, "ai_translate": True, + "ancestors_links_definitions": {}, "attachment_upload": True, "children_create": True, "children_list": True, @@ -461,7 +467,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries): assert all( value is False for key, value in document.get_abilities(user).items() - if key != "link_select_options" + if key not in ["link_select_options", "ancestors_links_definitions"] ) @@ -484,6 +490,7 @@ def test_models_documents_get_abilities_reader_user( # You should not access AI if it's restricted to users with specific access "ai_transform": access_from_link and ai_access_setting != "restricted", "ai_translate": access_from_link and ai_access_setting != "restricted", + "ancestors_links_definitions": {}, "attachment_upload": access_from_link, "children_create": access_from_link, "children_list": True, @@ -521,7 +528,7 @@ def test_models_documents_get_abilities_reader_user( assert all( value is False for key, value in document.get_abilities(user).items() - if key != "link_select_options" + if key not in ["link_select_options", "ancestors_links_definitions"] ) @@ -540,6 +547,7 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries): "accesses_view": True, "ai_transform": False, "ai_translate": False, + "ancestors_links_definitions": {}, "attachment_upload": False, "children_create": False, "children_list": True, From eda4e35f865906ba8e98a8ee7e9a8a80b4f4640b Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Fri, 11 Apr 2025 19:09:48 +0200 Subject: [PATCH 003/104] =?UTF-8?q?=E2=99=BB=EF=B8=8F(backend)=20remove=20?= =?UTF-8?q?different=20reach=20for=20authenticated=20and=20anonymous?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If anonymous users have reader access on a parent, we were considering that an edge use case was interesting: allowing an authenticated user to still be editor on the child. Although this use case could be interesting, we consider, as a first approach, that the value it carries is not big enough to justify the complexity for the user to understand this complex access right heritage. --- src/backend/core/models.py | 12 ++++++------ src/backend/core/tests/test_models_documents.py | 5 +---- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 9c855a2b2..d941992ab 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -115,16 +115,16 @@ def get_select_options(cls, ancestors_links): if LinkRoleChoices.EDITOR in reach_roles.get(cls.PUBLIC, set()): return {cls.PUBLIC: [LinkRoleChoices.EDITOR]} - # Rule 2: public/reader - if LinkRoleChoices.READER in reach_roles.get(cls.PUBLIC, set()): - result.get(cls.AUTHENTICATED, set()).discard(LinkRoleChoices.READER) - result.pop(cls.RESTRICTED, None) - - # Rule 3: authenticated/editor + # Rule 2: authenticated/editor if LinkRoleChoices.EDITOR in reach_roles.get(cls.AUTHENTICATED, set()): result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER) result.pop(cls.RESTRICTED, None) + # Rule 3: public/reader + if LinkRoleChoices.READER in reach_roles.get(cls.PUBLIC, set()): + result.pop(cls.AUTHENTICATED, None) + result.pop(cls.RESTRICTED, None) + # Rule 4: authenticated/reader if LinkRoleChoices.READER in reach_roles.get(cls.AUTHENTICATED, set()): result.pop(cls.RESTRICTED, None) diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index e96d6858f..2c5c59a0f 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -1182,7 +1182,6 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): ( [{"link_reach": "public", "link_role": "reader"}], { - "authenticated": ["editor"], "public": ["reader", "editor"], }, ), @@ -1247,7 +1246,6 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): {"link_reach": "public", "link_role": "reader"}, ], { - "authenticated": ["editor"], "public": ["reader", "editor"], }, ), @@ -1258,7 +1256,6 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): {"link_reach": "public", "link_role": "reader"}, ], { - "authenticated": ["editor"], "public": ["reader", "editor"], }, ), @@ -1268,7 +1265,7 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): {"link_reach": "authenticated", "link_role": "editor"}, {"link_reach": "public", "link_role": "reader"}, ], - {"authenticated": ["editor"], "public": ["reader", "editor"]}, + {"public": ["reader", "editor"]}, ), ( [ From 27e2db9341546551f61c881e5f95fe54287f9365 Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Sat, 12 Apr 2025 09:11:33 +0200 Subject: [PATCH 004/104] =?UTF-8?q?=E2=99=BB=EF=B8=8F(backend)=20refactor?= =?UTF-8?q?=20resource=20access=20viewset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The document viewset was overriding the get_queryset method from its own mixin. This was a sign that the mixin was not optimal anymore. In the next commit I will need to complexify it further so it's time to refactor the mixin. --- src/backend/core/api/viewsets.py | 110 ++++++++---------- .../documents/test_api_document_accesses.py | 30 ++--- .../templates/test_api_template_accesses.py | 11 +- 3 files changed, 59 insertions(+), 92 deletions(-) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 578c49d62..193a35465 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -219,14 +219,10 @@ def get_me(self, request): class ResourceAccessViewsetMixin: """Mixin with methods common to all access viewsets.""" - def get_permissions(self): - """User only needs to be authenticated to list resource accesses""" - if self.action == "list": - permission_classes = [permissions.IsAuthenticated] - else: - return super().get_permissions() - - return [permission() for permission in permission_classes] + def filter_queryset(self, queryset): + """Override to filter on related resource.""" + queryset = super().filter_queryset(queryset) + return queryset.filter(**{self.resource_field_name: self.kwargs["resource_id"]}) def get_serializer_context(self): """Extra context provided to the serializer class.""" @@ -234,43 +230,6 @@ def get_serializer_context(self): context["resource_id"] = self.kwargs["resource_id"] return context - def get_queryset(self): - """Return the queryset according to the action.""" - queryset = super().get_queryset() - queryset = queryset.filter( - **{self.resource_field_name: self.kwargs["resource_id"]} - ) - - if self.action == "list": - user = self.request.user - teams = user.teams - user_roles_query = ( - queryset.filter( - db.Q(user=user) | db.Q(team__in=teams), - **{self.resource_field_name: self.kwargs["resource_id"]}, - ) - .values(self.resource_field_name) - .annotate(roles_array=ArrayAgg("role")) - .values("roles_array") - ) - - # Limit to resource access instances related to a resource THAT also has - # a resource access - # instance for the logged-in user (we don't want to list only the resource - # access instances pointing to the logged-in user) - queryset = ( - queryset.filter( - db.Q(**{f"{self.resource_field_name}__accesses__user": user}) - | db.Q( - **{f"{self.resource_field_name}__accesses__team__in": teams} - ), - **{self.resource_field_name: self.kwargs["resource_id"]}, - ) - .annotate(user_roles=db.Subquery(user_roles_query)) - .distinct() - ) - return queryset - def destroy(self, request, *args, **kwargs): """Forbid deleting the last owner access""" instance = self.get_object() @@ -1413,7 +1372,11 @@ def cors_proxy(self, request, *args, **kwargs): class DocumentAccessViewSet( ResourceAccessViewsetMixin, - viewsets.ModelViewSet, + drf.mixins.CreateModelMixin, + drf.mixins.RetrieveModelMixin, + drf.mixins.UpdateModelMixin, + drf.mixins.DestroyModelMixin, + viewsets.GenericViewSet, ): """ API ViewSet for all interactions with document accesses. @@ -1440,31 +1403,35 @@ class DocumentAccessViewSet( """ lookup_field = "pk" - pagination_class = Pagination permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission] queryset = models.DocumentAccess.objects.select_related("user").all() resource_field_name = "document" serializer_class = serializers.DocumentAccessSerializer is_current_user_owner_or_admin = False - def get_queryset(self): - """Return the queryset according to the action.""" - queryset = super().get_queryset() + def list(self, request, *args, **kwargs): + """Return accesses for the current document with filters and annotations.""" + user = self.request.user + queryset = self.filter_queryset(self.get_queryset()) - if self.action == "list": - try: - document = models.Document.objects.get(pk=self.kwargs["resource_id"]) - except models.Document.DoesNotExist: - return queryset.none() + try: + document = models.Document.objects.get(pk=self.kwargs["resource_id"]) + except models.Document.DoesNotExist: + return drf.response.Response([]) - roles = set(document.get_roles(self.request.user)) - is_owner_or_admin = bool(roles.intersection(set(models.PRIVILEGED_ROLES))) - self.is_current_user_owner_or_admin = is_owner_or_admin - if not is_owner_or_admin: - # Return only the document owner access - queryset = queryset.filter(role__in=models.PRIVILEGED_ROLES) + roles = set(document.get_roles(user)) + if not roles: + return drf.response.Response([]) - return queryset + is_owner_or_admin = bool(roles.intersection(set(models.PRIVILEGED_ROLES))) + self.is_current_user_owner_or_admin = is_owner_or_admin + if not is_owner_or_admin: + # Return only the document's privileged accesses + queryset = queryset.filter(role__in=models.PRIVILEGED_ROLES) + + queryset = queryset.distinct() + serializer = self.get_serializer(queryset, many=True) + return drf.response.Response(serializer.data) def get_serializer_class(self): if self.action == "list" and not self.is_current_user_owner_or_admin: @@ -1582,7 +1549,6 @@ class TemplateAccessViewSet( ResourceAccessViewsetMixin, drf.mixins.CreateModelMixin, drf.mixins.DestroyModelMixin, - drf.mixins.ListModelMixin, drf.mixins.RetrieveModelMixin, drf.mixins.UpdateModelMixin, viewsets.GenericViewSet, @@ -1612,12 +1578,28 @@ class TemplateAccessViewSet( """ lookup_field = "pk" - pagination_class = Pagination permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission] queryset = models.TemplateAccess.objects.select_related("user").all() resource_field_name = "template" serializer_class = serializers.TemplateAccessSerializer + def list(self, request, *args, **kwargs): + """Restrict templates returned by the list endpoint""" + user = self.request.user + teams = user.teams + queryset = self.filter_queryset(self.get_queryset()) + + # Limit to resource access instances related to a resource THAT also has + # a resource access instance for the logged-in user (we don't want to list + # only the resource access instances pointing to the logged-in user) + queryset = queryset.filter( + db.Q(template__accesses__user=user) + | db.Q(template__accesses__team__in=teams), + ).distinct() + + serializer = self.get_serializer(queryset, many=True) + return drf.response.Response(serializer.data) + class InvitationViewset( drf.mixins.CreateModelMixin, diff --git a/src/backend/core/tests/documents/test_api_document_accesses.py b/src/backend/core/tests/documents/test_api_document_accesses.py index bf5ef1827..946ffcf0c 100644 --- a/src/backend/core/tests/documents/test_api_document_accesses.py +++ b/src/backend/core/tests/documents/test_api_document_accesses.py @@ -51,12 +51,7 @@ def test_api_document_accesses_list_authenticated_unrelated(): f"/api/v1.0/documents/{document.id!s}/accesses/", ) assert response.status_code == 200 - assert response.json() == { - "count": 0, - "next": None, - "previous": None, - "results": [], - } + assert response.json() == [] def test_api_document_accesses_list_unexisting_document(): @@ -70,12 +65,7 @@ def test_api_document_accesses_list_unexisting_document(): response = client.get(f"/api/v1.0/documents/{uuid4()!s}/accesses/") assert response.status_code == 200 - assert response.json() == { - "count": 0, - "next": None, - "previous": None, - "results": [], - } + assert response.json() == [] @pytest.mark.parametrize("via", VIA) @@ -129,14 +119,14 @@ def test_api_document_accesses_list_authenticated_related_non_privileged( f"/api/v1.0/documents/{document.id!s}/accesses/", ) - # Return only owners - owners_accesses = [ + # Return only privileged roles + privileged_accesses = [ access for access in accesses if access.role in models.PRIVILEGED_ROLES ] assert response.status_code == 200 content = response.json() - assert content["count"] == len(owners_accesses) - assert sorted(content["results"], key=lambda x: x["id"]) == sorted( + assert len(content) == len(privileged_accesses) + assert sorted(content, key=lambda x: x["id"]) == sorted( [ { "id": str(access.id), @@ -152,12 +142,12 @@ def test_api_document_accesses_list_authenticated_related_non_privileged( "role": access.role, "abilities": access.get_abilities(user), } - for access in owners_accesses + for access in privileged_accesses ], key=lambda x: x["id"], ) - for access in content["results"]: + for access in content: assert access["role"] in models.PRIVILEGED_ROLES @@ -216,8 +206,8 @@ def test_api_document_accesses_list_authenticated_related_privileged_roles( assert response.status_code == 200 content = response.json() - assert len(content["results"]) == 4 - assert sorted(content["results"], key=lambda x: x["id"]) == sorted( + assert len(content) == 4 + assert sorted(content, key=lambda x: x["id"]) == sorted( [ { "id": str(user_access.id), diff --git a/src/backend/core/tests/templates/test_api_template_accesses.py b/src/backend/core/tests/templates/test_api_template_accesses.py index 86e5f2bd5..6d1107768 100644 --- a/src/backend/core/tests/templates/test_api_template_accesses.py +++ b/src/backend/core/tests/templates/test_api_template_accesses.py @@ -48,12 +48,7 @@ def test_api_template_accesses_list_authenticated_unrelated(): f"/api/v1.0/templates/{template.id!s}/accesses/", ) assert response.status_code == 200 - assert response.json() == { - "count": 0, - "next": None, - "previous": None, - "results": [], - } + assert response.json() == [] @pytest.mark.parametrize("via", VIA) @@ -96,8 +91,8 @@ def test_api_template_accesses_list_authenticated_related(via, mock_user_teams): assert response.status_code == 200 content = response.json() - assert len(content["results"]) == 3 - assert sorted(content["results"], key=lambda x: x["id"]) == sorted( + assert len(content) == 3 + assert sorted(content, key=lambda x: x["id"]) == sorted( [ { "id": str(user_access.id), From 9a4405bb3f9fd2b3d4ca996c5327409851bfe169 Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Sat, 12 Apr 2025 11:35:36 +0200 Subject: [PATCH 005/104] =?UTF-8?q?=E2=99=BB=EF=B8=8F(backend)=20factorize?= =?UTF-8?q?=20document=20query=20set=20annotation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The methods to annotate a document queryset were factorized on the viewset but the correct place is the custom queryset itself now that we have one. --- src/backend/core/api/viewsets.py | 62 ++++++++------------------------ src/backend/core/models.py | 35 ++++++++++++++++++ 2 files changed, 49 insertions(+), 48 deletions(-) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 193a35465..314767e1f 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -400,44 +400,6 @@ class DocumentViewSet( trashbin_serializer_class = serializers.ListDocumentSerializer tree_serializer_class = serializers.ListDocumentSerializer - def annotate_is_favorite(self, queryset): - """ - Annotate document queryset with the favorite status for the current user. - """ - user = self.request.user - - if user.is_authenticated: - favorite_exists_subquery = models.DocumentFavorite.objects.filter( - document_id=db.OuterRef("pk"), user=user - ) - return queryset.annotate(is_favorite=db.Exists(favorite_exists_subquery)) - - return queryset.annotate(is_favorite=db.Value(False)) - - def annotate_user_roles(self, queryset): - """ - Annotate document queryset with the roles of the current user - on the document or its ancestors. - """ - user = self.request.user - output_field = ArrayField(base_field=db.CharField()) - - if user.is_authenticated: - user_roles_subquery = models.DocumentAccess.objects.filter( - db.Q(user=user) | db.Q(team__in=user.teams), - document__path=Left(db.OuterRef("path"), Length("document__path")), - ).values_list("role", flat=True) - - return queryset.annotate( - user_roles=db.Func( - user_roles_subquery, function="ARRAY", output_field=output_field - ) - ) - - return queryset.annotate( - user_roles=db.Value([], output_field=output_field), - ) - def get_queryset(self): """Get queryset performing all annotation and filtering on the document tree structure.""" user = self.request.user @@ -473,8 +435,9 @@ def get_queryset(self): def filter_queryset(self, queryset): """Override to apply annotations to generic views.""" queryset = super().filter_queryset(queryset) - queryset = self.annotate_is_favorite(queryset) - queryset = self.annotate_user_roles(queryset) + user = self.request.user + queryset = queryset.annotate_is_favorite(user) + queryset = queryset.annotate_user_roles(user) return queryset def get_response_for_queryset(self, queryset): @@ -498,9 +461,10 @@ def list(self, request, *args, **kwargs): Additional annotations (e.g., `is_highest_ancestor_for_user`, favorite status) are applied before ordering and returning the response. """ - queryset = ( - self.get_queryset() - ) # Not calling filter_queryset. We do our own cooking. + user = self.request.user + + # Not calling filter_queryset. We do our own cooking. + queryset = self.get_queryset() filterset = ListDocumentFilter( self.request.GET, queryset=queryset, request=self.request @@ -513,7 +477,7 @@ def list(self, request, *args, **kwargs): for field in ["is_creator_me", "title"]: queryset = filterset.filters[field].filter(queryset, filter_data[field]) - queryset = self.annotate_user_roles(queryset) + queryset = queryset.annotate_user_roles(user) # Among the results, we may have documents that are ancestors/descendants # of each other. In this case we want to keep only the highest ancestors. @@ -530,7 +494,7 @@ def list(self, request, *args, **kwargs): ) # Annotate favorite status and filter if applicable as late as possible - queryset = self.annotate_is_favorite(queryset) + queryset = queryset.annotate_is_favorite(user) queryset = filterset.filters["is_favorite"].filter( queryset, filter_data["is_favorite"] ) @@ -621,7 +585,7 @@ def trashbin(self, request, *args, **kwargs): deleted_at__isnull=False, deleted_at__gte=models.get_trashbin_cutoff(), ) - queryset = self.annotate_user_roles(queryset) + queryset = queryset.annotate_user_roles(self.request.user) queryset = queryset.filter(user_roles__contains=[models.RoleChoices.OWNER]) return self.get_response_for_queryset(queryset) @@ -815,6 +779,8 @@ def tree(self, request, pk, *args, **kwargs): List ancestors tree above the document. What we need to display is the tree structure opened for the current document. """ + user = self.request.user + try: current_document = self.queryset.only("depth", "path").get(pk=pk) except models.Document.DoesNotExist as excpt: @@ -869,8 +835,8 @@ def tree(self, request, pk, *args, **kwargs): output_field=db.BooleanField(), ) ) - queryset = self.annotate_user_roles(queryset) - queryset = self.annotate_is_favorite(queryset) + queryset = queryset.annotate_user_roles(user) + queryset = queryset.annotate_is_favorite(user) # Pass ancestors' links definitions to the serializer as a context variable # in order to allow saving time while computing abilities on the instance diff --git a/src/backend/core/models.py b/src/backend/core/models.py index d941992ab..9f3a78b95 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -464,6 +464,41 @@ def readable_per_se(self, user): return self.filter(link_reach=LinkReachChoices.PUBLIC) + def annotate_is_favorite(self, user): + """ + Annotate document queryset with the favorite status for the current user. + """ + if user.is_authenticated: + favorite_exists_subquery = DocumentFavorite.objects.filter( + document_id=models.OuterRef("pk"), user=user + ) + return self.annotate(is_favorite=models.Exists(favorite_exists_subquery)) + + return self.annotate(is_favorite=models.Value(False)) + + def annotate_user_roles(self, user): + """ + Annotate document queryset with the roles of the current user + on the document or its ancestors. + """ + output_field = ArrayField(base_field=models.CharField()) + + if user.is_authenticated: + user_roles_subquery = DocumentAccess.objects.filter( + models.Q(user=user) | models.Q(team__in=user.teams), + document__path=Left(models.OuterRef("path"), Length("document__path")), + ).values_list("role", flat=True) + + return self.annotate( + user_roles=models.Func( + user_roles_subquery, function="ARRAY", output_field=output_field + ) + ) + + return self.annotate( + user_roles=models.Value([], output_field=output_field), + ) + class DocumentManager(MP_NodeManager.from_queryset(DocumentQuerySet)): """ From e40911719ee9d0590e562ae854b49f17d7dc6186 Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Sat, 12 Apr 2025 13:43:30 +0200 Subject: [PATCH 006/104] =?UTF-8?q?=E2=9C=A8(backend)=20we=20want=20to=20d?= =?UTF-8?q?isplay=20ancestors=20accesses=20on=20a=20document=20share?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The document accesses a user have on a document's ancestors also apply to this document. The frontend needs to list them as "inherited" so we need to add them to the list. Adding a "document_id" field on the output will allow the frontend to differentiate between inherited and direct accesses on a document. --- CHANGELOG.md | 1 + src/backend/core/api/serializers.py | 12 +- src/backend/core/api/viewsets.py | 36 +++-- .../documents/test_api_document_accesses.py | 143 ++++++++++-------- .../test_api_document_accesses_create.py | 3 + 5 files changed, 113 insertions(+), 82 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index acac89010..34d2a8067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ and this project adheres to ## Added +- ✨(backend) include ancestors accesses on document accesses list view # 846 - ✨(backend) add ancestors links definitions to document abilities #846 - 🚸(backend) make document search on title accent-insensitive #874 - 🚩 add homepage feature flag #861 diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index e86288bb3..b0772412b 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -97,7 +97,7 @@ def validate(self, attrs): if not self.Meta.model.objects.filter( # pylint: disable=no-member Q(user=user) | Q(team__in=user.teams), - role__in=[models.RoleChoices.OWNER, models.RoleChoices.ADMIN], + role__in=models.PRIVILEGED_ROLES, **{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member ).exists(): raise exceptions.PermissionDenied( @@ -124,6 +124,10 @@ def validate(self, attrs): class DocumentAccessSerializer(BaseAccessSerializer): """Serialize document accesses.""" + document_id = serializers.PrimaryKeyRelatedField( + read_only=True, + source="document", + ) user_id = serializers.PrimaryKeyRelatedField( queryset=models.User.objects.all(), write_only=True, @@ -136,11 +140,11 @@ class DocumentAccessSerializer(BaseAccessSerializer): class Meta: model = models.DocumentAccess resource_field_name = "document" - fields = ["id", "user", "user_id", "team", "role", "abilities"] - read_only_fields = ["id", "abilities"] + fields = ["id", "document_id", "user", "user_id", "team", "role", "abilities"] + read_only_fields = ["id", "document_id", "abilities"] -class DocumentAccessLightSerializer(DocumentAccessSerializer): +class DocumentAccessLightSerializer(BaseAccessSerializer): """Serialize document accesses with limited fields.""" user = UserLightSerializer(read_only=True) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 314767e1f..f45d5573d 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -8,7 +8,6 @@ from django.conf import settings from django.contrib.postgres.aggregates import ArrayAgg -from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.search import TrigramSimilarity from django.core.cache import cache from django.core.exceptions import ValidationError @@ -1373,12 +1372,10 @@ class DocumentAccessViewSet( queryset = models.DocumentAccess.objects.select_related("user").all() resource_field_name = "document" serializer_class = serializers.DocumentAccessSerializer - is_current_user_owner_or_admin = False def list(self, request, *args, **kwargs): """Return accesses for the current document with filters and annotations.""" user = self.request.user - queryset = self.filter_queryset(self.get_queryset()) try: document = models.Document.objects.get(pk=self.kwargs["resource_id"]) @@ -1389,22 +1386,35 @@ def list(self, request, *args, **kwargs): if not roles: return drf.response.Response([]) - is_owner_or_admin = bool(roles.intersection(set(models.PRIVILEGED_ROLES))) - self.is_current_user_owner_or_admin = is_owner_or_admin - if not is_owner_or_admin: + ancestors = ( + (document.get_ancestors() | models.Document.objects.filter(pk=document.pk)) + .filter(ancestors_deleted_at__isnull=True) + .order_by("path") + ) + highest_readable = ancestors.readable_per_se(user).only("depth").first() + + if highest_readable is None: + return drf.response.Response([]) + + queryset = self.get_queryset() + queryset = queryset.filter( + document__in=ancestors.filter(depth__gte=highest_readable.depth) + ) + + is_privileged = bool(roles.intersection(set(models.PRIVILEGED_ROLES))) + if is_privileged: + serializer_class = serializers.DocumentAccessSerializer + else: # Return only the document's privileged accesses queryset = queryset.filter(role__in=models.PRIVILEGED_ROLES) + serializer_class = serializers.DocumentAccessLightSerializer queryset = queryset.distinct() - serializer = self.get_serializer(queryset, many=True) + serializer = serializer_class( + queryset, many=True, context=self.get_serializer_context() + ) return drf.response.Response(serializer.data) - def get_serializer_class(self): - if self.action == "list" and not self.is_current_user_owner_or_admin: - return serializers.DocumentAccessLightSerializer - - return super().get_serializer_class() - def perform_create(self, serializer): """Add a new access to the document and send an email to the new added user.""" access = serializer.save() diff --git a/src/backend/core/tests/documents/test_api_document_accesses.py b/src/backend/core/tests/documents/test_api_document_accesses.py index 946ffcf0c..e30a6c364 100644 --- a/src/backend/core/tests/documents/test_api_document_accesses.py +++ b/src/backend/core/tests/documents/test_api_document_accesses.py @@ -76,22 +76,30 @@ def test_api_document_accesses_list_authenticated_related_non_privileged( via, role, mock_user_teams ): """ - Authenticated users should be able to list document accesses for a document - to which they are directly related, whatever their role in the document. + Authenticated users with no privileged role should only be able to list document + accesses associated with privileged roles for a document, including from ancestors. """ user = factories.UserFactory() - client = APIClient() client.force_login(user) - owner = factories.UserFactory() - accesses = [] - - document_access = factories.UserDocumentAccessFactory( - user=owner, role=models.RoleChoices.OWNER + # Create documents structured as a tree + unreadable_ancestor = factories.DocumentFactory(link_reach="restricted") + # make all documents below the grand parent readable without a specific access for the user + grand_parent = factories.DocumentFactory( + parent=unreadable_ancestor, link_reach="authenticated" ) - accesses.append(document_access) - document = document_access.document + parent = factories.DocumentFactory(parent=grand_parent) + document = factories.DocumentFactory(parent=parent) + child = factories.DocumentFactory(parent=document) + + # Create accesses related to each document + factories.UserDocumentAccessFactory(document=unreadable_ancestor) + grand_parent_access = factories.UserDocumentAccessFactory(document=grand_parent) + parent_access = factories.UserDocumentAccessFactory(document=parent) + document_access = factories.UserDocumentAccessFactory(document=document) + factories.UserDocumentAccessFactory(document=child) + if via == USER: models.DocumentAccess.objects.create( document=document, @@ -108,8 +116,6 @@ def test_api_document_accesses_list_authenticated_related_non_privileged( access1 = factories.TeamDocumentAccessFactory(document=document) access2 = factories.UserDocumentAccessFactory(document=document) - accesses.append(access1) - accesses.append(access2) # Accesses for other documents to which the user is related should not be listed either other_access = factories.UserDocumentAccessFactory(user=user) @@ -119,13 +125,16 @@ def test_api_document_accesses_list_authenticated_related_non_privileged( f"/api/v1.0/documents/{document.id!s}/accesses/", ) - # Return only privileged roles - privileged_accesses = [ - access for access in accesses if access.role in models.PRIVILEGED_ROLES - ] assert response.status_code == 200 content = response.json() + + # Make sure only privileged roles are returned + accesses = [grand_parent_access, parent_access, document_access, access1, access2] + privileged_accesses = [ + acc for acc in accesses if acc.role in models.PRIVILEGED_ROLES + ] assert len(content) == len(privileged_accesses) + assert sorted(content, key=lambda x: x["id"]) == sorted( [ { @@ -147,33 +156,39 @@ def test_api_document_accesses_list_authenticated_related_non_privileged( key=lambda x: x["id"], ) - for access in content: - assert access["role"] in models.PRIVILEGED_ROLES - @pytest.mark.parametrize("via", VIA) -@pytest.mark.parametrize("role", models.PRIVILEGED_ROLES) -def test_api_document_accesses_list_authenticated_related_privileged_roles( +@pytest.mark.parametrize( + "role", [role for role in models.RoleChoices if role in models.PRIVILEGED_ROLES] +) +def test_api_document_accesses_list_authenticated_related_privileged( via, role, mock_user_teams ): """ - Authenticated users should be able to list document accesses for a document - to which they are directly related, whatever their role in the document. + Authenticated users with a privileged role should be able to list all + document accesses whatever the role, including from ancestors. """ user = factories.UserFactory() - client = APIClient() client.force_login(user) - owner = factories.UserFactory() - accesses = [] - - document_access = factories.UserDocumentAccessFactory( - user=owner, role=models.RoleChoices.OWNER + # Create documents structured as a tree + unreadable_ancestor = factories.DocumentFactory(link_reach="restricted") + # make all documents below the grand parent readable without a specific access for the user + grand_parent = factories.DocumentFactory( + parent=unreadable_ancestor, link_reach="authenticated" ) - accesses.append(document_access) - document = document_access.document - user_access = None + parent = factories.DocumentFactory(parent=grand_parent) + document = factories.DocumentFactory(parent=parent) + child = factories.DocumentFactory(parent=document) + + # Create accesses related to each document + factories.UserDocumentAccessFactory(document=unreadable_ancestor) + grand_parent_access = factories.UserDocumentAccessFactory(document=grand_parent) + parent_access = factories.UserDocumentAccessFactory(document=parent) + document_access = factories.UserDocumentAccessFactory(document=document) + factories.UserDocumentAccessFactory(document=child) + if via == USER: user_access = models.DocumentAccess.objects.create( document=document, @@ -187,11 +202,11 @@ def test_api_document_accesses_list_authenticated_related_privileged_roles( team="lasuite", role=role, ) + else: + raise RuntimeError() access1 = factories.TeamDocumentAccessFactory(document=document) access2 = factories.UserDocumentAccessFactory(document=document) - accesses.append(access1) - accesses.append(access2) # Accesses for other documents to which the user is related should not be listed either other_access = factories.UserDocumentAccessFactory(user=user) @@ -201,42 +216,39 @@ def test_api_document_accesses_list_authenticated_related_privileged_roles( f"/api/v1.0/documents/{document.id!s}/accesses/", ) - access2_user = serializers.UserSerializer(instance=access2.user).data - base_user = serializers.UserSerializer(instance=user).data - assert response.status_code == 200 content = response.json() - assert len(content) == 4 + + # Make sure all expected accesses are returned + accesses = [ + user_access, + grand_parent_access, + parent_access, + document_access, + access1, + access2, + ] + assert len(content) == 6 + assert sorted(content, key=lambda x: x["id"]) == sorted( [ { - "id": str(user_access.id), - "user": base_user if via == "user" else None, - "team": "lasuite" if via == "team" else "", - "role": user_access.role, - "abilities": user_access.get_abilities(user), - }, - { - "id": str(access1.id), - "user": None, - "team": access1.team, - "role": access1.role, - "abilities": access1.get_abilities(user), - }, - { - "id": str(access2.id), - "user": access2_user, - "team": "", - "role": access2.role, - "abilities": access2.get_abilities(user), - }, - { - "id": str(document_access.id), - "user": serializers.UserSerializer(instance=owner).data, - "team": "", - "role": models.RoleChoices.OWNER, - "abilities": document_access.get_abilities(user), - }, + "id": str(access.id), + "document_id": str(access.document_id), + "user": { + "id": str(access.user.id), + "email": access.user.email, + "language": access.user.language, + "full_name": access.user.full_name, + "short_name": access.user.short_name, + } + if access.user + else None, + "team": access.team, + "role": access.role, + "abilities": access.get_abilities(user), + } + for access in accesses ], key=lambda x: x["id"], ) @@ -331,6 +343,7 @@ def test_api_document_accesses_retrieve_authenticated_related( assert response.status_code == 200 assert response.json() == { "id": str(access.id), + "document_id": str(access.document_id), "user": access_user, "team": "", "role": access.role, diff --git a/src/backend/core/tests/documents/test_api_document_accesses_create.py b/src/backend/core/tests/documents/test_api_document_accesses_create.py index e356973ae..cd2d57ebd 100644 --- a/src/backend/core/tests/documents/test_api_document_accesses_create.py +++ b/src/backend/core/tests/documents/test_api_document_accesses_create.py @@ -165,6 +165,7 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user other_user = serializers.UserSerializer(instance=other_user).data assert response.json() == { "abilities": new_document_access.get_abilities(user), + "document_id": str(new_document_access.document_id), "id": str(new_document_access.id), "team": "", "role": role, @@ -222,6 +223,7 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams): new_document_access = models.DocumentAccess.objects.filter(user=other_user).get() other_user = serializers.UserSerializer(instance=other_user).data assert response.json() == { + "document_id": str(new_document_access.document_id), "id": str(new_document_access.id), "user": other_user, "team": "", @@ -286,6 +288,7 @@ def test_api_document_accesses_create_email_in_receivers_language(via, mock_user ).get() other_user_data = serializers.UserSerializer(instance=other_user).data assert response.json() == { + "document_id": str(new_document_access.document_id), "id": str(new_document_access.id), "user": other_user_data, "team": "", From 05f2170973d9b3391a47378e78f412c8b349254e Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Wed, 23 Apr 2025 22:47:24 +0200 Subject: [PATCH 007/104] =?UTF-8?q?=E2=9C=A8(backend)=20give=20an=20order?= =?UTF-8?q?=20to=20choices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We are going to need to compare choices to materialize the fact that choices are ordered. For example an admin role is higer than an editor role but lower than an owner role. We will need this to compute the reach and role resulting from all the document accesses (resp. link accesses) assigned on a document's ancestors. --- src/backend/core/api/serializers.py | 4 +- src/backend/core/api/viewsets.py | 10 +- src/backend/core/choices.py | 123 ++++++++++++++++++ src/backend/core/models.py | 96 +------------- .../documents/test_api_document_accesses.py | 11 +- 5 files changed, 138 insertions(+), 106 deletions(-) create mode 100644 src/backend/core/choices.py diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index b0772412b..f50a85afa 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -12,7 +12,7 @@ import magic from rest_framework import exceptions, serializers -from core import enums, models, utils +from core import choices, enums, models, utils from core.services.ai_services import AI_ACTIONS from core.services.converter_services import ( ConversionError, @@ -97,7 +97,7 @@ def validate(self, attrs): if not self.Meta.model.objects.filter( # pylint: disable=no-member Q(user=user) | Q(team__in=user.teams), - role__in=models.PRIVILEGED_ROLES, + role__in=choices.PRIVILEGED_ROLES, **{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member ).exists(): raise exceptions.PermissionDenied( diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index f45d5573d..c632ba661 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -29,7 +29,7 @@ from rest_framework.permissions import AllowAny from rest_framework.throttling import UserRateThrottle -from core import authentication, enums, models +from core import authentication, choices, enums, models from core.services.ai_services import AIService from core.services.collaboration_services import CollaborationService from core.utils import extract_attachments, filter_descendants @@ -1401,12 +1401,12 @@ def list(self, request, *args, **kwargs): document__in=ancestors.filter(depth__gte=highest_readable.depth) ) - is_privileged = bool(roles.intersection(set(models.PRIVILEGED_ROLES))) + is_privileged = bool(roles.intersection(set(choices.PRIVILEGED_ROLES))) if is_privileged: serializer_class = serializers.DocumentAccessSerializer else: # Return only the document's privileged accesses - queryset = queryset.filter(role__in=models.PRIVILEGED_ROLES) + queryset = queryset.filter(role__in=choices.PRIVILEGED_ROLES) serializer_class = serializers.DocumentAccessLightSerializer queryset = queryset.distinct() @@ -1648,11 +1648,11 @@ def get_queryset(self): queryset.filter( db.Q( document__accesses__user=user, - document__accesses__role__in=models.PRIVILEGED_ROLES, + document__accesses__role__in=choices.PRIVILEGED_ROLES, ) | db.Q( document__accesses__team__in=teams, - document__accesses__role__in=models.PRIVILEGED_ROLES, + document__accesses__role__in=choices.PRIVILEGED_ROLES, ), ) # Abilities are computed based on logged-in user's role and diff --git a/src/backend/core/choices.py b/src/backend/core/choices.py new file mode 100644 index 000000000..5bfd61c3c --- /dev/null +++ b/src/backend/core/choices.py @@ -0,0 +1,123 @@ +"""Declare and configure choices for Docs' core application.""" + +from django.db.models import TextChoices +from django.utils.translation import gettext_lazy as _ + + +class PriorityTextChoices(TextChoices): + """ + This class inherits from Django's TextChoices and provides a method to get the priority + of a given value based on its position in the class. + """ + + @classmethod + def get_priority(cls, value): + """Returns the priority of the given value based on its order in the class.""" + members = list(cls.__members__.values()) + return members.index(value) + 1 if value in members else 0 + + @classmethod + def max(cls, *roles): + """ + Return the highest-priority role among the given roles, using get_priority(). + If no valid roles are provided, returns None. + """ + + valid_roles = [role for role in roles if cls.get_priority(role) is not None] + if not valid_roles: + return None + return max(valid_roles, key=cls.get_priority) + + +class LinkRoleChoices(PriorityTextChoices): + """Defines the possible roles a link can offer on a document.""" + + READER = "reader", _("Reader") # Can read + EDITOR = "editor", _("Editor") # Can read and edit + + +class RoleChoices(PriorityTextChoices): + """Defines the possible roles a user can have in a resource.""" + + READER = "reader", _("Reader") # Can read + EDITOR = "editor", _("Editor") # Can read and edit + ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share + OWNER = "owner", _("Owner") + + +PRIVILEGED_ROLES = [RoleChoices.ADMIN, RoleChoices.OWNER] + + +class LinkReachChoices(PriorityTextChoices): + """Defines types of access for links""" + + RESTRICTED = ( + "restricted", + _("Restricted"), + ) # Only users with a specific access can read/edit the document + AUTHENTICATED = ( + "authenticated", + _("Authenticated"), + ) # Any authenticated user can access the document + PUBLIC = "public", _("Public") # Even anonymous users can access the document + + @classmethod + def get_select_options(cls, ancestors_links): + """ + Determines the valid select options for link reach and link role depending on the + list of ancestors' link reach/role. + Args: + ancestors_links: List of dictionaries, each with 'link_reach' and 'link_role' keys + representing the reach and role of ancestors links. + Returns: + Dictionary mapping possible reach levels to their corresponding possible roles. + """ + # If no ancestors, return all options + if not ancestors_links: + return { + reach: LinkRoleChoices.values if reach != cls.RESTRICTED else None + for reach in cls.values + } + + # Initialize result with all possible reaches and role options as sets + result = { + reach: set(LinkRoleChoices.values) if reach != cls.RESTRICTED else None + for reach in cls.values + } + + # Group roles by reach level + reach_roles = defaultdict(set) + for link in ancestors_links: + reach_roles[link["link_reach"]].add(link["link_role"]) + + # Rule 1: public/editor → override everything + if LinkRoleChoices.EDITOR in reach_roles.get(cls.PUBLIC, set()): + return {cls.PUBLIC: [LinkRoleChoices.EDITOR]} + + # Rule 2: authenticated/editor + if LinkRoleChoices.EDITOR in reach_roles.get(cls.AUTHENTICATED, set()): + result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER) + result.pop(cls.RESTRICTED, None) + + # Rule 3: public/reader + if LinkRoleChoices.READER in reach_roles.get(cls.PUBLIC, set()): + result.pop(cls.AUTHENTICATED, None) + result.pop(cls.RESTRICTED, None) + + # Rule 4: authenticated/reader + if LinkRoleChoices.READER in reach_roles.get(cls.AUTHENTICATED, set()): + result.pop(cls.RESTRICTED, None) + + # Clean up: remove empty entries and convert sets to ordered lists + cleaned = {} + for reach in cls.values: + if reach in result: + if result[reach]: + cleaned[reach] = [ + r for r in LinkRoleChoices.values if r in result[reach] + ] + else: + # Could be [] or None (for RESTRICTED reach) + cleaned[reach] = result[reach] + + return cleaned diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 9f3a78b95..f4ec439e7 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -33,6 +33,8 @@ from timezone_field import TimeZoneField from treebeard.mp_tree import MP_Node, MP_NodeManager, MP_NodeQuerySet +from .choices import PRIVILEGED_ROLES, LinkReachChoices, LinkRoleChoices, RoleChoices + logger = getLogger(__name__) @@ -50,100 +52,6 @@ def get_trashbin_cutoff(): return timezone.now() - timedelta(days=settings.TRASHBIN_CUTOFF_DAYS) -class LinkRoleChoices(models.TextChoices): - """Defines the possible roles a link can offer on a document.""" - - READER = "reader", _("Reader") # Can read - EDITOR = "editor", _("Editor") # Can read and edit - - -class RoleChoices(models.TextChoices): - """Defines the possible roles a user can have in a resource.""" - - READER = "reader", _("Reader") # Can read - EDITOR = "editor", _("Editor") # Can read and edit - ADMIN = "administrator", _("Administrator") # Can read, edit, delete and share - OWNER = "owner", _("Owner") - - -PRIVILEGED_ROLES = [RoleChoices.ADMIN, RoleChoices.OWNER] - - -class LinkReachChoices(models.TextChoices): - """Defines types of access for links""" - - RESTRICTED = ( - "restricted", - _("Restricted"), - ) # Only users with a specific access can read/edit the document - AUTHENTICATED = ( - "authenticated", - _("Authenticated"), - ) # Any authenticated user can access the document - PUBLIC = "public", _("Public") # Even anonymous users can access the document - - @classmethod - def get_select_options(cls, ancestors_links): - """ - Determines the valid select options for link reach and link role depending on the - list of ancestors' link reach/role. - Args: - ancestors_links: List of dictionaries, each with 'link_reach' and 'link_role' keys - representing the reach and role of ancestors links. - Returns: - Dictionary mapping possible reach levels to their corresponding possible roles. - """ - # If no ancestors, return all options - if not ancestors_links: - return { - reach: LinkRoleChoices.values if reach != cls.RESTRICTED else None - for reach in cls.values - } - - # Initialize result with all possible reaches and role options as sets - result = { - reach: set(LinkRoleChoices.values) if reach != cls.RESTRICTED else None - for reach in cls.values - } - - # Group roles by reach level - reach_roles = defaultdict(set) - for link in ancestors_links: - reach_roles[link["link_reach"]].add(link["link_role"]) - - # Rule 1: public/editor → override everything - if LinkRoleChoices.EDITOR in reach_roles.get(cls.PUBLIC, set()): - return {cls.PUBLIC: [LinkRoleChoices.EDITOR]} - - # Rule 2: authenticated/editor - if LinkRoleChoices.EDITOR in reach_roles.get(cls.AUTHENTICATED, set()): - result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER) - result.pop(cls.RESTRICTED, None) - - # Rule 3: public/reader - if LinkRoleChoices.READER in reach_roles.get(cls.PUBLIC, set()): - result.pop(cls.AUTHENTICATED, None) - result.pop(cls.RESTRICTED, None) - - # Rule 4: authenticated/reader - if LinkRoleChoices.READER in reach_roles.get(cls.AUTHENTICATED, set()): - result.pop(cls.RESTRICTED, None) - - # Clean up: remove empty entries and convert sets to ordered lists - cleaned = {} - for reach in cls.values: - if reach in result: - if result[reach]: - cleaned[reach] = [ - r for r in LinkRoleChoices.values if r in result[reach] - ] - else: - # Could be [] or None (for RESTRICTED reach) - cleaned[reach] = result[reach] - - return cleaned - - class DuplicateEmailError(Exception): """Raised when an email is already associated with a pre-existing user.""" diff --git a/src/backend/core/tests/documents/test_api_document_accesses.py b/src/backend/core/tests/documents/test_api_document_accesses.py index e30a6c364..bc6dcb510 100644 --- a/src/backend/core/tests/documents/test_api_document_accesses.py +++ b/src/backend/core/tests/documents/test_api_document_accesses.py @@ -8,7 +8,7 @@ import pytest from rest_framework.test import APIClient -from core import factories, models +from core import choices, factories, models from core.api import serializers from core.tests.conftest import TEAM, USER, VIA from core.tests.test_services_collaboration_services import ( # pylint: disable=unused-import @@ -70,7 +70,8 @@ def test_api_document_accesses_list_unexisting_document(): @pytest.mark.parametrize("via", VIA) @pytest.mark.parametrize( - "role", [role for role in models.RoleChoices if role not in models.PRIVILEGED_ROLES] + "role", + [role for role in choices.RoleChoices if role not in choices.PRIVILEGED_ROLES], ) def test_api_document_accesses_list_authenticated_related_non_privileged( via, role, mock_user_teams @@ -131,7 +132,7 @@ def test_api_document_accesses_list_authenticated_related_non_privileged( # Make sure only privileged roles are returned accesses = [grand_parent_access, parent_access, document_access, access1, access2] privileged_accesses = [ - acc for acc in accesses if acc.role in models.PRIVILEGED_ROLES + acc for acc in accesses if acc.role in choices.PRIVILEGED_ROLES ] assert len(content) == len(privileged_accesses) @@ -159,7 +160,7 @@ def test_api_document_accesses_list_authenticated_related_non_privileged( @pytest.mark.parametrize("via", VIA) @pytest.mark.parametrize( - "role", [role for role in models.RoleChoices if role in models.PRIVILEGED_ROLES] + "role", [role for role in choices.RoleChoices if role in choices.PRIVILEGED_ROLES] ) def test_api_document_accesses_list_authenticated_related_privileged( via, role, mock_user_teams @@ -335,7 +336,7 @@ def test_api_document_accesses_retrieve_authenticated_related( f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", ) - if not role in models.PRIVILEGED_ROLES: + if not role in choices.PRIVILEGED_ROLES: assert response.status_code == 403 else: access_user = serializers.UserSerializer(instance=access.user).data From 3650423261de375ba478ce0d107c95171b0109de Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Thu, 24 Apr 2025 08:59:30 +0200 Subject: [PATCH 008/104] =?UTF-8?q?=E2=99=BB=EF=B8=8F(backend)=20refactor?= =?UTF-8?q?=20get=5Fselect=5Foptions=20to=20take=20definitions=20dict?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This will allow us to simplify the get_abilities method. It is also more efficient because we have computed this definitions dict and the the get_select_options method was doing the conversion again. --- src/backend/core/choices.py | 75 ++++++------- src/backend/core/models.py | 4 +- .../documents/test_api_documents_retrieve.py | 16 ++- .../core/tests/test_models_documents.py | 102 +++++------------- 4 files changed, 72 insertions(+), 125 deletions(-) diff --git a/src/backend/core/choices.py b/src/backend/core/choices.py index 5bfd61c3c..ff618f216 100644 --- a/src/backend/core/choices.py +++ b/src/backend/core/choices.py @@ -61,63 +61,52 @@ class LinkReachChoices(PriorityTextChoices): ) # Any authenticated user can access the document PUBLIC = "public", _("Public") # Even anonymous users can access the document + @classmethod - def get_select_options(cls, ancestors_links): + def get_select_options(cls, link_reach, link_role): """ Determines the valid select options for link reach and link role depending on the - list of ancestors' link reach/role. - Args: - ancestors_links: List of dictionaries, each with 'link_reach' and 'link_role' keys - representing the reach and role of ancestors links. + list of ancestors' link reach/role definitions. Returns: Dictionary mapping possible reach levels to their corresponding possible roles. """ # If no ancestors, return all options - if not ancestors_links: + if not link_reach: return { reach: LinkRoleChoices.values if reach != cls.RESTRICTED else None for reach in cls.values } - # Initialize result with all possible reaches and role options as sets + # Initialize the result for all reaches with possible roles result = { reach: set(LinkRoleChoices.values) if reach != cls.RESTRICTED else None for reach in cls.values } - # Group roles by reach level - reach_roles = defaultdict(set) - for link in ancestors_links: - reach_roles[link["link_reach"]].add(link["link_role"]) - - # Rule 1: public/editor → override everything - if LinkRoleChoices.EDITOR in reach_roles.get(cls.PUBLIC, set()): - return {cls.PUBLIC: [LinkRoleChoices.EDITOR]} - - # Rule 2: authenticated/editor - if LinkRoleChoices.EDITOR in reach_roles.get(cls.AUTHENTICATED, set()): - result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER) - result.pop(cls.RESTRICTED, None) - - # Rule 3: public/reader - if LinkRoleChoices.READER in reach_roles.get(cls.PUBLIC, set()): - result.pop(cls.AUTHENTICATED, None) - result.pop(cls.RESTRICTED, None) - - # Rule 4: authenticated/reader - if LinkRoleChoices.READER in reach_roles.get(cls.AUTHENTICATED, set()): - result.pop(cls.RESTRICTED, None) - - # Clean up: remove empty entries and convert sets to ordered lists - cleaned = {} - for reach in cls.values: - if reach in result: - if result[reach]: - cleaned[reach] = [ - r for r in LinkRoleChoices.values if r in result[reach] - ] - else: - # Could be [] or None (for RESTRICTED reach) - cleaned[reach] = result[reach] - - return cleaned + # Handle special rules directly with early returns for efficiency + + if link_role == LinkRoleChoices.EDITOR: + # Rule 1: public/editor → override everything + if link_reach == cls.PUBLIC: + return {cls.PUBLIC: [LinkRoleChoices.EDITOR]} + + # Rule 2: authenticated/editor + if link_reach == cls.AUTHENTICATED: + result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER) + result.pop(cls.RESTRICTED, None) + + if link_role == LinkRoleChoices.READER: + # Rule 3: public/reader + if link_reach == cls.PUBLIC: + result.pop(cls.AUTHENTICATED, None) + result.pop(cls.RESTRICTED, None) + + # Rule 4: authenticated/reader + if link_reach == cls.AUTHENTICATED: + result.pop(cls.RESTRICTED, None) + + # Convert sets to ordered lists where applicable + return { + reach: sorted(roles, key=LinkRoleChoices.get_priority) if roles else roles + for reach, roles in result.items() + } diff --git a/src/backend/core/models.py b/src/backend/core/models.py index f4ec439e7..f1df432ce 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -817,7 +817,9 @@ def get_abilities(self, user, ancestors_links=None): "ancestors_links_definitions": { k: list(v) for k, v in ancestors_links_definitions.items() }, - "link_select_options": LinkReachChoices.get_select_options(ancestors_links), + "link_select_options": LinkReachChoices.get_select_options( + ancestors_links_definitions + ), "tree": can_get, "update": can_update, "versions_destroy": is_owner_or_admin, diff --git a/src/backend/core/tests/documents/test_api_documents_retrieve.py b/src/backend/core/tests/documents/test_api_documents_retrieve.py index 88507147a..3a224c8f0 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -1,6 +1,7 @@ """ Tests for Documents API endpoint in impress's core app: retrieve """ +# pylint: disable=too-many-lines import random from datetime import timedelta @@ -91,6 +92,7 @@ def test_api_documents_retrieve_anonymous_public_parent(): assert response.status_code == 200 links = document.get_ancestors().values("link_reach", "link_role") + links_definitions = document.get_ancestors_links_definitions(links) assert response.json() == { "id": str(document.id), "abilities": { @@ -114,7 +116,9 @@ def test_api_documents_retrieve_anonymous_public_parent(): "favorite": False, "invite_owner": False, "link_configuration": False, - "link_select_options": models.LinkReachChoices.get_select_options(links), + "link_select_options": models.LinkReachChoices.get_select_options( + links_definitions + ), "media_auth": True, "move": False, "partial_update": grand_parent.link_role == "editor", @@ -266,6 +270,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea assert response.status_code == 200 links = document.get_ancestors().values("link_reach", "link_role") + links_definitions = document.get_ancestors_links_definitions(links) assert response.json() == { "id": str(document.id), "abilities": { @@ -288,7 +293,9 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea "favorite": True, "invite_owner": False, "link_configuration": False, - "link_select_options": models.LinkReachChoices.get_select_options(links), + "link_select_options": models.LinkReachChoices.get_select_options( + links_definitions + ), "move": False, "media_auth": True, "partial_update": grand_parent.link_role == "editor", @@ -450,6 +457,7 @@ def test_api_documents_retrieve_authenticated_related_parent(): ) assert response.status_code == 200 links = document.get_ancestors().values("link_reach", "link_role") + links_definitions = document.get_ancestors_links_definitions(links) ancestors_roles = list({grand_parent.link_role, parent.link_role}) assert response.json() == { "id": str(document.id), @@ -470,7 +478,9 @@ def test_api_documents_retrieve_authenticated_related_parent(): "favorite": True, "invite_owner": access.role == "owner", "link_configuration": access.role in ["administrator", "owner"], - "link_select_options": models.LinkReachChoices.get_select_options(links), + "link_select_options": models.LinkReachChoices.get_select_options( + links_definitions + ), "media_auth": True, "move": access.role in ["administrator", "owner"], "partial_update": access.role != "reader", diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index 2c5c59a0f..49215f53b 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -1176,29 +1176,33 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): @pytest.mark.parametrize( - "ancestors_links, select_options", + "reach, role, select_options", [ # One ancestor ( - [{"link_reach": "public", "link_role": "reader"}], + "public", + "reader", { "public": ["reader", "editor"], }, ), - ([{"link_reach": "public", "link_role": "editor"}], {"public": ["editor"]}), + ("public", "editor", {"public": ["editor"]}), ( - [{"link_reach": "authenticated", "link_role": "reader"}], + "authenticated", + "reader", { "authenticated": ["reader", "editor"], "public": ["reader", "editor"], }, ), ( - [{"link_reach": "authenticated", "link_role": "editor"}], + "authenticated", + "editor", {"authenticated": ["editor"], "public": ["reader", "editor"]}, ), ( - [{"link_reach": "restricted", "link_role": "reader"}], + "restricted", + "reader", { "restricted": None, "authenticated": ["reader", "editor"], @@ -1206,94 +1210,36 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): }, ), ( - [{"link_reach": "restricted", "link_role": "editor"}], + "restricted", + "editor", { "restricted": None, "authenticated": ["reader", "editor"], "public": ["reader", "editor"], }, ), - # Multiple ancestors with different roles - ( - [ - {"link_reach": "public", "link_role": "reader"}, - {"link_reach": "public", "link_role": "editor"}, - ], - {"public": ["editor"]}, - ), - ( - [ - {"link_reach": "authenticated", "link_role": "reader"}, - {"link_reach": "authenticated", "link_role": "editor"}, - ], - {"authenticated": ["editor"], "public": ["reader", "editor"]}, - ), - ( - [ - {"link_reach": "restricted", "link_role": "reader"}, - {"link_reach": "restricted", "link_role": "editor"}, - ], - { - "restricted": None, - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], - }, - ), - # Multiple ancestors with different reaches + # No ancestors (edge case) ( - [ - {"link_reach": "authenticated", "link_role": "reader"}, - {"link_reach": "public", "link_role": "reader"}, - ], + "public", + None, { "public": ["reader", "editor"], + "authenticated": ["reader", "editor"], + "restricted": None, }, ), ( - [ - {"link_reach": "restricted", "link_role": "reader"}, - {"link_reach": "authenticated", "link_role": "reader"}, - {"link_reach": "public", "link_role": "reader"}, - ], + None, + "reader", { "public": ["reader", "editor"], - }, - ), - # Multiple ancestors with mixed reaches and roles - ( - [ - {"link_reach": "authenticated", "link_role": "editor"}, - {"link_reach": "public", "link_role": "reader"}, - ], - {"public": ["reader", "editor"]}, - ), - ( - [ - {"link_reach": "authenticated", "link_role": "reader"}, - {"link_reach": "public", "link_role": "editor"}, - ], - {"public": ["editor"]}, - ), - ( - [ - {"link_reach": "restricted", "link_role": "editor"}, - {"link_reach": "authenticated", "link_role": "reader"}, - ], - { "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "restricted": None, }, ), ( - [ - {"link_reach": "restricted", "link_role": "reader"}, - {"link_reach": "authenticated", "link_role": "editor"}, - ], - {"authenticated": ["editor"], "public": ["reader", "editor"]}, - ), - # No ancestors (edge case) - ( - [], + None, + None, { "public": ["reader", "editor"], "authenticated": ["reader", "editor"], @@ -1302,9 +1248,9 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): ), ], ) -def test_models_documents_get_select_options(ancestors_links, select_options): +def test_models_documents_get_select_options(reach, role, select_options): """Validate that the "get_select_options" method operates as expected.""" - assert models.LinkReachChoices.get_select_options(ancestors_links) == select_options + assert models.LinkReachChoices.get_select_options(reach, role) == select_options def test_models_documents_compute_ancestors_links_no_highest_readable(): From 4d6c4a0187248854ae9c23582bb2dd0a6909b496 Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Fri, 2 May 2025 06:39:50 +0200 Subject: [PATCH 009/104] =?UTF-8?q?=E2=9C=85(backend)=20fix=20randomly=20f?= =?UTF-8?q?ailing=20test=20on=20user=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user account created to query the API had a random email that could randomly interfere with our search results. --- src/backend/core/tests/test_api_users.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/core/tests/test_api_users.py b/src/backend/core/tests/test_api_users.py index 91863dc53..179b19add 100644 --- a/src/backend/core/tests/test_api_users.py +++ b/src/backend/core/tests/test_api_users.py @@ -186,7 +186,7 @@ def test_api_users_list_query_short_queries(): """ Queries shorter than 5 characters should return an empty result set. """ - user = factories.UserFactory() + user = factories.UserFactory(email="paul@example.com") client = APIClient() client.force_login(user) From fbcd5d0e77c36f449341ba2c3cfe32ba26f6c0bc Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Fri, 25 Apr 2025 08:03:12 +0200 Subject: [PATCH 010/104] =?UTF-8?q?=E2=99=BB=EF=B8=8F(backend)=20simplify?= =?UTF-8?q?=20roles=20by=20returning=20only=20the=20max=20role?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We were returning the list of roles a user has on a document (direct and inherited). Now that we introduced priority on roles, we are able to determine what is the max role and return only this one. This commit also changes the role that is returned for the restricted reach: we now return None because the role is not relevant in this case. --- CHANGELOG.md | 8 +- src/backend/core/api/serializers.py | 44 ++--- src/backend/core/api/viewsets.py | 61 +++--- src/backend/core/choices.py | 40 +++- src/backend/core/models.py | 178 +++++++++--------- .../test_api_documents_children_create.py | 4 +- .../test_api_documents_children_list.py | 125 +++++++----- .../test_api_documents_descendants.py | 42 ++--- .../test_api_documents_favorite_list.py | 2 +- .../documents/test_api_documents_list.py | 10 +- .../documents/test_api_documents_retrieve.py | 85 ++++----- .../documents/test_api_documents_trashbin.py | 3 +- .../documents/test_api_documents_tree.py | 73 +++---- .../core/tests/test_models_documents.py | 81 ++++---- 14 files changed, 406 insertions(+), 350 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34d2a8067..072352803 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,7 +43,7 @@ and this project adheres to ## Added -- ✨(backend) include ancestors accesses on document accesses list view # 846 +- ✨(backend) include ancestors accesses on document accesses list view #846 - ✨(backend) add ancestors links definitions to document abilities #846 - 🚸(backend) make document search on title accent-insensitive #874 - 🚩 add homepage feature flag #861 @@ -56,22 +56,20 @@ and this project adheres to ## Changed +- ♻️(backend) simplify roles by ranking them and return only the max role #846 - ⚡️(frontend) reduce unblocking time for config #867 - ♻️(frontend) bind UI with ability access #900 - ♻️(frontend) use built-in Quote block #908 ## Fixed +- 🐛(backend) fix link definition select options linked to ancestors #846 - 🐛(nginx) fix 404 when accessing a doc #866 - 🔒️(drf) disable browsable HTML API renderer #919 - 🔒(frontend) enhance file download security #889 - 🐛(backend) race condition create doc #633 - 🐛(frontend) fix breaklines in custom blocks #908 -## Fixed - -- 🐛(backend) fix link definition select options linked to ancestors #846 - ## [3.1.0] - 2025-04-07 ## Added diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index f50a85afa..23c8ccdc5 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -171,7 +171,7 @@ class ListDocumentSerializer(serializers.ModelSerializer): is_favorite = serializers.BooleanField(read_only=True) nb_accesses_ancestors = serializers.IntegerField(read_only=True) nb_accesses_direct = serializers.IntegerField(read_only=True) - user_roles = serializers.SerializerMethodField(read_only=True) + user_role = serializers.SerializerMethodField(read_only=True) abilities = serializers.SerializerMethodField(read_only=True) class Meta: @@ -192,7 +192,7 @@ class Meta: "path", "title", "updated_at", - "user_roles", + "user_role", ] read_only_fields = [ "id", @@ -209,34 +209,36 @@ class Meta: "numchild", "path", "updated_at", - "user_roles", + "user_role", ] - def get_abilities(self, document) -> dict: - """Return abilities of the logged-in user on the instance.""" - request = self.context.get("request") + def to_representation(self, instance): + """Precompute once per instance""" + paths_links_mapping = self.context.get("paths_links_mapping") - if request: - paths_links_mapping = self.context.get("paths_links_mapping", None) - # Retrieve ancestor links from paths_links_mapping (if provided) - ancestors_links = ( - paths_links_mapping.get(document.path[: -document.steplen]) - if paths_links_mapping - else None + if paths_links_mapping is not None: + links = paths_links_mapping.get(instance.path[: -instance.steplen], []) + instance.ancestors_link_definition = choices.get_equivalent_link_definition( + links ) - return document.get_abilities(request.user, ancestors_links=ancestors_links) - return {} + return super().to_representation(instance) - def get_user_roles(self, document): + def get_abilities(self, instance) -> dict: + """Return abilities of the logged-in user on the instance.""" + request = self.context.get("request") + if not request: + return {} + + return instance.get_abilities(request.user) + + def get_user_role(self, instance): """ Return roles of the logged-in user for the current document, taking into account ancestors. """ request = self.context.get("request") - if request: - return document.get_roles(request.user) - return [] + return instance.get_role(request.user) if request else None class DocumentSerializer(ListDocumentSerializer): @@ -263,7 +265,7 @@ class Meta: "path", "title", "updated_at", - "user_roles", + "user_role", ] read_only_fields = [ "id", @@ -279,7 +281,7 @@ class Meta: "numchild", "path", "updated_at", - "user_roles", + "user_role", ] def get_fields(self): diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index c632ba661..a2472fd57 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -439,14 +439,15 @@ def filter_queryset(self, queryset): queryset = queryset.annotate_user_roles(user) return queryset - def get_response_for_queryset(self, queryset): + def get_response_for_queryset(self, queryset, context=None): """Return paginated response for the queryset if requested.""" + context = context or self.get_serializer_context() page = self.paginate_queryset(queryset) if page is not None: - serializer = self.get_serializer(page, many=True) + serializer = self.get_serializer(page, many=True, context=context) return self.get_paginated_response(serializer.data) - serializer = self.get_serializer(queryset, many=True) + serializer = self.get_serializer(queryset, many=True, context=context) return drf.response.Response(serializer.data) def list(self, request, *args, **kwargs): @@ -456,9 +457,6 @@ def list(self, request, *args, **kwargs): This method applies filtering based on request parameters using `ListDocumentFilter`. It performs early filtering on model fields, annotates user roles, and removes descendant documents to keep only the highest ancestors readable by the current user. - - Additional annotations (e.g., `is_highest_ancestor_for_user`, favorite status) are - applied before ordering and returning the response. """ user = self.request.user @@ -486,12 +484,6 @@ def list(self, request, *args, **kwargs): ) queryset = queryset.filter(path__in=root_paths) - # Annotate the queryset with an attribute marking instances as highest ancestor - # in order to save some time while computing abilities on the instance - queryset = queryset.annotate( - is_highest_ancestor_for_user=db.Value(True, output_field=db.BooleanField()) - ) - # Annotate favorite status and filter if applicable as late as possible queryset = queryset.annotate_is_favorite(user) queryset = filterset.filters["is_favorite"].filter( @@ -746,7 +738,17 @@ def children(self, request, *args, **kwargs): queryset = filterset.qs - return self.get_response_for_queryset(queryset) + # Pass ancestors' links paths mapping to the serializer as a context variable + # in order to allow saving time while computing abilities on the instance + paths_links_mapping = document.compute_ancestors_links_paths_mapping() + + return self.get_response_for_queryset( + queryset, + context={ + "request": request, + "paths_links_mapping": paths_links_mapping, + }, + ) @drf.decorators.action( detail=True, @@ -805,13 +807,6 @@ def tree(self, request, pk, *args, **kwargs): ancestors_links = [] children_clause = db.Q() for ancestor in ancestors: - if ancestor.depth < highest_readable.depth: - continue - - children_clause |= db.Q( - path__startswith=ancestor.path, depth=ancestor.depth + 1 - ) - # Compute cache for ancestors links to avoid many queries while computing # abilities for his documents in the tree! ancestors_links.append( @@ -819,25 +814,21 @@ def tree(self, request, pk, *args, **kwargs): ) paths_links_mapping[ancestor.path] = ancestors_links.copy() + if ancestor.depth < highest_readable.depth: + continue + + children_clause |= db.Q( + path__startswith=ancestor.path, depth=ancestor.depth + 1 + ) + children = self.queryset.filter(children_clause, deleted_at__isnull=True) queryset = ancestors.filter(depth__gte=highest_readable.depth) | children queryset = queryset.order_by("path") - # Annotate if the current document is the highest ancestor for the user - queryset = queryset.annotate( - is_highest_ancestor_for_user=db.Case( - db.When( - path=db.Value(highest_readable.path), - then=db.Value(True), - ), - default=db.Value(False), - output_field=db.BooleanField(), - ) - ) queryset = queryset.annotate_user_roles(user) queryset = queryset.annotate_is_favorite(user) - # Pass ancestors' links definitions to the serializer as a context variable + # Pass ancestors' links paths mapping to the serializer as a context variable # in order to allow saving time while computing abilities on the instance serializer = self.get_serializer( queryset, @@ -1382,8 +1373,8 @@ def list(self, request, *args, **kwargs): except models.Document.DoesNotExist: return drf.response.Response([]) - roles = set(document.get_roles(user)) - if not roles: + role = document.get_role(user) + if role is None: return drf.response.Response([]) ancestors = ( @@ -1401,7 +1392,7 @@ def list(self, request, *args, **kwargs): document__in=ancestors.filter(depth__gte=highest_readable.depth) ) - is_privileged = bool(roles.intersection(set(choices.PRIVILEGED_ROLES))) + is_privileged = role in choices.PRIVILEGED_ROLES if is_privileged: serializer_class = serializers.DocumentAccessSerializer else: diff --git a/src/backend/core/choices.py b/src/backend/core/choices.py index ff618f216..f1a0e2981 100644 --- a/src/backend/core/choices.py +++ b/src/backend/core/choices.py @@ -11,10 +11,11 @@ class PriorityTextChoices(TextChoices): """ @classmethod - def get_priority(cls, value): - """Returns the priority of the given value based on its order in the class.""" + def get_priority(cls, role): + """Returns the priority of the given role based on its order in the class.""" + members = list(cls.__members__.values()) - return members.index(value) + 1 if value in members else 0 + return members.index(role) + 1 if role in members else 0 @classmethod def max(cls, *roles): @@ -22,7 +23,6 @@ def max(cls, *roles): Return the highest-priority role among the given roles, using get_priority(). If no valid roles are provided, returns None. """ - valid_roles = [role for role in roles if cls.get_priority(role) is not None] if not valid_roles: return None @@ -61,7 +61,6 @@ class LinkReachChoices(PriorityTextChoices): ) # Any authenticated user can access the document PUBLIC = "public", _("Public") # Even anonymous users can access the document - @classmethod def get_select_options(cls, link_reach, link_role): """ @@ -110,3 +109,34 @@ def get_select_options(cls, link_reach, link_role): reach: sorted(roles, key=LinkRoleChoices.get_priority) if roles else roles for reach, roles in result.items() } + + +def get_equivalent_link_definition(ancestors_links): + """ + Return the (reach, role) pair with: + 1. Highest reach + 2. Highest role among links having that reach + """ + if not ancestors_links: + return {"link_reach": None, "link_role": None} + + # 1) Find the highest reach + max_reach = max( + ancestors_links, + key=lambda link: LinkReachChoices.get_priority(link["link_reach"]), + )["link_reach"] + + # 2) Among those, find the highest role (ignore role if RESTRICTED) + if max_reach == LinkReachChoices.RESTRICTED: + max_role = None + else: + max_role = max( + ( + link["link_role"] + for link in ancestors_links + if link["link_reach"] == max_reach + ), + key=LinkRoleChoices.get_priority, + ) + + return {"link_reach": max_reach, "link_role": max_role} diff --git a/src/backend/core/models.py b/src/backend/core/models.py index f1df432ce..762881c57 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -6,7 +6,6 @@ import hashlib import smtplib import uuid -from collections import defaultdict from datetime import timedelta from logging import getLogger @@ -33,7 +32,13 @@ from timezone_field import TimeZoneField from treebeard.mp_tree import MP_Node, MP_NodeManager, MP_NodeQuerySet -from .choices import PRIVILEGED_ROLES, LinkReachChoices, LinkRoleChoices, RoleChoices +from .choices import ( + PRIVILEGED_ROLES, + LinkReachChoices, + LinkRoleChoices, + RoleChoices, + get_equivalent_link_definition, +) logger = getLogger(__name__) @@ -284,9 +289,9 @@ class BaseAccess(BaseModel): class Meta: abstract = True - def _get_roles(self, resource, user): + def _get_role(self, resource, user): """ - Get the roles a user has on a resource. + Get the role a user has on a resource. """ roles = [] if user.is_authenticated: @@ -301,23 +306,20 @@ def _get_roles(self, resource, user): except (self._meta.model.DoesNotExist, IndexError): roles = [] - return roles + return RoleChoices.max(*roles) def _get_abilities(self, resource, user): """ Compute and return abilities for a given user taking into account the current state of the object. """ - roles = self._get_roles(resource, user) + role = self._get_role(resource, user) + is_owner_or_admin = role in (RoleChoices.OWNER, RoleChoices.ADMIN) - is_owner_or_admin = bool( - set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN}) - ) if self.role == RoleChoices.OWNER: - can_delete = ( - RoleChoices.OWNER in roles - and resource.accesses.filter(role=RoleChoices.OWNER).count() > 1 - ) + can_delete = (role == RoleChoices.OWNER) and resource.accesses.filter( + role=RoleChoices.OWNER + ).count() > 1 set_role_to = ( [RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER] if can_delete @@ -326,7 +328,7 @@ def _get_abilities(self, resource, user): else: can_delete = is_owner_or_admin set_role_to = [] - if RoleChoices.OWNER in roles: + if role == RoleChoices.OWNER: set_role_to.append(RoleChoices.OWNER) if is_owner_or_admin: set_role_to.extend( @@ -343,7 +345,7 @@ def _get_abilities(self, resource, user): "destroy": can_delete, "update": bool(set_role_to), "partial_update": bool(set_role_to), - "retrieve": bool(roles), + "retrieve": bool(role), "set_role_to": set_role_to, } @@ -419,6 +421,7 @@ def get_queryset(self): return self._queryset_class(self.model).order_by("path") +# pylint: disable=too-many-public-methods class Document(MP_Node, BaseModel): """Pad document carrying the content.""" @@ -486,6 +489,11 @@ class Meta: def __str__(self): return str(self.title) if self.title else str(_("Untitled Document")) + def __init__(self, *args, **kwargs): + """Initialize cache property.""" + super().__init__(*args, **kwargs) + self._ancestors_link_definition = None + def save(self, *args, **kwargs): """Write content to object storage only if _content has changed.""" super().save(*args, **kwargs) @@ -673,37 +681,22 @@ def invalidate_nb_accesses_cache(self): cache_key = document.get_nb_accesses_cache_key() cache.delete(cache_key) - def get_roles(self, user): + def get_role(self, user): """Return the roles a user has on a document.""" if not user.is_authenticated: - return [] + return None try: roles = self.user_roles or [] except AttributeError: - try: - roles = DocumentAccess.objects.filter( - models.Q(user=user) | models.Q(team__in=user.teams), - document__path=Left( - models.Value(self.path), Length("document__path") - ), - ).values_list("role", flat=True) - except (models.ObjectDoesNotExist, IndexError): - roles = [] - return roles - - def get_ancestors_links_definitions(self, ancestors_links): - """Get links reach/role definitions for ancestors of the current document.""" - - ancestors_links_definitions = defaultdict(set) - for ancestor in ancestors_links: - ancestors_links_definitions[ancestor["link_reach"]].add( - ancestor["link_role"] - ) + roles = DocumentAccess.objects.filter( + models.Q(user=user) | models.Q(team__in=user.teams), + document__path=Left(models.Value(self.path), Length("document__path")), + ).values_list("role", flat=True) - return ancestors_links_definitions + return RoleChoices.max(*roles) - def compute_ancestors_links(self, user): + def compute_ancestors_links_paths_mapping(self): """ Compute the ancestors links for the current document up to the highest readable ancestor. """ @@ -712,73 +705,87 @@ def compute_ancestors_links(self, user): .filter(ancestors_deleted_at__isnull=True) .order_by("path") ) - highest_readable = ancestors.readable_per_se(user).only("depth").first() - - if highest_readable is None: - return [] - ancestors_links = [] paths_links_mapping = {} - for ancestor in ancestors.filter(depth__gte=highest_readable.depth): + + for ancestor in ancestors: ancestors_links.append( {"link_reach": ancestor.link_reach, "link_role": ancestor.link_role} ) paths_links_mapping[ancestor.path] = ancestors_links.copy() - ancestors_links = paths_links_mapping.get(self.path[: -self.steplen], []) + return paths_links_mapping + + @property + def ancestors_link_definition(self): + """Link defintion equivalent to all document's ancestors.""" + if getattr(self, "_ancestors_link_definition", None) is None: + if self.depth <= 1: + ancestors_links = [] + else: + mapping = self.compute_ancestors_links_paths_mapping() + ancestors_links = mapping.get(self.path[: -self.steplen], []) + self._ancestors_link_definition = get_equivalent_link_definition( + ancestors_links + ) - return ancestors_links + return self._ancestors_link_definition + + @ancestors_link_definition.setter + def ancestors_link_definition(self, definition): + """Cache the ancestors_link_definition.""" + self._ancestors_link_definition = definition + + @property + def ancestors_link_reach(self): + """Link reach equivalent to all document's ancestors.""" + return self.ancestors_link_definition["link_reach"] - def get_abilities(self, user, ancestors_links=None): + @property + def ancestors_link_role(self): + """Link role equivalent to all document's ancestors.""" + return self.ancestors_link_definition["link_role"] + + def get_abilities(self, user): """ Compute and return abilities for a given user on the document. """ - if self.depth <= 1 or getattr(self, "is_highest_ancestor_for_user", False): - ancestors_links = [] - elif ancestors_links is None: - ancestors_links = self.compute_ancestors_links(user=user) - - roles = set( - self.get_roles(user) - ) # at this point only roles based on specific access + # First get the role based on specific access + role = self.get_role(user) # Characteristics that are based only on specific access - is_owner = RoleChoices.OWNER in roles + is_owner = role == RoleChoices.OWNER is_deleted = self.ancestors_deleted_at and not is_owner - is_owner_or_admin = (is_owner or RoleChoices.ADMIN in roles) and not is_deleted + is_owner_or_admin = (is_owner or role == RoleChoices.ADMIN) and not is_deleted # Compute access roles before adding link roles because we don't # want anonymous users to access versions (we wouldn't know from # which date to allow them anyway) # Anonymous users should also not see document accesses - has_access_role = bool(roles) and not is_deleted + has_access_role = bool(role) and not is_deleted can_update_from_access = ( - is_owner_or_admin or RoleChoices.EDITOR in roles + is_owner_or_admin or role == RoleChoices.EDITOR ) and not is_deleted - # Add roles provided by the document link, taking into account its ancestors - ancestors_links_definitions = self.get_ancestors_links_definitions( - ancestors_links + link_select_options = LinkReachChoices.get_select_options( + **self.ancestors_link_definition ) - - public_roles = ancestors_links_definitions.get( - LinkReachChoices.PUBLIC, set() - ) | ({self.link_role} if self.link_reach == LinkReachChoices.PUBLIC else set()) - authenticated_roles = ( - ancestors_links_definitions.get(LinkReachChoices.AUTHENTICATED, set()) - | ( - {self.link_role} - if self.link_reach == LinkReachChoices.AUTHENTICATED - else set() - ) - if user.is_authenticated - else set() + link_definition = get_equivalent_link_definition( + [ + self.ancestors_link_definition, + {"link_reach": self.link_reach, "link_role": self.link_role}, + ] ) - roles = roles | public_roles | authenticated_roles - can_get = bool(roles) and not is_deleted + link_reach = link_definition["link_reach"] + if link_reach == LinkReachChoices.PUBLIC or ( + link_reach == LinkReachChoices.AUTHENTICATED and user.is_authenticated + ): + role = RoleChoices.max(role, link_definition["link_role"]) + + can_get = bool(role) and not is_deleted can_update = ( - is_owner_or_admin or RoleChoices.EDITOR in roles + is_owner_or_admin or role == RoleChoices.EDITOR ) and not is_deleted ai_allow_reach_from = settings.AI_ALLOW_REACH_FROM @@ -814,12 +821,7 @@ def get_abilities(self, user, ancestors_links=None): "restore": is_owner, "retrieve": can_get, "media_auth": can_get, - "ancestors_links_definitions": { - k: list(v) for k, v in ancestors_links_definitions.items() - }, - "link_select_options": LinkReachChoices.get_select_options( - ancestors_links_definitions - ), + "link_select_options": link_select_options, "tree": can_get, "update": can_update, "versions_destroy": is_owner_or_admin, @@ -1080,11 +1082,11 @@ def get_abilities(self, user): """ Compute and return abilities for a given user on the document access. """ - roles = self._get_roles(self.document, user) - is_owner_or_admin = bool(set(roles).intersection(set(PRIVILEGED_ROLES))) + role = self._get_role(self.document, user) + is_owner_or_admin = role in PRIVILEGED_ROLES if self.role == RoleChoices.OWNER: can_delete = ( - RoleChoices.OWNER in roles + role == RoleChoices.OWNER and self.document.accesses.filter(role=RoleChoices.OWNER).count() > 1 ) set_role_to = ( @@ -1095,7 +1097,7 @@ def get_abilities(self, user): else: can_delete = is_owner_or_admin set_role_to = [] - if RoleChoices.OWNER in roles: + if role == RoleChoices.OWNER: set_role_to.append(RoleChoices.OWNER) if is_owner_or_admin: set_role_to.extend( 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 5aea1b605..c5b6f2bf7 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 @@ -98,7 +98,9 @@ def test_api_documents_children_create_authenticated_success(reach, role, depth) if i == 0: document = factories.DocumentFactory(link_reach=reach, link_role=role) else: - document = factories.DocumentFactory(parent=document, link_role="reader") + document = factories.DocumentFactory( + parent=document, link_reach="restricted" + ) response = client.post( f"/api/v1.0/documents/{document.id!s}/children/", diff --git a/src/backend/core/tests/documents/test_api_documents_children_list.py b/src/backend/core/tests/documents/test_api_documents_children_list.py index 96e1d9b43..0267345b6 100644 --- a/src/backend/core/tests/documents/test_api_documents_children_list.py +++ b/src/backend/core/tests/documents/test_api_documents_children_list.py @@ -14,13 +14,18 @@ pytestmark = pytest.mark.django_db -def test_api_documents_children_list_anonymous_public_standalone(): +def test_api_documents_children_list_anonymous_public_standalone( + django_assert_num_queries, +): """Anonymous users should be allowed to retrieve the children of a public document.""" document = factories.DocumentFactory(link_reach="public") child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) factories.UserDocumentAccessFactory(document=child1) - response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/") + with django_assert_num_queries(8): + APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/") + with django_assert_num_queries(4): + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/") assert response.status_code == 200 assert response.json() == { @@ -44,7 +49,7 @@ def test_api_documents_children_list_anonymous_public_standalone(): "path": child1.path, "title": child1.title, "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, }, { "abilities": child2.get_abilities(AnonymousUser()), @@ -62,13 +67,13 @@ def test_api_documents_children_list_anonymous_public_standalone(): "path": child2.path, "title": child2.title, "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, }, ], } -def test_api_documents_children_list_anonymous_public_parent(): +def test_api_documents_children_list_anonymous_public_parent(django_assert_num_queries): """ Anonymous users should be allowed to retrieve the children of a document who has a public ancestor. @@ -83,7 +88,10 @@ def test_api_documents_children_list_anonymous_public_parent(): child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) factories.UserDocumentAccessFactory(document=child1) - response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/") + with django_assert_num_queries(9): + APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/") + with django_assert_num_queries(5): + response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/children/") assert response.status_code == 200 assert response.json() == { @@ -107,7 +115,7 @@ def test_api_documents_children_list_anonymous_public_parent(): "path": child1.path, "title": child1.title, "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, }, { "abilities": child2.get_abilities(AnonymousUser()), @@ -125,7 +133,7 @@ def test_api_documents_children_list_anonymous_public_parent(): "path": child2.path, "title": child2.title, "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, }, ], } @@ -149,7 +157,7 @@ def test_api_documents_children_list_anonymous_restricted_or_authenticated(reach @pytest.mark.parametrize("reach", ["public", "authenticated"]) def test_api_documents_children_list_authenticated_unrelated_public_or_authenticated( - reach, + reach, django_assert_num_queries ): """ Authenticated users should be able to retrieve the children of a public/authenticated @@ -163,9 +171,13 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) factories.UserDocumentAccessFactory(document=child1) - response = client.get( - f"/api/v1.0/documents/{document.id!s}/children/", - ) + with django_assert_num_queries(9): + client.get(f"/api/v1.0/documents/{document.id!s}/children/") + with django_assert_num_queries(5): + response = client.get( + f"/api/v1.0/documents/{document.id!s}/children/", + ) + assert response.status_code == 200 assert response.json() == { "count": 2, @@ -188,7 +200,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic "path": child1.path, "title": child1.title, "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, }, { "abilities": child2.get_abilities(user), @@ -206,7 +218,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic "path": child2.path, "title": child2.title, "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, }, ], } @@ -214,7 +226,7 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic @pytest.mark.parametrize("reach", ["public", "authenticated"]) def test_api_documents_children_list_authenticated_public_or_authenticated_parent( - reach, + reach, django_assert_num_queries ): """ Authenticated users should be allowed to retrieve the children of a document who @@ -231,7 +243,11 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) factories.UserDocumentAccessFactory(document=child1) - response = client.get(f"/api/v1.0/documents/{document.id!s}/children/") + with django_assert_num_queries(10): + client.get(f"/api/v1.0/documents/{document.id!s}/children/") + + with django_assert_num_queries(6): + response = client.get(f"/api/v1.0/documents/{document.id!s}/children/") assert response.status_code == 200 assert response.json() == { @@ -255,7 +271,7 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren "path": child1.path, "title": child1.title, "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, }, { "abilities": child2.get_abilities(user), @@ -273,13 +289,15 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren "path": child2.path, "title": child2.title, "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, }, ], } -def test_api_documents_children_list_authenticated_unrelated_restricted(): +def test_api_documents_children_list_authenticated_unrelated_restricted( + django_assert_num_queries, +): """ Authenticated users should not be allowed to retrieve the children of a document that is restricted and to which they are not related. @@ -293,16 +311,20 @@ def test_api_documents_children_list_authenticated_unrelated_restricted(): child1, _child2 = factories.DocumentFactory.create_batch(2, parent=document) factories.UserDocumentAccessFactory(document=child1) - response = client.get( - f"/api/v1.0/documents/{document.id!s}/children/", - ) + with django_assert_num_queries(2): + response = client.get( + f"/api/v1.0/documents/{document.id!s}/children/", + ) + assert response.status_code == 403 assert response.json() == { "detail": "You do not have permission to perform this action." } -def test_api_documents_children_list_authenticated_related_direct(): +def test_api_documents_children_list_authenticated_related_direct( + django_assert_num_queries, +): """ Authenticated users should be allowed to retrieve the children of a document to which they are directly related whatever the role. @@ -319,9 +341,11 @@ def test_api_documents_children_list_authenticated_related_direct(): child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) factories.UserDocumentAccessFactory(document=child1) - response = client.get( - f"/api/v1.0/documents/{document.id!s}/children/", - ) + with django_assert_num_queries(9): + response = client.get( + f"/api/v1.0/documents/{document.id!s}/children/", + ) + assert response.status_code == 200 assert response.json() == { "count": 2, @@ -344,7 +368,7 @@ def test_api_documents_children_list_authenticated_related_direct(): "path": child1.path, "title": child1.title, "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [access.role], + "user_role": access.role, }, { "abilities": child2.get_abilities(user), @@ -362,13 +386,15 @@ def test_api_documents_children_list_authenticated_related_direct(): "path": child2.path, "title": child2.title, "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [access.role], + "user_role": access.role, }, ], } -def test_api_documents_children_list_authenticated_related_parent(): +def test_api_documents_children_list_authenticated_related_parent( + django_assert_num_queries, +): """ Authenticated users should be allowed to retrieve the children of a document if they are related to one of its ancestors whatever the role. @@ -389,9 +415,11 @@ def test_api_documents_children_list_authenticated_related_parent(): document=grand_parent, user=user ) - response = client.get( - f"/api/v1.0/documents/{document.id!s}/children/", - ) + with django_assert_num_queries(10): + response = client.get( + f"/api/v1.0/documents/{document.id!s}/children/", + ) + assert response.status_code == 200 assert response.json() == { "count": 2, @@ -414,7 +442,7 @@ def test_api_documents_children_list_authenticated_related_parent(): "path": child1.path, "title": child1.title, "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [grand_parent_access.role], + "user_role": grand_parent_access.role, }, { "abilities": child2.get_abilities(user), @@ -432,13 +460,15 @@ def test_api_documents_children_list_authenticated_related_parent(): "path": child2.path, "title": child2.title, "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [grand_parent_access.role], + "user_role": grand_parent_access.role, }, ], } -def test_api_documents_children_list_authenticated_related_child(): +def test_api_documents_children_list_authenticated_related_child( + django_assert_num_queries, +): """ Authenticated users should not be allowed to retrieve all the children of a document as a result of being related to one of its children. @@ -454,16 +484,20 @@ def test_api_documents_children_list_authenticated_related_child(): factories.UserDocumentAccessFactory(document=child1, user=user) factories.UserDocumentAccessFactory(document=document) - response = client.get( - f"/api/v1.0/documents/{document.id!s}/children/", - ) + with django_assert_num_queries(2): + response = client.get( + f"/api/v1.0/documents/{document.id!s}/children/", + ) + assert response.status_code == 403 assert response.json() == { "detail": "You do not have permission to perform this action." } -def test_api_documents_children_list_authenticated_related_team_none(mock_user_teams): +def test_api_documents_children_list_authenticated_related_team_none( + mock_user_teams, django_assert_num_queries +): """ Authenticated users should not be able to retrieve the children of a restricted document related to teams in which the user is not. @@ -480,7 +514,9 @@ def test_api_documents_children_list_authenticated_related_team_none(mock_user_t factories.TeamDocumentAccessFactory(document=document, team="myteam") - response = client.get(f"/api/v1.0/documents/{document.id!s}/children/") + with django_assert_num_queries(2): + response = client.get(f"/api/v1.0/documents/{document.id!s}/children/") + assert response.status_code == 403 assert response.json() == { "detail": "You do not have permission to perform this action." @@ -488,7 +524,7 @@ def test_api_documents_children_list_authenticated_related_team_none(mock_user_t def test_api_documents_children_list_authenticated_related_team_members( - mock_user_teams, + mock_user_teams, django_assert_num_queries ): """ Authenticated users should be allowed to retrieve the children of a document to which they @@ -506,7 +542,8 @@ def test_api_documents_children_list_authenticated_related_team_members( access = factories.TeamDocumentAccessFactory(document=document, team="myteam") - response = client.get(f"/api/v1.0/documents/{document.id!s}/children/") + with django_assert_num_queries(9): + response = client.get(f"/api/v1.0/documents/{document.id!s}/children/") # pylint: disable=R0801 assert response.status_code == 200 @@ -531,7 +568,7 @@ def test_api_documents_children_list_authenticated_related_team_members( "path": child1.path, "title": child1.title, "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [access.role], + "user_role": access.role, }, { "abilities": child2.get_abilities(user), @@ -549,7 +586,7 @@ def test_api_documents_children_list_authenticated_related_team_members( "path": child2.path, "title": child2.title, "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [access.role], + "user_role": access.role, }, ], } diff --git a/src/backend/core/tests/documents/test_api_documents_descendants.py b/src/backend/core/tests/documents/test_api_documents_descendants.py index 302af2318..c3e7000b3 100644 --- a/src/backend/core/tests/documents/test_api_documents_descendants.py +++ b/src/backend/core/tests/documents/test_api_documents_descendants.py @@ -46,7 +46,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone(): "path": child1.path, "title": child1.title, "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, }, { "abilities": grand_child.get_abilities(AnonymousUser()), @@ -64,7 +64,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone(): "path": grand_child.path, "title": grand_child.title, "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, }, { "abilities": child2.get_abilities(AnonymousUser()), @@ -82,7 +82,7 @@ def test_api_documents_descendants_list_anonymous_public_standalone(): "path": child2.path, "title": child2.title, "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, }, ], } @@ -129,7 +129,7 @@ def test_api_documents_descendants_list_anonymous_public_parent(): "path": child1.path, "title": child1.title, "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, }, { "abilities": grand_child.get_abilities(AnonymousUser()), @@ -147,7 +147,7 @@ def test_api_documents_descendants_list_anonymous_public_parent(): "path": grand_child.path, "title": grand_child.title, "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, }, { "abilities": child2.get_abilities(AnonymousUser()), @@ -165,7 +165,7 @@ def test_api_documents_descendants_list_anonymous_public_parent(): "path": child2.path, "title": child2.title, "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, }, ], } @@ -231,7 +231,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen "path": child1.path, "title": child1.title, "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, }, { "abilities": grand_child.get_abilities(user), @@ -249,7 +249,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen "path": grand_child.path, "title": grand_child.title, "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, }, { "abilities": child2.get_abilities(user), @@ -267,7 +267,7 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen "path": child2.path, "title": child2.title, "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, }, ], } @@ -318,7 +318,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa "path": child1.path, "title": child1.title, "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, }, { "abilities": grand_child.get_abilities(user), @@ -336,7 +336,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa "path": grand_child.path, "title": grand_child.title, "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, }, { "abilities": child2.get_abilities(user), @@ -354,7 +354,7 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa "path": child2.path, "title": child2.title, "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, }, ], } @@ -428,7 +428,7 @@ def test_api_documents_descendants_list_authenticated_related_direct(): "path": child1.path, "title": child1.title, "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [access.role], + "user_role": access.role, }, { "abilities": grand_child.get_abilities(user), @@ -446,7 +446,7 @@ def test_api_documents_descendants_list_authenticated_related_direct(): "path": grand_child.path, "title": grand_child.title, "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [access.role], + "user_role": access.role, }, { "abilities": child2.get_abilities(user), @@ -464,7 +464,7 @@ def test_api_documents_descendants_list_authenticated_related_direct(): "path": child2.path, "title": child2.title, "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [access.role], + "user_role": access.role, }, ], } @@ -518,7 +518,7 @@ def test_api_documents_descendants_list_authenticated_related_parent(): "path": child1.path, "title": child1.title, "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [grand_parent_access.role], + "user_role": grand_parent_access.role, }, { "abilities": grand_child.get_abilities(user), @@ -536,7 +536,7 @@ def test_api_documents_descendants_list_authenticated_related_parent(): "path": grand_child.path, "title": grand_child.title, "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [grand_parent_access.role], + "user_role": grand_parent_access.role, }, { "abilities": child2.get_abilities(user), @@ -554,7 +554,7 @@ def test_api_documents_descendants_list_authenticated_related_parent(): "path": child2.path, "title": child2.title, "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [grand_parent_access.role], + "user_role": grand_parent_access.role, }, ], } @@ -654,7 +654,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members( "path": child1.path, "title": child1.title, "updated_at": child1.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [access.role], + "user_role": access.role, }, { "abilities": grand_child.get_abilities(user), @@ -672,7 +672,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members( "path": grand_child.path, "title": grand_child.title, "updated_at": grand_child.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [access.role], + "user_role": access.role, }, { "abilities": child2.get_abilities(user), @@ -690,7 +690,7 @@ def test_api_documents_descendants_list_authenticated_related_team_members( "path": child2.path, "title": child2.title, "updated_at": child2.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [access.role], + "user_role": access.role, }, ], } diff --git a/src/backend/core/tests/documents/test_api_documents_favorite_list.py b/src/backend/core/tests/documents/test_api_documents_favorite_list.py index 8791a6bfd..28ed31648 100644 --- a/src/backend/core/tests/documents/test_api_documents_favorite_list.py +++ b/src/backend/core/tests/documents/test_api_documents_favorite_list.py @@ -74,7 +74,7 @@ def test_api_document_favorite_list_authenticated_with_favorite(): "path": document.path, "title": document.title, "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": ["reader"], + "user_role": "reader", } ], } diff --git a/src/backend/core/tests/documents/test_api_documents_list.py b/src/backend/core/tests/documents/test_api_documents_list.py index 1120123e1..0fc14e6d8 100644 --- a/src/backend/core/tests/documents/test_api_documents_list.py +++ b/src/backend/core/tests/documents/test_api_documents_list.py @@ -76,7 +76,7 @@ def test_api_documents_list_format(): "path": document.path, "title": document.title, "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [access.role], + "user_role": access.role, } @@ -148,11 +148,11 @@ def test_api_documents_list_authenticated_direct(django_assert_num_queries): str(child4_with_access.id), } - with django_assert_num_queries(12): + with django_assert_num_queries(14): response = client.get("/api/v1.0/documents/") # nb_accesses should now be cached - with django_assert_num_queries(4): + with django_assert_num_queries(6): response = client.get("/api/v1.0/documents/") assert response.status_code == 200 @@ -268,11 +268,11 @@ def test_api_documents_list_authenticated_link_reach_public_or_authenticated( expected_ids = {str(document1.id), str(document2.id), str(visible_child.id)} - with django_assert_num_queries(10): + with django_assert_num_queries(11): response = client.get("/api/v1.0/documents/") # nb_accesses should now be cached - with django_assert_num_queries(4): + with django_assert_num_queries(5): response = client.get("/api/v1.0/documents/") assert response.status_code == 200 diff --git a/src/backend/core/tests/documents/test_api_documents_retrieve.py b/src/backend/core/tests/documents/test_api_documents_retrieve.py index 3a224c8f0..b614c9a57 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -12,7 +12,7 @@ import pytest from rest_framework.test import APIClient -from core import factories, models +from core import choices, factories, models pytestmark = pytest.mark.django_db @@ -43,7 +43,6 @@ def test_api_documents_retrieve_anonymous_public_standalone(): "favorite": False, "invite_owner": False, "link_configuration": False, - "ancestors_links_definitions": {}, "link_select_options": { "authenticated": ["reader", "editor"], "public": ["reader", "editor"], @@ -74,7 +73,7 @@ def test_api_documents_retrieve_anonymous_public_standalone(): "path": document.path, "title": document.title, "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, } @@ -92,7 +91,7 @@ def test_api_documents_retrieve_anonymous_public_parent(): assert response.status_code == 200 links = document.get_ancestors().values("link_reach", "link_role") - links_definitions = document.get_ancestors_links_definitions(links) + links_definition = choices.get_equivalent_link_definition(links) assert response.json() == { "id": str(document.id), "abilities": { @@ -100,10 +99,6 @@ def test_api_documents_retrieve_anonymous_public_parent(): "accesses_view": False, "ai_transform": False, "ai_translate": False, - "ancestors_links_definitions": { - "public": [grand_parent.link_role], - parent.link_reach: [parent.link_role], - }, "attachment_upload": grand_parent.link_role == "editor", "children_create": False, "children_list": True, @@ -117,7 +112,7 @@ def test_api_documents_retrieve_anonymous_public_parent(): "invite_owner": False, "link_configuration": False, "link_select_options": models.LinkReachChoices.get_select_options( - links_definitions + **links_definition ), "media_auth": True, "move": False, @@ -144,7 +139,7 @@ def test_api_documents_retrieve_anonymous_public_parent(): "path": document.path, "title": document.title, "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, } @@ -202,7 +197,6 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "accesses_view": False, "ai_transform": document.link_role == "editor", "ai_translate": document.link_role == "editor", - "ancestors_links_definitions": {}, "attachment_upload": document.link_role == "editor", "children_create": document.link_role == "editor", "children_list": True, @@ -244,7 +238,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "path": document.path, "title": document.title, "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, } assert ( models.LinkTrace.objects.filter(document=document, user=user).exists() is True @@ -270,7 +264,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea assert response.status_code == 200 links = document.get_ancestors().values("link_reach", "link_role") - links_definitions = document.get_ancestors_links_definitions(links) + links_definition = choices.get_equivalent_link_definition(links) assert response.json() == { "id": str(document.id), "abilities": { @@ -278,10 +272,6 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea "accesses_view": False, "ai_transform": grand_parent.link_role == "editor", "ai_translate": grand_parent.link_role == "editor", - "ancestors_links_definitions": { - grand_parent.link_reach: [grand_parent.link_role], - "restricted": [parent.link_role], - }, "attachment_upload": grand_parent.link_role == "editor", "children_create": grand_parent.link_role == "editor", "children_list": True, @@ -294,7 +284,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea "invite_owner": False, "link_configuration": False, "link_select_options": models.LinkReachChoices.get_select_options( - links_definitions + **links_definition ), "move": False, "media_auth": True, @@ -321,7 +311,7 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea "path": document.path, "title": document.title, "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, } @@ -431,7 +421,7 @@ def test_api_documents_retrieve_authenticated_related_direct(): "path": document.path, "title": document.title, "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [access.role], + "user_role": access.role, } @@ -457,8 +447,7 @@ def test_api_documents_retrieve_authenticated_related_parent(): ) assert response.status_code == 200 links = document.get_ancestors().values("link_reach", "link_role") - links_definitions = document.get_ancestors_links_definitions(links) - ancestors_roles = list({grand_parent.link_role, parent.link_role}) + link_definition = choices.get_equivalent_link_definition(links) assert response.json() == { "id": str(document.id), "abilities": { @@ -466,7 +455,6 @@ def test_api_documents_retrieve_authenticated_related_parent(): "accesses_view": True, "ai_transform": access.role != "reader", "ai_translate": access.role != "reader", - "ancestors_links_definitions": {"restricted": ancestors_roles}, "attachment_upload": access.role != "reader", "children_create": access.role != "reader", "children_list": True, @@ -479,7 +467,7 @@ def test_api_documents_retrieve_authenticated_related_parent(): "invite_owner": access.role == "owner", "link_configuration": access.role in ["administrator", "owner"], "link_select_options": models.LinkReachChoices.get_select_options( - links_definitions + **link_definition ), "media_auth": True, "move": access.role in ["administrator", "owner"], @@ -506,7 +494,7 @@ def test_api_documents_retrieve_authenticated_related_parent(): "path": document.path, "title": document.title, "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [access.role], + "user_role": access.role, } @@ -602,16 +590,16 @@ def test_api_documents_retrieve_authenticated_related_team_none(mock_user_teams) @pytest.mark.parametrize( - "teams,roles", + "teams,role", [ - [["readers"], ["reader"]], - [["unknown", "readers"], ["reader"]], - [["editors"], ["editor"]], - [["unknown", "editors"], ["editor"]], + [["readers"], "reader"], + [["unknown", "readers"], "reader"], + [["editors"], "editor"], + [["unknown", "editors"], "editor"], ], ) def test_api_documents_retrieve_authenticated_related_team_members( - teams, roles, mock_user_teams + teams, role, mock_user_teams ): """ Authenticated users should be allowed to retrieve a document to which they @@ -658,20 +646,20 @@ def test_api_documents_retrieve_authenticated_related_team_members( "path": document.path, "title": document.title, "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": roles, + "user_role": role, } @pytest.mark.parametrize( - "teams,roles", + "teams,role", [ - [["administrators"], ["administrator"]], - [["editors", "administrators"], ["administrator", "editor"]], - [["unknown", "administrators"], ["administrator"]], + [["administrators"], "administrator"], + [["editors", "administrators"], "administrator"], + [["unknown", "administrators"], "administrator"], ], ) def test_api_documents_retrieve_authenticated_related_team_administrators( - teams, roles, mock_user_teams + teams, role, mock_user_teams ): """ Authenticated users should be allowed to retrieve a document to which they @@ -720,21 +708,21 @@ def test_api_documents_retrieve_authenticated_related_team_administrators( "path": document.path, "title": document.title, "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": roles, + "user_role": role, } @pytest.mark.parametrize( - "teams,roles", + "teams,role", [ - [["owners"], ["owner"]], - [["owners", "administrators"], ["owner", "administrator"]], - [["members", "administrators", "owners"], ["owner", "administrator"]], - [["unknown", "owners"], ["owner"]], + [["owners"], "owner"], + [["owners", "administrators"], "owner"], + [["members", "administrators", "owners"], "owner"], + [["unknown", "owners"], "owner"], ], ) def test_api_documents_retrieve_authenticated_related_team_owners( - teams, roles, mock_user_teams + teams, role, mock_user_teams ): """ Authenticated users should be allowed to retrieve a restricted document to which @@ -782,11 +770,11 @@ def test_api_documents_retrieve_authenticated_related_team_owners( "path": document.path, "title": document.title, "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": roles, + "user_role": role, } -def test_api_documents_retrieve_user_roles(django_assert_max_num_queries): +def test_api_documents_retrieve_user_role(django_assert_max_num_queries): """ Roles should be annotated on querysets taking into account all documents ancestors. """ @@ -809,15 +797,14 @@ def test_api_documents_retrieve_user_roles(django_assert_max_num_queries): factories.UserDocumentAccessFactory(document=parent, user=user), factories.UserDocumentAccessFactory(document=document, user=user), ) - expected_roles = {access.role for access in accesses} + expected_role = choices.RoleChoices.max(*[access.role for access in accesses]) with django_assert_max_num_queries(14): response = client.get(f"/api/v1.0/documents/{document.id!s}/") assert response.status_code == 200 - user_roles = response.json()["user_roles"] - assert set(user_roles) == expected_roles + assert response.json()["user_role"] == expected_role def test_api_documents_retrieve_numqueries_with_link_trace(django_assert_num_queries): diff --git a/src/backend/core/tests/documents/test_api_documents_trashbin.py b/src/backend/core/tests/documents/test_api_documents_trashbin.py index b48ba252b..b3514c360 100644 --- a/src/backend/core/tests/documents/test_api_documents_trashbin.py +++ b/src/backend/core/tests/documents/test_api_documents_trashbin.py @@ -74,7 +74,6 @@ def test_api_documents_trashbin_format(): "accesses_view": True, "ai_transform": True, "ai_translate": True, - "ancestors_links_definitions": {}, "attachment_upload": True, "children_create": True, "children_list": True, @@ -114,7 +113,7 @@ def test_api_documents_trashbin_format(): "path": document.path, "title": document.title, "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": ["owner"], + "user_role": "owner", } diff --git a/src/backend/core/tests/documents/test_api_documents_tree.py b/src/backend/core/tests/documents/test_api_documents_tree.py index 33fa614b8..32de7c4b8 100644 --- a/src/backend/core/tests/documents/test_api_documents_tree.py +++ b/src/backend/core/tests/documents/test_api_documents_tree.py @@ -57,7 +57,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q "updated_at": child.updated_at.isoformat().replace( "+00:00", "Z" ), - "user_roles": [], + "user_role": None, }, ], "created_at": document.created_at.isoformat().replace("+00:00", "Z"), @@ -74,7 +74,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q "path": document.path, "title": document.title, "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, }, { "abilities": sibling1.get_abilities(AnonymousUser()), @@ -93,7 +93,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q "path": sibling1.path, "title": sibling1.title, "updated_at": sibling1.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, }, { "abilities": sibling2.get_abilities(AnonymousUser()), @@ -112,7 +112,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q "path": sibling2.path, "title": sibling2.title, "updated_at": sibling2.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, }, ], "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), @@ -129,7 +129,7 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q "path": parent.path, "title": parent.title, "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, } @@ -163,7 +163,7 @@ def test_api_documents_tree_list_anonymous_public_parent(): response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/tree/") assert response.status_code == 200 - assert response.json() == { + expected_tree = { "abilities": grand_parent.get_abilities(AnonymousUser()), "children": [ { @@ -193,7 +193,7 @@ def test_api_documents_tree_list_anonymous_public_parent(): "updated_at": child.updated_at.isoformat().replace( "+00:00", "Z" ), - "user_roles": [], + "user_role": None, }, ], "created_at": document.created_at.isoformat().replace( @@ -214,7 +214,7 @@ def test_api_documents_tree_list_anonymous_public_parent(): "updated_at": document.updated_at.isoformat().replace( "+00:00", "Z" ), - "user_roles": [], + "user_role": None, }, { "abilities": document_sibling.get_abilities(AnonymousUser()), @@ -237,7 +237,7 @@ def test_api_documents_tree_list_anonymous_public_parent(): "updated_at": document_sibling.updated_at.isoformat().replace( "+00:00", "Z" ), - "user_roles": [], + "user_role": None, }, ], "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), @@ -254,7 +254,7 @@ def test_api_documents_tree_list_anonymous_public_parent(): "path": parent.path, "title": parent.title, "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, }, { "abilities": parent_sibling.get_abilities(AnonymousUser()), @@ -277,7 +277,7 @@ def test_api_documents_tree_list_anonymous_public_parent(): "updated_at": parent_sibling.updated_at.isoformat().replace( "+00:00", "Z" ), - "user_roles": [], + "user_role": None, }, ], "created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"), @@ -294,8 +294,9 @@ def test_api_documents_tree_list_anonymous_public_parent(): "path": grand_parent.path, "title": grand_parent.title, "updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, } + assert response.json() == expected_tree @pytest.mark.parametrize("reach", ["restricted", "authenticated"]) @@ -366,7 +367,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated "updated_at": child.updated_at.isoformat().replace( "+00:00", "Z" ), - "user_roles": [], + "user_role": None, }, ], "created_at": document.created_at.isoformat().replace("+00:00", "Z"), @@ -383,7 +384,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated "path": document.path, "title": document.title, "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, }, { "abilities": sibling.get_abilities(user), @@ -402,7 +403,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated "path": sibling.path, "title": sibling.title, "updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, }, ], "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), @@ -419,7 +420,7 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated "path": parent.path, "title": parent.title, "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, } @@ -488,7 +489,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( "updated_at": child.updated_at.isoformat().replace( "+00:00", "Z" ), - "user_roles": [], + "user_role": None, }, ], "created_at": document.created_at.isoformat().replace( @@ -509,7 +510,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( "updated_at": document.updated_at.isoformat().replace( "+00:00", "Z" ), - "user_roles": [], + "user_role": None, }, { "abilities": document_sibling.get_abilities(user), @@ -532,7 +533,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( "updated_at": document_sibling.updated_at.isoformat().replace( "+00:00", "Z" ), - "user_roles": [], + "user_role": None, }, ], "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), @@ -549,7 +550,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( "path": parent.path, "title": parent.title, "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, }, { "abilities": parent_sibling.get_abilities(user), @@ -572,7 +573,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( "updated_at": parent_sibling.updated_at.isoformat().replace( "+00:00", "Z" ), - "user_roles": [], + "user_role": None, }, ], "created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"), @@ -589,7 +590,7 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( "path": grand_parent.path, "title": grand_parent.title, "updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [], + "user_role": None, } @@ -664,7 +665,7 @@ def test_api_documents_tree_list_authenticated_related_direct(): "updated_at": child.updated_at.isoformat().replace( "+00:00", "Z" ), - "user_roles": [access.role], + "user_role": access.role, }, ], "created_at": document.created_at.isoformat().replace("+00:00", "Z"), @@ -681,7 +682,7 @@ def test_api_documents_tree_list_authenticated_related_direct(): "path": document.path, "title": document.title, "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [access.role], + "user_role": access.role, }, { "abilities": sibling.get_abilities(user), @@ -700,7 +701,7 @@ def test_api_documents_tree_list_authenticated_related_direct(): "path": sibling.path, "title": sibling.title, "updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [access.role], + "user_role": access.role, }, ], "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), @@ -717,7 +718,7 @@ def test_api_documents_tree_list_authenticated_related_direct(): "path": parent.path, "title": parent.title, "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [access.role], + "user_role": access.role, } @@ -790,7 +791,7 @@ def test_api_documents_tree_list_authenticated_related_parent(): "updated_at": child.updated_at.isoformat().replace( "+00:00", "Z" ), - "user_roles": [access.role], + "user_role": access.role, }, ], "created_at": document.created_at.isoformat().replace( @@ -811,7 +812,7 @@ def test_api_documents_tree_list_authenticated_related_parent(): "updated_at": document.updated_at.isoformat().replace( "+00:00", "Z" ), - "user_roles": [access.role], + "user_role": access.role, }, { "abilities": document_sibling.get_abilities(user), @@ -834,7 +835,7 @@ def test_api_documents_tree_list_authenticated_related_parent(): "updated_at": document_sibling.updated_at.isoformat().replace( "+00:00", "Z" ), - "user_roles": [access.role], + "user_role": access.role, }, ], "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), @@ -851,7 +852,7 @@ def test_api_documents_tree_list_authenticated_related_parent(): "path": parent.path, "title": parent.title, "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [access.role], + "user_role": access.role, }, { "abilities": parent_sibling.get_abilities(user), @@ -874,7 +875,7 @@ def test_api_documents_tree_list_authenticated_related_parent(): "updated_at": parent_sibling.updated_at.isoformat().replace( "+00:00", "Z" ), - "user_roles": [access.role], + "user_role": access.role, }, ], "created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"), @@ -891,7 +892,7 @@ def test_api_documents_tree_list_authenticated_related_parent(): "path": grand_parent.path, "title": grand_parent.title, "updated_at": grand_parent.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [access.role], + "user_role": access.role, } @@ -974,7 +975,7 @@ def test_api_documents_tree_list_authenticated_related_team_members( "updated_at": child.updated_at.isoformat().replace( "+00:00", "Z" ), - "user_roles": [access.role], + "user_role": access.role, }, ], "created_at": document.created_at.isoformat().replace("+00:00", "Z"), @@ -991,7 +992,7 @@ def test_api_documents_tree_list_authenticated_related_team_members( "path": document.path, "title": document.title, "updated_at": document.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [access.role], + "user_role": access.role, }, { "abilities": sibling.get_abilities(user), @@ -1010,7 +1011,7 @@ def test_api_documents_tree_list_authenticated_related_team_members( "path": sibling.path, "title": sibling.title, "updated_at": sibling.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [access.role], + "user_role": access.role, }, ], "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), @@ -1027,5 +1028,5 @@ def test_api_documents_tree_list_authenticated_related_team_members( "path": parent.path, "title": parent.title, "updated_at": parent.updated_at.isoformat().replace("+00:00", "Z"), - "user_roles": [access.role], + "user_role": access.role, } diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index 49215f53b..1aa743a73 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -154,7 +154,6 @@ def test_models_documents_get_abilities_forbidden( "accesses_view": False, "ai_transform": False, "ai_translate": False, - "ancestors_links_definitions": {}, "attachment_upload": False, "children_create": False, "children_list": False, @@ -215,7 +214,6 @@ def test_models_documents_get_abilities_reader( "accesses_view": False, "ai_transform": False, "ai_translate": False, - "ancestors_links_definitions": {}, "attachment_upload": False, "children_create": False, "children_list": True, @@ -252,7 +250,7 @@ def test_models_documents_get_abilities_reader( assert all( value is False for key, value in document.get_abilities(user).items() - if key not in ["link_select_options", "ancestors_links_definitions"] + if key not in ["link_select_options", "ancestors_links_definition"] ) @@ -278,7 +276,6 @@ def test_models_documents_get_abilities_editor( "accesses_view": False, "ai_transform": is_authenticated, "ai_translate": is_authenticated, - "ancestors_links_definitions": {}, "attachment_upload": True, "children_create": is_authenticated, "children_list": True, @@ -314,7 +311,7 @@ def test_models_documents_get_abilities_editor( assert all( value is False for key, value in document.get_abilities(user).items() - if key not in ["link_select_options", "ancestors_links_definitions"] + if key not in ["link_select_options", "ancestors_links_definition"] ) @@ -330,7 +327,6 @@ def test_models_documents_get_abilities_owner(django_assert_num_queries): "accesses_view": True, "ai_transform": True, "ai_translate": True, - "ancestors_links_definitions": {}, "attachment_upload": True, "children_create": True, "children_list": True, @@ -379,7 +375,6 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries) "accesses_view": True, "ai_transform": True, "ai_translate": True, - "ancestors_links_definitions": {}, "attachment_upload": True, "children_create": True, "children_list": True, @@ -415,7 +410,7 @@ def test_models_documents_get_abilities_administrator(django_assert_num_queries) assert all( value is False for key, value in document.get_abilities(user).items() - if key not in ["link_select_options", "ancestors_links_definitions"] + if key not in ["link_select_options", "ancestors_links_definition"] ) @@ -431,7 +426,6 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries): "accesses_view": True, "ai_transform": True, "ai_translate": True, - "ancestors_links_definitions": {}, "attachment_upload": True, "children_create": True, "children_list": True, @@ -467,7 +461,7 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries): assert all( value is False for key, value in document.get_abilities(user).items() - if key not in ["link_select_options", "ancestors_links_definitions"] + if key not in ["link_select_options", "ancestors_links_definition"] ) @@ -490,7 +484,6 @@ def test_models_documents_get_abilities_reader_user( # You should not access AI if it's restricted to users with specific access "ai_transform": access_from_link and ai_access_setting != "restricted", "ai_translate": access_from_link and ai_access_setting != "restricted", - "ancestors_links_definitions": {}, "attachment_upload": access_from_link, "children_create": access_from_link, "children_list": True, @@ -528,7 +521,7 @@ def test_models_documents_get_abilities_reader_user( assert all( value is False for key, value in document.get_abilities(user).items() - if key not in ["link_select_options", "ancestors_links_definitions"] + if key not in ["link_select_options", "ancestors_links_definition"] ) @@ -547,7 +540,6 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries): "accesses_view": True, "ai_transform": False, "ai_translate": False, - "ancestors_links_definitions": {}, "attachment_upload": False, "children_create": False, "children_list": True, @@ -1253,45 +1245,60 @@ def test_models_documents_get_select_options(reach, role, select_options): assert models.LinkReachChoices.get_select_options(reach, role) == select_options -def test_models_documents_compute_ancestors_links_no_highest_readable(): - """Test the compute_ancestors_links method.""" +def test_models_documents_compute_ancestors_links_paths_mapping_single( + django_assert_num_queries, +): + """Test the compute_ancestors_links_paths_mapping method on a single document.""" document = factories.DocumentFactory(link_reach="public") - assert document.compute_ancestors_links(user=AnonymousUser()) == [] + with django_assert_num_queries(1): + assert document.compute_ancestors_links_paths_mapping() == { + document.path: [{"link_reach": "public", "link_role": document.link_role}] + } -def test_models_documents_compute_ancestors_links_highest_readable( +def test_models_documents_compute_ancestors_links_paths_mapping_structure( django_assert_num_queries, ): - """Test the compute_ancestors_links method.""" + """Test the compute_ancestors_links_paths_mapping method on a tree of documents.""" user = factories.UserFactory() other_user = factories.UserFactory() - root = factories.DocumentFactory( - link_reach="restricted", link_role="reader", users=[user] - ) - factories.DocumentFactory( - parent=root, link_reach="public", link_role="reader", users=[user] - ) - child2 = factories.DocumentFactory( + root = factories.DocumentFactory(link_reach="restricted", users=[user]) + document = factories.DocumentFactory( parent=root, link_reach="authenticated", link_role="editor", users=[user, other_user], ) - child3 = factories.DocumentFactory( - parent=child2, + sibling = factories.DocumentFactory(parent=root, link_reach="public", users=[user]) + child = factories.DocumentFactory( + parent=document, link_reach="authenticated", link_role="reader", users=[user, other_user], ) - with django_assert_num_queries(2): - assert child3.compute_ancestors_links(user=user) == [ - {"link_reach": root.link_reach, "link_role": root.link_role}, - {"link_reach": child2.link_reach, "link_role": child2.link_role}, - ] - - with django_assert_num_queries(2): - assert child3.compute_ancestors_links(user=other_user) == [ - {"link_reach": child2.link_reach, "link_role": child2.link_role}, - ] + # Child + with django_assert_num_queries(1): + assert child.compute_ancestors_links_paths_mapping() == { + root.path: [{"link_reach": "restricted", "link_role": root.link_role}], + document.path: [ + {"link_reach": "restricted", "link_role": root.link_role}, + {"link_reach": document.link_reach, "link_role": document.link_role}, + ], + child.path: [ + {"link_reach": "restricted", "link_role": root.link_role}, + {"link_reach": document.link_reach, "link_role": document.link_role}, + {"link_reach": child.link_reach, "link_role": child.link_role}, + ], + } + + # Sibling + with django_assert_num_queries(1): + assert sibling.compute_ancestors_links_paths_mapping() == { + root.path: [{"link_reach": "restricted", "link_role": root.link_role}], + sibling.path: [ + {"link_reach": "restricted", "link_role": root.link_role}, + {"link_reach": sibling.link_reach, "link_role": sibling.link_role}, + ], + } From 83f1c22fb680c69a4612c8c80f7344770a7c16a5 Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Mon, 28 Apr 2025 08:03:39 +0200 Subject: [PATCH 011/104] =?UTF-8?q?=E2=9C=A8(backend)=20add=20ancestors=20?= =?UTF-8?q?link=20reach=20and=20role=20to=20document=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a document, we need to display the status of the link (reach and role) as inherited from its ancestors. --- CHANGELOG.md | 2 +- src/backend/core/api/serializers.py | 8 +++ .../test_api_documents_children_list.py | 29 ++++++++ .../test_api_documents_descendants.py | 52 +++++++++++++- .../test_api_documents_favorite_list.py | 2 + .../documents/test_api_documents_list.py | 2 + .../documents/test_api_documents_retrieve.py | 18 +++++ .../documents/test_api_documents_trashbin.py | 2 + .../documents/test_api_documents_tree.py | 70 +++++++++++++++++++ .../documents/test_api_documents_update.py | 4 ++ 10 files changed, 186 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 072352803..2f3e6e2d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,7 +44,7 @@ and this project adheres to ## Added - ✨(backend) include ancestors accesses on document accesses list view #846 -- ✨(backend) add ancestors links definitions to document abilities #846 +- ✨(backend) add ancestors links reach and role to document API #846 - 🚸(backend) make document search on title accent-insensitive #874 - 🚩 add homepage feature flag #861 - 📝(doc) update contributing policy (commit signatures are now mandatory) #895 diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 23c8ccdc5..bd822ddd6 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -179,6 +179,8 @@ class Meta: fields = [ "id", "abilities", + "ancestors_link_reach", + "ancestors_link_role", "created_at", "creator", "depth", @@ -197,6 +199,8 @@ class Meta: read_only_fields = [ "id", "abilities", + "ancestors_link_reach", + "ancestors_link_role", "created_at", "creator", "depth", @@ -251,6 +255,8 @@ class Meta: fields = [ "id", "abilities", + "ancestors_link_reach", + "ancestors_link_role", "content", "created_at", "creator", @@ -270,6 +276,8 @@ class Meta: read_only_fields = [ "id", "abilities", + "ancestors_link_reach", + "ancestors_link_role", "created_at", "creator", "depth", diff --git a/src/backend/core/tests/documents/test_api_documents_children_list.py b/src/backend/core/tests/documents/test_api_documents_children_list.py index 0267345b6..26c412042 100644 --- a/src/backend/core/tests/documents/test_api_documents_children_list.py +++ b/src/backend/core/tests/documents/test_api_documents_children_list.py @@ -35,6 +35,8 @@ def test_api_documents_children_list_anonymous_public_standalone( "results": [ { "abilities": child1.get_abilities(AnonymousUser()), + "ancestors_link_reach": "public", + "ancestors_link_role": document.link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), "depth": 2, @@ -53,6 +55,8 @@ def test_api_documents_children_list_anonymous_public_standalone( }, { "abilities": child2.get_abilities(AnonymousUser()), + "ancestors_link_reach": "public", + "ancestors_link_role": document.link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), "depth": 2, @@ -101,6 +105,8 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q "results": [ { "abilities": child1.get_abilities(AnonymousUser()), + "ancestors_link_reach": child1.ancestors_link_reach, + "ancestors_link_role": child1.ancestors_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), "depth": 4, @@ -119,6 +125,8 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q }, { "abilities": child2.get_abilities(AnonymousUser()), + "ancestors_link_reach": child2.ancestors_link_reach, + "ancestors_link_role": child2.ancestors_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), "depth": 4, @@ -186,6 +194,8 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic "results": [ { "abilities": child1.get_abilities(user), + "ancestors_link_reach": reach, + "ancestors_link_role": document.link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), "depth": 2, @@ -204,6 +214,8 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic }, { "abilities": child2.get_abilities(user), + "ancestors_link_reach": reach, + "ancestors_link_role": document.link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), "depth": 2, @@ -257,6 +269,8 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren "results": [ { "abilities": child1.get_abilities(user), + "ancestors_link_reach": child1.ancestors_link_reach, + "ancestors_link_role": child1.ancestors_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), "depth": 4, @@ -275,6 +289,8 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren }, { "abilities": child2.get_abilities(user), + "ancestors_link_reach": child2.ancestors_link_reach, + "ancestors_link_role": child2.ancestors_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), "depth": 4, @@ -347,6 +363,7 @@ def test_api_documents_children_list_authenticated_related_direct( ) assert response.status_code == 200 + link_role = None if document.link_reach == "restricted" else document.link_role assert response.json() == { "count": 2, "next": None, @@ -354,6 +371,8 @@ def test_api_documents_children_list_authenticated_related_direct( "results": [ { "abilities": child1.get_abilities(user), + "ancestors_link_reach": document.link_reach, + "ancestors_link_role": link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), "depth": 2, @@ -372,6 +391,8 @@ def test_api_documents_children_list_authenticated_related_direct( }, { "abilities": child2.get_abilities(user), + "ancestors_link_reach": document.link_reach, + "ancestors_link_role": link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), "depth": 2, @@ -428,6 +449,8 @@ def test_api_documents_children_list_authenticated_related_parent( "results": [ { "abilities": child1.get_abilities(user), + "ancestors_link_reach": "restricted", + "ancestors_link_role": None, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), "depth": 4, @@ -446,6 +469,8 @@ def test_api_documents_children_list_authenticated_related_parent( }, { "abilities": child2.get_abilities(user), + "ancestors_link_reach": "restricted", + "ancestors_link_role": None, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), "depth": 4, @@ -554,6 +579,8 @@ def test_api_documents_children_list_authenticated_related_team_members( "results": [ { "abilities": child1.get_abilities(user), + "ancestors_link_reach": "restricted", + "ancestors_link_role": None, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), "depth": 2, @@ -572,6 +599,8 @@ def test_api_documents_children_list_authenticated_related_team_members( }, { "abilities": child2.get_abilities(user), + "ancestors_link_reach": "restricted", + "ancestors_link_role": None, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), "depth": 2, diff --git a/src/backend/core/tests/documents/test_api_documents_descendants.py b/src/backend/core/tests/documents/test_api_documents_descendants.py index c3e7000b3..fdfd7258a 100644 --- a/src/backend/core/tests/documents/test_api_documents_descendants.py +++ b/src/backend/core/tests/documents/test_api_documents_descendants.py @@ -32,6 +32,8 @@ def test_api_documents_descendants_list_anonymous_public_standalone(): "results": [ { "abilities": child1.get_abilities(AnonymousUser()), + "ancestors_link_reach": "public", + "ancestors_link_role": document.link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), "depth": 2, @@ -50,6 +52,10 @@ def test_api_documents_descendants_list_anonymous_public_standalone(): }, { "abilities": grand_child.get_abilities(AnonymousUser()), + "ancestors_link_reach": "public", + "ancestors_link_role": "editor" + if (child1.link_reach == "public" and child1.link_role == "editor") + else document.link_role, "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), "creator": str(grand_child.creator.id), "depth": 3, @@ -68,6 +74,8 @@ def test_api_documents_descendants_list_anonymous_public_standalone(): }, { "abilities": child2.get_abilities(AnonymousUser()), + "ancestors_link_reach": "public", + "ancestors_link_role": document.link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), "depth": 2, @@ -115,6 +123,8 @@ def test_api_documents_descendants_list_anonymous_public_parent(): "results": [ { "abilities": child1.get_abilities(AnonymousUser()), + "ancestors_link_reach": "public", + "ancestors_link_role": grand_parent.link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), "depth": 4, @@ -133,6 +143,8 @@ def test_api_documents_descendants_list_anonymous_public_parent(): }, { "abilities": grand_child.get_abilities(AnonymousUser()), + "ancestors_link_reach": "public", + "ancestors_link_role": grand_child.ancestors_link_role, "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), "creator": str(grand_child.creator.id), "depth": 5, @@ -151,6 +163,8 @@ def test_api_documents_descendants_list_anonymous_public_parent(): }, { "abilities": child2.get_abilities(AnonymousUser()), + "ancestors_link_reach": "public", + "ancestors_link_role": grand_parent.link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), "depth": 4, @@ -201,7 +215,9 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen client.force_login(user) document = factories.DocumentFactory(link_reach=reach) - child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + child1, child2 = factories.DocumentFactory.create_batch( + 2, parent=document, link_reach="restricted" + ) grand_child = factories.DocumentFactory(parent=child1) factories.UserDocumentAccessFactory(document=child1) @@ -217,6 +233,8 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen "results": [ { "abilities": child1.get_abilities(user), + "ancestors_link_reach": reach, + "ancestors_link_role": document.link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), "depth": 2, @@ -235,6 +253,8 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen }, { "abilities": grand_child.get_abilities(user), + "ancestors_link_reach": reach, + "ancestors_link_role": document.link_role, "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), "creator": str(grand_child.creator.id), "depth": 3, @@ -253,6 +273,8 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen }, { "abilities": child2.get_abilities(user), + "ancestors_link_reach": reach, + "ancestors_link_role": document.link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), "depth": 2, @@ -289,7 +311,9 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa grand_parent = factories.DocumentFactory(link_reach=reach) parent = factories.DocumentFactory(parent=grand_parent, link_reach="restricted") document = factories.DocumentFactory(link_reach="restricted", parent=parent) - child1, child2 = factories.DocumentFactory.create_batch(2, parent=document) + child1, child2 = factories.DocumentFactory.create_batch( + 2, parent=document, link_reach="restricted" + ) grand_child = factories.DocumentFactory(parent=child1) factories.UserDocumentAccessFactory(document=child1) @@ -304,6 +328,8 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa "results": [ { "abilities": child1.get_abilities(user), + "ancestors_link_reach": reach, + "ancestors_link_role": grand_parent.link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), "depth": 4, @@ -322,6 +348,8 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa }, { "abilities": grand_child.get_abilities(user), + "ancestors_link_reach": reach, + "ancestors_link_role": grand_parent.link_role, "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), "creator": str(grand_child.creator.id), "depth": 5, @@ -340,6 +368,8 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa }, { "abilities": child2.get_abilities(user), + "ancestors_link_reach": reach, + "ancestors_link_role": grand_parent.link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), "depth": 4, @@ -414,6 +444,8 @@ def test_api_documents_descendants_list_authenticated_related_direct(): "results": [ { "abilities": child1.get_abilities(user), + "ancestors_link_reach": child1.ancestors_link_reach, + "ancestors_link_role": child1.ancestors_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), "depth": 2, @@ -432,6 +464,8 @@ def test_api_documents_descendants_list_authenticated_related_direct(): }, { "abilities": grand_child.get_abilities(user), + "ancestors_link_reach": grand_child.ancestors_link_reach, + "ancestors_link_role": grand_child.ancestors_link_role, "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), "creator": str(grand_child.creator.id), "depth": 3, @@ -450,6 +484,8 @@ def test_api_documents_descendants_list_authenticated_related_direct(): }, { "abilities": child2.get_abilities(user), + "ancestors_link_reach": child2.ancestors_link_reach, + "ancestors_link_role": child2.ancestors_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), "depth": 2, @@ -504,6 +540,8 @@ def test_api_documents_descendants_list_authenticated_related_parent(): "results": [ { "abilities": child1.get_abilities(user), + "ancestors_link_reach": child1.ancestors_link_reach, + "ancestors_link_role": child1.ancestors_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), "depth": 4, @@ -522,6 +560,8 @@ def test_api_documents_descendants_list_authenticated_related_parent(): }, { "abilities": grand_child.get_abilities(user), + "ancestors_link_reach": grand_child.ancestors_link_reach, + "ancestors_link_role": grand_child.ancestors_link_role, "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), "creator": str(grand_child.creator.id), "depth": 5, @@ -540,6 +580,8 @@ def test_api_documents_descendants_list_authenticated_related_parent(): }, { "abilities": child2.get_abilities(user), + "ancestors_link_reach": child2.ancestors_link_reach, + "ancestors_link_role": child2.ancestors_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), "depth": 4, @@ -640,6 +682,8 @@ def test_api_documents_descendants_list_authenticated_related_team_members( "results": [ { "abilities": child1.get_abilities(user), + "ancestors_link_reach": child1.ancestors_link_reach, + "ancestors_link_role": child1.ancestors_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), "depth": 2, @@ -658,6 +702,8 @@ def test_api_documents_descendants_list_authenticated_related_team_members( }, { "abilities": grand_child.get_abilities(user), + "ancestors_link_reach": grand_child.ancestors_link_reach, + "ancestors_link_role": grand_child.ancestors_link_role, "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), "creator": str(grand_child.creator.id), "depth": 3, @@ -676,6 +722,8 @@ def test_api_documents_descendants_list_authenticated_related_team_members( }, { "abilities": child2.get_abilities(user), + "ancestors_link_reach": child2.ancestors_link_reach, + "ancestors_link_role": child2.ancestors_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), "depth": 2, diff --git a/src/backend/core/tests/documents/test_api_documents_favorite_list.py b/src/backend/core/tests/documents/test_api_documents_favorite_list.py index 28ed31648..8b7327539 100644 --- a/src/backend/core/tests/documents/test_api_documents_favorite_list.py +++ b/src/backend/core/tests/documents/test_api_documents_favorite_list.py @@ -59,6 +59,8 @@ def test_api_document_favorite_list_authenticated_with_favorite(): "results": [ { "abilities": document.get_abilities(user), + "ancestors_link_reach": None, + "ancestors_link_role": None, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), "content": document.content, diff --git a/src/backend/core/tests/documents/test_api_documents_list.py b/src/backend/core/tests/documents/test_api_documents_list.py index 0fc14e6d8..6a173f3c5 100644 --- a/src/backend/core/tests/documents/test_api_documents_list.py +++ b/src/backend/core/tests/documents/test_api_documents_list.py @@ -63,6 +63,8 @@ def test_api_documents_list_format(): assert results[0] == { "id": str(document.id), "abilities": document.get_abilities(user), + "ancestors_link_reach": None, + "ancestors_link_role": None, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), "depth": 1, diff --git a/src/backend/core/tests/documents/test_api_documents_retrieve.py b/src/backend/core/tests/documents/test_api_documents_retrieve.py index b614c9a57..d31419aa0 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -59,6 +59,8 @@ def test_api_documents_retrieve_anonymous_public_standalone(): "versions_list": False, "versions_retrieve": False, }, + "ancestors_link_reach": None, + "ancestors_link_role": None, "content": document.content, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), @@ -125,6 +127,8 @@ def test_api_documents_retrieve_anonymous_public_parent(): "versions_list": False, "versions_retrieve": False, }, + "ancestors_link_reach": "public", + "ancestors_link_role": grand_parent.link_role, "content": document.content, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), @@ -224,6 +228,8 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( "versions_list": False, "versions_retrieve": False, }, + "ancestors_link_reach": None, + "ancestors_link_role": None, "content": document.content, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), @@ -297,6 +303,8 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea "versions_list": False, "versions_retrieve": False, }, + "ancestors_link_reach": reach, + "ancestors_link_role": grand_parent.link_role, "content": document.content, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), @@ -407,6 +415,8 @@ def test_api_documents_retrieve_authenticated_related_direct(): assert response.json() == { "id": str(document.id), "abilities": document.get_abilities(user), + "ancestors_link_reach": None, + "ancestors_link_role": None, "content": document.content, "creator": str(document.creator.id), "created_at": document.created_at.isoformat().replace("+00:00", "Z"), @@ -480,6 +490,8 @@ def test_api_documents_retrieve_authenticated_related_parent(): "versions_list": True, "versions_retrieve": True, }, + "ancestors_link_reach": "restricted", + "ancestors_link_role": None, "content": document.content, "creator": str(document.creator.id), "created_at": document.created_at.isoformat().replace("+00:00", "Z"), @@ -632,6 +644,8 @@ def test_api_documents_retrieve_authenticated_related_team_members( assert response.json() == { "id": str(document.id), "abilities": document.get_abilities(user), + "ancestors_link_reach": None, + "ancestors_link_role": None, "content": document.content, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), @@ -694,6 +708,8 @@ def test_api_documents_retrieve_authenticated_related_team_administrators( assert response.json() == { "id": str(document.id), "abilities": document.get_abilities(user), + "ancestors_link_reach": None, + "ancestors_link_role": None, "content": document.content, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), @@ -756,6 +772,8 @@ def test_api_documents_retrieve_authenticated_related_team_owners( assert response.json() == { "id": str(document.id), "abilities": document.get_abilities(user), + "ancestors_link_reach": None, + "ancestors_link_role": None, "content": document.content, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), diff --git a/src/backend/core/tests/documents/test_api_documents_trashbin.py b/src/backend/core/tests/documents/test_api_documents_trashbin.py index b3514c360..2eb7cc3d9 100644 --- a/src/backend/core/tests/documents/test_api_documents_trashbin.py +++ b/src/backend/core/tests/documents/test_api_documents_trashbin.py @@ -101,6 +101,8 @@ def test_api_documents_trashbin_format(): "versions_list": True, "versions_retrieve": True, }, + "ancestors_link_reach": None, + "ancestors_link_role": None, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), "depth": 1, diff --git a/src/backend/core/tests/documents/test_api_documents_tree.py b/src/backend/core/tests/documents/test_api_documents_tree.py index 32de7c4b8..ad79d8b99 100644 --- a/src/backend/core/tests/documents/test_api_documents_tree.py +++ b/src/backend/core/tests/documents/test_api_documents_tree.py @@ -32,12 +32,16 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q assert response.status_code == 200 assert response.json() == { "abilities": parent.get_abilities(AnonymousUser()), + "ancestors_link_reach": parent.ancestors_link_reach, + "ancestors_link_role": parent.ancestors_link_role, "children": [ { "abilities": document.get_abilities(AnonymousUser()), "children": [ { "abilities": child.get_abilities(AnonymousUser()), + "ancestors_link_reach": child.ancestors_link_reach, + "ancestors_link_role": child.ancestors_link_role, "children": [], "created_at": child.created_at.isoformat().replace( "+00:00", "Z" @@ -60,6 +64,8 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q "user_role": None, }, ], + "ancestors_link_reach": document.ancestors_link_reach, + "ancestors_link_role": document.ancestors_link_role, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), "depth": 2, @@ -78,6 +84,8 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q }, { "abilities": sibling1.get_abilities(AnonymousUser()), + "ancestors_link_reach": sibling1.ancestors_link_reach, + "ancestors_link_role": sibling1.ancestors_link_role, "children": [], "created_at": sibling1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(sibling1.creator.id), @@ -97,6 +105,8 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q }, { "abilities": sibling2.get_abilities(AnonymousUser()), + "ancestors_link_reach": sibling2.ancestors_link_reach, + "ancestors_link_role": sibling2.ancestors_link_role, "children": [], "created_at": sibling2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(sibling2.creator.id), @@ -165,15 +175,23 @@ def test_api_documents_tree_list_anonymous_public_parent(): assert response.status_code == 200 expected_tree = { "abilities": grand_parent.get_abilities(AnonymousUser()), + "ancestors_link_reach": grand_parent.ancestors_link_reach, + "ancestors_link_role": grand_parent.ancestors_link_role, "children": [ { "abilities": parent.get_abilities(AnonymousUser()), + "ancestors_link_reach": parent.ancestors_link_reach, + "ancestors_link_role": parent.ancestors_link_role, "children": [ { "abilities": document.get_abilities(AnonymousUser()), + "ancestors_link_reach": document.ancestors_link_reach, + "ancestors_link_role": document.ancestors_link_role, "children": [ { "abilities": child.get_abilities(AnonymousUser()), + "ancestors_link_reach": child.ancestors_link_reach, + "ancestors_link_role": child.ancestors_link_role, "children": [], "created_at": child.created_at.isoformat().replace( "+00:00", "Z" @@ -218,6 +236,8 @@ def test_api_documents_tree_list_anonymous_public_parent(): }, { "abilities": document_sibling.get_abilities(AnonymousUser()), + "ancestors_link_reach": document.ancestors_link_reach, + "ancestors_link_role": document.ancestors_link_role, "children": [], "created_at": document_sibling.created_at.isoformat().replace( "+00:00", "Z" @@ -258,6 +278,8 @@ def test_api_documents_tree_list_anonymous_public_parent(): }, { "abilities": parent_sibling.get_abilities(AnonymousUser()), + "ancestors_link_reach": parent_sibling.ancestors_link_reach, + "ancestors_link_role": parent_sibling.ancestors_link_role, "children": [], "created_at": parent_sibling.created_at.isoformat().replace( "+00:00", "Z" @@ -342,12 +364,18 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated assert response.status_code == 200 assert response.json() == { "abilities": parent.get_abilities(user), + "ancestors_link_reach": None, + "ancestors_link_role": None, "children": [ { "abilities": document.get_abilities(user), + "ancestors_link_reach": document.ancestors_link_reach, + "ancestors_link_role": document.ancestors_link_role, "children": [ { "abilities": child.get_abilities(user), + "ancestors_link_reach": child.ancestors_link_reach, + "ancestors_link_role": child.ancestors_link_role, "children": [], "created_at": child.created_at.isoformat().replace( "+00:00", "Z" @@ -388,6 +416,8 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated }, { "abilities": sibling.get_abilities(user), + "ancestors_link_reach": sibling.ancestors_link_reach, + "ancestors_link_role": sibling.ancestors_link_role, "children": [], "created_at": sibling.created_at.isoformat().replace("+00:00", "Z"), "creator": str(sibling.creator.id), @@ -461,15 +491,23 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( assert response.status_code == 200 assert response.json() == { "abilities": grand_parent.get_abilities(user), + "ancestors_link_reach": grand_parent.ancestors_link_reach, + "ancestors_link_role": grand_parent.ancestors_link_role, "children": [ { "abilities": parent.get_abilities(user), + "ancestors_link_reach": parent.ancestors_link_reach, + "ancestors_link_role": parent.ancestors_link_role, "children": [ { "abilities": document.get_abilities(user), + "ancestors_link_reach": document.ancestors_link_reach, + "ancestors_link_role": document.ancestors_link_role, "children": [ { "abilities": child.get_abilities(user), + "ancestors_link_reach": child.ancestors_link_reach, + "ancestors_link_role": child.ancestors_link_role, "children": [], "created_at": child.created_at.isoformat().replace( "+00:00", "Z" @@ -514,6 +552,8 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( }, { "abilities": document_sibling.get_abilities(user), + "ancestors_link_reach": document_sibling.ancestors_link_reach, + "ancestors_link_role": document_sibling.ancestors_link_role, "children": [], "created_at": document_sibling.created_at.isoformat().replace( "+00:00", "Z" @@ -554,6 +594,8 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( }, { "abilities": parent_sibling.get_abilities(user), + "ancestors_link_reach": parent.ancestors_link_reach, + "ancestors_link_role": parent.ancestors_link_role, "children": [], "created_at": parent_sibling.created_at.isoformat().replace( "+00:00", "Z" @@ -640,12 +682,18 @@ def test_api_documents_tree_list_authenticated_related_direct(): assert response.status_code == 200 assert response.json() == { "abilities": parent.get_abilities(user), + "ancestors_link_reach": parent.ancestors_link_reach, + "ancestors_link_role": parent.ancestors_link_role, "children": [ { "abilities": document.get_abilities(user), + "ancestors_link_reach": document.ancestors_link_reach, + "ancestors_link_role": document.ancestors_link_role, "children": [ { "abilities": child.get_abilities(user), + "ancestors_link_reach": child.ancestors_link_reach, + "ancestors_link_role": child.ancestors_link_role, "children": [], "created_at": child.created_at.isoformat().replace( "+00:00", "Z" @@ -686,6 +734,8 @@ def test_api_documents_tree_list_authenticated_related_direct(): }, { "abilities": sibling.get_abilities(user), + "ancestors_link_reach": sibling.ancestors_link_reach, + "ancestors_link_role": sibling.ancestors_link_role, "children": [], "created_at": sibling.created_at.isoformat().replace("+00:00", "Z"), "creator": str(sibling.creator.id), @@ -763,15 +813,23 @@ def test_api_documents_tree_list_authenticated_related_parent(): assert response.status_code == 200 assert response.json() == { "abilities": grand_parent.get_abilities(user), + "ancestors_link_reach": grand_parent.ancestors_link_reach, + "ancestors_link_role": grand_parent.ancestors_link_role, "children": [ { "abilities": parent.get_abilities(user), + "ancestors_link_reach": parent.ancestors_link_reach, + "ancestors_link_role": parent.ancestors_link_role, "children": [ { "abilities": document.get_abilities(user), + "ancestors_link_reach": document.ancestors_link_reach, + "ancestors_link_role": document.ancestors_link_role, "children": [ { "abilities": child.get_abilities(user), + "ancestors_link_reach": child.ancestors_link_reach, + "ancestors_link_role": child.ancestors_link_role, "children": [], "created_at": child.created_at.isoformat().replace( "+00:00", "Z" @@ -816,6 +874,8 @@ def test_api_documents_tree_list_authenticated_related_parent(): }, { "abilities": document_sibling.get_abilities(user), + "ancestors_link_reach": document_sibling.ancestors_link_reach, + "ancestors_link_role": document_sibling.ancestors_link_role, "children": [], "created_at": document_sibling.created_at.isoformat().replace( "+00:00", "Z" @@ -856,6 +916,8 @@ def test_api_documents_tree_list_authenticated_related_parent(): }, { "abilities": parent_sibling.get_abilities(user), + "ancestors_link_reach": parent_sibling.ancestors_link_reach, + "ancestors_link_role": parent_sibling.ancestors_link_role, "children": [], "created_at": parent_sibling.created_at.isoformat().replace( "+00:00", "Z" @@ -950,12 +1012,18 @@ def test_api_documents_tree_list_authenticated_related_team_members( assert response.status_code == 200 assert response.json() == { "abilities": parent.get_abilities(user), + "ancestors_link_reach": None, + "ancestors_link_role": None, "children": [ { "abilities": document.get_abilities(user), + "ancestors_link_reach": "restricted", + "ancestors_link_role": None, "children": [ { "abilities": child.get_abilities(user), + "ancestors_link_reach": "restricted", + "ancestors_link_role": None, "children": [], "created_at": child.created_at.isoformat().replace( "+00:00", "Z" @@ -996,6 +1064,8 @@ def test_api_documents_tree_list_authenticated_related_team_members( }, { "abilities": sibling.get_abilities(user), + "ancestors_link_reach": "restricted", + "ancestors_link_role": None, "children": [], "created_at": sibling.created_at.isoformat().replace("+00:00", "Z"), "creator": str(sibling.creator.id), diff --git a/src/backend/core/tests/documents/test_api_documents_update.py b/src/backend/core/tests/documents/test_api_documents_update.py index 1c583bc95..0147552f7 100644 --- a/src/backend/core/tests/documents/test_api_documents_update.py +++ b/src/backend/core/tests/documents/test_api_documents_update.py @@ -155,6 +155,8 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated( for key, value in document_values.items(): if key in [ "id", + "ancestors_link_reach", + "ancestors_link_role", "accesses", "created_at", "creator", @@ -270,6 +272,8 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner( for key, value in document_values.items(): if key in [ "id", + "ancestors_link_reach", + "ancestors_link_role", "created_at", "creator", "depth", From f64d251546e954a903da8e280d82f7dc7122cc12 Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Mon, 28 Apr 2025 21:43:59 +0200 Subject: [PATCH 012/104] =?UTF-8?q?=E2=9C=A8(backend)=20add=20computed=20l?= =?UTF-8?q?ink=20reach=20and=20role=20to=20document=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a document, we need to display the status of the link (reach and role) taking into account the ancestors link reach/role as well as the current document. --- src/backend/core/api/serializers.py | 8 ++ src/backend/core/models.py | 28 +++++++ .../test_api_documents_children_list.py | 28 +++++++ .../test_api_documents_descendants.py | 42 ++++++++++ .../test_api_documents_favorite_list.py | 2 + .../documents/test_api_documents_list.py | 2 + .../documents/test_api_documents_retrieve.py | 18 +++++ .../documents/test_api_documents_trashbin.py | 2 + .../documents/test_api_documents_tree.py | 78 ++++++++++++++++++- .../documents/test_api_documents_update.py | 4 + 10 files changed, 208 insertions(+), 4 deletions(-) diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index bd822ddd6..c7afd8dab 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -181,6 +181,8 @@ class Meta: "abilities", "ancestors_link_reach", "ancestors_link_role", + "computed_link_reach", + "computed_link_role", "created_at", "creator", "depth", @@ -201,6 +203,8 @@ class Meta: "abilities", "ancestors_link_reach", "ancestors_link_role", + "computed_link_reach", + "computed_link_role", "created_at", "creator", "depth", @@ -257,6 +261,8 @@ class Meta: "abilities", "ancestors_link_reach", "ancestors_link_role", + "computed_link_reach", + "computed_link_role", "content", "created_at", "creator", @@ -278,6 +284,8 @@ class Meta: "abilities", "ancestors_link_reach", "ancestors_link_role", + "computed_link_reach", + "computed_link_role", "created_at", "creator", "depth", diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 762881c57..53f1621c3 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -493,6 +493,7 @@ def __init__(self, *args, **kwargs): """Initialize cache property.""" super().__init__(*args, **kwargs) self._ancestors_link_definition = None + self._computed_link_definition = None def save(self, *args, **kwargs): """Write content to object storage only if _content has changed.""" @@ -716,6 +717,11 @@ def compute_ancestors_links_paths_mapping(self): return paths_links_mapping + @property + def link_definition(self): + """Returns link reach/role as a definition in dictionary format.""" + return {"link_reach": self.link_reach, "link_role": self.link_role} + @property def ancestors_link_definition(self): """Link defintion equivalent to all document's ancestors.""" @@ -746,6 +752,28 @@ def ancestors_link_role(self): """Link role equivalent to all document's ancestors.""" return self.ancestors_link_definition["link_role"] + @property + def computed_link_definition(self): + """ + Link reach/role on the document, combining inherited ancestors' link + definitions and the document's own link definition. + """ + if getattr(self, "_computed_link_definition", None) is None: + self._computed_link_definition = get_equivalent_link_definition( + [self.ancestors_link_definition, self.link_definition] + ) + return self._computed_link_definition + + @property + def computed_link_reach(self): + """Actual link reach on the document.""" + return self.computed_link_definition["link_reach"] + + @property + def computed_link_role(self): + """Actual link role on the document.""" + return self.computed_link_definition["link_role"] + def get_abilities(self, user): """ Compute and return abilities for a given user on the document. diff --git a/src/backend/core/tests/documents/test_api_documents_children_list.py b/src/backend/core/tests/documents/test_api_documents_children_list.py index 26c412042..19bcfd192 100644 --- a/src/backend/core/tests/documents/test_api_documents_children_list.py +++ b/src/backend/core/tests/documents/test_api_documents_children_list.py @@ -37,6 +37,8 @@ def test_api_documents_children_list_anonymous_public_standalone( "abilities": child1.get_abilities(AnonymousUser()), "ancestors_link_reach": "public", "ancestors_link_role": document.link_role, + "computed_link_reach": "public", + "computed_link_role": child1.computed_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), "depth": 2, @@ -57,6 +59,8 @@ def test_api_documents_children_list_anonymous_public_standalone( "abilities": child2.get_abilities(AnonymousUser()), "ancestors_link_reach": "public", "ancestors_link_role": document.link_role, + "computed_link_reach": "public", + "computed_link_role": child2.computed_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), "depth": 2, @@ -107,6 +111,8 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q "abilities": child1.get_abilities(AnonymousUser()), "ancestors_link_reach": child1.ancestors_link_reach, "ancestors_link_role": child1.ancestors_link_role, + "computed_link_reach": child1.computed_link_reach, + "computed_link_role": child1.computed_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), "depth": 4, @@ -127,6 +133,8 @@ def test_api_documents_children_list_anonymous_public_parent(django_assert_num_q "abilities": child2.get_abilities(AnonymousUser()), "ancestors_link_reach": child2.ancestors_link_reach, "ancestors_link_role": child2.ancestors_link_role, + "computed_link_reach": child2.computed_link_reach, + "computed_link_role": child2.computed_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), "depth": 4, @@ -196,6 +204,8 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic "abilities": child1.get_abilities(user), "ancestors_link_reach": reach, "ancestors_link_role": document.link_role, + "computed_link_reach": child1.computed_link_reach, + "computed_link_role": child1.computed_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), "depth": 2, @@ -216,6 +226,8 @@ def test_api_documents_children_list_authenticated_unrelated_public_or_authentic "abilities": child2.get_abilities(user), "ancestors_link_reach": reach, "ancestors_link_role": document.link_role, + "computed_link_reach": child2.computed_link_reach, + "computed_link_role": child2.computed_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), "depth": 2, @@ -271,6 +283,8 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren "abilities": child1.get_abilities(user), "ancestors_link_reach": child1.ancestors_link_reach, "ancestors_link_role": child1.ancestors_link_role, + "computed_link_reach": child1.computed_link_reach, + "computed_link_role": child1.computed_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), "depth": 4, @@ -291,6 +305,8 @@ def test_api_documents_children_list_authenticated_public_or_authenticated_paren "abilities": child2.get_abilities(user), "ancestors_link_reach": child2.ancestors_link_reach, "ancestors_link_role": child2.ancestors_link_role, + "computed_link_reach": child2.computed_link_reach, + "computed_link_role": child2.computed_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), "depth": 4, @@ -373,6 +389,8 @@ def test_api_documents_children_list_authenticated_related_direct( "abilities": child1.get_abilities(user), "ancestors_link_reach": document.link_reach, "ancestors_link_role": link_role, + "computed_link_reach": child1.computed_link_reach, + "computed_link_role": child1.computed_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), "depth": 2, @@ -393,6 +411,8 @@ def test_api_documents_children_list_authenticated_related_direct( "abilities": child2.get_abilities(user), "ancestors_link_reach": document.link_reach, "ancestors_link_role": link_role, + "computed_link_reach": child2.computed_link_reach, + "computed_link_role": child2.computed_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), "depth": 2, @@ -451,6 +471,8 @@ def test_api_documents_children_list_authenticated_related_parent( "abilities": child1.get_abilities(user), "ancestors_link_reach": "restricted", "ancestors_link_role": None, + "computed_link_reach": child1.computed_link_reach, + "computed_link_role": child1.computed_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), "depth": 4, @@ -471,6 +493,8 @@ def test_api_documents_children_list_authenticated_related_parent( "abilities": child2.get_abilities(user), "ancestors_link_reach": "restricted", "ancestors_link_role": None, + "computed_link_reach": child2.computed_link_reach, + "computed_link_role": child2.computed_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), "depth": 4, @@ -581,6 +605,8 @@ def test_api_documents_children_list_authenticated_related_team_members( "abilities": child1.get_abilities(user), "ancestors_link_reach": "restricted", "ancestors_link_role": None, + "computed_link_reach": child1.computed_link_reach, + "computed_link_role": child1.computed_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), "depth": 2, @@ -601,6 +627,8 @@ def test_api_documents_children_list_authenticated_related_team_members( "abilities": child2.get_abilities(user), "ancestors_link_reach": "restricted", "ancestors_link_role": None, + "computed_link_reach": child2.computed_link_reach, + "computed_link_role": child2.computed_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), "depth": 2, diff --git a/src/backend/core/tests/documents/test_api_documents_descendants.py b/src/backend/core/tests/documents/test_api_documents_descendants.py index fdfd7258a..bd2785a7f 100644 --- a/src/backend/core/tests/documents/test_api_documents_descendants.py +++ b/src/backend/core/tests/documents/test_api_documents_descendants.py @@ -34,6 +34,8 @@ def test_api_documents_descendants_list_anonymous_public_standalone(): "abilities": child1.get_abilities(AnonymousUser()), "ancestors_link_reach": "public", "ancestors_link_role": document.link_role, + "computed_link_reach": child1.computed_link_reach, + "computed_link_role": child1.computed_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), "depth": 2, @@ -56,6 +58,8 @@ def test_api_documents_descendants_list_anonymous_public_standalone(): "ancestors_link_role": "editor" if (child1.link_reach == "public" and child1.link_role == "editor") else document.link_role, + "computed_link_reach": "public", + "computed_link_role": grand_child.computed_link_role, "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), "creator": str(grand_child.creator.id), "depth": 3, @@ -76,6 +80,8 @@ def test_api_documents_descendants_list_anonymous_public_standalone(): "abilities": child2.get_abilities(AnonymousUser()), "ancestors_link_reach": "public", "ancestors_link_role": document.link_role, + "computed_link_reach": "public", + "computed_link_role": child2.computed_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), "depth": 2, @@ -125,6 +131,8 @@ def test_api_documents_descendants_list_anonymous_public_parent(): "abilities": child1.get_abilities(AnonymousUser()), "ancestors_link_reach": "public", "ancestors_link_role": grand_parent.link_role, + "computed_link_reach": child1.computed_link_reach, + "computed_link_role": child1.computed_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), "depth": 4, @@ -145,6 +153,8 @@ def test_api_documents_descendants_list_anonymous_public_parent(): "abilities": grand_child.get_abilities(AnonymousUser()), "ancestors_link_reach": "public", "ancestors_link_role": grand_child.ancestors_link_role, + "computed_link_reach": "public", + "computed_link_role": grand_child.computed_link_role, "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), "creator": str(grand_child.creator.id), "depth": 5, @@ -165,6 +175,8 @@ def test_api_documents_descendants_list_anonymous_public_parent(): "abilities": child2.get_abilities(AnonymousUser()), "ancestors_link_reach": "public", "ancestors_link_role": grand_parent.link_role, + "computed_link_reach": "public", + "computed_link_role": child2.computed_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), "depth": 4, @@ -235,6 +247,8 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen "abilities": child1.get_abilities(user), "ancestors_link_reach": reach, "ancestors_link_role": document.link_role, + "computed_link_reach": child1.computed_link_reach, + "computed_link_role": child1.computed_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), "depth": 2, @@ -255,6 +269,8 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen "abilities": grand_child.get_abilities(user), "ancestors_link_reach": reach, "ancestors_link_role": document.link_role, + "computed_link_reach": grand_child.computed_link_reach, + "computed_link_role": grand_child.computed_link_role, "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), "creator": str(grand_child.creator.id), "depth": 3, @@ -275,6 +291,8 @@ def test_api_documents_descendants_list_authenticated_unrelated_public_or_authen "abilities": child2.get_abilities(user), "ancestors_link_reach": reach, "ancestors_link_role": document.link_role, + "computed_link_reach": child2.computed_link_reach, + "computed_link_role": child2.computed_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), "depth": 2, @@ -330,6 +348,8 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa "abilities": child1.get_abilities(user), "ancestors_link_reach": reach, "ancestors_link_role": grand_parent.link_role, + "computed_link_reach": child1.computed_link_reach, + "computed_link_role": child1.computed_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), "depth": 4, @@ -350,6 +370,8 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa "abilities": grand_child.get_abilities(user), "ancestors_link_reach": reach, "ancestors_link_role": grand_parent.link_role, + "computed_link_reach": grand_child.computed_link_reach, + "computed_link_role": grand_child.computed_link_role, "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), "creator": str(grand_child.creator.id), "depth": 5, @@ -370,6 +392,8 @@ def test_api_documents_descendants_list_authenticated_public_or_authenticated_pa "abilities": child2.get_abilities(user), "ancestors_link_reach": reach, "ancestors_link_role": grand_parent.link_role, + "computed_link_reach": child2.computed_link_reach, + "computed_link_role": child2.computed_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), "depth": 4, @@ -446,6 +470,8 @@ def test_api_documents_descendants_list_authenticated_related_direct(): "abilities": child1.get_abilities(user), "ancestors_link_reach": child1.ancestors_link_reach, "ancestors_link_role": child1.ancestors_link_role, + "computed_link_reach": child1.computed_link_reach, + "computed_link_role": child1.computed_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), "depth": 2, @@ -466,6 +492,8 @@ def test_api_documents_descendants_list_authenticated_related_direct(): "abilities": grand_child.get_abilities(user), "ancestors_link_reach": grand_child.ancestors_link_reach, "ancestors_link_role": grand_child.ancestors_link_role, + "computed_link_reach": grand_child.computed_link_reach, + "computed_link_role": grand_child.computed_link_role, "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), "creator": str(grand_child.creator.id), "depth": 3, @@ -486,6 +514,8 @@ def test_api_documents_descendants_list_authenticated_related_direct(): "abilities": child2.get_abilities(user), "ancestors_link_reach": child2.ancestors_link_reach, "ancestors_link_role": child2.ancestors_link_role, + "computed_link_reach": child2.computed_link_reach, + "computed_link_role": child2.computed_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), "depth": 2, @@ -542,6 +572,8 @@ def test_api_documents_descendants_list_authenticated_related_parent(): "abilities": child1.get_abilities(user), "ancestors_link_reach": child1.ancestors_link_reach, "ancestors_link_role": child1.ancestors_link_role, + "computed_link_reach": child1.computed_link_reach, + "computed_link_role": child1.computed_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), "depth": 4, @@ -562,6 +594,8 @@ def test_api_documents_descendants_list_authenticated_related_parent(): "abilities": grand_child.get_abilities(user), "ancestors_link_reach": grand_child.ancestors_link_reach, "ancestors_link_role": grand_child.ancestors_link_role, + "computed_link_reach": grand_child.computed_link_reach, + "computed_link_role": grand_child.computed_link_role, "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), "creator": str(grand_child.creator.id), "depth": 5, @@ -582,6 +616,8 @@ def test_api_documents_descendants_list_authenticated_related_parent(): "abilities": child2.get_abilities(user), "ancestors_link_reach": child2.ancestors_link_reach, "ancestors_link_role": child2.ancestors_link_role, + "computed_link_reach": child2.computed_link_reach, + "computed_link_role": child2.computed_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), "depth": 4, @@ -684,6 +720,8 @@ def test_api_documents_descendants_list_authenticated_related_team_members( "abilities": child1.get_abilities(user), "ancestors_link_reach": child1.ancestors_link_reach, "ancestors_link_role": child1.ancestors_link_role, + "computed_link_reach": child1.computed_link_reach, + "computed_link_role": child1.computed_link_role, "created_at": child1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child1.creator.id), "depth": 2, @@ -704,6 +742,8 @@ def test_api_documents_descendants_list_authenticated_related_team_members( "abilities": grand_child.get_abilities(user), "ancestors_link_reach": grand_child.ancestors_link_reach, "ancestors_link_role": grand_child.ancestors_link_role, + "computed_link_reach": grand_child.computed_link_reach, + "computed_link_role": grand_child.computed_link_role, "created_at": grand_child.created_at.isoformat().replace("+00:00", "Z"), "creator": str(grand_child.creator.id), "depth": 3, @@ -724,6 +764,8 @@ def test_api_documents_descendants_list_authenticated_related_team_members( "abilities": child2.get_abilities(user), "ancestors_link_reach": child2.ancestors_link_reach, "ancestors_link_role": child2.ancestors_link_role, + "computed_link_reach": child2.computed_link_reach, + "computed_link_role": child2.computed_link_role, "created_at": child2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(child2.creator.id), "depth": 2, diff --git a/src/backend/core/tests/documents/test_api_documents_favorite_list.py b/src/backend/core/tests/documents/test_api_documents_favorite_list.py index 8b7327539..7b9f5ec0c 100644 --- a/src/backend/core/tests/documents/test_api_documents_favorite_list.py +++ b/src/backend/core/tests/documents/test_api_documents_favorite_list.py @@ -61,6 +61,8 @@ def test_api_document_favorite_list_authenticated_with_favorite(): "abilities": document.get_abilities(user), "ancestors_link_reach": None, "ancestors_link_role": None, + "computed_link_reach": document.computed_link_reach, + "computed_link_role": document.computed_link_role, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), "content": document.content, diff --git a/src/backend/core/tests/documents/test_api_documents_list.py b/src/backend/core/tests/documents/test_api_documents_list.py index 6a173f3c5..cfaa3e0a1 100644 --- a/src/backend/core/tests/documents/test_api_documents_list.py +++ b/src/backend/core/tests/documents/test_api_documents_list.py @@ -65,6 +65,8 @@ def test_api_documents_list_format(): "abilities": document.get_abilities(user), "ancestors_link_reach": None, "ancestors_link_role": None, + "computed_link_reach": document.computed_link_reach, + "computed_link_role": document.computed_link_role, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), "depth": 1, diff --git a/src/backend/core/tests/documents/test_api_documents_retrieve.py b/src/backend/core/tests/documents/test_api_documents_retrieve.py index d31419aa0..b4967ec07 100644 --- a/src/backend/core/tests/documents/test_api_documents_retrieve.py +++ b/src/backend/core/tests/documents/test_api_documents_retrieve.py @@ -61,6 +61,8 @@ def test_api_documents_retrieve_anonymous_public_standalone(): }, "ancestors_link_reach": None, "ancestors_link_role": None, + "computed_link_reach": document.computed_link_reach, + "computed_link_role": document.computed_link_role, "content": document.content, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), @@ -129,6 +131,8 @@ def test_api_documents_retrieve_anonymous_public_parent(): }, "ancestors_link_reach": "public", "ancestors_link_role": grand_parent.link_role, + "computed_link_reach": "public", + "computed_link_role": grand_parent.link_role, "content": document.content, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), @@ -230,6 +234,8 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated( }, "ancestors_link_reach": None, "ancestors_link_role": None, + "computed_link_reach": document.computed_link_reach, + "computed_link_role": document.computed_link_role, "content": document.content, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), @@ -305,6 +311,8 @@ def test_api_documents_retrieve_authenticated_public_or_authenticated_parent(rea }, "ancestors_link_reach": reach, "ancestors_link_role": grand_parent.link_role, + "computed_link_reach": document.computed_link_reach, + "computed_link_role": document.computed_link_role, "content": document.content, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), @@ -417,6 +425,8 @@ def test_api_documents_retrieve_authenticated_related_direct(): "abilities": document.get_abilities(user), "ancestors_link_reach": None, "ancestors_link_role": None, + "computed_link_reach": document.computed_link_reach, + "computed_link_role": document.computed_link_role, "content": document.content, "creator": str(document.creator.id), "created_at": document.created_at.isoformat().replace("+00:00", "Z"), @@ -492,6 +502,8 @@ def test_api_documents_retrieve_authenticated_related_parent(): }, "ancestors_link_reach": "restricted", "ancestors_link_role": None, + "computed_link_reach": "restricted", + "computed_link_role": None, "content": document.content, "creator": str(document.creator.id), "created_at": document.created_at.isoformat().replace("+00:00", "Z"), @@ -646,6 +658,8 @@ def test_api_documents_retrieve_authenticated_related_team_members( "abilities": document.get_abilities(user), "ancestors_link_reach": None, "ancestors_link_role": None, + "computed_link_reach": document.computed_link_reach, + "computed_link_role": document.computed_link_role, "content": document.content, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), @@ -710,6 +724,8 @@ def test_api_documents_retrieve_authenticated_related_team_administrators( "abilities": document.get_abilities(user), "ancestors_link_reach": None, "ancestors_link_role": None, + "computed_link_reach": document.computed_link_reach, + "computed_link_role": document.computed_link_role, "content": document.content, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), @@ -774,6 +790,8 @@ def test_api_documents_retrieve_authenticated_related_team_owners( "abilities": document.get_abilities(user), "ancestors_link_reach": None, "ancestors_link_role": None, + "computed_link_reach": document.computed_link_reach, + "computed_link_role": document.computed_link_role, "content": document.content, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), diff --git a/src/backend/core/tests/documents/test_api_documents_trashbin.py b/src/backend/core/tests/documents/test_api_documents_trashbin.py index 2eb7cc3d9..00fea7caa 100644 --- a/src/backend/core/tests/documents/test_api_documents_trashbin.py +++ b/src/backend/core/tests/documents/test_api_documents_trashbin.py @@ -103,6 +103,8 @@ def test_api_documents_trashbin_format(): }, "ancestors_link_reach": None, "ancestors_link_role": None, + "computed_link_reach": document.computed_link_reach, + "computed_link_role": document.computed_link_role, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), "depth": 1, diff --git a/src/backend/core/tests/documents/test_api_documents_tree.py b/src/backend/core/tests/documents/test_api_documents_tree.py index ad79d8b99..0124b5075 100644 --- a/src/backend/core/tests/documents/test_api_documents_tree.py +++ b/src/backend/core/tests/documents/test_api_documents_tree.py @@ -43,6 +43,8 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q "ancestors_link_reach": child.ancestors_link_reach, "ancestors_link_role": child.ancestors_link_role, "children": [], + "computed_link_reach": child.computed_link_reach, + "computed_link_role": child.computed_link_role, "created_at": child.created_at.isoformat().replace( "+00:00", "Z" ), @@ -66,6 +68,8 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q ], "ancestors_link_reach": document.ancestors_link_reach, "ancestors_link_role": document.ancestors_link_role, + "computed_link_reach": document.computed_link_reach, + "computed_link_role": document.computed_link_role, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), "depth": 2, @@ -87,6 +91,8 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q "ancestors_link_reach": sibling1.ancestors_link_reach, "ancestors_link_role": sibling1.ancestors_link_role, "children": [], + "computed_link_reach": sibling1.computed_link_reach, + "computed_link_role": sibling1.computed_link_role, "created_at": sibling1.created_at.isoformat().replace("+00:00", "Z"), "creator": str(sibling1.creator.id), "depth": 2, @@ -108,6 +114,8 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q "ancestors_link_reach": sibling2.ancestors_link_reach, "ancestors_link_role": sibling2.ancestors_link_role, "children": [], + "computed_link_reach": sibling2.computed_link_reach, + "computed_link_role": sibling2.computed_link_role, "created_at": sibling2.created_at.isoformat().replace("+00:00", "Z"), "creator": str(sibling2.creator.id), "depth": 2, @@ -125,6 +133,8 @@ def test_api_documents_tree_list_anonymous_public_standalone(django_assert_num_q "user_role": None, }, ], + "computed_link_reach": parent.computed_link_reach, + "computed_link_role": parent.computed_link_role, "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), "creator": str(parent.creator.id), "depth": 1, @@ -193,6 +203,8 @@ def test_api_documents_tree_list_anonymous_public_parent(): "ancestors_link_reach": child.ancestors_link_reach, "ancestors_link_role": child.ancestors_link_role, "children": [], + "computed_link_reach": child.computed_link_reach, + "computed_link_role": child.computed_link_role, "created_at": child.created_at.isoformat().replace( "+00:00", "Z" ), @@ -214,6 +226,8 @@ def test_api_documents_tree_list_anonymous_public_parent(): "user_role": None, }, ], + "computed_link_reach": document.computed_link_reach, + "computed_link_role": document.computed_link_role, "created_at": document.created_at.isoformat().replace( "+00:00", "Z" ), @@ -236,9 +250,11 @@ def test_api_documents_tree_list_anonymous_public_parent(): }, { "abilities": document_sibling.get_abilities(AnonymousUser()), - "ancestors_link_reach": document.ancestors_link_reach, - "ancestors_link_role": document.ancestors_link_role, + "ancestors_link_reach": document_sibling.ancestors_link_reach, + "ancestors_link_role": document_sibling.ancestors_link_role, "children": [], + "computed_link_reach": document_sibling.computed_link_reach, + "computed_link_role": document_sibling.computed_link_role, "created_at": document_sibling.created_at.isoformat().replace( "+00:00", "Z" ), @@ -260,6 +276,8 @@ def test_api_documents_tree_list_anonymous_public_parent(): "user_role": None, }, ], + "computed_link_reach": parent.computed_link_reach, + "computed_link_role": parent.computed_link_role, "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), "creator": str(parent.creator.id), "depth": 3, @@ -281,6 +299,8 @@ def test_api_documents_tree_list_anonymous_public_parent(): "ancestors_link_reach": parent_sibling.ancestors_link_reach, "ancestors_link_role": parent_sibling.ancestors_link_role, "children": [], + "computed_link_reach": parent_sibling.computed_link_reach, + "computed_link_role": parent_sibling.computed_link_role, "created_at": parent_sibling.created_at.isoformat().replace( "+00:00", "Z" ), @@ -302,6 +322,8 @@ def test_api_documents_tree_list_anonymous_public_parent(): "user_role": None, }, ], + "computed_link_reach": grand_parent.computed_link_reach, + "computed_link_role": grand_parent.computed_link_role, "created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"), "creator": str(grand_parent.creator.id), "depth": 2, @@ -377,6 +399,8 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated "ancestors_link_reach": child.ancestors_link_reach, "ancestors_link_role": child.ancestors_link_role, "children": [], + "computed_link_reach": child.computed_link_reach, + "computed_link_role": child.computed_link_role, "created_at": child.created_at.isoformat().replace( "+00:00", "Z" ), @@ -398,6 +422,8 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated "user_role": None, }, ], + "computed_link_reach": document.computed_link_reach, + "computed_link_role": document.computed_link_role, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), "depth": 2, @@ -419,6 +445,8 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated "ancestors_link_reach": sibling.ancestors_link_reach, "ancestors_link_role": sibling.ancestors_link_role, "children": [], + "computed_link_reach": sibling.computed_link_reach, + "computed_link_role": sibling.computed_link_role, "created_at": sibling.created_at.isoformat().replace("+00:00", "Z"), "creator": str(sibling.creator.id), "depth": 2, @@ -436,6 +464,8 @@ def test_api_documents_tree_list_authenticated_unrelated_public_or_authenticated "user_role": None, }, ], + "computed_link_reach": parent.computed_link_reach, + "computed_link_role": parent.computed_link_role, "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), "creator": str(parent.creator.id), "depth": 1, @@ -509,6 +539,8 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( "ancestors_link_reach": child.ancestors_link_reach, "ancestors_link_role": child.ancestors_link_role, "children": [], + "computed_link_reach": child.computed_link_reach, + "computed_link_role": child.computed_link_role, "created_at": child.created_at.isoformat().replace( "+00:00", "Z" ), @@ -530,6 +562,8 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( "user_role": None, }, ], + "computed_link_reach": document.computed_link_reach, + "computed_link_role": document.computed_link_role, "created_at": document.created_at.isoformat().replace( "+00:00", "Z" ), @@ -555,6 +589,8 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( "ancestors_link_reach": document_sibling.ancestors_link_reach, "ancestors_link_role": document_sibling.ancestors_link_role, "children": [], + "computed_link_reach": document_sibling.computed_link_reach, + "computed_link_role": document_sibling.computed_link_role, "created_at": document_sibling.created_at.isoformat().replace( "+00:00", "Z" ), @@ -576,6 +612,8 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( "user_role": None, }, ], + "computed_link_reach": parent.computed_link_reach, + "computed_link_role": parent.computed_link_role, "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), "creator": str(parent.creator.id), "depth": 3, @@ -594,9 +632,11 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( }, { "abilities": parent_sibling.get_abilities(user), - "ancestors_link_reach": parent.ancestors_link_reach, - "ancestors_link_role": parent.ancestors_link_role, + "ancestors_link_reach": parent_sibling.ancestors_link_reach, + "ancestors_link_role": parent_sibling.ancestors_link_role, "children": [], + "computed_link_reach": parent_sibling.computed_link_reach, + "computed_link_role": parent_sibling.computed_link_role, "created_at": parent_sibling.created_at.isoformat().replace( "+00:00", "Z" ), @@ -618,6 +658,8 @@ def test_api_documents_tree_list_authenticated_public_or_authenticated_parent( "user_role": None, }, ], + "computed_link_reach": grand_parent.computed_link_reach, + "computed_link_role": grand_parent.computed_link_role, "created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"), "creator": str(grand_parent.creator.id), "depth": 2, @@ -695,6 +737,8 @@ def test_api_documents_tree_list_authenticated_related_direct(): "ancestors_link_reach": child.ancestors_link_reach, "ancestors_link_role": child.ancestors_link_role, "children": [], + "computed_link_reach": child.computed_link_reach, + "computed_link_role": child.computed_link_role, "created_at": child.created_at.isoformat().replace( "+00:00", "Z" ), @@ -716,6 +760,8 @@ def test_api_documents_tree_list_authenticated_related_direct(): "user_role": access.role, }, ], + "computed_link_reach": document.computed_link_reach, + "computed_link_role": document.computed_link_role, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), "depth": 2, @@ -737,6 +783,8 @@ def test_api_documents_tree_list_authenticated_related_direct(): "ancestors_link_reach": sibling.ancestors_link_reach, "ancestors_link_role": sibling.ancestors_link_role, "children": [], + "computed_link_reach": sibling.computed_link_reach, + "computed_link_role": sibling.computed_link_role, "created_at": sibling.created_at.isoformat().replace("+00:00", "Z"), "creator": str(sibling.creator.id), "depth": 2, @@ -754,6 +802,8 @@ def test_api_documents_tree_list_authenticated_related_direct(): "user_role": access.role, }, ], + "computed_link_reach": parent.computed_link_reach, + "computed_link_role": parent.computed_link_role, "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), "creator": str(parent.creator.id), "depth": 1, @@ -830,7 +880,9 @@ def test_api_documents_tree_list_authenticated_related_parent(): "abilities": child.get_abilities(user), "ancestors_link_reach": child.ancestors_link_reach, "ancestors_link_role": child.ancestors_link_role, + "computed_link_reach": child.computed_link_reach, "children": [], + "computed_link_role": child.computed_link_role, "created_at": child.created_at.isoformat().replace( "+00:00", "Z" ), @@ -852,6 +904,8 @@ def test_api_documents_tree_list_authenticated_related_parent(): "user_role": access.role, }, ], + "computed_link_reach": document.computed_link_reach, + "computed_link_role": document.computed_link_role, "created_at": document.created_at.isoformat().replace( "+00:00", "Z" ), @@ -877,6 +931,8 @@ def test_api_documents_tree_list_authenticated_related_parent(): "ancestors_link_reach": document_sibling.ancestors_link_reach, "ancestors_link_role": document_sibling.ancestors_link_role, "children": [], + "computed_link_reach": document_sibling.computed_link_reach, + "computed_link_role": document_sibling.computed_link_role, "created_at": document_sibling.created_at.isoformat().replace( "+00:00", "Z" ), @@ -898,6 +954,8 @@ def test_api_documents_tree_list_authenticated_related_parent(): "user_role": access.role, }, ], + "computed_link_reach": parent.computed_link_reach, + "computed_link_role": parent.computed_link_role, "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), "creator": str(parent.creator.id), "depth": 3, @@ -919,6 +977,8 @@ def test_api_documents_tree_list_authenticated_related_parent(): "ancestors_link_reach": parent_sibling.ancestors_link_reach, "ancestors_link_role": parent_sibling.ancestors_link_role, "children": [], + "computed_link_reach": parent_sibling.computed_link_reach, + "computed_link_role": parent_sibling.computed_link_role, "created_at": parent_sibling.created_at.isoformat().replace( "+00:00", "Z" ), @@ -940,6 +1000,8 @@ def test_api_documents_tree_list_authenticated_related_parent(): "user_role": access.role, }, ], + "computed_link_reach": grand_parent.computed_link_reach, + "computed_link_role": grand_parent.computed_link_role, "created_at": grand_parent.created_at.isoformat().replace("+00:00", "Z"), "creator": str(grand_parent.creator.id), "depth": 2, @@ -1025,6 +1087,8 @@ def test_api_documents_tree_list_authenticated_related_team_members( "ancestors_link_reach": "restricted", "ancestors_link_role": None, "children": [], + "computed_link_reach": child.computed_link_reach, + "computed_link_role": child.computed_link_role, "created_at": child.created_at.isoformat().replace( "+00:00", "Z" ), @@ -1046,6 +1110,8 @@ def test_api_documents_tree_list_authenticated_related_team_members( "user_role": access.role, }, ], + "computed_link_reach": document.computed_link_reach, + "computed_link_role": document.computed_link_role, "created_at": document.created_at.isoformat().replace("+00:00", "Z"), "creator": str(document.creator.id), "depth": 2, @@ -1067,6 +1133,8 @@ def test_api_documents_tree_list_authenticated_related_team_members( "ancestors_link_reach": "restricted", "ancestors_link_role": None, "children": [], + "computed_link_reach": sibling.computed_link_reach, + "computed_link_role": sibling.computed_link_role, "created_at": sibling.created_at.isoformat().replace("+00:00", "Z"), "creator": str(sibling.creator.id), "depth": 2, @@ -1084,6 +1152,8 @@ def test_api_documents_tree_list_authenticated_related_team_members( "user_role": access.role, }, ], + "computed_link_reach": parent.computed_link_reach, + "computed_link_role": parent.computed_link_role, "created_at": parent.created_at.isoformat().replace("+00:00", "Z"), "creator": str(parent.creator.id), "depth": 1, diff --git a/src/backend/core/tests/documents/test_api_documents_update.py b/src/backend/core/tests/documents/test_api_documents_update.py index 0147552f7..ef9d4d938 100644 --- a/src/backend/core/tests/documents/test_api_documents_update.py +++ b/src/backend/core/tests/documents/test_api_documents_update.py @@ -157,6 +157,8 @@ def test_api_documents_update_anonymous_or_authenticated_unrelated( "id", "ancestors_link_reach", "ancestors_link_role", + "computed_link_reach", + "computed_link_role", "accesses", "created_at", "creator", @@ -274,6 +276,8 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner( "id", "ancestors_link_reach", "ancestors_link_role", + "computed_link_reach", + "computed_link_role", "created_at", "creator", "depth", From 53f205f467c65e06d042b906ea34f461f804a637 Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Fri, 2 May 2025 18:30:12 +0200 Subject: [PATCH 013/104] =?UTF-8?q?=E2=99=BB=EF=B8=8F(backend)=20optimize?= =?UTF-8?q?=20refactoring=20access=20abilities=20and=20fix=20inheritance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The latest refactoring in a445278 kept some factorizations that are not legit anymore after the refactoring. It is also cleaner to not make serializer choice in the list view if the reason for this choice is related to something else b/c other views would then use the wrong serializer and that would be a security leak. This commit also fixes a bug in the access rights inheritance: if a user is allowed to see accesses on a document, he should see all acesses related to ancestors, even the ancestors that he can not read. This is because the access that was granted on all ancestors also apply on the current document... so it must be displayed. Lastly, we optimize database queries because the number of accesses we fetch is going up with multi-pages and we were generating a lot of useless queries. --- src/backend/core/api/serializers.py | 50 +++-- src/backend/core/api/viewsets.py | 102 +++++++--- src/backend/core/models.py | 191 +++++++++++------- .../documents/test_api_document_accesses.py | 131 ++++++------ .../tests/test_models_document_accesses.py | 20 +- 5 files changed, 287 insertions(+), 207 deletions(-) diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index c7afd8dab..d39a4100b 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -32,21 +32,10 @@ class Meta: class UserLightSerializer(UserSerializer): """Serialize users with limited fields.""" - id = serializers.SerializerMethodField(read_only=True) - email = serializers.SerializerMethodField(read_only=True) - - def get_id(self, _user): - """Return always None. Here to have the same fields than in UserSerializer.""" - return None - - def get_email(self, _user): - """Return always None. Here to have the same fields than in UserSerializer.""" - return None - class Meta: model = models.User - fields = ["id", "email", "full_name", "short_name"] - read_only_fields = ["id", "email", "full_name", "short_name"] + fields = ["full_name", "short_name"] + read_only_fields = ["full_name", "short_name"] class BaseAccessSerializer(serializers.ModelSerializer): @@ -59,11 +48,11 @@ def update(self, instance, validated_data): validated_data.pop("user", None) return super().update(instance, validated_data) - def get_abilities(self, access) -> dict: + def get_abilities(self, instance) -> dict: """Return abilities of the logged-in user on the instance.""" request = self.context.get("request") if request: - return access.get_abilities(request.user) + return instance.get_abilities(request.user) return {} def validate(self, attrs): @@ -77,7 +66,6 @@ def validate(self, attrs): # Update if self.instance: can_set_role_to = self.instance.get_abilities(user)["set_role_to"] - if role and role not in can_set_role_to: message = ( f"You are only allowed to set role to {', '.join(can_set_role_to)}" @@ -140,19 +128,41 @@ class DocumentAccessSerializer(BaseAccessSerializer): class Meta: model = models.DocumentAccess resource_field_name = "document" - fields = ["id", "document_id", "user", "user_id", "team", "role", "abilities"] + fields = [ + "id", + "document_id", + "user", + "user_id", + "team", + "role", + "abilities", + ] read_only_fields = ["id", "document_id", "abilities"] -class DocumentAccessLightSerializer(BaseAccessSerializer): +class DocumentAccessLightSerializer(DocumentAccessSerializer): """Serialize document accesses with limited fields.""" user = UserLightSerializer(read_only=True) class Meta: model = models.DocumentAccess - fields = ["id", "user", "team", "role", "abilities"] - read_only_fields = ["id", "team", "role", "abilities"] + resource_field_name = "document" + fields = [ + "id", + "document_id", + "user", + "team", + "role", + "abilities", + ] + read_only_fields = [ + "id", + "document_id", + "team", + "role", + "abilities", + ] class TemplateAccessSerializer(BaseAccessSerializer): diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index a2472fd57..178428b71 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -4,6 +4,7 @@ import json import logging import uuid +from collections import defaultdict from urllib.parse import unquote, urlparse from django.conf import settings @@ -1362,49 +1363,88 @@ class DocumentAccessViewSet( permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission] queryset = models.DocumentAccess.objects.select_related("user").all() resource_field_name = "document" - serializer_class = serializers.DocumentAccessSerializer - def list(self, request, *args, **kwargs): - """Return accesses for the current document with filters and annotations.""" - user = self.request.user + def __init__(self, *args, **kwargs): + """Initialize the viewset and define default value for contextual document.""" + super().__init__(*args, **kwargs) + self.document = None - try: - document = models.Document.objects.get(pk=self.kwargs["resource_id"]) - except models.Document.DoesNotExist: - return drf.response.Response([]) + def initial(self, request, *args, **kwargs): + """Retrieve self.document with annotated user roles.""" + super().initial(request, *args, **kwargs) - role = document.get_role(user) - if role is None: - return drf.response.Response([]) + try: + self.document = models.Document.objects.annotate_user_roles( + self.request.user + ).get(pk=self.kwargs["resource_id"]) + except models.Document.DoesNotExist as excpt: + raise Http404() from excpt - ancestors = ( - (document.get_ancestors() | models.Document.objects.filter(pk=document.pk)) - .filter(ancestors_deleted_at__isnull=True) - .order_by("path") + def get_serializer_class(self): + """Use light serializer for unprivileged users.""" + return ( + serializers.DocumentAccessSerializer + if self.document.get_role(self.request.user) in choices.PRIVILEGED_ROLES + else serializers.DocumentAccessLightSerializer ) - highest_readable = ancestors.readable_per_se(user).only("depth").first() - if highest_readable is None: + def list(self, request, *args, **kwargs): + """Return accesses for the current document with filters and annotations.""" + user = request.user + + role = self.document.get_role(user) + if not role: return drf.response.Response([]) - queryset = self.get_queryset() - queryset = queryset.filter( - document__in=ancestors.filter(depth__gte=highest_readable.depth) - ) + ancestors = ( + self.document.get_ancestors() + | models.Document.objects.filter(pk=self.document.pk) + ).filter(ancestors_deleted_at__isnull=True) - is_privileged = role in choices.PRIVILEGED_ROLES - if is_privileged: - serializer_class = serializers.DocumentAccessSerializer - else: - # Return only the document's privileged accesses + queryset = self.get_queryset().filter(document__in=ancestors) + + if role not in choices.PRIVILEGED_ROLES: queryset = queryset.filter(role__in=choices.PRIVILEGED_ROLES) - serializer_class = serializers.DocumentAccessLightSerializer - queryset = queryset.distinct() - serializer = serializer_class( - queryset, many=True, context=self.get_serializer_context() + accesses = list( + queryset.annotate(document_path=db.F("document__path")).order_by( + "document_path" + ) ) - return drf.response.Response(serializer.data) + + # Annotate more information on roles + path_to_ancestors_roles = defaultdict(list) + path_to_role = defaultdict(lambda: None) + for access in accesses: + if access.user_id == user.id or access.team in user.teams: + parent_path = access.document_path[: -models.Document.steplen] + if parent_path: + path_to_ancestors_roles[access.document_path].extend( + path_to_ancestors_roles[parent_path] + ) + path_to_ancestors_roles[access.document_path].append( + path_to_role[parent_path] + ) + else: + path_to_ancestors_roles[access.document_path] = [] + + path_to_role[access.document_path] = choices.RoleChoices.max( + path_to_role[access.document_path], access.role + ) + + # serialize and return the response + context = self.get_serializer_context() + serializer_class = self.get_serializer_class() + serialized_data = [] + for access in accesses: + access.user_roles_tuple = ( + choices.RoleChoices.max(*path_to_ancestors_roles[access.document_path]), + path_to_role.get(access.document_path), + ) + serializer = serializer_class(access, context=context) + serialized_data.append(serializer.data) + + return drf.response.Response(serialized_data) def perform_create(self, serializer): """Add a new access to the document and send an email to the new added user.""" diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 53f1621c3..47beb683b 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -289,66 +289,6 @@ class BaseAccess(BaseModel): class Meta: abstract = True - def _get_role(self, resource, user): - """ - Get the role a user has on a resource. - """ - roles = [] - if user.is_authenticated: - teams = user.teams - try: - roles = self.user_roles or [] - except AttributeError: - try: - roles = resource.accesses.filter( - models.Q(user=user) | models.Q(team__in=teams), - ).values_list("role", flat=True) - except (self._meta.model.DoesNotExist, IndexError): - roles = [] - - return RoleChoices.max(*roles) - - def _get_abilities(self, resource, user): - """ - Compute and return abilities for a given user taking into account - the current state of the object. - """ - role = self._get_role(resource, user) - is_owner_or_admin = role in (RoleChoices.OWNER, RoleChoices.ADMIN) - - if self.role == RoleChoices.OWNER: - can_delete = (role == RoleChoices.OWNER) and resource.accesses.filter( - role=RoleChoices.OWNER - ).count() > 1 - set_role_to = ( - [RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER] - if can_delete - else [] - ) - else: - can_delete = is_owner_or_admin - set_role_to = [] - if role == RoleChoices.OWNER: - set_role_to.append(RoleChoices.OWNER) - if is_owner_or_admin: - set_role_to.extend( - [RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER] - ) - - # Remove the current role as we don't want to propose it as an option - try: - set_role_to.remove(self.role) - except ValueError: - pass - - return { - "destroy": can_delete, - "update": bool(set_role_to), - "partial_update": bool(set_role_to), - "retrieve": bool(role), - "set_role_to": set_role_to, - } - class DocumentQuerySet(MP_NodeQuerySet): """ @@ -1101,48 +1041,91 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) self.document.invalidate_nb_accesses_cache() + @property + def target_key(self): + """Get a unique key for the actor targeted by the access, without possible conflict.""" + return f"user:{self.user_id!s}" if self.user_id else f"team:{self.team:s}" + def delete(self, *args, **kwargs): """Override delete to clear the document's cache for number of accesses.""" super().delete(*args, **kwargs) self.document.invalidate_nb_accesses_cache() + def get_roles_tuple(self, user): + """ + Get a tuple of: + - the role equivalent to all ancestors of the + document related to the current access + - the role the user has on the current access + """ + if not user.is_authenticated: + return None, None + + try: + return self.user_roles_tuple + except AttributeError: + ancestors = ( + self.document.get_ancestors() + | Document.objects.filter(pk=self.document_id) + ).filter(ancestors_deleted_at__isnull=True) + + access_tuples = DocumentAccess.objects.filter( + models.Q(user=user) | models.Q(team__in=user.teams), + document__in=ancestors, + ).values_list("document_id", "role") + + ancestors_roles = [] + current_roles = [] + for doc_id, role in access_tuples: + if doc_id == self.document_id: + current_roles.append(role) + else: + ancestors_roles.append(role) + + return RoleChoices.max(*ancestors_roles), RoleChoices.max(*current_roles) + def get_abilities(self, user): """ Compute and return abilities for a given user on the document access. """ - role = self._get_role(self.document, user) + ancestors_role, current_role = self.get_roles_tuple(user) + role = RoleChoices.max(ancestors_role, current_role) is_owner_or_admin = role in PRIVILEGED_ROLES + if self.role == RoleChoices.OWNER: can_delete = ( role == RoleChoices.OWNER - and self.document.accesses.filter(role=RoleChoices.OWNER).count() > 1 - ) - set_role_to = ( - [RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER] - if can_delete - else [] + and DocumentAccess.objects.filter( + document_id=self.document_id, role=RoleChoices.OWNER + ).count() + > 1 ) + set_role_to = RoleChoices.values if can_delete else [] else: can_delete = is_owner_or_admin set_role_to = [] - if role == RoleChoices.OWNER: - set_role_to.append(RoleChoices.OWNER) if is_owner_or_admin: set_role_to.extend( - [RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER] + [RoleChoices.READER, RoleChoices.EDITOR, RoleChoices.ADMIN] ) + if role == RoleChoices.OWNER: + set_role_to.append(RoleChoices.OWNER) - # Remove the current role as we don't want to propose it as an option - try: - set_role_to.remove(self.role) - except ValueError: - pass + # Filter out roles that would be lower than the one the user already has + ancestors_role_priority = RoleChoices.get_priority(ancestors_role) + set_role_to = [ + candidate_role + for candidate_role in set_role_to + if RoleChoices.get_priority(candidate_role) >= ancestors_role_priority + ] + if len(set_role_to) == 1: + set_role_to = [] return { "destroy": can_delete, "update": bool(set_role_to) and is_owner_or_admin, "partial_update": bool(set_role_to) and is_owner_or_admin, - "retrieve": self.user and self.user.id == user.id or is_owner_or_admin, + "retrieve": (self.user and self.user.id == user.id) or is_owner_or_admin, "set_role_to": set_role_to, } @@ -1244,11 +1227,65 @@ class Meta: def __str__(self): return f"{self.user!s} is {self.role:s} in template {self.template!s}" + def get_role(self, user): + """ + Get the role a user has on a resource. + """ + if not user.is_authenticated: + return None + + try: + roles = self.user_roles or [] + except AttributeError: + teams = user.teams + try: + roles = self.template.accesses.filter( + models.Q(user=user) | models.Q(team__in=teams), + ).values_list("role", flat=True) + except (Template.DoesNotExist, IndexError): + roles = [] + + return RoleChoices.max(*roles) + def get_abilities(self, user): """ Compute and return abilities for a given user on the template access. """ - return self._get_abilities(self.template, user) + role = self.get_role(user) + is_owner_or_admin = role in PRIVILEGED_ROLES + + if self.role == RoleChoices.OWNER: + can_delete = (role == RoleChoices.OWNER) and self.template.accesses.filter( + role=RoleChoices.OWNER + ).count() > 1 + set_role_to = ( + [RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER] + if can_delete + else [] + ) + else: + can_delete = is_owner_or_admin + set_role_to = [] + if role == RoleChoices.OWNER: + set_role_to.append(RoleChoices.OWNER) + if is_owner_or_admin: + set_role_to.extend( + [RoleChoices.ADMIN, RoleChoices.EDITOR, RoleChoices.READER] + ) + + # Remove the current role as we don't want to propose it as an option + try: + set_role_to.remove(self.role) + except ValueError: + pass + + return { + "destroy": can_delete, + "update": bool(set_role_to), + "partial_update": bool(set_role_to), + "retrieve": bool(role), + "set_role_to": set_role_to, + } class Invitation(BaseModel): diff --git a/src/backend/core/tests/documents/test_api_document_accesses.py b/src/backend/core/tests/documents/test_api_document_accesses.py index bc6dcb510..1b4151f9a 100644 --- a/src/backend/core/tests/documents/test_api_document_accesses.py +++ b/src/backend/core/tests/documents/test_api_document_accesses.py @@ -1,6 +1,7 @@ """ Test document accesses API endpoints for users in impress's core app. """ +# pylint: disable=too-many-lines import random from uuid import uuid4 @@ -64,8 +65,8 @@ def test_api_document_accesses_list_unexisting_document(): client.force_login(user) response = client.get(f"/api/v1.0/documents/{uuid4()!s}/accesses/") - assert response.status_code == 200 - assert response.json() == [] + assert response.status_code == 404 + assert response.json() == {"detail": "Not found."} @pytest.mark.parametrize("via", VIA) @@ -74,7 +75,7 @@ def test_api_document_accesses_list_unexisting_document(): [role for role in choices.RoleChoices if role not in choices.PRIVILEGED_ROLES], ) def test_api_document_accesses_list_authenticated_related_non_privileged( - via, role, mock_user_teams + via, role, mock_user_teams, django_assert_num_queries ): """ Authenticated users with no privileged role should only be able to list document @@ -95,10 +96,13 @@ def test_api_document_accesses_list_authenticated_related_non_privileged( child = factories.DocumentFactory(parent=document) # Create accesses related to each document - factories.UserDocumentAccessFactory(document=unreadable_ancestor) - grand_parent_access = factories.UserDocumentAccessFactory(document=grand_parent) - parent_access = factories.UserDocumentAccessFactory(document=parent) - document_access = factories.UserDocumentAccessFactory(document=document) + accesses = ( + factories.UserDocumentAccessFactory(document=unreadable_ancestor), + factories.UserDocumentAccessFactory(document=grand_parent), + factories.UserDocumentAccessFactory(document=parent), + factories.UserDocumentAccessFactory(document=document), + factories.TeamDocumentAccessFactory(document=document), + ) factories.UserDocumentAccessFactory(document=child) if via == USER: @@ -115,22 +119,17 @@ def test_api_document_accesses_list_authenticated_related_non_privileged( role=role, ) - access1 = factories.TeamDocumentAccessFactory(document=document) - access2 = factories.UserDocumentAccessFactory(document=document) - # Accesses for other documents to which the user is related should not be listed either other_access = factories.UserDocumentAccessFactory(user=user) factories.UserDocumentAccessFactory(document=other_access.document) - response = client.get( - f"/api/v1.0/documents/{document.id!s}/accesses/", - ) + with django_assert_num_queries(3): + response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/") assert response.status_code == 200 content = response.json() # Make sure only privileged roles are returned - accesses = [grand_parent_access, parent_access, document_access, access1, access2] privileged_accesses = [ acc for acc in accesses if acc.role in choices.PRIVILEGED_ROLES ] @@ -140,9 +139,8 @@ def test_api_document_accesses_list_authenticated_related_non_privileged( [ { "id": str(access.id), + "document_id": str(access.document_id), "user": { - "id": None, - "email": None, "full_name": access.user.full_name, "short_name": access.user.short_name, } @@ -150,7 +148,13 @@ def test_api_document_accesses_list_authenticated_related_non_privileged( else None, "team": access.team, "role": access.role, - "abilities": access.get_abilities(user), + "abilities": { + "destroy": False, + "partial_update": False, + "retrieve": False, + "set_role_to": [], + "update": False, + }, } for access in privileged_accesses ], @@ -163,7 +167,7 @@ def test_api_document_accesses_list_authenticated_related_non_privileged( "role", [role for role in choices.RoleChoices if role in choices.PRIVILEGED_ROLES] ) def test_api_document_accesses_list_authenticated_related_privileged( - via, role, mock_user_teams + via, role, mock_user_teams, django_assert_num_queries ): """ Authenticated users with a privileged role should be able to list all @@ -183,13 +187,6 @@ def test_api_document_accesses_list_authenticated_related_privileged( document = factories.DocumentFactory(parent=parent) child = factories.DocumentFactory(parent=document) - # Create accesses related to each document - factories.UserDocumentAccessFactory(document=unreadable_ancestor) - grand_parent_access = factories.UserDocumentAccessFactory(document=grand_parent) - parent_access = factories.UserDocumentAccessFactory(document=parent) - document_access = factories.UserDocumentAccessFactory(document=document) - factories.UserDocumentAccessFactory(document=child) - if via == USER: user_access = models.DocumentAccess.objects.create( document=document, @@ -206,31 +203,37 @@ def test_api_document_accesses_list_authenticated_related_privileged( else: raise RuntimeError() - access1 = factories.TeamDocumentAccessFactory(document=document) - access2 = factories.UserDocumentAccessFactory(document=document) + # Create accesses related to each document + ancestors_accesses = [ + # Access on unreadable ancestor should still be listed + # as the related user gains access to our document + factories.UserDocumentAccessFactory(document=unreadable_ancestor), + factories.UserDocumentAccessFactory(document=grand_parent), + factories.UserDocumentAccessFactory(document=parent), + ] + document_accesses = [ + factories.UserDocumentAccessFactory(document=document), + factories.TeamDocumentAccessFactory(document=document), + factories.UserDocumentAccessFactory(document=document), + user_access, + ] + factories.UserDocumentAccessFactory(document=child) # Accesses for other documents to which the user is related should not be listed either other_access = factories.UserDocumentAccessFactory(user=user) factories.UserDocumentAccessFactory(document=other_access.document) - response = client.get( - f"/api/v1.0/documents/{document.id!s}/accesses/", - ) + nb_queries = 3 + if role == "owner": + # Queries that secure the owner status + nb_queries += sum(acc.role == "owner" for acc in document_accesses) + + with django_assert_num_queries(nb_queries): + response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/") assert response.status_code == 200 content = response.json() - - # Make sure all expected accesses are returned - accesses = [ - user_access, - grand_parent_access, - parent_access, - document_access, - access1, - access2, - ] - assert len(content) == 6 - + assert len(content) == 7 assert sorted(content, key=lambda x: x["id"]) == sorted( [ { @@ -249,7 +252,7 @@ def test_api_document_accesses_list_authenticated_related_privileged( "role": access.role, "abilities": access.get_abilities(user), } - for access in accesses + for access in ancestors_accesses + document_accesses ], key=lambda x: x["id"], ) @@ -310,7 +313,9 @@ def test_api_document_accesses_retrieve_authenticated_unrelated(): @pytest.mark.parametrize("via", VIA) @pytest.mark.parametrize("role", models.RoleChoices) def test_api_document_accesses_retrieve_authenticated_related( - via, role, mock_user_teams + via, + role, + mock_user_teams, ): """ A user who is related to a document should be allowed to retrieve the @@ -491,26 +496,21 @@ def test_api_document_accesses_update_administrator_except_owner( for field, value in new_values.items(): new_data = {**old_values, field: value} - if new_data["role"] == old_values["role"]: + with mock_reset_connections(document.id, str(access.user_id)): response = client.put( f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", data=new_data, format="json", ) - assert response.status_code == 403 - else: - with mock_reset_connections(document.id, str(access.user_id)): - response = client.put( - f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", - data=new_data, - format="json", - ) - assert response.status_code == 200 + assert response.status_code == 200 access.refresh_from_db() updated_values = serializers.DocumentAccessSerializer(instance=access).data if field == "role": - assert updated_values == {**old_values, "role": new_values["role"]} + assert updated_values == { + **old_values, + "role": new_values["role"], + } else: assert updated_values == old_values @@ -605,7 +605,7 @@ def test_api_document_accesses_update_administrator_to_owner( for field, value in new_values.items(): new_data = {**old_values, field: value} # We are not allowed or not really updating the role - if field == "role" or new_data["role"] == old_values["role"]: + if field == "role": response = client.put( f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", data=new_data, @@ -665,30 +665,23 @@ def test_api_document_accesses_update_owner( for field, value in new_values.items(): new_data = {**old_values, field: value} - if ( - new_data["role"] == old_values["role"] - ): # we are not really updating the role + with mock_reset_connections(document.id, str(access.user_id)): response = client.put( f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", data=new_data, format="json", ) - assert response.status_code == 403 - else: - with mock_reset_connections(document.id, str(access.user_id)): - response = client.put( - f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", - data=new_data, - format="json", - ) - assert response.status_code == 200 + assert response.status_code == 200 access.refresh_from_db() updated_values = serializers.DocumentAccessSerializer(instance=access).data if field == "role": - assert updated_values == {**old_values, "role": new_values["role"]} + assert updated_values == { + **old_values, + "role": new_values["role"], + } else: assert updated_values == old_values diff --git a/src/backend/core/tests/test_models_document_accesses.py b/src/backend/core/tests/test_models_document_accesses.py index fe0e7c1c7..217028429 100644 --- a/src/backend/core/tests/test_models_document_accesses.py +++ b/src/backend/core/tests/test_models_document_accesses.py @@ -123,7 +123,7 @@ def test_models_document_access_get_abilities_for_owner_of_self_allowed(): "retrieve": True, "update": True, "partial_update": True, - "set_role_to": ["administrator", "editor", "reader"], + "set_role_to": ["reader", "editor", "administrator", "owner"], } @@ -155,7 +155,7 @@ def test_models_document_access_get_abilities_for_owner_of_owner(): "retrieve": True, "update": True, "partial_update": True, - "set_role_to": ["administrator", "editor", "reader"], + "set_role_to": ["reader", "editor", "administrator", "owner"], } @@ -172,7 +172,7 @@ def test_models_document_access_get_abilities_for_owner_of_administrator(): "retrieve": True, "update": True, "partial_update": True, - "set_role_to": ["owner", "editor", "reader"], + "set_role_to": ["reader", "editor", "administrator", "owner"], } @@ -189,7 +189,7 @@ def test_models_document_access_get_abilities_for_owner_of_editor(): "retrieve": True, "update": True, "partial_update": True, - "set_role_to": ["owner", "administrator", "reader"], + "set_role_to": ["reader", "editor", "administrator", "owner"], } @@ -206,7 +206,7 @@ def test_models_document_access_get_abilities_for_owner_of_reader(): "retrieve": True, "update": True, "partial_update": True, - "set_role_to": ["owner", "administrator", "editor"], + "set_role_to": ["reader", "editor", "administrator", "owner"], } @@ -243,7 +243,7 @@ def test_models_document_access_get_abilities_for_administrator_of_administrator "retrieve": True, "update": True, "partial_update": True, - "set_role_to": ["editor", "reader"], + "set_role_to": ["reader", "editor", "administrator"], } @@ -260,7 +260,7 @@ def test_models_document_access_get_abilities_for_administrator_of_editor(): "retrieve": True, "update": True, "partial_update": True, - "set_role_to": ["administrator", "reader"], + "set_role_to": ["reader", "editor", "administrator"], } @@ -277,7 +277,7 @@ def test_models_document_access_get_abilities_for_administrator_of_reader(): "retrieve": True, "update": True, "partial_update": True, - "set_role_to": ["administrator", "editor"], + "set_role_to": ["reader", "editor", "administrator"], } @@ -400,12 +400,12 @@ def test_models_document_access_get_abilities_for_reader_of_reader_user( def test_models_document_access_get_abilities_preset_role(django_assert_num_queries): - """No query is done if the role is preset, e.g., with a query annotation.""" + """No query is done if user roles are preset on the document, e.g., with a query annotation.""" access = factories.UserDocumentAccessFactory(role="reader") user = factories.UserDocumentAccessFactory( document=access.document, role="reader" ).user - access.user_roles = ["reader"] + access.user_roles_tuple = (None, "reader") with django_assert_num_queries(0): abilities = access.get_abilities(user) From f8cedbfaef70afbd09141367fc21ef4e2278b565 Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Tue, 20 May 2025 08:41:39 +0200 Subject: [PATCH 014/104] =?UTF-8?q?fixup!=20=E2=99=BB=EF=B8=8F(backend)=20?= =?UTF-8?q?optimize=20refactoring=20access=20abilities=20and=20fix=20inher?= =?UTF-8?q?itance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/backend/core/api/viewsets.py | 2 +- src/backend/core/models.py | 74 +++++++++++++------ .../tests/test_models_document_accesses.py | 2 +- 3 files changed, 52 insertions(+), 26 deletions(-) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 178428b71..3601bec00 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1437,7 +1437,7 @@ def list(self, request, *args, **kwargs): serializer_class = self.get_serializer_class() serialized_data = [] for access in accesses: - access.user_roles_tuple = ( + access.set_user_roles_tuple( choices.RoleChoices.max(*path_to_ancestors_roles[access.document_path]), path_to_role.get(access.document_path), ) diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 47beb683b..9aae753a3 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1051,44 +1051,70 @@ def delete(self, *args, **kwargs): super().delete(*args, **kwargs) self.document.invalidate_nb_accesses_cache() - def get_roles_tuple(self, user): + def set_user_roles_tuple(self, ancestors_role, current_role): """ - Get a tuple of: - - the role equivalent to all ancestors of the - document related to the current access - - the role the user has on the current access + Set a precomputed (ancestor_role, current_role) tuple for this instance. + + This avoids querying the database in `get_roles_tuple()` and is useful + when roles are already known, such as in bulk serialization. + + Args: + ancestor_role (str | None): Highest role on any ancestor document. + current_role (str | None): Role on the current document. + """ + # pylint: disable=attribute-defined-outside-init + self._prefetched_user_roles_tuple = (ancestors_role, current_role) + + def get_user_roles_tuple(self, user): + """ + Return a tuple of: + - the highest role the user has on any ancestor of the document + - the role the user has on the current document + + If roles have been explicitly set using `set_user_roles_tuple()`, + those will be returned instead of querying the database. + + This allows viewsets or serializers to precompute roles for performance + when handling multiple documents at once. + + Args: + user (User): The user whose roles are being evaluated. + + Returns: + tuple[str | None, str | None]: (max_ancestor_role, current_document_role) """ if not user.is_authenticated: return None, None try: - return self.user_roles_tuple + return self._prefetched_user_roles_tuple except AttributeError: - ancestors = ( - self.document.get_ancestors() - | Document.objects.filter(pk=self.document_id) - ).filter(ancestors_deleted_at__isnull=True) + pass - access_tuples = DocumentAccess.objects.filter( - models.Q(user=user) | models.Q(team__in=user.teams), - document__in=ancestors, - ).values_list("document_id", "role") - - ancestors_roles = [] - current_roles = [] - for doc_id, role in access_tuples: - if doc_id == self.document_id: - current_roles.append(role) - else: - ancestors_roles.append(role) + ancestors = ( + self.document.get_ancestors() | Document.objects.filter(pk=self.document_id) + ).filter(ancestors_deleted_at__isnull=True) + + access_tuples = DocumentAccess.objects.filter( + models.Q(user=user) | models.Q(team__in=user.teams), + document__in=ancestors, + ).values_list("document_id", "role") + + ancestors_roles = [] + current_roles = [] + for doc_id, role in access_tuples: + if doc_id == self.document_id: + current_roles.append(role) + else: + ancestors_roles.append(role) - return RoleChoices.max(*ancestors_roles), RoleChoices.max(*current_roles) + return RoleChoices.max(*ancestors_roles), RoleChoices.max(*current_roles) def get_abilities(self, user): """ Compute and return abilities for a given user on the document access. """ - ancestors_role, current_role = self.get_roles_tuple(user) + ancestors_role, current_role = self.get_user_roles_tuple(user) role = RoleChoices.max(ancestors_role, current_role) is_owner_or_admin = role in PRIVILEGED_ROLES diff --git a/src/backend/core/tests/test_models_document_accesses.py b/src/backend/core/tests/test_models_document_accesses.py index 217028429..1de3e019f 100644 --- a/src/backend/core/tests/test_models_document_accesses.py +++ b/src/backend/core/tests/test_models_document_accesses.py @@ -405,7 +405,7 @@ def test_models_document_access_get_abilities_preset_role(django_assert_num_quer user = factories.UserDocumentAccessFactory( document=access.document, role="reader" ).user - access.user_roles_tuple = (None, "reader") + access.set_user_roles_tuple(None, "reader") with django_assert_num_queries(0): abilities = access.get_abilities(user) From 9cdf72684e322510fe91c6b57fafbea3dcba55a0 Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Fri, 2 May 2025 19:18:30 +0200 Subject: [PATCH 015/104] =?UTF-8?q?=E2=9C=A8(backend)=20add=20max=20ancest?= =?UTF-8?q?ors=20role=20field=20to=20document=20access=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This field is set only on the list view when all accesses for a given document and all its ancestors are listed. It gives the highest role among all accesses related to each document. --- src/backend/core/api/serializers.py | 10 +- src/backend/core/api/viewsets.py | 49 ++-- src/backend/core/models.py | 4 +- .../documents/test_api_document_accesses.py | 242 ++++++++++++++++++ .../test_api_document_accesses_create.py | 3 + 5 files changed, 291 insertions(+), 17 deletions(-) diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index d39a4100b..adc77af9d 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -124,6 +124,7 @@ class DocumentAccessSerializer(BaseAccessSerializer): allow_null=True, ) user = UserSerializer(read_only=True) + max_ancestors_role = serializers.SerializerMethodField(read_only=True) class Meta: model = models.DocumentAccess @@ -136,8 +137,13 @@ class Meta: "team", "role", "abilities", + "max_ancestors_role", ] - read_only_fields = ["id", "document_id", "abilities"] + read_only_fields = ["id", "document_id", "abilities", "max_ancestors_role"] + + def get_max_ancestors_role(self, instance): + """Return max_ancestors_role if annotated; else None.""" + return getattr(instance, "max_ancestors_role", None) class DocumentAccessLightSerializer(DocumentAccessSerializer): @@ -155,6 +161,7 @@ class Meta: "team", "role", "abilities", + "max_ancestors_role", ] read_only_fields = [ "id", @@ -162,6 +169,7 @@ class Meta: "team", "role", "abilities", + "max_ancestors_role", ] diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 3601bec00..77cedb525 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1413,23 +1413,35 @@ def list(self, request, *args, **kwargs): ) # Annotate more information on roles + path_to_key_to_max_ancestors_role = defaultdict( + lambda: defaultdict(lambda: None) + ) path_to_ancestors_roles = defaultdict(list) path_to_role = defaultdict(lambda: None) for access in accesses: - if access.user_id == user.id or access.team in user.teams: - parent_path = access.document_path[: -models.Document.steplen] - if parent_path: - path_to_ancestors_roles[access.document_path].extend( - path_to_ancestors_roles[parent_path] - ) - path_to_ancestors_roles[access.document_path].append( - path_to_role[parent_path] - ) - else: - path_to_ancestors_roles[access.document_path] = [] + key = access.target_key + path = access.document.path + parent_path = path[: -models.Document.steplen] + + path_to_key_to_max_ancestors_role[path][key] = choices.RoleChoices.max( + path_to_key_to_max_ancestors_role[path][key], access.role + ) - path_to_role[access.document_path] = choices.RoleChoices.max( - path_to_role[access.document_path], access.role + if parent_path: + path_to_key_to_max_ancestors_role[path][key] = choices.RoleChoices.max( + path_to_key_to_max_ancestors_role[parent_path][key], + path_to_key_to_max_ancestors_role[path][key], + ) + path_to_ancestors_roles[path].extend( + path_to_ancestors_roles[parent_path] + ) + path_to_ancestors_roles[path].append(path_to_role[parent_path]) + else: + path_to_ancestors_roles[path] = [] + + if access.user_id == user.id or access.team in user.teams: + path_to_role[path] = choices.RoleChoices.max( + path_to_role[path], access.role ) # serialize and return the response @@ -1437,9 +1449,16 @@ def list(self, request, *args, **kwargs): serializer_class = self.get_serializer_class() serialized_data = [] for access in accesses: + path = access.document.path + parent_path = path[: -models.Document.steplen] + access.max_ancestors_role = ( + path_to_key_to_max_ancestors_role[parent_path][access.target_key] + if parent_path + else None + ) access.set_user_roles_tuple( - choices.RoleChoices.max(*path_to_ancestors_roles[access.document_path]), - path_to_role.get(access.document_path), + choices.RoleChoices.max(*path_to_ancestors_roles[path]), + path_to_role.get(path), ) serializer = serializer_class(access, context=context) serialized_data.append(serializer.data) diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 9aae753a3..5fab29df7 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1138,7 +1138,9 @@ def get_abilities(self, user): set_role_to.append(RoleChoices.OWNER) # Filter out roles that would be lower than the one the user already has - ancestors_role_priority = RoleChoices.get_priority(ancestors_role) + ancestors_role_priority = RoleChoices.get_priority( + getattr(self, "max_ancestors_role", None) + ) set_role_to = [ candidate_role for candidate_role in set_role_to diff --git a/src/backend/core/tests/documents/test_api_document_accesses.py b/src/backend/core/tests/documents/test_api_document_accesses.py index 1b4151f9a..593d2a7b1 100644 --- a/src/backend/core/tests/documents/test_api_document_accesses.py +++ b/src/backend/core/tests/documents/test_api_document_accesses.py @@ -148,6 +148,7 @@ def test_api_document_accesses_list_authenticated_related_non_privileged( else None, "team": access.team, "role": access.role, + "max_ancestors_role": None, "abilities": { "destroy": False, "partial_update": False, @@ -248,6 +249,7 @@ def test_api_document_accesses_list_authenticated_related_privileged( } if access.user else None, + "max_ancestors_role": None, "team": access.team, "role": access.role, "abilities": access.get_abilities(user), @@ -258,6 +260,245 @@ def test_api_document_accesses_list_authenticated_related_privileged( ) +def test_api_document_accesses_retrieve_set_role_to_child(): + """Check set_role_to for an access with no access on the ancestor.""" + user, other_user = factories.UserFactory.create_batch(2) + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory() + parent_access = factories.UserDocumentAccessFactory( + document=parent, user=user, role="owner" + ) + + document = factories.DocumentFactory(parent=parent) + document_access_other_user = factories.UserDocumentAccessFactory( + document=document, user=other_user, role="editor" + ) + + response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/") + + assert response.status_code == 200 + content = response.json() + assert len(content) == 2 + + result_dict = { + result["id"]: result["abilities"]["set_role_to"] for result in content + } + assert result_dict[str(document_access_other_user.id)] == [ + "reader", + "editor", + "administrator", + "owner", + ] + assert result_dict[str(parent_access.id)] == [] + + # Add an access for the other user on the parent + parent_access_other_user = factories.UserDocumentAccessFactory( + document=parent, user=other_user, role="editor" + ) + + response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/") + + assert response.status_code == 200 + content = response.json() + assert len(content) == 3 + + result_dict = { + result["id"]: result["abilities"]["set_role_to"] for result in content + } + assert result_dict[str(document_access_other_user.id)] == [ + "editor", + "administrator", + "owner", + ] + assert result_dict[str(parent_access.id)] == [] + assert result_dict[str(parent_access_other_user.id)] == [ + "reader", + "editor", + "administrator", + "owner", + ] + + +@pytest.mark.parametrize( + "roles,results", + [ + [ + ["administrator", "reader", "reader", "reader"], + [ + ["reader", "editor", "administrator"], + [], + [], + ["reader", "editor", "administrator"], + ], + ], + [ + ["owner", "reader", "reader", "reader"], + [[], [], [], ["reader", "editor", "administrator", "owner"]], + ], + [ + ["owner", "reader", "reader", "owner"], + [ + ["reader", "editor", "administrator", "owner"], + [], + [], + ["reader", "editor", "administrator", "owner"], + ], + ], + ], +) +def test_api_document_accesses_list_authenticated_related_same_user(roles, results): + """ + The maximum role across ancestor documents and set_role_to optionsfor + a given user should be filled as expected. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # Create documents structured as a tree + grand_parent = factories.DocumentFactory(link_reach="authenticated") + parent = factories.DocumentFactory(parent=grand_parent) + document = factories.DocumentFactory(parent=parent) + + # Create accesses for another user + other_user = factories.UserFactory() + accesses = [ + factories.UserDocumentAccessFactory( + document=document, user=user, role=roles[0] + ), + factories.UserDocumentAccessFactory( + document=grand_parent, user=other_user, role=roles[1] + ), + factories.UserDocumentAccessFactory( + document=parent, user=other_user, role=roles[2] + ), + factories.UserDocumentAccessFactory( + document=document, user=other_user, role=roles[3] + ), + ] + + response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/") + + assert response.status_code == 200 + content = response.json() + assert len(content) == 4 + + for result in content: + assert ( + result["max_ancestors_role"] is None + if result["user"]["id"] == str(user.id) + else choices.RoleChoices.max(roles[1], roles[2]) + ) + + result_dict = { + result["id"]: result["abilities"]["set_role_to"] for result in content + } + assert [result_dict[str(access.id)] for access in accesses] == results + + +@pytest.mark.parametrize( + "roles,results", + [ + [ + ["administrator", "reader", "reader", "reader"], + [ + ["reader", "editor", "administrator"], + [], + [], + ["reader", "editor", "administrator"], + ], + ], + [ + ["owner", "reader", "reader", "reader"], + [[], [], [], ["reader", "editor", "administrator", "owner"]], + ], + [ + ["owner", "reader", "reader", "owner"], + [ + ["reader", "editor", "administrator", "owner"], + [], + [], + ["reader", "editor", "administrator", "owner"], + ], + ], + [ + ["reader", "reader", "reader", "owner"], + [["reader", "editor", "administrator", "owner"], [], [], []], + ], + [ + ["reader", "administrator", "reader", "editor"], + [ + ["reader", "editor", "administrator"], + ["reader", "editor", "administrator"], + [], + [], + ], + ], + [ + ["editor", "editor", "administrator", "editor"], + [ + ["reader", "editor", "administrator"], + [], + ["editor", "administrator"], + [], + ], + ], + ], +) +def test_api_document_accesses_list_authenticated_related_same_team( + roles, results, mock_user_teams +): + """ + The maximum role across ancestor documents and set_role_to optionsfor + a given team should be filled as expected. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + # Create documents structured as a tree + grand_parent = factories.DocumentFactory(link_reach="authenticated") + parent = factories.DocumentFactory(parent=grand_parent) + document = factories.DocumentFactory(parent=parent) + + mock_user_teams.return_value = ["lasuite", "unknown"] + accesses = [ + factories.UserDocumentAccessFactory( + document=document, user=user, role=roles[0] + ), + # Create accesses for a team + factories.TeamDocumentAccessFactory( + document=grand_parent, team="lasuite", role=roles[1] + ), + factories.TeamDocumentAccessFactory( + document=parent, team="lasuite", role=roles[2] + ), + factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role=roles[3] + ), + ] + + response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/") + + assert response.status_code == 200 + content = response.json() + assert len(content) == 4 + + for result in content: + assert ( + result["max_ancestors_role"] is None + if result["user"] and result["user"]["id"] == str(user.id) + else choices.RoleChoices.max(roles[1], roles[2]) + ) + + result_dict = { + result["id"]: result["abilities"]["set_role_to"] for result in content + } + assert [result_dict[str(access.id)] for access in accesses] == results + + def test_api_document_accesses_retrieve_anonymous(): """ Anonymous users should not be allowed to retrieve a document access. @@ -353,6 +594,7 @@ def test_api_document_accesses_retrieve_authenticated_related( "user": access_user, "team": "", "role": access.role, + "max_ancestors_role": None, "abilities": access.get_abilities(user), } diff --git a/src/backend/core/tests/documents/test_api_document_accesses_create.py b/src/backend/core/tests/documents/test_api_document_accesses_create.py index cd2d57ebd..8b8319802 100644 --- a/src/backend/core/tests/documents/test_api_document_accesses_create.py +++ b/src/backend/core/tests/documents/test_api_document_accesses_create.py @@ -169,6 +169,7 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user "id": str(new_document_access.id), "team": "", "role": role, + "max_ancestors_role": None, "user": other_user, } assert len(mail.outbox) == 1 @@ -228,6 +229,7 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams): "user": other_user, "team": "", "role": role, + "max_ancestors_role": None, "abilities": new_document_access.get_abilities(user), } assert len(mail.outbox) == 1 @@ -293,6 +295,7 @@ def test_api_document_accesses_create_email_in_receivers_language(via, mock_user "user": other_user_data, "team": "", "role": role, + "max_ancestors_role": None, "abilities": new_document_access.get_abilities(user), } assert len(mail.outbox) == index + 1 From ae970c849b19fa79c28a554dd73475ea8bdbc1b0 Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Sun, 4 May 2025 22:16:34 +0200 Subject: [PATCH 016/104] =?UTF-8?q?=E2=99=BB=EF=B8=8F(backend)=20stop=20re?= =?UTF-8?q?quiring=20owner=20for=20non-root=20documents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If root documents are guaranteed to have a owner, non-root documents will automatically have them as owner by inheritance. We should not require non-root documents to have their own direct owner because this will make it difficult to manage access rights when we move documents around or when we want to remove access rights for someone on a document subtree... There should be as few overrides as possible. --- CHANGELOG.md | 1 + src/backend/core/api/viewsets.py | 73 +++------ src/backend/core/models.py | 9 +- .../documents/test_api_document_accesses.py | 144 ++++++++++++++++-- .../test_api_documents_children_create.py | 6 +- .../documents/test_api_documents_move.py | 105 ++++++++++++- .../tests/test_models_document_accesses.py | 34 ++++- 7 files changed, 298 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f3e6e2d1..7c1b4ab07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ and this project adheres to ## Changed +- ♻️(backend) stop requiring owner for non-root documents #846 - ♻️(backend) simplify roles by ranking them and return only the max role #846 - ⚡️(frontend) reduce unblocking time for config #867 - ♻️(frontend) bind UI with ability access #900 diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 77cedb525..744e92b65 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -230,43 +230,6 @@ def get_serializer_context(self): context["resource_id"] = self.kwargs["resource_id"] return context - def destroy(self, request, *args, **kwargs): - """Forbid deleting the last owner access""" - instance = self.get_object() - resource = getattr(instance, self.resource_field_name) - - # Check if the access being deleted is the last owner access for the resource - if ( - instance.role == "owner" - and resource.accesses.filter(role="owner").count() == 1 - ): - return drf.response.Response( - {"detail": "Cannot delete the last owner access for the resource."}, - status=drf.status.HTTP_403_FORBIDDEN, - ) - - return super().destroy(request, *args, **kwargs) - - def perform_update(self, serializer): - """Check that we don't change the role if it leads to losing the last owner.""" - instance = serializer.instance - - # Check if the role is being updated and the new role is not "owner" - if ( - "role" in self.request.data - and self.request.data["role"] != models.RoleChoices.OWNER - ): - resource = getattr(instance, self.resource_field_name) - # Check if the access being updated is the last owner access for the resource - if ( - instance.role == models.RoleChoices.OWNER - and resource.accesses.filter(role=models.RoleChoices.OWNER).count() == 1 - ): - message = "Cannot change the role to a non-owner role for the last owner access." - raise drf.exceptions.PermissionDenied({"detail": message}) - - serializer.save() - class DocumentMetadata(drf.metadata.SimpleMetadata): """Custom metadata class to add information""" @@ -645,7 +608,7 @@ def move(self, request, *args, **kwargs): position = validated_data["position"] message = None - + owner_accesses = [] if position in [ enums.MoveNodePositionChoices.FIRST_CHILD, enums.MoveNodePositionChoices.LAST_CHILD, @@ -655,12 +618,15 @@ def move(self, request, *args, **kwargs): "You do not have permission to move documents " "as a child to this target document." ) - elif not target_document.is_root(): - if not target_document.get_parent().get_abilities(user).get("move"): - message = ( - "You do not have permission to move documents " - "as a sibling of this target document." - ) + elif target_document.is_root(): + owner_accesses = document.get_root().accesses.filter( + role=models.RoleChoices.OWNER + ) + elif not target_document.get_parent().get_abilities(user).get("move"): + message = ( + "You do not have permission to move documents " + "as a sibling of this target document." + ) if message: return drf.response.Response( @@ -670,6 +636,19 @@ def move(self, request, *args, **kwargs): document.move(target_document, pos=position) + # Make sure we have at least one owner + if ( + owner_accesses + and not document.accesses.filter(role=models.RoleChoices.OWNER).exists() + ): + for owner_access in owner_accesses: + models.DocumentAccess.objects.update_or_create( + document=document, + user=owner_access.user, + team=owner_access.team, + defaults={"role": models.RoleChoices.OWNER}, + ) + return drf.response.Response( {"message": "Document moved successfully."}, status=status.HTTP_200_OK ) @@ -716,11 +695,7 @@ def children(self, request, *args, **kwargs): creator=request.user, **serializer.validated_data, ) - models.DocumentAccess.objects.create( - document=child_document, - user=request.user, - role=models.RoleChoices.OWNER, - ) + # Set the created instance to the serializer serializer.instance = child_document diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 5fab29df7..0adc9b5d5 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1119,9 +1119,12 @@ def get_abilities(self, user): is_owner_or_admin = role in PRIVILEGED_ROLES if self.role == RoleChoices.OWNER: - can_delete = ( - role == RoleChoices.OWNER - and DocumentAccess.objects.filter( + can_delete = role == RoleChoices.OWNER and ( + # check if document is not root trying to avoid an extra query + # "document_path" is annotated by the viewset's list method + len(getattr(self, "document_path", "")) > Document.steplen + or not self.document.is_root() + or DocumentAccess.objects.filter( document_id=self.document_id, role=RoleChoices.OWNER ).count() > 1 diff --git a/src/backend/core/tests/documents/test_api_document_accesses.py b/src/backend/core/tests/documents/test_api_document_accesses.py index 593d2a7b1..beae157f6 100644 --- a/src/backend/core/tests/documents/test_api_document_accesses.py +++ b/src/backend/core/tests/documents/test_api_document_accesses.py @@ -224,12 +224,7 @@ def test_api_document_accesses_list_authenticated_related_privileged( other_access = factories.UserDocumentAccessFactory(user=user) factories.UserDocumentAccessFactory(document=other_access.document) - nb_queries = 3 - if role == "owner": - # Queries that secure the owner status - nb_queries += sum(acc.role == "owner" for acc in document_accesses) - - with django_assert_num_queries(nb_queries): + with django_assert_num_queries(3): response = client.get(f"/api/v1.0/documents/{document.id!s}/accesses/") assert response.status_code == 200 @@ -335,7 +330,12 @@ def test_api_document_accesses_retrieve_set_role_to_child(): ], [ ["owner", "reader", "reader", "reader"], - [[], [], [], ["reader", "editor", "administrator", "owner"]], + [ + ["reader", "editor", "administrator", "owner"], + [], + [], + ["reader", "editor", "administrator", "owner"], + ], ], [ ["owner", "reader", "reader", "owner"], @@ -412,7 +412,12 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul ], [ ["owner", "reader", "reader", "reader"], - [[], [], [], ["reader", "editor", "administrator", "owner"]], + [ + ["reader", "editor", "administrator", "owner"], + [], + [], + ["reader", "editor", "administrator", "owner"], + ], ], [ ["owner", "reader", "reader", "owner"], @@ -425,7 +430,12 @@ def test_api_document_accesses_list_authenticated_related_same_user(roles, resul ], [ ["reader", "reader", "reader", "owner"], - [["reader", "editor", "administrator", "owner"], [], [], []], + [ + ["reader", "editor", "administrator", "owner"], + [], + [], + ["reader", "editor", "administrator", "owner"], + ], ], [ ["reader", "administrator", "reader", "editor"], @@ -929,7 +939,7 @@ def test_api_document_accesses_update_owner( @pytest.mark.parametrize("via", VIA) -def test_api_document_accesses_update_owner_self( +def test_api_document_accesses_update_owner_self_root( via, mock_user_teams, mock_reset_connections, # pylint: disable=redefined-outer-name @@ -990,6 +1000,51 @@ def test_api_document_accesses_update_owner_self( assert access.role == new_role +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_update_owner_self_child( + via, + mock_user_teams, + mock_reset_connections, # pylint: disable=redefined-outer-name +): + """ + A user who is owner of a document should be allowed to update + their own user access even if they are the only owner in the document, + provided the document is not a root. + """ + user = factories.UserFactory(with_owned_document=True) + + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory() + document = factories.DocumentFactory(parent=parent) + access = None + if via == USER: + access = factories.UserDocumentAccessFactory( + document=document, user=user, role="owner" + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + access = factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="owner" + ) + + old_values = serializers.DocumentAccessSerializer(instance=access).data + new_role = random.choice(["administrator", "editor", "reader"]) + + user_id = str(access.user_id) if via == USER else None + with mock_reset_connections(document.id, user_id): + response = client.put( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + data={**old_values, "role": new_role}, + format="json", + ) + + assert response.status_code == 200 + access.refresh_from_db() + assert access.role == new_role + + # Delete @@ -1170,17 +1225,16 @@ def test_api_document_accesses_delete_owners( f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", ) - assert response.status_code == 204 - assert models.DocumentAccess.objects.count() == 1 + assert response.status_code == 204 + assert models.DocumentAccess.objects.count() == 1 @pytest.mark.parametrize("via", VIA) -def test_api_document_accesses_delete_owners_last_owner(via, mock_user_teams): +def test_api_document_accesses_delete_owners_last_owner_root(via, mock_user_teams): """ - It should not be possible to delete the last owner access from a document + It should not be possible to delete the last owner access from a root document """ user = factories.UserFactory(with_owned_document=True) - client = APIClient() client.force_login(user) @@ -1203,3 +1257,63 @@ def test_api_document_accesses_delete_owners_last_owner(via, mock_user_teams): assert response.status_code == 403 assert models.DocumentAccess.objects.count() == 2 + + +def test_api_document_accesses_delete_owners_last_owner_child_user( + mock_reset_connections, # pylint: disable=redefined-outer-name +): + """ + It should be possible to delete the last owner access from a document that is not a root. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory() + document = factories.DocumentFactory(parent=parent) + access = None + access = factories.UserDocumentAccessFactory( + document=document, user=user, role="owner" + ) + + assert models.DocumentAccess.objects.count() == 2 + with mock_reset_connections(document.id, str(access.user_id)): + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 204 + assert models.DocumentAccess.objects.count() == 1 + + +@pytest.mark.skip( + reason="Pending fix on https://github.com/suitenumerique/docs/issues/969" +) +def test_api_document_accesses_delete_owners_last_owner_child_team( + mock_user_teams, + mock_reset_connections, # pylint: disable=redefined-outer-name +): + """ + It should be possible to delete the last owner access from a document that + is not a root. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory() + document = factories.DocumentFactory(parent=parent) + access = None + mock_user_teams.return_value = ["lasuite", "unknown"] + access = factories.TeamDocumentAccessFactory( + document=document, team="lasuite", role="owner" + ) + + assert models.DocumentAccess.objects.count() == 2 + with mock_reset_connections(document.id, str(access.user_id)): + response = client.delete( + f"/api/v1.0/documents/{document.id!s}/accesses/{access.id!s}/", + ) + + assert response.status_code == 204 + assert models.DocumentAccess.objects.count() == 1 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 c5b6f2bf7..b39653d5f 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 @@ -114,7 +114,8 @@ def test_api_documents_children_create_authenticated_success(reach, role, depth) child = Document.objects.get(id=response.json()["id"]) assert child.title == "my child" assert child.link_reach == "restricted" - assert child.accesses.filter(role="owner", user=user).exists() + # Access objects on the child are not necessary + assert child.accesses.exists() is False @pytest.mark.parametrize("depth", [1, 2, 3]) @@ -182,7 +183,8 @@ def test_api_documents_children_create_related_success(role, depth): child = Document.objects.get(id=response.json()["id"]) assert child.title == "my child" assert child.link_reach == "restricted" - assert child.accesses.filter(role="owner", user=user).exists() + # Access objects on the child are not necessary + assert child.accesses.exists() is False def test_api_documents_children_create_authenticated_title_null(): diff --git a/src/backend/core/tests/documents/test_api_documents_move.py b/src/backend/core/tests/documents/test_api_documents_move.py index a0dd83500..ad4f68d41 100644 --- a/src/backend/core/tests/documents/test_api_documents_move.py +++ b/src/backend/core/tests/documents/test_api_documents_move.py @@ -124,8 +124,8 @@ def test_api_documents_move_authenticated_target_roles_mocked( target_role, target_parent_role, position ): """ - Authenticated users with insufficient permissions on the target document (or its - parent depending on the position chosen), should not be allowed to move documents. + Only authenticated users with sufficient permissions on the target document (or its + parent depending on the position chosen), should be allowed to move documents. """ user = factories.UserFactory() @@ -208,6 +208,107 @@ def test_api_documents_move_authenticated_target_roles_mocked( assert document.is_root() is True +def test_api_documents_move_authenticated_no_owner_user_and_team(): + """ + Moving a document with no owner to the root of the tree should automatically declare + the owner of the previous root of the document as owner of the document itself. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + parent_owner = factories.UserFactory() + parent = factories.DocumentFactory( + users=[(parent_owner, "owner")], teams=[("lasuite", "owner")] + ) + # A document with no owner + document = factories.DocumentFactory(parent=parent, users=[(user, "administrator")]) + child = factories.DocumentFactory(parent=document) + target = factories.DocumentFactory() + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/move/", + data={"target_document_id": str(target.id), "position": "first-sibling"}, + ) + + assert response.status_code == 200 + assert response.json() == {"message": "Document moved successfully."} + assert list(target.get_siblings()) == [document, parent, target] + + document.refresh_from_db() + assert list(document.get_children()) == [child] + assert document.accesses.count() == 3 + assert document.accesses.get(user__isnull=False, role="owner").user == parent_owner + assert document.accesses.get(user__isnull=True, role="owner").team == "lasuite" + assert document.accesses.get(role="administrator").user == user + + +def test_api_documents_move_authenticated_no_owner_same_user(): + """ + Moving a document should not fail if the user moving a document with no owner was + at the same time owner of the previous root and has a role on the document being moved. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory( + users=[(user, "owner")], teams=[("lasuite", "owner")] + ) + # A document with no owner + document = factories.DocumentFactory(parent=parent, users=[(user, "reader")]) + child = factories.DocumentFactory(parent=document) + target = factories.DocumentFactory() + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/move/", + data={"target_document_id": str(target.id), "position": "first-sibling"}, + ) + + assert response.status_code == 200 + assert response.json() == {"message": "Document moved successfully."} + assert list(target.get_siblings()) == [document, parent, target] + + document.refresh_from_db() + assert list(document.get_children()) == [child] + assert document.accesses.count() == 2 + assert document.accesses.get(user__isnull=False, role="owner").user == user + assert document.accesses.get(user__isnull=True, role="owner").team == "lasuite" + + +def test_api_documents_move_authenticated_no_owner_same_team(): + """ + Moving a document should not fail if the team that is owner of the document root was + already declared on the document with a different role. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + parent = factories.DocumentFactory(teams=[("lasuite", "owner")]) + # A document with no owner but same team + document = factories.DocumentFactory( + parent=parent, users=[(user, "administrator")], teams=[("lasuite", "reader")] + ) + child = factories.DocumentFactory(parent=document) + target = factories.DocumentFactory() + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/move/", + data={"target_document_id": str(target.id), "position": "first-sibling"}, + ) + + assert response.status_code == 200 + assert response.json() == {"message": "Document moved successfully."} + assert list(target.get_siblings()) == [document, parent, target] + + document.refresh_from_db() + assert list(document.get_children()) == [child] + assert document.accesses.count() == 2 + assert document.accesses.get(user__isnull=False, role="administrator").user == user + assert document.accesses.get(user__isnull=True, role="owner").team == "lasuite" + + def test_api_documents_move_authenticated_deleted_document(): """ It should not be possible to move a deleted document or its descendants, even diff --git a/src/backend/core/tests/test_models_document_accesses.py b/src/backend/core/tests/test_models_document_accesses.py index 1de3e019f..2fa88cf1f 100644 --- a/src/backend/core/tests/test_models_document_accesses.py +++ b/src/backend/core/tests/test_models_document_accesses.py @@ -127,12 +127,18 @@ def test_models_document_access_get_abilities_for_owner_of_self_allowed(): } -def test_models_document_access_get_abilities_for_owner_of_self_last(): +def test_models_document_access_get_abilities_for_owner_of_self_last_on_root( + django_assert_num_queries, +): """ - Check abilities of self access for the owner of a document when there is only one owner left. + Check abilities of self access for the owner of a root document when there + is only one owner left. """ access = factories.UserDocumentAccessFactory(role="owner") - abilities = access.get_abilities(access.user) + + with django_assert_num_queries(2): + abilities = access.get_abilities(access.user) + assert abilities == { "destroy": False, "retrieve": True, @@ -142,6 +148,28 @@ def test_models_document_access_get_abilities_for_owner_of_self_last(): } +def test_models_document_access_get_abilities_for_owner_of_self_last_on_child( + django_assert_num_queries, +): + """ + Check abilities of self access for the owner of a child document when there + is only one owner left. + """ + parent = factories.DocumentFactory() + access = factories.UserDocumentAccessFactory(document__parent=parent, role="owner") + + with django_assert_num_queries(1): + abilities = access.get_abilities(access.user) + + assert abilities == { + "destroy": True, + "retrieve": True, + "update": True, + "partial_update": True, + "set_role_to": ["reader", "editor", "administrator", "owner"], + } + + def test_models_document_access_get_abilities_for_owner_of_owner(): """Check abilities of owner access for the owner of a document.""" access = factories.UserDocumentAccessFactory(role="owner") From 31ff6951bb1080e39b757dd3f34670b1f200ee91 Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Sun, 4 May 2025 22:21:39 +0200 Subject: [PATCH 017/104] =?UTF-8?q?=E2=9C=85(backend)=20fix=20randomly=20f?= =?UTF-8?q?ailing=20test=20due=20to=20delay=20before=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There is a delay between the time the signature is issued and the time it is checked. Although this delay is minimal, if the signature is issued at the end of a second, both timestamps can differ of 1s. > assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ") AssertionError: assert equals failed '20250504T175307Z' '20250504T175308Z' --- .../documents/test_api_documents_duplicate.py | 13 +++-- .../test_api_documents_media_auth.py | 51 +++++++++++-------- 2 files changed, 39 insertions(+), 25 deletions(-) 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 82acfa984..a1d920354 100644 --- a/src/backend/core/tests/documents/test_api_documents_duplicate.py +++ b/src/backend/core/tests/documents/test_api_documents_duplicate.py @@ -14,6 +14,7 @@ import pycrdt import pytest import requests +from freezegun import freeze_time from rest_framework.test import APIClient from core import factories, models @@ -133,19 +134,21 @@ def test_api_documents_duplicate_success(index): # Ensure access persists after the owner loses access to the original document models.DocumentAccess.objects.filter(document=document).delete() - response = client.get( - "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=image_refs[0][1] - ) - assert response.status_code == 200 + now = timezone.now() + with freeze_time(now): + response = client.get( + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=image_refs[0][1] + ) + assert response.status_code == 200 + assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ") authorization = response["Authorization"] assert "AWS4-HMAC-SHA256 Credential=" in authorization assert ( "SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=" in authorization ) - assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ") s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL) response = requests.get( diff --git a/src/backend/core/tests/documents/test_api_documents_media_auth.py b/src/backend/core/tests/documents/test_api_documents_media_auth.py index 37f88daa3..ee76ef944 100644 --- a/src/backend/core/tests/documents/test_api_documents_media_auth.py +++ b/src/backend/core/tests/documents/test_api_documents_media_auth.py @@ -12,6 +12,7 @@ import pytest import requests +from freezegun import freeze_time from rest_framework.test import APIClient from core import factories, models @@ -52,9 +53,11 @@ def test_api_documents_media_auth_anonymous_public(): factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key]) original_url = f"http://localhost/media/{key:s}" - response = APIClient().get( - "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url - ) + now = timezone.now() + with freeze_time(now): + response = APIClient().get( + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url + ) assert response.status_code == 200 @@ -64,7 +67,7 @@ def test_api_documents_media_auth_anonymous_public(): "SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=" in authorization ) - assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ") + assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ") s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL) file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}" @@ -167,9 +170,11 @@ def test_api_documents_media_auth_anonymous_attachments(): parent = factories.DocumentFactory(link_reach="public") factories.DocumentFactory(parent=parent, link_reach="restricted", attachments=[key]) - response = APIClient().get( - "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url - ) + now = timezone.now() + with freeze_time(now): + response = APIClient().get( + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url + ) assert response.status_code == 200 @@ -179,7 +184,7 @@ def test_api_documents_media_auth_anonymous_attachments(): "SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=" in authorization ) - assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ") + assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ") s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL) file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}" @@ -221,9 +226,11 @@ def test_api_documents_media_auth_authenticated_public_or_authenticated(reach): factories.DocumentFactory(id=document_id, link_reach=reach, attachments=[key]) - response = client.get( - "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url - ) + now = timezone.now() + with freeze_time(now): + response = client.get( + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url + ) assert response.status_code == 200 @@ -233,7 +240,7 @@ def test_api_documents_media_auth_authenticated_public_or_authenticated(reach): "SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=" in authorization ) - assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ") + assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ") s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL) file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}" @@ -307,9 +314,11 @@ def test_api_documents_media_auth_related(via, mock_user_teams): mock_user_teams.return_value = ["lasuite", "unknown"] factories.TeamDocumentAccessFactory(document=document, team="lasuite") - response = client.get( - "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url - ) + now = timezone.now() + with freeze_time(now): + response = client.get( + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=media_url + ) assert response.status_code == 200 @@ -319,7 +328,7 @@ def test_api_documents_media_auth_related(via, mock_user_teams): "SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=" in authorization ) - assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ") + assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ") s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL) file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}" @@ -373,10 +382,12 @@ def test_api_documents_media_auth_missing_status_metadata(): factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key]) + now = timezone.now() original_url = f"http://localhost/media/{key:s}" - response = APIClient().get( - "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url - ) + with freeze_time(now): + response = APIClient().get( + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url + ) assert response.status_code == 200 @@ -386,7 +397,7 @@ def test_api_documents_media_auth_missing_status_metadata(): "SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=" in authorization ) - assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ") + assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ") s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL) file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}" From da6dda72ae2b37767c035fd9385c3457e87b9ae2 Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Tue, 6 May 2025 09:41:16 +0200 Subject: [PATCH 018/104] =?UTF-8?q?=F0=9F=90=9B(backend)=20allow=20creatin?= =?UTF-8?q?g=20accesses=20when=20privileged=20by=20heritage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We took the opportunity of this bug to refactor serializers and permissions as advised one day by @qbey: no permission checks in serializers. --- src/backend/core/api/permissions.py | 55 +++++++-- src/backend/core/api/serializers.py | 104 +++++------------- src/backend/core/api/viewsets.py | 100 +++++++++++++---- src/backend/core/models.py | 19 ++-- .../test_api_document_accesses_create.py | 46 +++++--- .../test_api_template_accesses_create.py | 2 +- 6 files changed, 192 insertions(+), 134 deletions(-) diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py index 43a0465f4..09007847b 100644 --- a/src/backend/core/api/permissions.py +++ b/src/backend/core/api/permissions.py @@ -6,6 +6,7 @@ from rest_framework import permissions +from core import choices from core.models import DocumentAccess, RoleChoices, get_trashbin_cutoff ACTION_FOR_METHOD_TO_PERMISSION = { @@ -96,26 +97,27 @@ def has_permission(self, request, view): ).exists() -class AccessPermission(permissions.BasePermission): - """Permission class for access objects.""" +class ResourceWithAccessPermission(permissions.BasePermission): + """A permission class for templates and invitations.""" def has_permission(self, request, view): + """check create permission for templates.""" return request.user.is_authenticated or view.action != "create" def has_object_permission(self, request, view, obj): """Check permission for a given object.""" abilities = obj.get_abilities(request.user) action = view.action - try: - action = ACTION_FOR_METHOD_TO_PERMISSION[view.action][request.method] - except KeyError: - pass return abilities.get(action, False) -class DocumentAccessPermission(AccessPermission): +class DocumentPermission(permissions.BasePermission): """Subclass to handle soft deletion specificities.""" + def has_permission(self, request, view): + """check create permission for documents.""" + return request.user.is_authenticated or view.action != "create" + def has_object_permission(self, request, view, obj): """ Return a 404 on deleted documents @@ -127,10 +129,45 @@ def has_object_permission(self, request, view, obj): ) and deleted_at < get_trashbin_cutoff(): raise Http404 - # Compute permission first to ensure the "user_roles" attribute is set - has_permission = super().has_object_permission(request, view, obj) + abilities = obj.get_abilities(request.user) + action = view.action + try: + action = ACTION_FOR_METHOD_TO_PERMISSION[view.action][request.method] + except KeyError: + pass + + has_permission = abilities.get(action, False) if obj.ancestors_deleted_at and not RoleChoices.OWNER in obj.user_roles: raise Http404 return has_permission + + +class ResourceAccessPermission(IsAuthenticated): + """Permission class for document access objects.""" + + def has_permission(self, request, view): + """check create permission for accesses in documents tree.""" + if super().has_permission(request, view) is False: + return False + + if view.action == "create": + role = getattr(view, view.resource_field_name).get_role(request.user) + if role not in choices.PRIVILEGED_ROLES: + raise exceptions.PermissionDenied( + "You are not allowed to manage accesses for this resource." + ) + + return True + + def has_object_permission(self, request, view, obj): + """Check permission for a given object.""" + abilities = obj.get_abilities(request.user) + + requested_role = request.data.get("role") + if requested_role and requested_role not in abilities.get("set_role_to", []): + return False + + action = view.action + return abilities.get(action, False) diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index adc77af9d..052aebec9 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -10,7 +10,7 @@ from django.utils.translation import gettext_lazy as _ import magic -from rest_framework import exceptions, serializers +from rest_framework import serializers from core import choices, enums, models, utils from core.services.ai_services import AI_ACTIONS @@ -38,78 +38,7 @@ class Meta: read_only_fields = ["full_name", "short_name"] -class BaseAccessSerializer(serializers.ModelSerializer): - """Serialize template accesses.""" - - abilities = serializers.SerializerMethodField(read_only=True) - - def update(self, instance, validated_data): - """Make "user" field is readonly but only on update.""" - validated_data.pop("user", None) - return super().update(instance, validated_data) - - def get_abilities(self, instance) -> dict: - """Return abilities of the logged-in user on the instance.""" - request = self.context.get("request") - if request: - return instance.get_abilities(request.user) - return {} - - def validate(self, attrs): - """ - Check access rights specific to writing (create/update) - """ - request = self.context.get("request") - user = getattr(request, "user", None) - role = attrs.get("role") - - # Update - if self.instance: - can_set_role_to = self.instance.get_abilities(user)["set_role_to"] - if role and role not in can_set_role_to: - message = ( - f"You are only allowed to set role to {', '.join(can_set_role_to)}" - if can_set_role_to - else "You are not allowed to set this role for this template." - ) - raise exceptions.PermissionDenied(message) - - # Create - else: - try: - resource_id = self.context["resource_id"] - except KeyError as exc: - raise exceptions.ValidationError( - "You must set a resource ID in kwargs to create a new access." - ) from exc - - if not self.Meta.model.objects.filter( # pylint: disable=no-member - Q(user=user) | Q(team__in=user.teams), - role__in=choices.PRIVILEGED_ROLES, - **{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member - ).exists(): - raise exceptions.PermissionDenied( - "You are not allowed to manage accesses for this resource." - ) - - if ( - role == models.RoleChoices.OWNER - and not self.Meta.model.objects.filter( # pylint: disable=no-member - Q(user=user) | Q(team__in=user.teams), - role=models.RoleChoices.OWNER, - **{self.Meta.resource_field_name: resource_id}, # pylint: disable=no-member - ).exists() - ): - raise exceptions.PermissionDenied( - "Only owners of a resource can assign other users as owners." - ) - - # pylint: disable=no-member - attrs[f"{self.Meta.resource_field_name}_id"] = self.context["resource_id"] - return attrs - - -class DocumentAccessSerializer(BaseAccessSerializer): +class DocumentAccessSerializer(serializers.ModelSerializer): """Serialize document accesses.""" document_id = serializers.PrimaryKeyRelatedField( @@ -124,6 +53,7 @@ class DocumentAccessSerializer(BaseAccessSerializer): allow_null=True, ) user = UserSerializer(read_only=True) + abilities = serializers.SerializerMethodField(read_only=True) max_ancestors_role = serializers.SerializerMethodField(read_only=True) class Meta: @@ -141,10 +71,22 @@ class Meta: ] read_only_fields = ["id", "document_id", "abilities", "max_ancestors_role"] + def get_abilities(self, instance) -> dict: + """Return abilities of the logged-in user on the instance.""" + request = self.context.get("request") + if request: + return instance.get_abilities(request.user) + return {} + def get_max_ancestors_role(self, instance): """Return max_ancestors_role if annotated; else None.""" return getattr(instance, "max_ancestors_role", None) + def update(self, instance, validated_data): + """Make "user" field is readonly but only on update.""" + validated_data.pop("user", None) + return super().update(instance, validated_data) + class DocumentAccessLightSerializer(DocumentAccessSerializer): """Serialize document accesses with limited fields.""" @@ -173,15 +115,29 @@ class Meta: ] -class TemplateAccessSerializer(BaseAccessSerializer): +class TemplateAccessSerializer(serializers.ModelSerializer): """Serialize template accesses.""" + abilities = serializers.SerializerMethodField(read_only=True) + class Meta: model = models.TemplateAccess resource_field_name = "template" fields = ["id", "user", "team", "role", "abilities"] read_only_fields = ["id", "abilities"] + def get_abilities(self, instance) -> dict: + """Return abilities of the logged-in user on the instance.""" + request = self.context.get("request") + if request: + return instance.get_abilities(request.user) + return {} + + def update(self, instance, validated_data): + """Make "user" field is readonly but only on update.""" + validated_data.pop("user", None) + return super().update(instance, validated_data) + class ListDocumentSerializer(serializers.ModelSerializer): """Serialize documents with limited fields for display in lists.""" diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 744e92b65..c6fc36650 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -18,6 +18,7 @@ from django.db.models.expressions import RawSQL from django.db.models.functions import Left, Length from django.http import Http404, StreamingHttpResponse +from django.utils.functional import cached_property from django.utils.text import capfirst, slugify from django.utils.translation import gettext_lazy as _ @@ -352,7 +353,7 @@ class DocumentViewSet( ordering_fields = ["created_at", "updated_at", "title"] pagination_class = Pagination permission_classes = [ - permissions.DocumentAccessPermission, + permissions.DocumentPermission, ] queryset = models.Document.objects.all() serializer_class = serializers.DocumentSerializer @@ -761,7 +762,7 @@ def tree(self, request, pk, *args, **kwargs): try: current_document = self.queryset.only("depth", "path").get(pk=pk) except models.Document.DoesNotExist as excpt: - raise drf.exceptions.NotFound from excpt + raise drf.exceptions.NotFound() from excpt ancestors = ( (current_document.get_ancestors() | self.queryset.filter(pk=pk)) @@ -821,7 +822,10 @@ def tree(self, request, pk, *args, **kwargs): @drf.decorators.action( detail=True, methods=["post"], - permission_classes=[permissions.IsAuthenticated, permissions.AccessPermission], + permission_classes=[ + permissions.IsAuthenticated, + permissions.DocumentPermission, + ], url_path="duplicate", ) @transaction.atomic @@ -1335,25 +1339,32 @@ class DocumentAccessViewSet( """ lookup_field = "pk" - permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission] - queryset = models.DocumentAccess.objects.select_related("user").all() + permission_classes = [permissions.ResourceAccessPermission] + queryset = models.DocumentAccess.objects.select_related("user", "document").only( + "id", + "created_at", + "role", + "team", + "user__id", + "user__short_name", + "user__full_name", + "user__email", + "user__language", + "document__id", + "document__path", + "document__depth", + ) resource_field_name = "document" - def __init__(self, *args, **kwargs): - """Initialize the viewset and define default value for contextual document.""" - super().__init__(*args, **kwargs) - self.document = None - - def initial(self, request, *args, **kwargs): - """Retrieve self.document with annotated user roles.""" - super().initial(request, *args, **kwargs) - + @cached_property + def document(self): + """Get related document from resource ID in url and annotate user roles.""" try: - self.document = models.Document.objects.annotate_user_roles( - self.request.user - ).get(pk=self.kwargs["resource_id"]) + return models.Document.objects.annotate_user_roles(self.request.user).get( + pk=self.kwargs["resource_id"] + ) except models.Document.DoesNotExist as excpt: - raise Http404() from excpt + raise drf.exceptions.NotFound() from excpt def get_serializer_class(self): """Use light serializer for unprivileged users.""" @@ -1441,8 +1452,24 @@ def list(self, request, *args, **kwargs): return drf.response.Response(serialized_data) def perform_create(self, serializer): - """Add a new access to the document and send an email to the new added user.""" - access = serializer.save() + """ + Actually create the new document access: + - Ensures the `document_id` is explicitly set from the URL + - If the assigned role is `OWNER`, checks that the requesting user is an owner + of the document. This is the only permission check deferred until this step; + all other access checks are handled earlier in the permission lifecycle. + - Sends an invitation email to the newly added user after saving the access. + """ + role = serializer.validated_data.get("role") + if ( + role == choices.RoleChoices.OWNER + and self.document.get_role(self.request.user) != choices.RoleChoices.OWNER + ): + raise drf.exceptions.PermissionDenied( + "Only owners of a document can assign other users as owners." + ) + + access = serializer.save(document_id=self.kwargs["resource_id"]) access.document.send_invitation_email( access.user.email, @@ -1488,7 +1515,7 @@ class TemplateViewSet( filter_backends = [drf.filters.OrderingFilter] permission_classes = [ permissions.IsAuthenticatedOrSafe, - permissions.AccessPermission, + permissions.ResourceWithAccessPermission, ] ordering = ["-created_at"] ordering_fields = ["created_at", "updated_at", "title"] @@ -1579,11 +1606,19 @@ class TemplateAccessViewSet( """ lookup_field = "pk" - permission_classes = [permissions.IsAuthenticated, permissions.AccessPermission] + permission_classes = [permissions.ResourceAccessPermission] queryset = models.TemplateAccess.objects.select_related("user").all() resource_field_name = "template" serializer_class = serializers.TemplateAccessSerializer + @cached_property + def template(self): + """Get related template from resource ID in url.""" + try: + return models.Template.objects.get(pk=self.kwargs["resource_id"]) + except models.Template.DoesNotExist as excpt: + raise drf.exceptions.NotFound() from excpt + def list(self, request, *args, **kwargs): """Restrict templates returned by the list endpoint""" user = self.request.user @@ -1601,6 +1636,25 @@ def list(self, request, *args, **kwargs): serializer = self.get_serializer(queryset, many=True) return drf.response.Response(serializer.data) + def perform_create(self, serializer): + """ + Actually create the new template access: + - Ensures the `template_id` is explicitly set from the URL. + - If the assigned role is `OWNER`, checks that the requesting user is an owner + of the document. This is the only permission check deferred until this step; + all other access checks are handled earlier in the permission lifecycle. + """ + role = serializer.validated_data.get("role") + if ( + role == choices.RoleChoices.OWNER + and self.template.get_role(self.request.user) != choices.RoleChoices.OWNER + ): + raise drf.exceptions.PermissionDenied( + "Only owners of a template can assign other users as owners." + ) + + serializer.save(template_id=self.kwargs["resource_id"]) + class InvitationViewset( drf.mixins.CreateModelMixin, @@ -1633,7 +1687,7 @@ class InvitationViewset( pagination_class = Pagination permission_classes = [ permissions.CanCreateInvitationPermission, - permissions.AccessPermission, + permissions.ResourceWithAccessPermission, ] queryset = ( models.Invitation.objects.all() diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 0adc9b5d5..deca3772f 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1183,10 +1183,10 @@ class Meta: def __str__(self): return self.title - def get_roles(self, user): + def get_role(self, user): """Return the roles a user has on a resource as an iterable.""" if not user.is_authenticated: - return [] + return None try: roles = self.user_roles or [] @@ -1197,21 +1197,20 @@ def get_roles(self, user): ).values_list("role", flat=True) except (models.ObjectDoesNotExist, IndexError): roles = [] - return roles + + return RoleChoices.max(*roles) def get_abilities(self, user): """ Compute and return abilities for a given user on the template. """ - roles = self.get_roles(user) - is_owner_or_admin = bool( - set(roles).intersection({RoleChoices.OWNER, RoleChoices.ADMIN}) - ) - can_get = self.is_public or bool(roles) - can_update = is_owner_or_admin or RoleChoices.EDITOR in roles + role = self.get_role(user) + is_owner_or_admin = role in PRIVILEGED_ROLES + can_get = self.is_public or bool(role) + can_update = is_owner_or_admin or role == RoleChoices.EDITOR return { - "destroy": RoleChoices.OWNER in roles, + "destroy": role == RoleChoices.OWNER, "generate_document": can_get, "accesses_manage": is_owner_or_admin, "update": can_update, diff --git a/src/backend/core/tests/documents/test_api_document_accesses_create.py b/src/backend/core/tests/documents/test_api_document_accesses_create.py index 8b8319802..5af0da91e 100644 --- a/src/backend/core/tests/documents/test_api_document_accesses_create.py +++ b/src/backend/core/tests/documents/test_api_document_accesses_create.py @@ -103,32 +103,37 @@ def test_api_document_accesses_create_authenticated_reader_or_editor( assert not models.DocumentAccess.objects.filter(user=other_user).exists() +@pytest.mark.parametrize("depth", [1, 2, 3]) @pytest.mark.parametrize("via", VIA) -def test_api_document_accesses_create_authenticated_administrator(via, mock_user_teams): +def test_api_document_accesses_create_authenticated_administrator( + via, depth, mock_user_teams +): """ - Administrators of a document should be able to create document accesses - except for the "owner" role. + Administrators of a document (direct or by heritage) should be able to create + document accesses except for the "owner" role. An email should be sent to the accesses to notify them of the adding. """ user = factories.UserFactory(with_owned_document=True) - client = APIClient() client.force_login(user) - document = factories.DocumentFactory() + documents = [] + for i in range(depth): + parent = documents[i - 1] if i > 0 else None + documents.append(factories.DocumentFactory(parent=parent)) + if via == USER: factories.UserDocumentAccessFactory( - document=document, user=user, role="administrator" + document=documents[0], user=user, role="administrator" ) elif via == TEAM: mock_user_teams.return_value = ["lasuite", "unknown"] factories.TeamDocumentAccessFactory( - document=document, team="lasuite", role="administrator" + document=documents[0], team="lasuite", role="administrator" ) other_user = factories.UserFactory(language="en-us") - - # It should not be allowed to create an owner access + document = documents[-1] response = client.post( f"/api/v1.0/documents/{document.id!s}/accesses/", { @@ -140,7 +145,7 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user assert response.status_code == 403 assert response.json() == { - "detail": "Only owners of a resource can assign other users as owners." + "detail": "Only owners of a document can assign other users as owners." } # It should be allowed to create a lower access @@ -184,28 +189,35 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user assert "docs/" + str(document.id) + "/" in email_content +@pytest.mark.parametrize("depth", [1, 2, 3]) @pytest.mark.parametrize("via", VIA) -def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams): +def test_api_document_accesses_create_authenticated_owner(via, depth, mock_user_teams): """ - Owners of a document should be able to create document accesses whatever the role. - An email should be sent to the accesses to notify them of the adding. + Owners of a document (direct or by heritage) should be able to create document accesses + whatever the role. An email should be sent to the accesses to notify them of the adding. """ user = factories.UserFactory() client = APIClient() client.force_login(user) - document = factories.DocumentFactory() + documents = [] + for i in range(depth): + parent = documents[i - 1] if i > 0 else None + documents.append(factories.DocumentFactory(parent=parent)) + if via == USER: - factories.UserDocumentAccessFactory(document=document, user=user, role="owner") + factories.UserDocumentAccessFactory( + document=documents[0], user=user, role="owner" + ) elif via == TEAM: mock_user_teams.return_value = ["lasuite", "unknown"] factories.TeamDocumentAccessFactory( - document=document, team="lasuite", role="owner" + document=documents[0], team="lasuite", role="owner" ) other_user = factories.UserFactory(language="en-us") - + document = documents[-1] role = random.choice([role[0] for role in models.RoleChoices.choices]) assert len(mail.outbox) == 0 diff --git a/src/backend/core/tests/templates/test_api_template_accesses_create.py b/src/backend/core/tests/templates/test_api_template_accesses_create.py index f52a5344f..a33cd470a 100644 --- a/src/backend/core/tests/templates/test_api_template_accesses_create.py +++ b/src/backend/core/tests/templates/test_api_template_accesses_create.py @@ -133,7 +133,7 @@ def test_api_template_accesses_create_authenticated_administrator(via, mock_user assert response.status_code == 403 assert response.json() == { - "detail": "Only owners of a resource can assign other users as owners." + "detail": "Only owners of a template can assign other users as owners." } # It should be allowed to create a lower access From e695ed5c2dd3cea0039c3a9916fe21e5f9aefa46 Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Wed, 7 May 2025 18:48:08 +0200 Subject: [PATCH 019/104] =?UTF-8?q?=E2=9C=A8(backend)=20add=20document=20p?= =?UTF-8?q?ath=20and=20depth=20to=20accesses=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frontend requires this information about the ancestor document to which each access is related. We make sure it does not generate more db queries and does not fetch useless and heavy fields from the document like "excerpt". --- src/backend/core/api/serializers.py | 162 +++++++++--------- src/backend/core/api/viewsets.py | 6 +- src/backend/core/models.py | 4 +- .../documents/test_api_document_accesses.py | 18 +- .../test_api_document_accesses_create.py | 20 ++- 5 files changed, 118 insertions(+), 92 deletions(-) diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 052aebec9..f545efaaf 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -38,83 +38,6 @@ class Meta: read_only_fields = ["full_name", "short_name"] -class DocumentAccessSerializer(serializers.ModelSerializer): - """Serialize document accesses.""" - - document_id = serializers.PrimaryKeyRelatedField( - read_only=True, - source="document", - ) - user_id = serializers.PrimaryKeyRelatedField( - queryset=models.User.objects.all(), - write_only=True, - source="user", - required=False, - allow_null=True, - ) - user = UserSerializer(read_only=True) - abilities = serializers.SerializerMethodField(read_only=True) - max_ancestors_role = serializers.SerializerMethodField(read_only=True) - - class Meta: - model = models.DocumentAccess - resource_field_name = "document" - fields = [ - "id", - "document_id", - "user", - "user_id", - "team", - "role", - "abilities", - "max_ancestors_role", - ] - read_only_fields = ["id", "document_id", "abilities", "max_ancestors_role"] - - def get_abilities(self, instance) -> dict: - """Return abilities of the logged-in user on the instance.""" - request = self.context.get("request") - if request: - return instance.get_abilities(request.user) - return {} - - def get_max_ancestors_role(self, instance): - """Return max_ancestors_role if annotated; else None.""" - return getattr(instance, "max_ancestors_role", None) - - def update(self, instance, validated_data): - """Make "user" field is readonly but only on update.""" - validated_data.pop("user", None) - return super().update(instance, validated_data) - - -class DocumentAccessLightSerializer(DocumentAccessSerializer): - """Serialize document accesses with limited fields.""" - - user = UserLightSerializer(read_only=True) - - class Meta: - model = models.DocumentAccess - resource_field_name = "document" - fields = [ - "id", - "document_id", - "user", - "team", - "role", - "abilities", - "max_ancestors_role", - ] - read_only_fields = [ - "id", - "document_id", - "team", - "role", - "abilities", - "max_ancestors_role", - ] - - class TemplateAccessSerializer(serializers.ModelSerializer): """Serialize template accesses.""" @@ -223,6 +146,15 @@ def get_user_role(self, instance): return instance.get_role(request.user) if request else None +class DocumentLightSerializer(serializers.ModelSerializer): + """Minial document serializer for nesting in document accesses.""" + + class Meta: + model = models.Document + fields = ["id", "path", "depth"] + read_only_fields = ["id", "path", "depth"] + + class DocumentSerializer(ListDocumentSerializer): """Serialize documents with all fields for display in detail views.""" @@ -357,6 +289,82 @@ def save(self, **kwargs): return super().save(**kwargs) +class DocumentAccessSerializer(serializers.ModelSerializer): + """Serialize document accesses.""" + + document = DocumentLightSerializer(read_only=True) + user_id = serializers.PrimaryKeyRelatedField( + queryset=models.User.objects.all(), + write_only=True, + source="user", + required=False, + allow_null=True, + ) + user = UserSerializer(read_only=True) + team = serializers.CharField(required=False, allow_blank=True) + abilities = serializers.SerializerMethodField(read_only=True) + max_ancestors_role = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = models.DocumentAccess + resource_field_name = "document" + fields = [ + "id", + "document", + "user", + "user_id", + "team", + "role", + "abilities", + "max_ancestors_role", + ] + read_only_fields = ["id", "document", "abilities", "max_ancestors_role"] + + def get_abilities(self, instance) -> dict: + """Return abilities of the logged-in user on the instance.""" + request = self.context.get("request") + if request: + return instance.get_abilities(request.user) + return {} + + def get_max_ancestors_role(self, instance): + """Return max_ancestors_role if annotated; else None.""" + return getattr(instance, "max_ancestors_role", None) + + def update(self, instance, validated_data): + """Make "user" field readonly but only on update.""" + validated_data.pop("team", None) + validated_data.pop("user", None) + return super().update(instance, validated_data) + + +class DocumentAccessLightSerializer(DocumentAccessSerializer): + """Serialize document accesses with limited fields.""" + + user = UserLightSerializer(read_only=True) + + class Meta: + model = models.DocumentAccess + resource_field_name = "document" + fields = [ + "id", + "document", + "user", + "team", + "role", + "abilities", + "max_ancestors_role", + ] + read_only_fields = [ + "id", + "document", + "team", + "role", + "abilities", + "max_ancestors_role", + ] + + class ServerCreateDocumentSerializer(serializers.Serializer): """ Serializer for creating a document from a server-to-server request. diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index c6fc36650..806dbfaef 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1392,11 +1392,7 @@ def list(self, request, *args, **kwargs): if role not in choices.PRIVILEGED_ROLES: queryset = queryset.filter(role__in=choices.PRIVILEGED_ROLES) - accesses = list( - queryset.annotate(document_path=db.F("document__path")).order_by( - "document_path" - ) - ) + accesses = list(queryset.order_by("document__path")) # Annotate more information on roles path_to_key_to_max_ancestors_role = defaultdict( diff --git a/src/backend/core/models.py b/src/backend/core/models.py index deca3772f..6dcd94018 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -1121,9 +1121,7 @@ def get_abilities(self, user): if self.role == RoleChoices.OWNER: can_delete = role == RoleChoices.OWNER and ( # check if document is not root trying to avoid an extra query - # "document_path" is annotated by the viewset's list method - len(getattr(self, "document_path", "")) > Document.steplen - or not self.document.is_root() + self.document.depth > 1 or DocumentAccess.objects.filter( document_id=self.document_id, role=RoleChoices.OWNER ).count() diff --git a/src/backend/core/tests/documents/test_api_document_accesses.py b/src/backend/core/tests/documents/test_api_document_accesses.py index beae157f6..4f12b3057 100644 --- a/src/backend/core/tests/documents/test_api_document_accesses.py +++ b/src/backend/core/tests/documents/test_api_document_accesses.py @@ -139,7 +139,11 @@ def test_api_document_accesses_list_authenticated_related_non_privileged( [ { "id": str(access.id), - "document_id": str(access.document_id), + "document": { + "id": str(access.document_id), + "path": access.document.path, + "depth": access.document.depth, + }, "user": { "full_name": access.user.full_name, "short_name": access.user.short_name, @@ -234,7 +238,11 @@ def test_api_document_accesses_list_authenticated_related_privileged( [ { "id": str(access.id), - "document_id": str(access.document_id), + "document": { + "id": str(access.document_id), + "path": access.document.path, + "depth": access.document.depth, + }, "user": { "id": str(access.user.id), "email": access.user.email, @@ -600,7 +608,11 @@ def test_api_document_accesses_retrieve_authenticated_related( assert response.status_code == 200 assert response.json() == { "id": str(access.id), - "document_id": str(access.document_id), + "document": { + "id": str(access.document_id), + "path": access.document.path, + "depth": access.document.depth, + }, "user": access_user, "team": "", "role": access.role, diff --git a/src/backend/core/tests/documents/test_api_document_accesses_create.py b/src/backend/core/tests/documents/test_api_document_accesses_create.py index 5af0da91e..eaaf63c72 100644 --- a/src/backend/core/tests/documents/test_api_document_accesses_create.py +++ b/src/backend/core/tests/documents/test_api_document_accesses_create.py @@ -170,12 +170,16 @@ def test_api_document_accesses_create_authenticated_administrator( other_user = serializers.UserSerializer(instance=other_user).data assert response.json() == { "abilities": new_document_access.get_abilities(user), - "document_id": str(new_document_access.document_id), + "document": { + "id": str(new_document_access.document_id), + "depth": new_document_access.document.depth, + "path": new_document_access.document.path, + }, "id": str(new_document_access.id), + "user": other_user, "team": "", "role": role, "max_ancestors_role": None, - "user": other_user, } assert len(mail.outbox) == 1 email = mail.outbox[0] @@ -236,7 +240,11 @@ def test_api_document_accesses_create_authenticated_owner(via, depth, mock_user_ new_document_access = models.DocumentAccess.objects.filter(user=other_user).get() other_user = serializers.UserSerializer(instance=other_user).data assert response.json() == { - "document_id": str(new_document_access.document_id), + "document": { + "id": str(new_document_access.document_id), + "path": new_document_access.document.path, + "depth": new_document_access.document.depth, + }, "id": str(new_document_access.id), "user": other_user, "team": "", @@ -302,7 +310,11 @@ def test_api_document_accesses_create_email_in_receivers_language(via, mock_user ).get() other_user_data = serializers.UserSerializer(instance=other_user).data assert response.json() == { - "document_id": str(new_document_access.document_id), + "document": { + "id": str(new_document_access.document_id), + "path": new_document_access.document.path, + "depth": new_document_access.document.depth, + }, "id": str(new_document_access.id), "user": other_user_data, "team": "", From 26bf92ed73a9f94237884b2984f6be535d085ae9 Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Wed, 7 May 2025 19:52:02 +0200 Subject: [PATCH 020/104] =?UTF-8?q?=F0=9F=90=9B(backend)=20fix=20creating/?= =?UTF-8?q?updating=20document=20accesses=20for=20teams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This use case was forgotten when the support for team accesses was added. We add tests to stabilize the feature and its security. --- src/backend/core/api/viewsets.py | 17 +- .../documents/test_api_document_accesses.py | 14 +- .../test_api_document_accesses_create.py | 151 +++++++++++++++++- 3 files changed, 170 insertions(+), 12 deletions(-) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 806dbfaef..3230bd3ce 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1467,14 +1467,15 @@ def perform_create(self, serializer): access = serializer.save(document_id=self.kwargs["resource_id"]) - access.document.send_invitation_email( - access.user.email, - access.role, - self.request.user, - access.user.language - or self.request.user.language - or settings.LANGUAGE_CODE, - ) + if access.user: + access.document.send_invitation_email( + access.user.email, + access.role, + self.request.user, + access.user.language + or self.request.user.language + or settings.LANGUAGE_CODE, + ) def perform_update(self, serializer): """Update an access to the document and notify the collaboration server.""" diff --git a/src/backend/core/tests/documents/test_api_document_accesses.py b/src/backend/core/tests/documents/test_api_document_accesses.py index 4f12b3057..fd301f540 100644 --- a/src/backend/core/tests/documents/test_api_document_accesses.py +++ b/src/backend/core/tests/documents/test_api_document_accesses.py @@ -721,7 +721,9 @@ def test_api_document_accesses_update_authenticated_reader_or_editor( @pytest.mark.parametrize("via", VIA) +@pytest.mark.parametrize("create_for", VIA) def test_api_document_accesses_update_administrator_except_owner( + create_for, via, mock_user_teams, mock_reset_connections, # pylint: disable=redefined-outer-name @@ -754,9 +756,12 @@ def test_api_document_accesses_update_administrator_except_owner( new_values = { "id": uuid4(), - "user_id": factories.UserFactory().id, "role": random.choice(["administrator", "editor", "reader"]), } + if create_for == USER: + new_values["user_id"] = factories.UserFactory().id + elif create_for == TEAM: + new_values["team"] = "new-team" for field, value in new_values.items(): new_data = {**old_values, field: value} @@ -892,7 +897,9 @@ def test_api_document_accesses_update_administrator_to_owner( @pytest.mark.parametrize("via", VIA) +@pytest.mark.parametrize("create_for", VIA) def test_api_document_accesses_update_owner( + create_for, via, mock_user_teams, mock_reset_connections, # pylint: disable=redefined-outer-name @@ -923,9 +930,12 @@ def test_api_document_accesses_update_owner( new_values = { "id": uuid4(), - "user_id": factories.UserFactory().id, "role": random.choice(models.RoleChoices.values), } + if create_for == USER: + new_values["user_id"] = factories.UserFactory().id + elif create_for == TEAM: + new_values["team"] = "new-team" for field, value in new_values.items(): new_data = {**old_values, field: value} diff --git a/src/backend/core/tests/documents/test_api_document_accesses_create.py b/src/backend/core/tests/documents/test_api_document_accesses_create.py index eaaf63c72..8a32aa230 100644 --- a/src/backend/core/tests/documents/test_api_document_accesses_create.py +++ b/src/backend/core/tests/documents/test_api_document_accesses_create.py @@ -105,7 +105,7 @@ def test_api_document_accesses_create_authenticated_reader_or_editor( @pytest.mark.parametrize("depth", [1, 2, 3]) @pytest.mark.parametrize("via", VIA) -def test_api_document_accesses_create_authenticated_administrator( +def test_api_document_accesses_create_authenticated_administrator_share_to_user( via, depth, mock_user_teams ): """ @@ -195,7 +195,90 @@ def test_api_document_accesses_create_authenticated_administrator( @pytest.mark.parametrize("depth", [1, 2, 3]) @pytest.mark.parametrize("via", VIA) -def test_api_document_accesses_create_authenticated_owner(via, depth, mock_user_teams): +def test_api_document_accesses_create_authenticated_administrator_share_to_team( + via, depth, mock_user_teams +): + """ + Administrators of a document (direct or by heritage) should be able to create + document accesses except for the "owner" role. + An email should be sent to the accesses to notify them of the adding. + """ + user = factories.UserFactory(with_owned_document=True) + client = APIClient() + client.force_login(user) + + documents = [] + for i in range(depth): + parent = documents[i - 1] if i > 0 else None + documents.append(factories.DocumentFactory(parent=parent)) + + if via == USER: + factories.UserDocumentAccessFactory( + document=documents[0], user=user, role="administrator" + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=documents[0], team="lasuite", role="administrator" + ) + + other_user = factories.UserFactory(language="en-us") + document = documents[-1] + response = client.post( + f"/api/v1.0/documents/{document.id!s}/accesses/", + { + "team": "new-team", + "role": "owner", + }, + format="json", + ) + + assert response.status_code == 403 + assert response.json() == { + "detail": "Only owners of a document can assign other users as owners." + } + + # It should be allowed to create a lower access + role = random.choice( + [role[0] for role in models.RoleChoices.choices if role[0] != "owner"] + ) + + assert len(mail.outbox) == 0 + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/accesses/", + { + "team": "new-team", + "role": role, + }, + format="json", + ) + + assert response.status_code == 201 + assert models.DocumentAccess.objects.filter(team="new-team").count() == 1 + new_document_access = models.DocumentAccess.objects.filter(team="new-team").get() + other_user = serializers.UserSerializer(instance=other_user).data + assert response.json() == { + "abilities": new_document_access.get_abilities(user), + "document": { + "id": str(new_document_access.document_id), + "depth": new_document_access.document.depth, + "path": new_document_access.document.path, + }, + "id": str(new_document_access.id), + "user": None, + "team": "new-team", + "role": role, + "max_ancestors_role": None, + } + assert len(mail.outbox) == 0 + + +@pytest.mark.parametrize("depth", [1, 2, 3]) +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_create_authenticated_owner_share_to_user( + via, depth, mock_user_teams +): """ Owners of a document (direct or by heritage) should be able to create document accesses whatever the role. An email should be sent to the accesses to notify them of the adding. @@ -264,6 +347,70 @@ def test_api_document_accesses_create_authenticated_owner(via, depth, mock_user_ assert "docs/" + str(document.id) + "/" in email_content +@pytest.mark.parametrize("depth", [1, 2, 3]) +@pytest.mark.parametrize("via", VIA) +def test_api_document_accesses_create_authenticated_owner_share_to_team( + via, depth, mock_user_teams +): + """ + Owners of a document (direct or by heritage) should be able to create document accesses + whatever the role. An email should be sent to the accesses to notify them of the adding. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + documents = [] + for i in range(depth): + parent = documents[i - 1] if i > 0 else None + documents.append(factories.DocumentFactory(parent=parent)) + + if via == USER: + factories.UserDocumentAccessFactory( + document=documents[0], user=user, role="owner" + ) + elif via == TEAM: + mock_user_teams.return_value = ["lasuite", "unknown"] + factories.TeamDocumentAccessFactory( + document=documents[0], team="lasuite", role="owner" + ) + + other_user = factories.UserFactory(language="en-us") + document = documents[-1] + role = random.choice([role[0] for role in models.RoleChoices.choices]) + + assert len(mail.outbox) == 0 + + response = client.post( + f"/api/v1.0/documents/{document.id!s}/accesses/", + { + "team": "new-team", + "role": role, + }, + format="json", + ) + + assert response.status_code == 201 + assert models.DocumentAccess.objects.filter(team="new-team").count() == 1 + new_document_access = models.DocumentAccess.objects.filter(team="new-team").get() + other_user = serializers.UserSerializer(instance=other_user).data + assert response.json() == { + "document": { + "id": str(new_document_access.document_id), + "path": new_document_access.document.path, + "depth": new_document_access.document.depth, + }, + "id": str(new_document_access.id), + "user": None, + "team": "new-team", + "role": role, + "max_ancestors_role": None, + "abilities": new_document_access.get_abilities(user), + } + assert len(mail.outbox) == 0 + + @pytest.mark.parametrize("via", VIA) def test_api_document_accesses_create_email_in_receivers_language(via, mock_user_teams): """ From c9c58aa4a9057b9b4aefbcceba76f18ef897c561 Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Fri, 9 May 2025 08:05:38 +0200 Subject: [PATCH 021/104] =?UTF-8?q?=E2=99=BB=EF=B8=8F(backend)=20simplify?= =?UTF-8?q?=20further=20select=20options=20on=20link=20reach/role?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We reduce the number of options even more by treating link reach and link role independently: link reach must be higher than its ancestors' equivalent link reach and link role must be higher than its ancestors' link role. This reduces the number of possibilities but we decided to start with the most restrictive and simple offer and extend it if we realize it faces too many criticism instead of risking to offer too many options that are too complex and must be reduced afterwards. --- src/backend/core/choices.py | 51 +++++-------------- .../core/tests/test_models_documents.py | 11 ++-- 2 files changed, 16 insertions(+), 46 deletions(-) diff --git a/src/backend/core/choices.py b/src/backend/core/choices.py index f1a0e2981..e6b975111 100644 --- a/src/backend/core/choices.py +++ b/src/backend/core/choices.py @@ -65,49 +65,22 @@ class LinkReachChoices(PriorityTextChoices): def get_select_options(cls, link_reach, link_role): """ Determines the valid select options for link reach and link role depending on the - list of ancestors' link reach/role definitions. + ancestors' link reach/role given as arguments. Returns: Dictionary mapping possible reach levels to their corresponding possible roles. """ - # If no ancestors, return all options - if not link_reach: - return { - reach: LinkRoleChoices.values if reach != cls.RESTRICTED else None - for reach in cls.values - } - - # Initialize the result for all reaches with possible roles - result = { - reach: set(LinkRoleChoices.values) if reach != cls.RESTRICTED else None - for reach in cls.values - } - - # Handle special rules directly with early returns for efficiency - - if link_role == LinkRoleChoices.EDITOR: - # Rule 1: public/editor → override everything - if link_reach == cls.PUBLIC: - return {cls.PUBLIC: [LinkRoleChoices.EDITOR]} - - # Rule 2: authenticated/editor - if link_reach == cls.AUTHENTICATED: - result[cls.AUTHENTICATED].discard(LinkRoleChoices.READER) - result.pop(cls.RESTRICTED, None) - - if link_role == LinkRoleChoices.READER: - # Rule 3: public/reader - if link_reach == cls.PUBLIC: - result.pop(cls.AUTHENTICATED, None) - result.pop(cls.RESTRICTED, None) - - # Rule 4: authenticated/reader - if link_reach == cls.AUTHENTICATED: - result.pop(cls.RESTRICTED, None) - - # Convert sets to ordered lists where applicable return { - reach: sorted(roles, key=LinkRoleChoices.get_priority) if roles else roles - for reach, roles in result.items() + reach: [ + role + for role in LinkRoleChoices.values + if LinkRoleChoices.get_priority(role) + >= LinkRoleChoices.get_priority(link_role) + ] + if reach != cls.RESTRICTED + else None + for reach in cls.values + if LinkReachChoices.get_priority(reach) + >= LinkReachChoices.get_priority(link_reach) } diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index 1aa743a73..dbedd3205 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -1170,7 +1170,6 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): @pytest.mark.parametrize( "reach, role, select_options", [ - # One ancestor ( "public", "reader", @@ -1190,7 +1189,7 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): ( "authenticated", "editor", - {"authenticated": ["editor"], "public": ["reader", "editor"]}, + {"authenticated": ["editor"], "public": ["editor"]}, ), ( "restricted", @@ -1206,18 +1205,16 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries): "editor", { "restricted": None, - "authenticated": ["reader", "editor"], - "public": ["reader", "editor"], + "authenticated": ["editor"], + "public": ["editor"], }, ), - # No ancestors (edge case) + # Edge cases ( "public", None, { "public": ["reader", "editor"], - "authenticated": ["reader", "editor"], - "restricted": None, }, ), ( From fc9cda9d1eda49c363a4f0eff93c1f12e5e78792 Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Tue, 13 May 2025 17:58:45 +0200 Subject: [PATCH 022/104] =?UTF-8?q?=E2=9C=A8(backend)=20add=20max=5Frole?= =?UTF-8?q?=20field=20to=20the=20document=20access=20API=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frontend needs to know what to display on an access. The maximum role between the access role and the role equivalent to all accesses on the document's ancestors should be computed on the backend. --- src/backend/core/api/serializers.py | 19 +++++++- .../documents/test_api_document_accesses.py | 9 +++- .../test_api_document_accesses_create.py | 43 +++++++++++-------- 3 files changed, 49 insertions(+), 22 deletions(-) diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index f545efaaf..8ba20392c 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -304,6 +304,7 @@ class DocumentAccessSerializer(serializers.ModelSerializer): team = serializers.CharField(required=False, allow_blank=True) abilities = serializers.SerializerMethodField(read_only=True) max_ancestors_role = serializers.SerializerMethodField(read_only=True) + max_role = serializers.SerializerMethodField(read_only=True) class Meta: model = models.DocumentAccess @@ -317,8 +318,15 @@ class Meta: "role", "abilities", "max_ancestors_role", + "max_role", + ] + read_only_fields = [ + "id", + "document", + "abilities", + "max_ancestors_role", + "max_role", ] - read_only_fields = ["id", "document", "abilities", "max_ancestors_role"] def get_abilities(self, instance) -> dict: """Return abilities of the logged-in user on the instance.""" @@ -331,6 +339,13 @@ def get_max_ancestors_role(self, instance): """Return max_ancestors_role if annotated; else None.""" return getattr(instance, "max_ancestors_role", None) + def get_max_role(self, instance): + """Return max_ancestors_role if annotated; else None.""" + return choices.RoleChoices.max( + getattr(instance, "max_ancestors_role", None), + instance.role, + ) + def update(self, instance, validated_data): """Make "user" field readonly but only on update.""" validated_data.pop("team", None) @@ -354,6 +369,7 @@ class Meta: "role", "abilities", "max_ancestors_role", + "max_role", ] read_only_fields = [ "id", @@ -362,6 +378,7 @@ class Meta: "role", "abilities", "max_ancestors_role", + "max_role", ] diff --git a/src/backend/core/tests/documents/test_api_document_accesses.py b/src/backend/core/tests/documents/test_api_document_accesses.py index fd301f540..eb6fa92b7 100644 --- a/src/backend/core/tests/documents/test_api_document_accesses.py +++ b/src/backend/core/tests/documents/test_api_document_accesses.py @@ -153,6 +153,7 @@ def test_api_document_accesses_list_authenticated_related_non_privileged( "team": access.team, "role": access.role, "max_ancestors_role": None, + "max_role": access.role, "abilities": { "destroy": False, "partial_update": False, @@ -253,6 +254,7 @@ def test_api_document_accesses_list_authenticated_related_privileged( if access.user else None, "max_ancestors_role": None, + "max_role": access.role, "team": access.team, "role": access.role, "abilities": access.get_abilities(user), @@ -617,6 +619,7 @@ def test_api_document_accesses_retrieve_authenticated_related( "team": "", "role": access.role, "max_ancestors_role": None, + "max_role": access.role, "abilities": access.get_abilities(user), } @@ -775,10 +778,11 @@ def test_api_document_accesses_update_administrator_except_owner( access.refresh_from_db() updated_values = serializers.DocumentAccessSerializer(instance=access).data - if field == "role": + if field in ["role", "max_role"]: assert updated_values == { **old_values, "role": new_values["role"], + "max_role": new_values["role"], } else: assert updated_values == old_values @@ -951,10 +955,11 @@ def test_api_document_accesses_update_owner( access.refresh_from_db() updated_values = serializers.DocumentAccessSerializer(instance=access).data - if field == "role": + if field in ["role", "max_role"]: assert updated_values == { **old_values, "role": new_values["role"], + "max_role": new_values["role"], } else: assert updated_values == old_values diff --git a/src/backend/core/tests/documents/test_api_document_accesses_create.py b/src/backend/core/tests/documents/test_api_document_accesses_create.py index 8a32aa230..3c1e1b93a 100644 --- a/src/backend/core/tests/documents/test_api_document_accesses_create.py +++ b/src/backend/core/tests/documents/test_api_document_accesses_create.py @@ -176,10 +176,11 @@ def test_api_document_accesses_create_authenticated_administrator_share_to_user( "path": new_document_access.document.path, }, "id": str(new_document_access.id), - "user": other_user, - "team": "", - "role": role, "max_ancestors_role": None, + "max_role": role, + "role": role, + "team": "", + "user": other_user, } assert len(mail.outbox) == 1 email = mail.outbox[0] @@ -266,10 +267,11 @@ def test_api_document_accesses_create_authenticated_administrator_share_to_team( "path": new_document_access.document.path, }, "id": str(new_document_access.id), - "user": None, - "team": "new-team", - "role": role, "max_ancestors_role": None, + "max_role": role, + "role": role, + "team": "new-team", + "user": None, } assert len(mail.outbox) == 0 @@ -323,17 +325,18 @@ def test_api_document_accesses_create_authenticated_owner_share_to_user( new_document_access = models.DocumentAccess.objects.filter(user=other_user).get() other_user = serializers.UserSerializer(instance=other_user).data assert response.json() == { + "abilities": new_document_access.get_abilities(user), "document": { "id": str(new_document_access.document_id), - "path": new_document_access.document.path, "depth": new_document_access.document.depth, + "path": new_document_access.document.path, }, "id": str(new_document_access.id), - "user": other_user, - "team": "", - "role": role, "max_ancestors_role": None, - "abilities": new_document_access.get_abilities(user), + "max_role": role, + "role": role, + "team": "", + "user": other_user, } assert len(mail.outbox) == 1 email = mail.outbox[0] @@ -396,17 +399,18 @@ def test_api_document_accesses_create_authenticated_owner_share_to_team( new_document_access = models.DocumentAccess.objects.filter(team="new-team").get() other_user = serializers.UserSerializer(instance=other_user).data assert response.json() == { + "abilities": new_document_access.get_abilities(user), "document": { "id": str(new_document_access.document_id), "path": new_document_access.document.path, "depth": new_document_access.document.depth, }, "id": str(new_document_access.id), - "user": None, - "team": "new-team", - "role": role, "max_ancestors_role": None, - "abilities": new_document_access.get_abilities(user), + "max_role": role, + "role": role, + "team": "new-team", + "user": None, } assert len(mail.outbox) == 0 @@ -457,17 +461,18 @@ def test_api_document_accesses_create_email_in_receivers_language(via, mock_user ).get() other_user_data = serializers.UserSerializer(instance=other_user).data assert response.json() == { + "abilities": new_document_access.get_abilities(user), "document": { "id": str(new_document_access.document_id), "path": new_document_access.document.path, "depth": new_document_access.document.depth, }, "id": str(new_document_access.id), - "user": other_user_data, - "team": "", - "role": role, "max_ancestors_role": None, - "abilities": new_document_access.get_abilities(user), + "max_role": role, + "role": role, + "team": "", + "user": other_user_data, } assert len(mail.outbox) == index + 1 email = mail.outbox[index] From fe8a926f6a0b89bb0199f7c794bafbfdaf873a11 Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Sun, 6 Apr 2025 21:12:34 +0200 Subject: [PATCH 023/104] =?UTF-8?q?=F0=9F=90=9B(backend)=20fix=20link=20de?= =?UTF-8?q?finition=20select=20options=20linked=20to=20ancestors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We were returning too many select options for the restricted link reach: - when the "restricted" reach is an option (key present in the returned dictionary), the possible values for link roles are now always None to make it clearer that they don't matter and no select box should be shown for roles. - Never propose "restricted" as option for link reach when the ancestors already offer a public access. Indeed, restricted/editor was shown when the ancestors had public/read access. The logic was to propose editor role on more restricted reaches... but this does not make sense for restricted since the role does is not taken into account for this reach. Roles are set by each access line assign to users/teams. --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c1b4ab07..0e355c0bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,10 @@ and this project adheres to - 🐛(backend) race condition create doc #633 - 🐛(frontend) fix breaklines in custom blocks #908 +## Fixed + +- 🐛(backend) fix link definition select options linked to ancestors #846 + ## [3.1.0] - 2025-04-07 ## Added From c9a0234d3b8d5fb5da71347d65f975e1257ff026 Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Sun, 6 Apr 2025 21:20:04 +0200 Subject: [PATCH 024/104] =?UTF-8?q?=E2=9C=A8(backend)=20add=20ancestors=20?= =?UTF-8?q?links=20definitions=20to=20document=20abilities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frontend needs to display inherited link accesses when it displays possible selection options. We need to return this information to the client. --- src/backend/core/tests/documents/test_api_documents_trashbin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/backend/core/tests/documents/test_api_documents_trashbin.py b/src/backend/core/tests/documents/test_api_documents_trashbin.py index 00fea7caa..246633852 100644 --- a/src/backend/core/tests/documents/test_api_documents_trashbin.py +++ b/src/backend/core/tests/documents/test_api_documents_trashbin.py @@ -74,6 +74,7 @@ def test_api_documents_trashbin_format(): "accesses_view": True, "ai_transform": True, "ai_translate": True, + "ancestors_links_definitions": {}, "attachment_upload": True, "children_create": True, "children_list": True, From b95f061fa238f34f6df6abc433bcfa7ce35f9c3e Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Fri, 25 Apr 2025 08:03:12 +0200 Subject: [PATCH 025/104] =?UTF-8?q?=E2=99=BB=EF=B8=8F(backend)=20simplify?= =?UTF-8?q?=20roles=20by=20returning=20only=20the=20max=20role?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We were returning the list of roles a user has on a document (direct and inherited). Now that we introduced priority on roles, we are able to determine what is the max role and return only this one. This commit also changes the role that is returned for the restricted reach: we now return None because the role is not relevant in this case. --- src/backend/core/tests/documents/test_api_documents_trashbin.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/backend/core/tests/documents/test_api_documents_trashbin.py b/src/backend/core/tests/documents/test_api_documents_trashbin.py index 246633852..00fea7caa 100644 --- a/src/backend/core/tests/documents/test_api_documents_trashbin.py +++ b/src/backend/core/tests/documents/test_api_documents_trashbin.py @@ -74,7 +74,6 @@ def test_api_documents_trashbin_format(): "accesses_view": True, "ai_transform": True, "ai_translate": True, - "ancestors_links_definitions": {}, "attachment_upload": True, "children_create": True, "children_list": True, From 41ae26745065556c5557cb5bf74cf00ae899583c Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Sun, 6 Apr 2025 21:12:34 +0200 Subject: [PATCH 026/104] =?UTF-8?q?=F0=9F=90=9B(backend)=20fix=20link=20de?= =?UTF-8?q?finition=20select=20options=20linked=20to=20ancestors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We were returning too many select options for the restricted link reach: - when the "restricted" reach is an option (key present in the returned dictionary), the possible values for link roles are now always None to make it clearer that they don't matter and no select box should be shown for roles. - Never propose "restricted" as option for link reach when the ancestors already offer a public access. Indeed, restricted/editor was shown when the ancestors had public/read access. The logic was to propose editor role on more restricted reaches... but this does not make sense for restricted since the role does is not taken into account for this reach. Roles are set by each access line assign to users/teams. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e355c0bf..f85ef9a5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,7 @@ and this project adheres to ## Fixed +- 🐛(backend) fix link definition select options linked to ancestors #846 - 🐛(back) validate document content in serializer #822 - 🐛(frontend) fix selection click past end of content #840 From e99ad9c1fabce6d83260cff0676c43dd1ee152c3 Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Sun, 6 Apr 2025 21:20:04 +0200 Subject: [PATCH 027/104] =?UTF-8?q?=E2=9C=A8(backend)=20add=20ancestors=20?= =?UTF-8?q?links=20definitions=20to=20document=20abilities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frontend needs to display inherited link accesses when it displays possible selection options. We need to return this information to the client. --- CHANGELOG.md | 1 + src/backend/core/tests/documents/test_api_documents_trashbin.py | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f85ef9a5c..dab81ec95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,7 @@ and this project adheres to ## Added +- ✨(backend) add ancestors links definitions to document abilities #846 - 🚩(backend) add feature flag for the footer #841 - 🔧(backend) add view to manage footer json #841 - ✨(frontend) add custom css style #771 diff --git a/src/backend/core/tests/documents/test_api_documents_trashbin.py b/src/backend/core/tests/documents/test_api_documents_trashbin.py index 00fea7caa..246633852 100644 --- a/src/backend/core/tests/documents/test_api_documents_trashbin.py +++ b/src/backend/core/tests/documents/test_api_documents_trashbin.py @@ -74,6 +74,7 @@ def test_api_documents_trashbin_format(): "accesses_view": True, "ai_transform": True, "ai_translate": True, + "ancestors_links_definitions": {}, "attachment_upload": True, "children_create": True, "children_list": True, From f48ad0343f66d9e2d9c9513006d033477c913c9c Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Mon, 17 Mar 2025 14:46:59 +0100 Subject: [PATCH 028/104] =?UTF-8?q?=F0=9F=90=9B(back)=20keep=20info=20if?= =?UTF-8?q?=20document=20has=20deleted=20children?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the soft delete feature, relying on the is_leaf method from the treebeard is not accurate anymore. To determine if a node is a leaf, it checks if the number of numchild is equal to 0. But a node can have soft deleted children, then numchild is equal to 0, but it is not a leaf because if we want to add a child we have to look for the last child to compute a correct path. Otherwise we will have an error saying that the path already exists. --- .../0021_remove_document_is_public_and_more.py | 17 +++++++++++++++++ src/backend/core/models.py | 10 +++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 src/backend/core/migrations/0021_remove_document_is_public_and_more.py diff --git a/src/backend/core/migrations/0021_remove_document_is_public_and_more.py b/src/backend/core/migrations/0021_remove_document_is_public_and_more.py new file mode 100644 index 000000000..97eaa4681 --- /dev/null +++ b/src/backend/core/migrations/0021_remove_document_is_public_and_more.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.7 on 2025-03-14 14:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0020_remove_is_public_add_field_attachments_and_duplicated_from"), + ] + + operations = [ + migrations.AddField( + model_name="document", + name="has_deleted_children", + field=models.BooleanField(default=False), + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 6dcd94018..48e257d73 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -384,6 +384,7 @@ class Document(MP_Node, BaseModel): ) deleted_at = models.DateTimeField(null=True, blank=True) ancestors_deleted_at = models.DateTimeField(null=True, blank=True) + has_deleted_children = models.BooleanField(default=False) duplicated_from = models.ForeignKey( "self", on_delete=models.SET_NULL, @@ -465,6 +466,12 @@ def save(self, *args, **kwargs): content_file = ContentFile(bytes_content) default_storage.save(file_key, content_file) + def is_leaf(self): + """ + :returns: True if the node is has no children + """ + return not self.has_deleted_children and self.numchild == 0 + @property def key_base(self): """Key base of the location where the document is stored in object storage.""" @@ -884,7 +891,8 @@ def soft_delete(self): if self.depth > 1: self._meta.model.objects.filter(pk=self.get_parent().pk).update( - numchild=models.F("numchild") - 1 + numchild=models.F("numchild") - 1, + has_deleted_children=True, ) # Mark all descendants as soft deleted From 32373fa6617caf36bbd7cb07b0c695b0a4cb216e Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Mon, 17 Mar 2025 14:48:42 +0100 Subject: [PATCH 029/104] =?UTF-8?q?=E2=9E=95(frontend)=20updated=20depende?= =?UTF-8?q?ncies=20and=20added=20new=20packages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added several new dependencies to the `package.json` file, including `@dnd-kit/core`, `@dnd-kit/modifiers`, `@fontsource/material-icons`, and `@gouvfr-lasuite/ui-kit`. --- src/frontend/apps/impress/package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index dc1d0c59e..799961318 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -22,6 +22,8 @@ "@blocknote/react": "0.30.0", "@blocknote/xl-docx-exporter": "0.30.0", "@blocknote/xl-pdf-exporter": "0.30.0", + "@dnd-kit/core": "6.3.1", + "@dnd-kit/modifiers": "9.0.0", "@emoji-mart/data": "1.2.1", "@emoji-mart/react": "1.1.1", "@fontsource/material-icons": "5.2.5", From 544f740334f37a0450bc7ae9bf41e1501f5b2850 Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Mon, 17 Mar 2025 15:12:12 +0100 Subject: [PATCH 030/104] =?UTF-8?q?=E2=9C=A8(frontend)=20Added=20drag-and-?= =?UTF-8?q?drop=20functionality=20for=20document=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added a new feature for moving documents within the user interface via drag-and-drop. This includes the creation of Draggable and Droppable components, as well as tests to verify document creation and movement behavior. Changes have also been made to document types to include user roles and child management capabilities. --- .../apps/e2e/__tests__/app-impress/common.ts | 17 + .../app-impress/doc-grid-dnd.spec.ts | 311 ++++++++++++++++++ .../__tests__/app-impress/doc-grid.spec.ts | 2 + .../__tests__/app-impress/doc-header.spec.ts | 4 +- .../features/docs/doc-management/types.tsx | 8 +- .../features/docs/doc-tree/api/useMove.tsx | 36 ++ .../components/DocGridContentList.tsx | 173 ++++++++++ .../docs/docs-grid/components/DocsGrid.tsx | 11 +- .../docs-grid/components/DocsGridItem.tsx | 55 ++-- .../docs/docs-grid/components/Draggable.tsx | 26 ++ .../docs/docs-grid/components/Droppable.tsx | 53 +++ .../docs-grid/components/SimpleDocItem.tsx | 1 + .../docs/docs-grid/hooks/useDragAndDrop.tsx | 70 ++++ .../service-worker/plugins/ApiPlugin.ts | 2 + 14 files changed, 739 insertions(+), 30 deletions(-) create mode 100644 src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/api/useMove.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/docs-grid/components/Draggable.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/docs-grid/components/Droppable.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/docs-grid/hooks/useDragAndDrop.tsx diff --git a/src/frontend/apps/e2e/__tests__/app-impress/common.ts b/src/frontend/apps/e2e/__tests__/app-impress/common.ts index 09e1ce749..89125f599 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/common.ts @@ -214,6 +214,7 @@ export const mockedDocument = async (page: Page, json: object) => { }, link_reach: 'restricted', created_at: '2021-09-01T09:00:00Z', + user_roles: ['owner'], ...json, }, }); @@ -223,6 +224,22 @@ export const mockedDocument = async (page: Page, json: object) => { }); }; +export const mockedListDocs = async (page: Page, data: object[] = []) => { + await page.route('**/documents/**/', async (route) => { + const request = route.request(); + if (request.method().includes('GET') && request.url().includes('page=')) { + await route.fulfill({ + json: { + count: data.length, + next: null, + previous: null, + results: data, + }, + }); + } + }); +}; + export const mockedInvitations = async (page: Page, json?: object) => { await page.route('**/invitations/**/', async (route) => { const request = route.request(); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts new file mode 100644 index 000000000..924d3db5f --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts @@ -0,0 +1,311 @@ +import { expect, test } from '@playwright/test'; + +import { createDoc, mockedListDocs } from './common'; + +test.describe('Doc grid dnd', () => { + test('it creates a doc', async ({ page, browserName }) => { + await page.goto('/'); + const header = page.locator('header').first(); + await createDoc(page, 'Draggable doc', browserName, 1); + await header.locator('h2').getByText('Docs').click(); + await createDoc(page, 'Droppable doc', browserName, 1); + await header.locator('h2').getByText('Docs').click(); + + const response = await page.waitForResponse( + (response) => + response.url().endsWith('documents/?page=1') && + response.status() === 200, + ); + const responseJson = await response.json(); + + const items = responseJson.results; + + const docsGrid = page.getByTestId('docs-grid'); + await expect(docsGrid).toBeVisible(); + await expect(page.getByTestId('grid-loader')).toBeHidden(); + const draggableElement = page.getByTestId(`draggable-doc-${items[1].id}`); + const dropZone = page.getByTestId(`droppable-doc-${items[0].id}`); + await expect(draggableElement).toBeVisible(); + await expect(dropZone).toBeVisible(); + + // Obtenir les positions des éléments + const draggableBoundingBox = await draggableElement.boundingBox(); + const dropZoneBoundingBox = await dropZone.boundingBox(); + + expect(draggableBoundingBox).toBeDefined(); + expect(dropZoneBoundingBox).toBeDefined(); + + // eslint-disable-next-line playwright/no-conditional-in-test + if (!draggableBoundingBox || !dropZoneBoundingBox) { + throw new Error('Impossible de déterminer la position des éléments'); + } + + await page.mouse.move( + draggableBoundingBox.x + draggableBoundingBox.width / 2, + draggableBoundingBox.y + draggableBoundingBox.height / 2, + ); + await page.mouse.down(); + + // Déplacer vers la zone cible + await page.mouse.move( + dropZoneBoundingBox.x + dropZoneBoundingBox.width / 2, + dropZoneBoundingBox.y + dropZoneBoundingBox.height / 2, + { steps: 10 }, // Rendre le mouvement plus fluide + ); + + const dragOverlay = page.getByTestId('drag-doc-overlay'); + + await expect(dragOverlay).toBeVisible(); + await expect(dragOverlay).toHaveText(items[1].title as string); + await page.mouse.up(); + + await expect(dragOverlay).toBeHidden(); + }); + + test('it checks cant drop when we have not the minimum role', async ({ + page, + }) => { + await mockedListDocs(page, data); + await page.goto('/'); + const docsGrid = page.getByTestId('docs-grid'); + await expect(docsGrid).toBeVisible(); + await expect(page.getByTestId('grid-loader')).toBeHidden(); + + const canDropAndDrag = page.getByTestId('droppable-doc-can-drop-and-drag'); + + const noDropAndNoDrag = page.getByTestId( + 'droppable-doc-no-drop-and-no-drag', + ); + + await expect(canDropAndDrag).toBeVisible(); + + await expect(noDropAndNoDrag).toBeVisible(); + + const canDropAndDragBoundigBox = await canDropAndDrag.boundingBox(); + + const noDropAndNoDragBoundigBox = await noDropAndNoDrag.boundingBox(); + + // eslint-disable-next-line playwright/no-conditional-in-test + if (!canDropAndDragBoundigBox || !noDropAndNoDragBoundigBox) { + throw new Error('Impossible de déterminer la position des éléments'); + } + + await page.mouse.move( + canDropAndDragBoundigBox.x + canDropAndDragBoundigBox.width / 2, + canDropAndDragBoundigBox.y + canDropAndDragBoundigBox.height / 2, + ); + + await page.mouse.down(); + + await page.mouse.move( + noDropAndNoDragBoundigBox.x + noDropAndNoDragBoundigBox.width / 2, + noDropAndNoDragBoundigBox.y + noDropAndNoDragBoundigBox.height / 2, + { steps: 10 }, + ); + + const dragOverlay = page.getByTestId('drag-doc-overlay'); + + await expect(dragOverlay).toBeVisible(); + await expect(dragOverlay).toHaveText( + 'You must be at least the editor of the target document', + ); + + await page.mouse.up(); + }); + + test('it checks cant drag when we have not the minimum role', async ({ + page, + }) => { + await mockedListDocs(page, data); + await page.goto('/'); + const docsGrid = page.getByTestId('docs-grid'); + await expect(docsGrid).toBeVisible(); + await expect(page.getByTestId('grid-loader')).toBeHidden(); + + const canDropAndDrag = page.getByTestId('droppable-doc-can-drop-and-drag'); + + const noDropAndNoDrag = page.getByTestId( + 'droppable-doc-no-drop-and-no-drag', + ); + + await expect(canDropAndDrag).toBeVisible(); + + await expect(noDropAndNoDrag).toBeVisible(); + + const canDropAndDragBoundigBox = await canDropAndDrag.boundingBox(); + + const noDropAndNoDragBoundigBox = await noDropAndNoDrag.boundingBox(); + + // eslint-disable-next-line playwright/no-conditional-in-test + if (!canDropAndDragBoundigBox || !noDropAndNoDragBoundigBox) { + throw new Error('Impossible de déterminer la position des éléments'); + } + + await page.mouse.move( + noDropAndNoDragBoundigBox.x + noDropAndNoDragBoundigBox.width / 2, + noDropAndNoDragBoundigBox.y + noDropAndNoDragBoundigBox.height / 2, + ); + + await page.mouse.down(); + + await page.mouse.move( + canDropAndDragBoundigBox.x + canDropAndDragBoundigBox.width / 2, + canDropAndDragBoundigBox.y + canDropAndDragBoundigBox.height / 2, + { steps: 10 }, + ); + + const dragOverlay = page.getByTestId('drag-doc-overlay'); + + await expect(dragOverlay).toBeVisible(); + await expect(dragOverlay).toHaveText( + 'You must be the owner to move the document', + ); + + await page.mouse.up(); + }); +}); + +const data = [ + { + id: 'can-drop-and-drag', + abilities: { + accesses_manage: true, + accesses_view: true, + ai_transform: true, + ai_translate: true, + attachment_upload: true, + children_list: true, + children_create: true, + collaboration_auth: true, + descendants: true, + destroy: true, + favorite: true, + link_configuration: true, + invite_owner: true, + move: true, + partial_update: true, + restore: true, + retrieve: true, + media_auth: true, + link_select_options: { + restricted: ['reader', 'editor'], + authenticated: ['reader', 'editor'], + public: ['reader', 'editor'], + }, + tree: true, + update: true, + versions_destroy: true, + versions_list: true, + versions_retrieve: true, + }, + created_at: '2025-03-14T14:45:22.527221Z', + creator: 'bc6895e0-8f6d-4b00-827d-c143aa6b2ecb', + depth: 1, + excerpt: null, + is_favorite: false, + link_role: 'reader', + link_reach: 'restricted', + nb_accesses_ancestors: 1, + nb_accesses_direct: 1, + numchild: 5, + path: '000000o', + title: 'Can drop and drag', + updated_at: '2025-03-14T14:45:27.699542Z', + user_roles: ['owner'], + }, + { + id: 'can-only-drop', + title: 'Can only drop', + abilities: { + accesses_manage: true, + accesses_view: true, + ai_transform: true, + ai_translate: true, + attachment_upload: true, + children_list: true, + children_create: true, + collaboration_auth: true, + descendants: true, + destroy: true, + favorite: true, + link_configuration: true, + invite_owner: true, + move: true, + partial_update: true, + restore: true, + retrieve: true, + media_auth: true, + link_select_options: { + restricted: ['reader', 'editor'], + authenticated: ['reader', 'editor'], + public: ['reader', 'editor'], + }, + tree: true, + update: true, + versions_destroy: true, + versions_list: true, + versions_retrieve: true, + }, + created_at: '2025-03-14T14:45:22.527221Z', + creator: 'bc6895e0-8f6d-4b00-827d-c143aa6b2ecb', + depth: 1, + excerpt: null, + is_favorite: false, + link_role: 'reader', + link_reach: 'restricted', + nb_accesses_ancestors: 1, + nb_accesses_direct: 1, + numchild: 5, + path: '000000o', + + updated_at: '2025-03-14T14:45:27.699542Z', + user_roles: ['editor'], + }, + { + id: 'no-drop-and-no-drag', + abilities: { + accesses_manage: false, + accesses_view: true, + ai_transform: false, + ai_translate: false, + attachment_upload: false, + children_list: true, + children_create: false, + collaboration_auth: true, + descendants: true, + destroy: false, + favorite: true, + link_configuration: false, + invite_owner: false, + move: false, + partial_update: false, + restore: false, + retrieve: true, + media_auth: true, + link_select_options: { + restricted: ['reader', 'editor'], + authenticated: ['reader', 'editor'], + public: ['reader', 'editor'], + }, + tree: true, + update: false, + versions_destroy: false, + versions_list: true, + versions_retrieve: true, + }, + created_at: '2025-03-14T14:44:16.032773Z', + creator: '9264f420-f018-4bd6-96ae-4788f41af56d', + depth: 1, + excerpt: null, + is_favorite: false, + link_role: 'reader', + link_reach: 'restricted', + nb_accesses_ancestors: 14, + nb_accesses_direct: 14, + numchild: 0, + path: '000000l', + title: 'No drop and no drag', + updated_at: '2025-03-14T14:44:16.032774Z', + user_roles: ['reader'], + }, +]; diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts index 758c8712c..71083ef52 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts @@ -59,6 +59,7 @@ test.describe('Documents Grid mobile', () => { link_reach: 'public', created_at: '2024-10-07T13:02:41.085298Z', updated_at: '2024-10-07T13:30:21.829690Z', + user_roles: ['owner'], }, ], }, @@ -168,6 +169,7 @@ test.describe('Document grid item options', () => { }, link_reach: 'restricted', created_at: '2021-09-01T09:00:00Z', + user_roles: ['editor'], }, ], }, diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts index be1bfcad1..ba33ddb0f 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts @@ -96,7 +96,9 @@ test.describe('Doc Header', () => { ).toBeVisible(); await expect( - page.getByText(`Are you sure you want to delete this document ?`), + page.getByText( + `Are you sure you want to delete the document "${randomDoc}"?`, + ), ).toBeVisible(); await page diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx index e57dc6e14..2cc117b73 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx @@ -42,10 +42,14 @@ export interface Doc { is_favorite: boolean; link_reach: LinkReach; link_role: LinkRole; - nb_accesses_ancestors: number; - nb_accesses_direct: number; + user_roles: Role[]; created_at: string; updated_at: string; + nb_accesses_direct: number; + nb_accesses_ancestors: number; + children?: Doc[]; + childrenCount?: number; + numchild: number; abilities: { accesses_manage: boolean; accesses_view: boolean; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/useMove.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useMove.tsx new file mode 100644 index 000000000..1ba87df43 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useMove.tsx @@ -0,0 +1,36 @@ +import { TreeViewMoveModeEnum } from '@gouvfr-lasuite/ui-kit'; +import { useMutation } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +export type MoveDocParam = { + sourceDocumentId: string; + targetDocumentId: string; + position: TreeViewMoveModeEnum; +}; + +export const moveDoc = async ({ + sourceDocumentId, + targetDocumentId, + position, +}: MoveDocParam): Promise => { + const response = await fetchAPI(`documents/${sourceDocumentId}/move/`, { + method: 'POST', + body: JSON.stringify({ + target_document_id: targetDocumentId, + position, + }), + }); + + if (!response.ok) { + throw new APIError('Failed to move the doc', await errorCauses(response)); + } + + return response.json() as Promise; +}; + +export function useMoveDoc() { + return useMutation({ + mutationFn: moveDoc, + }); +} diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx new file mode 100644 index 000000000..6d03cac77 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx @@ -0,0 +1,173 @@ +import { DndContext, DragOverlay, Modifier } from '@dnd-kit/core'; +import { getEventCoordinates } from '@dnd-kit/utilities'; +import { TreeViewMoveModeEnum } from '@gouvfr-lasuite/ui-kit'; +import { useQueryClient } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Box, Text } from '@/components'; +import { Doc, KEY_LIST_DOC, Role } from '@/docs/doc-management'; +import { useMoveDoc } from '@/docs/doc-tree/api/useMove'; + +import { useDragAndDrop } from '../hooks/useDragAndDrop'; + +import { DocsGridItem } from './DocsGridItem'; +import { Draggable } from './Draggable'; +import { Droppable } from './Droppable'; + +const snapToTopLeft: Modifier = ({ + activatorEvent, + draggingNodeRect, + transform, +}) => { + if (draggingNodeRect && activatorEvent) { + const activatorCoordinates = getEventCoordinates(activatorEvent); + + if (!activatorCoordinates) { + return transform; + } + + const offsetX = activatorCoordinates.x - draggingNodeRect.left; + const offsetY = activatorCoordinates.y - draggingNodeRect.top; + + return { + ...transform, + x: transform.x + offsetX - 3, + y: transform.y + offsetY - 3, + }; + } + + return transform; +}; + +type DocGridContentListProps = { + docs: Doc[]; +}; + +export const DocGridContentList = ({ docs }: DocGridContentListProps) => { + const { mutate: handleMove, isError } = useMoveDoc(); + const queryClient = useQueryClient(); + const onDrag = (sourceDocumentId: string, targetDocumentId: string) => + handleMove( + { + sourceDocumentId, + targetDocumentId, + position: TreeViewMoveModeEnum.FIRST_CHILD, + }, + { + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [KEY_LIST_DOC], + }); + }, + }, + ); + + const { + selectedDoc, + canDrag, + canDrop, + sensors, + handleDragStart, + handleDragEnd, + updateCanDrop, + } = useDragAndDrop(onDrag); + + const { t } = useTranslation(); + + const overlayText = useMemo(() => { + if (!canDrag) { + return t('You must be the owner to move the document'); + } + if (!canDrop) { + return t('You must be at least the editor of the target document'); + } + + return selectedDoc?.title || t('Unnamed document'); + }, [canDrag, canDrop, selectedDoc, t]); + + const overlayBgColor = useMemo(() => { + if (!canDrag) { + return 'var(--c--theme--colors--danger-600)'; + } + if (canDrop !== undefined && !canDrop) { + return 'var(--c--theme--colors--danger-600)'; + } + if (isError) { + return 'var(--c--theme--colors--danger-600)'; + } + + return '#5858D3'; + }, [canDrag, canDrop, isError]); + + if (docs.length === 0) { + return null; + } + + return ( + + {docs.map((doc) => ( + + ))} + + + + {overlayText} + + + + + ); +}; + +interface DocGridItemProps { + doc: Doc; + dragMode: boolean; + canDrag: boolean; + updateCanDrop: (canDrop: boolean, isOver: boolean) => void; +} + +export const DraggableDocGridItem = ({ + doc, + dragMode, + canDrag, + updateCanDrop, +}: DocGridItemProps) => { + const canDropItem = doc.user_roles.some( + (role) => + role === Role.ADMIN || role === Role.OWNER || role === Role.EDITOR, + ); + + return ( + updateCanDrop(canDropItem, isOver)} + id={doc.id} + data={doc} + > + + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx index 534252102..fc0dba2ff 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx @@ -9,7 +9,7 @@ import { useResponsiveStore } from '@/stores'; import { useResponsiveDocGrid } from '../hooks/useResponsiveDocGrid'; -import { DocsGridItem } from './DocsGridItem'; +import { DocGridContentList } from './DocGridContentList'; import { DocsGridLoader } from './DocsGridLoader'; type DocsGridProps = { @@ -37,6 +37,9 @@ export const DocsGrid = ({ is_creator_me: target === DocDefaultFilter.MY_DOCS, }), }); + + const docs = data?.pages.flatMap((page) => page.results) ?? []; + const loading = isFetching || isLoading; const hasDocs = data?.pages.some((page) => page.results.length > 0); const loadMore = (inView: boolean) => { @@ -115,11 +118,7 @@ export const DocsGrid = ({ )} - {data?.pages.map((currentPage) => { - return currentPage.results.map((doc) => ( - - )); - })} + {hasNextPage && !loading && ( { +export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => { const { t } = useTranslation(); const { isDesktop } = useResponsiveStore(); const { flexLeft, flexRight } = useResponsiveDocGrid(); @@ -45,7 +46,9 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => { cursor: pointer; border-radius: 4px; &:hover { - background-color: var(--c--theme--colors--greyscale-100); + background-color: ${dragMode + ? 'none' + : 'var(--c--theme--colors--greyscale-100)'}; } `} className="--docs--doc-grid-item" @@ -79,25 +82,35 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => { : undefined } > - - {isPublic - ? t('Accessible to anyone') - : t('Accessible to authenticated users')} - - } - placement="top" - > -
- -
-
+ {dragMode && ( + + )} + {!dragMode && ( + + {isPublic + ? t('Accessible to anyone') + : t('Accessible to authenticated users')} + + } + placement="top" + > +
+ +
+
+ )} )} diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/Draggable.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/Draggable.tsx new file mode 100644 index 000000000..bafacf0b4 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/Draggable.tsx @@ -0,0 +1,26 @@ +import { Data, useDraggable } from '@dnd-kit/core'; + +type DraggableProps = { + id: string; + data?: Data; + children: React.ReactNode; +}; + +export const Draggable = (props: DraggableProps) => { + const { attributes, listeners, setNodeRef } = useDraggable({ + id: props.id, + data: props.data, + }); + + return ( +
+ {props.children} +
+ ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/Droppable.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/Droppable.tsx new file mode 100644 index 000000000..851bf6f6e --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/Droppable.tsx @@ -0,0 +1,53 @@ +import { Data, useDroppable } from '@dnd-kit/core'; +import { PropsWithChildren, useEffect } from 'react'; +import { css } from 'styled-components'; + +import { Box } from '@/components'; +import { Doc } from '@/docs/doc-management'; + +type DroppableProps = { + id: string; + onOver?: (isOver: boolean, data?: Data) => void; + data?: Data; + enabledDrop?: boolean; + canDrop?: boolean; +}; + +export const Droppable = ({ + onOver, + canDrop, + data, + children, + id, +}: PropsWithChildren) => { + const { isOver, setNodeRef } = useDroppable({ + id, + data, + }); + + const enableHover = canDrop && isOver; + + useEffect(() => { + onOver?.(isOver, data); + }, [isOver, data, onOver]); + + return ( + + {children} + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/SimpleDocItem.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/SimpleDocItem.tsx index 1a02d87a6..87c9387c9 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/SimpleDocItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/SimpleDocItem.tsx @@ -41,6 +41,7 @@ export const SimpleDocItem = ({ $direction="row" $gap={spacingsTokens.sm} $overflow="auto" + $width="100%" className="--docs--simple-doc-item" > void, +) { + const [selectedDoc, setSelectedDoc] = useState(); + const [canDrop, setCanDrop] = useState(); + + const canDrag = selectedDoc?.user_roles.some((role) => role === Role.OWNER); + + const mouseSensor = useSensor(MouseSensor, { activationConstraint }); + const touchSensor = useSensor(TouchSensor, { activationConstraint }); + const keyboardSensor = useSensor(KeyboardSensor, {}); + const sensors = useSensors(mouseSensor, touchSensor, keyboardSensor); + + const handleDragStart = (e: DragStartEvent) => { + document.body.style.cursor = 'grabbing'; + if (e.active.data.current) { + setSelectedDoc(e.active.data.current as Doc); + } + }; + + const handleDragEnd = (e: DragEndEvent) => { + setSelectedDoc(undefined); + setCanDrop(undefined); + document.body.style.cursor = 'default'; + if (!canDrag || !canDrop) { + return; + } + + const { active, over } = e; + + if (!over?.id || active.id === over?.id) { + return; + } + + onDrag(active.id as string, over.id as string); + }; + + const updateCanDrop = (docCanDrop: boolean, isOver: boolean) => { + if (isOver) { + setCanDrop(docCanDrop); + } + }; + + return { + selectedDoc, + canDrag, + canDrop, + sensors, + handleDragStart, + handleDragEnd, + updateCanDrop, + }; +} diff --git a/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts index 7a03f291d..31df23bf4 100644 --- a/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts +++ b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts @@ -175,6 +175,7 @@ export class ApiPlugin implements WorkboxPlugin { is_favorite: false, nb_accesses_direct: 1, nb_accesses_ancestors: 1, + numchild: 0, updated_at: new Date().toISOString(), abilities: { accesses_manage: true, @@ -201,6 +202,7 @@ export class ApiPlugin implements WorkboxPlugin { }, link_reach: LinkReach.RESTRICTED, link_role: LinkRole.READER, + user_roles: [], }; await DocsDB.cacheResponse( From 32a44c769ea7c6478663f8ecb818baa977586558 Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Mon, 17 Mar 2025 15:13:02 +0100 Subject: [PATCH 031/104] =?UTF-8?q?=E2=9C=A8(frontend)=20added=20subpage?= =?UTF-8?q?=20management=20and=20document=20tree=20features?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New components were created to manage subpages in the document tree, including the ability to add, reorder, and view subpages. Tests were added to verify the functionality of these features. Additionally, API changes were made to manage the creation and retrieval of document children. --- CHANGELOG.md | 1 + .../apps/e2e/__tests__/app-impress/common.ts | 9 +- .../__tests__/app-impress/doc-create.spec.ts | 4 +- .../__tests__/app-impress/doc-editor.spec.ts | 1 + .../__tests__/app-impress/doc-routing.spec.ts | 2 +- .../__tests__/app-impress/doc-search.spec.ts | 83 +++++- .../__tests__/app-impress/doc-tree.spec.ts | 279 ++++++++++++++++++ .../app-impress/doc-visibility.spec.ts | 4 +- .../impress/src/components/DropdownMenu.tsx | 9 +- .../src/components/filter/FilterDropdown.tsx | 63 ++++ .../quick-search/QuickSearchInput.tsx | 3 + .../docs/doc-header/components/DocTitle.tsx | 21 +- .../docs/doc-management/api/useDoc.tsx | 3 +- .../docs/doc-management/api/useDocs.tsx | 1 - .../components/ModalRemoveDoc.tsx | 19 +- .../components/DocSearchContent.tsx | 68 +++++ .../components/DocSearchFilters.tsx | 67 +++++ .../doc-search/components/DocSearchModal.tsx | 98 +++--- .../components/DocSearchSubPageContent.tsx | 73 +++++ .../docs/doc-search/components/index.ts | 1 + .../components/DocShareAddMemberList.tsx | 40 ++- .../components/DocShareInvitationItem.tsx | 14 +- .../components/DocShareMemberItem.tsx | 17 +- .../src/features/docs/doc-tree/api/index.ts | 1 + .../docs/doc-tree/api/useCreateChildren.tsx | 44 +++ .../docs/doc-tree/api/useDocChildren.tsx | 58 ++++ .../features/docs/doc-tree/api/useDocTree.tsx | 44 +++ .../docs/doc-tree/assets/doc-extract-bold.svg | 10 + .../docs/doc-tree/assets/sub-page-logo.svg | 3 + .../doc-tree/components/DocSubPageItem.tsx | 169 +++++++++++ .../docs/doc-tree/components/DocTree.tsx | 233 +++++++++++++++ .../components/DocTreeItemActions.tsx | 171 +++++++++++ .../src/features/docs/doc-tree/hooks/index.ts | 1 + .../docs/doc-tree/hooks/useTreeUtils.tsx | 13 + .../src/features/docs/doc-tree/index.ts | 3 + .../src/features/docs/doc-tree/utils.ts | 29 ++ .../components/LeftPanelDocContent.tsx | 31 +- .../left-panel/components/LeftPanelHeader.tsx | 39 ++- .../components/LeftPanelHeaderButton.tsx | 73 +++++ .../impress/src/pages/docs/[id]/index.tsx | 26 +- src/frontend/apps/impress/src/tests/utils.tsx | 9 +- 41 files changed, 1708 insertions(+), 129 deletions(-) create mode 100644 src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts create mode 100644 src/frontend/apps/impress/src/components/filter/FilterDropdown.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchContent.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchFilters.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchSubPageContent.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/api/index.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/api/useCreateChildren.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocChildren.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocTree.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/assets/doc-extract-bold.svg create mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/assets/sub-page-logo.svg create mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/hooks/index.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useTreeUtils.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/index.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/utils.ts create mode 100644 src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeaderButton.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index dab81ec95..639e622dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ and this project adheres to ## Added +- ✨(frontend) multi-pages #701 - ✨(backend) include ancestors accesses on document accesses list view #846 - ✨(backend) add ancestors links reach and role to document API #846 - 🚸(backend) make document search on title accent-insensitive #874 diff --git a/src/frontend/apps/e2e/__tests__/app-impress/common.ts b/src/frontend/apps/e2e/__tests__/app-impress/common.ts index 89125f599..380c3c970 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/common.ts @@ -60,16 +60,19 @@ export const createDoc = async ( docName: string, browserName: string, length: number = 1, + isChild: boolean = false, ) => { const randomDocs = randomName(docName, browserName, length); for (let i = 0; i < randomDocs.length; i++) { - const header = page.locator('header').first(); - await header.locator('h2').getByText('Docs').click(); + if (!isChild) { + const header = page.locator('header').first(); + await header.locator('h2').getByText('Docs').click(); + } await page .getByRole('button', { - name: 'New doc', + name: isChild ? 'New page' : 'New doc', }) .click(); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-create.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-create.spec.ts index 52aedce08..cf1b90370 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-create.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-create.spec.ts @@ -14,10 +14,10 @@ test.beforeEach(async ({ page }) => { test.describe('Doc Create', () => { test('it creates a doc', async ({ page, browserName }) => { - const [docTitle] = await createDoc(page, 'My new doc', browserName, 1); + const [docTitle] = await createDoc(page, 'my-new-doc', browserName, 1); await page.waitForFunction( - () => document.title.match(/My new doc - Docs/), + () => document.title.match(/my-new-doc - Docs/), { timeout: 5000 }, ); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts index 87d13c802..1b0ae56c8 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -173,6 +173,7 @@ test.describe('Doc Editor', () => { await expect(editor.getByText('Hello World Doc 2')).toBeHidden(); await expect(editor.getByText('Hello World Doc 1')).toBeVisible(); + await page.goto('/'); await page .getByRole('button', { name: 'New doc', diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-routing.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-routing.spec.ts index 9aebb4084..84c861763 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-routing.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-routing.spec.ts @@ -60,7 +60,7 @@ test.describe('Doc Routing', () => { }); test('checks 401 on docs/[id] page', async ({ page, browserName }) => { - const [docTitle] = await createDoc(page, 'My new doc', browserName, 1); + const [docTitle] = await createDoc(page, '401-doc', browserName, 1); await verifyDocName(page, docTitle); const responsePromise = page.route( diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts index 27088c931..73534b273 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test'; -import { createDoc, verifyDocName } from './common'; +import { createDoc, randomName, verifyDocName } from './common'; test.beforeEach(async ({ page }) => { await page.goto('/'); @@ -94,4 +94,85 @@ test.describe('Document search', () => { page.getByLabel('Search modal').getByText('search'), ).toBeHidden(); }); + + test("it checks we don't see filters in search modal", async ({ page }) => { + const searchButton = page + .getByTestId('left-panel-desktop') + .getByRole('button', { name: 'search' }); + + await expect(searchButton).toBeVisible(); + await page.getByRole('button', { name: 'search', exact: true }).click(); + await expect( + page.getByRole('combobox', { name: 'Quick search input' }), + ).toBeVisible(); + await expect(page.getByTestId('doc-search-filters')).toBeHidden(); + }); +}); + +test.describe('Sub page search', () => { + test('it check the precense of filters in search modal', async ({ + page, + browserName, + }) => { + await page.goto('/'); + const [doc1Title] = await createDoc( + page, + 'My sub page search', + browserName, + 1, + ); + await verifyDocName(page, doc1Title); + const searchButton = page + .getByTestId('left-panel-desktop') + .getByRole('button', { name: 'search' }); + await searchButton.click(); + const filters = page.getByTestId('doc-search-filters'); + await expect(filters).toBeVisible(); + await filters.click(); + await filters.getByRole('button', { name: 'Current doc' }).click(); + await expect( + page.getByRole('menuitem', { name: 'All docs' }), + ).toBeVisible(); + await expect( + page.getByRole('menuitem', { name: 'Current doc' }), + ).toBeVisible(); + await page.getByRole('menuitem', { name: 'Current doc' }).click(); + + await expect(page.getByRole('button', { name: 'Reset' })).toBeVisible(); + }); + + test('it searches sub pages', async ({ page, browserName }) => { + await page.goto('/'); + + const [doc1Title] = await createDoc( + page, + 'My sub page search', + browserName, + 1, + ); + await verifyDocName(page, doc1Title); + await page.getByRole('button', { name: 'New page' }).click(); + await verifyDocName(page, ''); + await page.getByRole('textbox', { name: 'doc title input' }).click(); + await page + .getByRole('textbox', { name: 'doc title input' }) + .press('ControlOrMeta+a'); + const [randomDocName] = randomName('doc-sub-page', browserName, 1); + await page + .getByRole('textbox', { name: 'doc title input' }) + .fill(randomDocName); + const searchButton = page + .getByTestId('left-panel-desktop') + .getByRole('button', { name: 'search' }); + + await searchButton.click(); + await expect( + page.getByRole('button', { name: 'Current doc' }), + ).toBeVisible(); + await page.getByRole('combobox', { name: 'Quick search input' }).click(); + await page + .getByRole('combobox', { name: 'Quick search input' }) + .fill('sub'); + await expect(page.getByLabel(randomDocName)).toBeVisible(); + }); }); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts new file mode 100644 index 000000000..bf4b31349 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts @@ -0,0 +1,279 @@ +/* eslint-disable playwright/no-conditional-in-test */ +import { expect, test } from '@playwright/test'; + +import { + createDoc, + expectLoginPage, + keyCloakSignIn, + randomName, + verifyDocName, +} from './common'; + +test.describe('Doc Tree', () => { + test('create new sub pages', async ({ page, browserName }) => { + await page.goto('/'); + const [titleParent] = await createDoc( + page, + 'doc-tree-content', + browserName, + 1, + ); + await verifyDocName(page, titleParent); + const addButton = page.getByRole('button', { name: 'New page' }); + const docTree = page.getByTestId('doc-tree'); + + await expect(addButton).toBeVisible(); + + // Wait for and intercept the POST request to create a new page + const responsePromise = page.waitForResponse( + (response) => + response.url().includes('/documents/') && + response.url().includes('/children/') && + response.request().method() === 'POST', + ); + + await addButton.click(); + const response = await responsePromise; + expect(response.ok()).toBeTruthy(); + const subPageJson = await response.json(); + + await expect(docTree).toBeVisible(); + const subPageItem = docTree + .getByTestId(`doc-sub-page-item-${subPageJson.id}`) + .first(); + + await expect(subPageItem).toBeVisible(); + await subPageItem.click(); + await verifyDocName(page, ''); + const input = page.getByRole('textbox', { name: 'doc title input' }); + await input.click(); + const [randomDocName] = randomName('doc-tree-test', browserName, 1); + await input.fill(randomDocName); + await input.press('Enter'); + await expect(subPageItem.getByText(randomDocName)).toBeVisible(); + await page.reload(); + await expect(subPageItem.getByText(randomDocName)).toBeVisible(); + }); + + test('check the reorder of sub pages', async ({ page, browserName }) => { + await page.goto('/'); + await createDoc(page, 'doc-tree-content', browserName, 1); + const addButton = page.getByRole('button', { name: 'New page' }); + await expect(addButton).toBeVisible(); + + const docTree = page.getByTestId('doc-tree'); + + // Create first sub page + const firstResponsePromise = page.waitForResponse( + (response) => + response.url().includes('/documents/') && + response.url().includes('/children/') && + response.request().method() === 'POST', + ); + + await addButton.click(); + const firstResponse = await firstResponsePromise; + expect(firstResponse.ok()).toBeTruthy(); + + const secondResponsePromise = page.waitForResponse( + (response) => + response.url().includes('/documents/') && + response.url().includes('/children/') && + response.request().method() === 'POST', + ); + + // Create second sub page + await addButton.click(); + const secondResponse = await secondResponsePromise; + expect(secondResponse.ok()).toBeTruthy(); + + const secondSubPageJson = await secondResponse.json(); + const firstSubPageJson = await firstResponse.json(); + + const firstSubPageItem = docTree + .getByTestId(`doc-sub-page-item-${firstSubPageJson.id}`) + .first(); + + const secondSubPageItem = docTree + .getByTestId(`doc-sub-page-item-${secondSubPageJson.id}`) + .first(); + + // check that the sub pages are visible in the tree + await expect(firstSubPageItem).toBeVisible(); + await expect(secondSubPageItem).toBeVisible(); + + // get the bounding boxes of the sub pages + const firstSubPageBoundingBox = await firstSubPageItem.boundingBox(); + const secondSubPageBoundingBox = await secondSubPageItem.boundingBox(); + + expect(firstSubPageBoundingBox).toBeDefined(); + expect(secondSubPageBoundingBox).toBeDefined(); + + if (!firstSubPageBoundingBox || !secondSubPageBoundingBox) { + throw new Error('Impossible de déterminer la position des éléments'); + } + + // move the first sub page to the second position + await page.mouse.move( + firstSubPageBoundingBox.x + firstSubPageBoundingBox.width / 2, + firstSubPageBoundingBox.y + firstSubPageBoundingBox.height / 2, + ); + + await page.mouse.down(); + + await page.mouse.move( + secondSubPageBoundingBox.x + secondSubPageBoundingBox.width / 2, + secondSubPageBoundingBox.y + secondSubPageBoundingBox.height + 4, + { steps: 10 }, + ); + + await page.mouse.up(); + + // check that the sub pages are visible in the tree + await expect(firstSubPageItem).toBeVisible(); + await expect(secondSubPageItem).toBeVisible(); + + // reload the page + await page.reload(); + + // check that the sub pages are visible in the tree + await expect(firstSubPageItem).toBeVisible(); + await expect(secondSubPageItem).toBeVisible(); + + // Check the position of the sub pages + const allSubPageItems = await docTree + .getByTestId(/^doc-sub-page-item/) + .all(); + + expect(allSubPageItems.length).toBe(2); + + // Check that the first element has the ID of the second sub page after the drag and drop + await expect(allSubPageItems[0]).toHaveAttribute( + 'data-testid', + `doc-sub-page-item-${secondSubPageJson.id}`, + ); + + // Check that the second element has the ID of the first sub page after the drag and drop + await expect(allSubPageItems[1]).toHaveAttribute( + 'data-testid', + `doc-sub-page-item-${firstSubPageJson.id}`, + ); + }); +}); + +test.describe('Doc Tree: Inheritance', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('A child inherit from the parent', async ({ page, browserName }) => { + await page.goto('/'); + await keyCloakSignIn(page, browserName); + + const [docParent] = await createDoc( + page, + 'doc-tree-inheritance-parent', + browserName, + 1, + ); + await verifyDocName(page, docParent); + + await page.getByRole('button', { name: 'Share' }).click(); + const selectVisibility = page.getByLabel('Visibility', { exact: true }); + await selectVisibility.click(); + + await page + .getByRole('menuitem', { + name: 'Public', + }) + .click(); + + await expect( + page.getByText('The document visibility has been updated.'), + ).toBeVisible(); + + await page.getByRole('button', { name: 'close' }).click(); + + const [docChild] = await createDoc( + page, + 'doc-tree-inheritance-child', + browserName, + 1, + true, + ); + await verifyDocName(page, docChild); + + const urlDoc = page.url(); + + await page + .getByRole('button', { + name: 'Logout', + }) + .click(); + + await expectLoginPage(page); + + await page.goto(urlDoc); + + await expect(page.locator('h2').getByText(docChild)).toBeVisible(); + + const docTree = page.getByTestId('doc-tree'); + await expect(docTree.getByText(docParent)).toBeVisible(); + }); + + test('Do not show private parent from children', async ({ + page, + browserName, + }) => { + await page.goto('/'); + await keyCloakSignIn(page, browserName); + + const [docParent] = await createDoc( + page, + 'doc-tree-inheritance-private-parent', + browserName, + 1, + ); + await verifyDocName(page, docParent); + + const [docChild] = await createDoc( + page, + 'doc-tree-inheritance-private-child', + browserName, + 1, + true, + ); + await verifyDocName(page, docChild); + + await page.getByRole('button', { name: 'Share' }).click(); + const selectVisibility = page.getByLabel('Visibility', { exact: true }); + await selectVisibility.click(); + + await page + .getByRole('menuitem', { + name: 'Public', + }) + .click(); + + await expect( + page.getByText('The document visibility has been updated.'), + ).toBeVisible(); + + await page.getByRole('button', { name: 'close' }).click(); + + const urlDoc = page.url(); + + await page + .getByRole('button', { + name: 'Logout', + }) + .click(); + + await expectLoginPage(page); + + await page.goto(urlDoc); + + await expect(page.locator('h2').getByText(docChild)).toBeVisible(); + + const docTree = page.getByTestId('doc-tree'); + await expect(docTree.getByText(docParent)).toBeHidden(); + }); +}); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts index a28200b08..d27138cad 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts @@ -241,7 +241,7 @@ test.describe('Doc Visibility: Public', () => { ).toBeVisible(); await expect(page.getByRole('button', { name: 'search' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'New doc' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'New page' })).toBeVisible(); const urlDoc = page.url(); @@ -257,7 +257,7 @@ test.describe('Doc Visibility: Public', () => { await expect(page.locator('h2').getByText(docTitle)).toBeVisible(); await expect(page.getByRole('button', { name: 'search' })).toBeHidden(); - await expect(page.getByRole('button', { name: 'New doc' })).toBeHidden(); + await expect(page.getByRole('button', { name: 'New page' })).toBeHidden(); await expect(page.getByRole('button', { name: 'Share' })).toBeVisible(); const card = page.getByLabel('It is the card information'); await expect(card).toBeVisible(); diff --git a/src/frontend/apps/impress/src/components/DropdownMenu.tsx b/src/frontend/apps/impress/src/components/DropdownMenu.tsx index 8758588ed..5fc3d64be 100644 --- a/src/frontend/apps/impress/src/components/DropdownMenu.tsx +++ b/src/frontend/apps/impress/src/components/DropdownMenu.tsx @@ -8,6 +8,7 @@ export type DropdownMenuOption = { icon?: string; label: string; testId?: string; + value?: string; callback?: () => void | Promise; danger?: boolean; isSelected?: boolean; @@ -23,6 +24,8 @@ export type DropdownMenuProps = { buttonCss?: BoxProps['$css']; disabled?: boolean; topMessage?: string; + selectedValues?: string[]; + afterOpenChange?: (isOpen: boolean) => void; }; export const DropdownMenu = ({ @@ -34,6 +37,8 @@ export const DropdownMenu = ({ buttonCss, label, topMessage, + afterOpenChange, + selectedValues, }: PropsWithChildren) => { const { spacingsTokens, colorsTokens } = useCunninghamTheme(); const [isOpen, setIsOpen] = useState(false); @@ -41,6 +46,7 @@ export const DropdownMenu = ({ const onOpenChange = (isOpen: boolean) => { setIsOpen(isOpen); + afterOpenChange?.(isOpen); }; if (disabled) { @@ -163,7 +169,8 @@ export const DropdownMenu = ({ {option.label} - {option.isSelected && ( + {(option.isSelected || + selectedValues?.includes(option.value ?? '')) && ( )} diff --git a/src/frontend/apps/impress/src/components/filter/FilterDropdown.tsx b/src/frontend/apps/impress/src/components/filter/FilterDropdown.tsx new file mode 100644 index 000000000..313209bf4 --- /dev/null +++ b/src/frontend/apps/impress/src/components/filter/FilterDropdown.tsx @@ -0,0 +1,63 @@ +import { css } from 'styled-components'; + +import { Box } from '../Box'; +import { DropdownMenu, DropdownMenuOption } from '../DropdownMenu'; +import { Icon } from '../Icon'; +import { Text } from '../Text'; + +export type FilterDropdownProps = { + options: DropdownMenuOption[]; + selectedValue?: string; +}; + +export const FilterDropdown = ({ + options, + selectedValue, +}: FilterDropdownProps) => { + const selectedOption = options.find( + (option) => option.value === selectedValue, + ); + + if (options.length === 0) { + return null; + } + + return ( + + + + {selectedOption?.label ?? options[0].label} + + + + + ); +}; diff --git a/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx b/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx index 9ab52f53b..e78f6a564 100644 --- a/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx +++ b/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx @@ -56,6 +56,9 @@ export const QuickSearchInput = ({ /* eslint-disable-next-line jsx-a11y/no-autofocus */ autoFocus={true} aria-label={t('Quick search input')} + onClick={(e) => { + e.stopPropagation(); + }} value={inputValue} role="combobox" placeholder={placeholder ?? t('Search')} diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx index b838ef440..5175cfe7e 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx @@ -1,10 +1,7 @@ /* eslint-disable jsx-a11y/click-events-have-key-events */ /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ -import { - Tooltip, - VariantType, - useToastProvider, -} from '@openfun/cunningham-react'; +import { Tooltip } from '@openfun/cunningham-react'; +import { useQueryClient } from '@tanstack/react-query'; import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -15,6 +12,7 @@ import { Doc, KEY_DOC, KEY_LIST_DOC, + KEY_SUB_PAGE, useTrans, useUpdateDoc, } from '@/docs/doc-management'; @@ -54,21 +52,24 @@ export const DocTitleText = ({ title }: DocTitleTextProps) => { const DocTitleInput = ({ doc }: DocTitleProps) => { const { isDesktop } = useResponsiveStore(); + const queryClient = useQueryClient(); const { t } = useTranslation(); const { colorsTokens } = useCunninghamTheme(); const [titleDisplay, setTitleDisplay] = useState(doc.title); - const { toast } = useToastProvider(); + const { untitledDocument } = useTrans(); const { broadcast } = useBroadcastStore(); const { mutate: updateDoc } = useUpdateDoc({ listInvalideQueries: [KEY_DOC, KEY_LIST_DOC], - onSuccess(data) { - toast(t('Document title updated successfully'), VariantType.SUCCESS); - + onSuccess(updatedDoc) { // Broadcast to every user connected to the document - broadcast(`${KEY_DOC}-${data.id}`); + broadcast(`${KEY_DOC}-${updatedDoc.id}`); + queryClient.setQueryData( + [KEY_SUB_PAGE, { id: updatedDoc.id }], + updatedDoc, + ); }, }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDoc.tsx index ebbb1d543..5365ad4d9 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDoc.tsx @@ -19,6 +19,7 @@ export const getDoc = async ({ id }: DocParams): Promise => { }; export const KEY_DOC = 'doc'; +export const KEY_SUB_PAGE = 'sub-page'; export const KEY_DOC_VISIBILITY = 'doc-visibility'; export function useDoc( @@ -26,7 +27,7 @@ export function useDoc( queryConfig?: UseQueryOptions, ) { return useQuery({ - queryKey: [KEY_DOC, param], + queryKey: queryConfig?.queryKey ?? [KEY_DOC, param], queryFn: () => getDoc(param), ...queryConfig, }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx index c9881ad70..5f5636d57 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx @@ -53,7 +53,6 @@ export const getDocs = async (params: DocsParams): Promise => { if (params.is_favorite !== undefined) { searchParams.set('is_favorite', params.is_favorite.toString()); } - const response = await fetchAPI(`documents/?${searchParams.toString()}`); if (!response.ok) { diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx index c3ff4b5b7..f83b1aff8 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx @@ -12,21 +12,27 @@ import { useRouter } from 'next/router'; import { Box, Text, TextErrors } from '@/components'; import { useRemoveDoc } from '../api/useRemoveDoc'; +import { useTrans } from '../hooks'; import { Doc } from '../types'; interface ModalRemoveDocProps { onClose: () => void; doc: Doc; + afterDelete?: (doc: Doc) => void; } -export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => { +export const ModalRemoveDoc = ({ + onClose, + doc, + afterDelete, +}: ModalRemoveDocProps) => { const { toast } = useToastProvider(); const { push } = useRouter(); const pathname = usePathname(); + const { untitledDocument } = useTrans(); const { mutate: removeDoc, - isError, error, } = useRemoveDoc({ @@ -34,6 +40,11 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => { toast(t('The document has been deleted.'), VariantType.SUCCESS, { duration: 4000, }); + if (afterDelete) { + afterDelete(doc); + return; + } + if (pathname === '/') { onClose(); } else { @@ -90,7 +101,9 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => { > {!isError && ( - {t('Are you sure you want to delete this document ?')} + {t('Are you sure you want to delete the document "{{title}}"?', { + title: doc.title ?? untitledDocument, + })} )} diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchContent.tsx b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchContent.tsx new file mode 100644 index 000000000..fc14d8bc6 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchContent.tsx @@ -0,0 +1,68 @@ +import { t } from 'i18next'; +import { useEffect, useMemo } from 'react'; +import { InView } from 'react-intersection-observer'; + +import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search'; + +import { Doc, useInfiniteDocs } from '../../doc-management'; + +import { DocSearchFiltersValues } from './DocSearchFilters'; +import { DocSearchItem } from './DocSearchItem'; + +type DocSearchContentProps = { + search: string; + filters: DocSearchFiltersValues; + onSelect: (doc: Doc) => void; + onLoadingChange?: (loading: boolean) => void; +}; + +export const DocSearchContent = ({ + search, + filters, + onSelect, + onLoadingChange, +}: DocSearchContentProps) => { + const { + data, + isFetching, + isRefetching, + isLoading, + fetchNextPage, + hasNextPage, + } = useInfiniteDocs({ + page: 1, + title: search, + ...filters, + }); + + const loading = isFetching || isRefetching || isLoading; + + const docsData: QuickSearchData = useMemo(() => { + const docs = data?.pages.flatMap((page) => page.results) || []; + + return { + groupName: docs.length > 0 ? t('Select a document') : '', + elements: search ? docs : [], + emptyString: t('No document found'), + endActions: hasNextPage + ? [ + { + content: void fetchNextPage()} />, + }, + ] + : [], + }; + }, [search, data?.pages, fetchNextPage, hasNextPage]); + + useEffect(() => { + onLoadingChange?.(loading); + }, [loading, onLoadingChange]); + + return ( + } + /> + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchFilters.tsx b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchFilters.tsx new file mode 100644 index 000000000..96edcf71d --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchFilters.tsx @@ -0,0 +1,67 @@ +import { Button } from '@openfun/cunningham-react'; +import { useTranslation } from 'react-i18next'; + +import { Box } from '@/components'; +import { FilterDropdown } from '@/components/filter/FilterDropdown'; + +export enum DocSearchTarget { + ALL = 'all', + CURRENT = 'current', +} + +export type DocSearchFiltersValues = { + target?: DocSearchTarget; +}; + +export type DocSearchFiltersProps = { + values?: DocSearchFiltersValues; + onValuesChange?: (values: DocSearchFiltersValues) => void; + onReset?: () => void; +}; + +export const DocSearchFilters = ({ + values, + onValuesChange, + onReset, +}: DocSearchFiltersProps) => { + const { t } = useTranslation(); + const hasFilters = Object.keys(values ?? {}).length > 0; + const handleTargetChange = (target: DocSearchTarget) => { + onValuesChange?.({ ...values, target }); + }; + + return ( + + + handleTargetChange(DocSearchTarget.ALL), + }, + { + label: t('Current doc'), + value: DocSearchTarget.CURRENT, + callback: () => handleTargetChange(DocSearchTarget.CURRENT), + }, + ]} + /> + + {hasFilters && ( + + )} + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx index 48fbbf48c..caede5f1a 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx @@ -1,65 +1,61 @@ import { Modal, ModalSize } from '@openfun/cunningham-react'; import Image from 'next/image'; -import { useRouter } from 'next/navigation'; -import { useMemo, useState } from 'react'; +import { useRouter } from 'next/router'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { InView } from 'react-intersection-observer'; import { useDebouncedCallback } from 'use-debounce'; import { Box } from '@/components'; -import { - QuickSearch, - QuickSearchData, - QuickSearchGroup, -} from '@/components/quick-search'; -import { Doc, useInfiniteDocs } from '@/docs/doc-management'; +import { QuickSearch } from '@/components/quick-search'; import { useResponsiveStore } from '@/stores'; +import { Doc } from '../../doc-management'; import EmptySearchIcon from '../assets/illustration-docs-empty.png'; -import { DocSearchItem } from './DocSearchItem'; +import { DocSearchContent } from './DocSearchContent'; +import { + DocSearchFilters, + DocSearchFiltersValues, + DocSearchTarget, +} from './DocSearchFilters'; +import { DocSearchSubPageContent } from './DocSearchSubPageContent'; type DocSearchModalProps = { onClose: () => void; isOpen: boolean; + showFilters?: boolean; + defaultFilters?: DocSearchFiltersValues; }; -export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => { +export const DocSearchModal = ({ + showFilters = false, + defaultFilters, + ...modalProps +}: DocSearchModalProps) => { const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const router = useRouter(); + const isDocPage = router.pathname === '/docs/[id]'; + const [search, setSearch] = useState(''); + const [filters, setFilters] = useState( + defaultFilters ?? {}, + ); + + const target = filters.target ?? DocSearchTarget.ALL; const { isDesktop } = useResponsiveStore(); - const { - data, - isFetching, - isRefetching, - isLoading, - fetchNextPage, - hasNextPage, - } = useInfiniteDocs({ - page: 1, - title: search, - }); - const loading = isFetching || isRefetching || isLoading; + const handleInputSearch = useDebouncedCallback(setSearch, 300); const handleSelect = (doc: Doc) => { - router.push(`/docs/${doc.id}`); + void router.push(`/docs/${doc.id}`); modalProps.onClose?.(); }; - const docsData: QuickSearchData = useMemo(() => { - const docs = data?.pages.flatMap((page) => page.results) || []; - - return { - groupName: docs.length > 0 ? t('Select a document') : '', - elements: search ? docs : [], - emptyString: t('No document found'), - endActions: hasNextPage - ? [{ content: void fetchNextPage()} /> }] - : [], - }; - }, [data, hasNextPage, fetchNextPage, t, search]); + const handleResetFilters = () => { + setFilters({}); + }; return ( { onFilter={handleInputSearch} > + {showFilters && ( + + )} {search.length === 0 && ( { )} {search && ( - } - /> + <> + {target === DocSearchTarget.ALL && ( + + )} + {isDocPage && target === DocSearchTarget.CURRENT && ( + + )} + )} diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchSubPageContent.tsx b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchSubPageContent.tsx new file mode 100644 index 000000000..e4fa2c7e7 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchSubPageContent.tsx @@ -0,0 +1,73 @@ +import { useTreeContext } from '@gouvfr-lasuite/ui-kit'; +import { t } from 'i18next'; +import { useEffect, useMemo } from 'react'; +import { InView } from 'react-intersection-observer'; + +import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search'; + +import { Doc } from '../../doc-management'; +import { useInfiniteSubDocs } from '../../doc-management/api/useSubDocs'; + +import { DocSearchFiltersValues } from './DocSearchFilters'; +import { DocSearchItem } from './DocSearchItem'; + +type DocSearchSubPageContentProps = { + search: string; + filters: DocSearchFiltersValues; + onSelect: (doc: Doc) => void; + onLoadingChange?: (loading: boolean) => void; +}; + +export const DocSearchSubPageContent = ({ + search, + filters, + onSelect, + onLoadingChange, +}: DocSearchSubPageContentProps) => { + const treeContext = useTreeContext(); + + const { + data: subDocsData, + isFetching, + isRefetching, + isLoading, + fetchNextPage: subDocsFetchNextPage, + hasNextPage: subDocsHasNextPage, + } = useInfiniteSubDocs({ + page: 1, + title: search, + ...filters, + parent_id: treeContext?.root?.id ?? '', + }); + + const loading = isFetching || isRefetching || isLoading; + + const docsData: QuickSearchData = useMemo(() => { + const subDocs = subDocsData?.pages.flatMap((page) => page.results) || []; + + return { + groupName: subDocs.length > 0 ? t('Select a page') : '', + elements: search ? subDocs : [], + emptyString: t('No document found'), + endActions: subDocsHasNextPage + ? [ + { + content: void subDocsFetchNextPage()} />, + }, + ] + : [], + }; + }, [search, subDocsData, subDocsFetchNextPage, subDocsHasNextPage]); + + useEffect(() => { + onLoadingChange?.(loading); + }, [loading, onLoadingChange]); + + return ( + } + /> + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/index.ts b/src/frontend/apps/impress/src/features/docs/doc-search/components/index.ts index a5cb98858..1a0889239 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-search/components/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/index.ts @@ -1 +1,2 @@ export * from './DocSearchModal'; +export * from './DocSearchFilters'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx index fdded178d..67c907f0a 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx @@ -3,6 +3,7 @@ import { VariantType, useToastProvider, } from '@openfun/cunningham-react'; +import { useQueryClient } from '@tanstack/react-query'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -11,7 +12,7 @@ import { APIError } from '@/api'; import { Box } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; import { User } from '@/features/auth'; -import { Doc, Role } from '@/features/docs'; +import { Doc, KEY_SUB_PAGE, Role } from '@/features/docs'; import { useCreateDocAccess, useCreateDocInvitation } from '../api'; import { OptionType } from '../types'; @@ -39,11 +40,12 @@ export const DocShareAddMemberList = ({ }: Props) => { const { t } = useTranslation(); const { toast } = useToastProvider(); + const [isLoading, setIsLoading] = useState(false); const { spacingsTokens, colorsTokens } = useCunninghamTheme(); const [invitationRole, setInvitationRole] = useState(Role.EDITOR); const canShare = doc.abilities.accesses_manage; - + const queryClient = useQueryClient(); const { mutateAsync: createInvitation } = useCreateDocInvitation(); const { mutateAsync: createDocAccess } = useCreateDocAccess(); @@ -89,14 +91,32 @@ export const DocShareAddMemberList = ({ }; return isInvitationMode - ? createInvitation({ - ...payload, - email: user.email, - }) - : createDocAccess({ - ...payload, - memberId: user.id, - }); + ? createInvitation( + { + ...payload, + email: user.email, + }, + { + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [KEY_SUB_PAGE, { id: doc.id }], + }); + }, + }, + ) + : createDocAccess( + { + ...payload, + memberId: user.id, + }, + { + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [KEY_SUB_PAGE, { id: doc.id }], + }); + }, + }, + ); }); const settledPromises = await Promise.allSettled(promises); diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitationItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitationItem.tsx index 76a04fbd0..343bbb968 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitationItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitationItem.tsx @@ -1,4 +1,5 @@ import { VariantType, useToastProvider } from '@openfun/cunningham-react'; +import { useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { @@ -8,7 +9,7 @@ import { IconOptions, } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; -import { Doc, Role } from '@/docs/doc-management'; +import { Doc, KEY_SUB_PAGE, Role } from '@/docs/doc-management'; import { User } from '@/features/auth'; import { useDeleteDocInvitation, useUpdateDocInvitation } from '../api'; @@ -23,6 +24,7 @@ type Props = { }; export const DocShareInvitationItem = ({ doc, invitation }: Props) => { const { t } = useTranslation(); + const queryClient = useQueryClient(); const { spacingsTokens } = useCunninghamTheme(); const fakeUser: User = { id: invitation.email, @@ -36,6 +38,11 @@ export const DocShareInvitationItem = ({ doc, invitation }: Props) => { const canUpdate = doc.abilities.accesses_manage; const { mutate: updateDocInvitation } = useUpdateDocInvitation({ + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [KEY_SUB_PAGE, { id: doc.id }], + }); + }, onError: (error) => { toast( error?.data?.role?.[0] ?? t('Error during update invitation'), @@ -48,6 +55,11 @@ export const DocShareInvitationItem = ({ doc, invitation }: Props) => { }); const { mutate: removeDocInvitation } = useDeleteDocInvitation({ + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [KEY_SUB_PAGE, { id: doc.id }], + }); + }, onError: (error) => { toast( error?.data?.role?.[0] ?? t('Error during delete invitation'), diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMemberItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMemberItem.tsx index 4da05ec71..e978f5e87 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMemberItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMemberItem.tsx @@ -1,4 +1,5 @@ import { VariantType, useToastProvider } from '@openfun/cunningham-react'; +import { useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { @@ -8,7 +9,7 @@ import { IconOptions, } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; -import { Access, Doc, Role } from '@/docs/doc-management/'; +import { Access, Doc, KEY_SUB_PAGE, Role } from '@/docs/doc-management/'; import { useResponsiveStore } from '@/stores'; import { useDeleteDocAccess, useUpdateDocAccess } from '../api'; @@ -23,8 +24,10 @@ type Props = { }; export const DocShareMemberItem = ({ doc, access }: Props) => { const { t } = useTranslation(); - const { isLastOwner } = useWhoAmI(access); + const queryClient = useQueryClient(); + const { isLastOwner, isOtherOwner } = useWhoAmI(access); const { toast } = useToastProvider(); + const { isDesktop } = useResponsiveStore(); const { spacingsTokens } = useCunninghamTheme(); @@ -35,6 +38,11 @@ export const DocShareMemberItem = ({ doc, access }: Props) => { : undefined; const { mutate: updateDocAccess } = useUpdateDocAccess({ + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [KEY_SUB_PAGE, { id: doc.id }], + }); + }, onError: () => { toast(t('Error during invitation update'), VariantType.ERROR, { duration: 4000, @@ -43,6 +51,11 @@ export const DocShareMemberItem = ({ doc, access }: Props) => { }); const { mutate: removeDocAccess } = useDeleteDocAccess({ + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [KEY_SUB_PAGE, { id: doc.id }], + }); + }, onError: () => { toast(t('Error while deleting invitation'), VariantType.ERROR, { duration: 4000, diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/index.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/api/index.ts new file mode 100644 index 000000000..98d4836ff --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/index.ts @@ -0,0 +1 @@ +export * from './useDocChildren'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/useCreateChildren.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useCreateChildren.tsx new file mode 100644 index 000000000..b9f774a81 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useCreateChildren.tsx @@ -0,0 +1,44 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +import { Doc, KEY_LIST_DOC } from '../../doc-management'; + +export type CreateDocParam = Pick & { + parentId: string; +}; + +export const createDocChildren = async ({ + title, + parentId, +}: CreateDocParam): Promise => { + const response = await fetchAPI(`documents/${parentId}/children/`, { + method: 'POST', + body: JSON.stringify({ + title, + }), + }); + + if (!response.ok) { + throw new APIError('Failed to create the doc', await errorCauses(response)); + } + + return response.json() as Promise; +}; + +interface CreateDocProps { + onSuccess: (data: Doc) => void; +} + +export function useCreateChildrenDoc({ onSuccess }: CreateDocProps) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createDocChildren, + onSuccess: (data) => { + void queryClient.resetQueries({ + queryKey: [KEY_LIST_DOC], + }); + onSuccess(data); + }, + }); +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocChildren.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocChildren.tsx new file mode 100644 index 000000000..406c32a77 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocChildren.tsx @@ -0,0 +1,58 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI, useAPIInfiniteQuery } from '@/api'; + +import { DocsResponse } from '../../doc-management'; + +export type DocsChildrenParams = { + docId: string; + page?: number; + page_size?: number; +}; + +export const getDocChildren = async ( + params: DocsChildrenParams, +): Promise => { + const { docId, page, page_size } = params; + const searchParams = new URLSearchParams(); + + if (page) { + searchParams.set('page', page.toString()); + } + if (page_size) { + searchParams.set('page_size', page_size.toString()); + } + + const response = await fetchAPI( + `documents/${docId}/children/?${searchParams.toString()}`, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to get the doc children', + await errorCauses(response), + ); + } + + return response.json() as Promise; +}; + +export const KEY_LIST_DOC_CHILDREN = 'doc-children'; + +export function useDocChildren( + params: DocsChildrenParams, + queryConfig?: Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' + >, +) { + return useQuery({ + queryKey: [KEY_LIST_DOC_CHILDREN, params], + queryFn: () => getDocChildren(params), + ...queryConfig, + }); +} + +export const useInfiniteDocChildren = (params: DocsChildrenParams) => { + return useAPIInfiniteQuery(KEY_LIST_DOC_CHILDREN, getDocChildren, params); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocTree.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocTree.tsx new file mode 100644 index 000000000..bebb1d828 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocTree.tsx @@ -0,0 +1,44 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +import { Doc } from '../../doc-management'; + +export type DocsTreeParams = { + docId: string; +}; + +export const getDocTree = async ({ docId }: DocsTreeParams): Promise => { + const searchParams = new URLSearchParams(); + + const response = await fetchAPI( + `documents/${docId}/tree/?${searchParams.toString()}`, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to get the doc tree', + await errorCauses(response), + ); + } + + return response.json() as Promise; +}; + +export const KEY_LIST_DOC_CHILDREN = 'doc-tree'; + +export function useDocTree( + params: DocsTreeParams, + queryConfig?: Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' + >, +) { + return useQuery({ + queryKey: [KEY_LIST_DOC_CHILDREN, params], + queryFn: () => getDocTree(params), + staleTime: 0, + refetchOnWindowFocus: false, + ...queryConfig, + }); +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/assets/doc-extract-bold.svg b/src/frontend/apps/impress/src/features/docs/doc-tree/assets/doc-extract-bold.svg new file mode 100644 index 000000000..47d4fa1a9 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/assets/doc-extract-bold.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/assets/sub-page-logo.svg b/src/frontend/apps/impress/src/features/docs/doc-tree/assets/sub-page-logo.svg new file mode 100644 index 000000000..790684c6e --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/assets/sub-page-logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx new file mode 100644 index 000000000..789686bae --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx @@ -0,0 +1,169 @@ +import { + TreeViewItem, + TreeViewNodeProps, + useTreeContext, +} from '@gouvfr-lasuite/ui-kit'; +import { useRouter } from 'next/navigation'; +import { useEffect, useRef, useState } from 'react'; +import { css } from 'styled-components'; + +import { Box, Icon, Text } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; +import { + Doc, + KEY_SUB_PAGE, + useDoc, + useTrans, +} from '@/features/docs/doc-management'; +import { useLeftPanelStore } from '@/features/left-panel'; + +import Logo from './../assets/sub-page-logo.svg'; +import { DocTreeItemActions } from './DocTreeItemActions'; + +const ItemTextCss = css` + overflow: hidden; + text-overflow: ellipsis; + white-space: initial; + display: -webkit-box; + line-clamp: 1; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; +`; + +type Props = TreeViewNodeProps; +export const DocSubPageItem = (props: Props) => { + const doc = props.node.data.value as Doc; + const treeContext = useTreeContext(); + const { untitledDocument } = useTrans(); + const { node } = props; + const { spacingsTokens } = useCunninghamTheme(); + const [isHover, setIsHover] = useState(false); + + const spacing = spacingsTokens(); + const router = useRouter(); + const { togglePanel } = useLeftPanelStore(); + + const isInitialLoad = useRef(false); + const { data: docQuery } = useDoc( + { id: doc.id }, + { + initialData: doc, + queryKey: [KEY_SUB_PAGE, { id: doc.id }], + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + ); + + useEffect(() => { + if (docQuery && isInitialLoad.current === true) { + treeContext?.treeData.updateNode(docQuery.id, docQuery); + } + + if (docQuery) { + isInitialLoad.current = true; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [docQuery]); + + const afterCreate = (createdDoc: Doc) => { + const actualChildren = node.data.children ?? []; + + if (actualChildren.length === 0) { + treeContext?.treeData + .handleLoadChildren(node?.data.value.id) + .then((allChildren) => { + node.open(); + + router.push(`/docs/${doc.id}`); + treeContext?.treeData.setChildren(node.data.value.id, allChildren); + togglePanel(); + }) + .catch(console.error); + } else { + const newDoc = { + ...createdDoc, + children: [], + childrenCount: 0, + parentId: node.id, + }; + treeContext?.treeData.addChild(node.data.value.id, newDoc); + node.open(); + router.push(`/docs/${createdDoc.id}`); + togglePanel(); + } + }; + + return ( + setIsHover(true)} + onMouseLeave={() => setIsHover(false)} + $css={css` + &:not(:has(.isSelected)):has(.light-doc-item-actions) { + background-color: var(--c--theme--colors--greyscale-100); + } + `} + > + { + treeContext?.treeData.setSelectedNode(props.node.data.value as Doc); + router.push(`/docs/${props.node.data.value.id}`); + }} + > + + + + + + + + {doc.title || untitledDocument} + + {doc.nb_accesses_direct > 1 && ( + + )} + + + {isHover && ( + + + + )} + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx new file mode 100644 index 000000000..32bea3e54 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx @@ -0,0 +1,233 @@ +import { + OpenMap, + TreeView, + TreeViewMoveResult, + useTreeContext, +} from '@gouvfr-lasuite/ui-kit'; +import { useRouter } from 'next/navigation'; +import { useEffect, useRef, useState } from 'react'; +import { css } from 'styled-components'; + +import { Box, StyledLink } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; + +import { Doc, KEY_SUB_PAGE, useDoc, useDocStore } from '../../doc-management'; +import { SimpleDocItem } from '../../docs-grid'; +import { useDocTree } from '../api/useDocTree'; +import { useMoveDoc } from '../api/useMove'; +import { canDrag, canDrop, serializeDocToSubPage } from '../utils'; + +import { DocSubPageItem } from './DocSubPageItem'; +import { DocTreeItemActions } from './DocTreeItemActions'; + +type DocTreeProps = { + initialTargetId: string; +}; +export const DocTree = ({ initialTargetId }: DocTreeProps) => { + const { spacingsTokens } = useCunninghamTheme(); + const spacing = spacingsTokens(); + const treeContext = useTreeContext(); + const { currentDoc } = useDocStore(); + const router = useRouter(); + + const previousDocId = useRef(initialTargetId); + + const { data: rootNode } = useDoc( + { id: treeContext?.root?.id ?? '' }, + { + enabled: !!treeContext?.root?.id, + initialData: treeContext?.root ?? undefined, + queryKey: [KEY_SUB_PAGE, { id: treeContext?.root?.id ?? '' }], + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + ); + + const [initialOpenState, setInitialOpenState] = useState( + undefined, + ); + + const { mutate: moveDoc } = useMoveDoc(); + + const { data } = useDocTree({ + docId: initialTargetId, + }); + + const handleMove = (result: TreeViewMoveResult) => { + moveDoc({ + sourceDocumentId: result.sourceId, + targetDocumentId: result.targetModeId, + position: result.mode, + }); + treeContext?.treeData.handleMove(result); + }; + + useEffect(() => { + if (!data) { + return; + } + + const { children: rootChildren, ...root } = data; + const children = rootChildren ?? []; + treeContext?.setRoot(root); + const initialOpenState: OpenMap = {}; + initialOpenState[root.id] = true; + const serialize = (children: Doc[]) => { + children.forEach((child) => { + child.childrenCount = child.numchild ?? 0; + if (child?.children?.length && child?.children?.length > 0) { + initialOpenState[child.id] = true; + } + serialize(child.children ?? []); + }); + }; + serialize(children); + + treeContext?.treeData.resetTree(children); + setInitialOpenState(initialOpenState); + if (initialTargetId === root.id) { + treeContext?.treeData.setSelectedNode(root); + } else { + treeContext?.treeData.selectNodeById(initialTargetId); + } + + // Because treeData change in the treeContext, we have a infinite loop + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data, initialTargetId]); + + useEffect(() => { + if ( + !currentDoc || + (previousDocId.current && previousDocId.current === currentDoc.id) + ) { + return; + } + + const item = treeContext?.treeData.getNode(currentDoc?.id ?? ''); + if (!item && currentDoc.id !== rootNode?.id) { + treeContext?.treeData.resetTree([]); + treeContext?.setRoot(currentDoc); + treeContext?.setInitialTargetId(currentDoc.id); + } else if (item) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { children, ...rest } = currentDoc; + treeContext?.treeData.updateNode( + currentDoc.id, + serializeDocToSubPage(rest), + ); + } + if (currentDoc?.id && currentDoc?.id !== previousDocId.current) { + previousDocId.current = currentDoc?.id; + } + + treeContext?.treeData.setSelectedNode(currentDoc); + + // we don't need to run this effect on every change of treeContext.data bacause it cause an infinite loop + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentDoc, rootNode?.id]); + + const rootIsSelected = + treeContext?.treeData.selectedNode?.id === treeContext?.root?.id; + + if (!initialTargetId || !treeContext) { + return null; + } + + return ( + + + + {treeContext.root !== null && rootNode && ( + { + e.stopPropagation(); + e.preventDefault(); + treeContext.treeData.setSelectedNode( + treeContext.root ?? undefined, + ); + router.push(`/docs/${treeContext?.root?.id}`); + }} + > + + +
+ { + const newDoc = { + ...createdDoc, + children: [], + childrenCount: 0, + parentId: treeContext.root?.id ?? undefined, + }; + treeContext?.treeData.addChild(null, newDoc); + }} + /> +
+
+
+ )} +
+
+ + {initialOpenState && treeContext.treeData.nodes.length > 0 && ( + { + if (!rootNode) { + return false; + } + const parentDoc = parentNode?.data.value as Doc; + if (!parentDoc) { + return canDrop(rootNode); + } + return canDrop(parentDoc); + }} + canDrag={(node) => { + const doc = node.value as Doc; + return canDrag(doc); + }} + rootNodeId={treeContext.root?.id ?? ''} + renderNode={DocSubPageItem} + /> + )} +
+ ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx new file mode 100644 index 000000000..785513649 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx @@ -0,0 +1,171 @@ +import { + DropdownMenu, + DropdownMenuOption, + useTreeContext, +} from '@gouvfr-lasuite/ui-kit'; +import { useModal } from '@openfun/cunningham-react'; +import { useRouter } from 'next/navigation'; +import { Fragment, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; + +import { Box, BoxButton, Icon } from '@/components'; +import { useLeftPanelStore } from '@/features/left-panel'; + +import { Doc, ModalRemoveDoc, useCopyDocLink } from '../../doc-management'; +import { useCreateChildrenDoc } from '../api/useCreateChildren'; +import { useDetachDoc } from '../api/useDetach'; +import MoveDocIcon from '../assets/doc-extract-bold.svg'; +import { useTreeUtils } from '../hooks'; +import { isOwnerOrAdmin } from '../utils'; + +type DocTreeItemActionsProps = { + doc: Doc; + parentId?: string | null; + onCreateSuccess?: (newDoc: Doc) => void; +}; + +export const DocTreeItemActions = ({ + doc, + parentId, + onCreateSuccess, +}: DocTreeItemActionsProps) => { + const [isOpen, setIsOpen] = useState(false); + const router = useRouter(); + const { t } = useTranslation(); + const deleteModal = useModal(); + const { togglePanel } = useLeftPanelStore(); + const copyLink = useCopyDocLink(doc.id); + const canUpdate = isOwnerOrAdmin(doc); + const { isChild } = useTreeUtils(doc); + const { mutate: detachDoc } = useDetachDoc(); + const treeContext = useTreeContext(); + + const handleDetachDoc = () => { + if (!treeContext?.root) { + return; + } + + detachDoc( + { documentId: doc.id, rootId: treeContext.root.id }, + { + onSuccess: () => { + treeContext.treeData.deleteNode(doc.id); + if (treeContext.root) { + treeContext.treeData.setSelectedNode(treeContext.root); + router.push(`/docs/${treeContext.root.id}`); + } + }, + }, + ); + }; + + const options: DropdownMenuOption[] = [ + { + label: t('Copy link'), + icon: , + callback: copyLink, + }, + ...(isChild + ? [ + { + label: t('Convert to doc'), + isDisabled: !canUpdate, + icon: ( + + + + ), + callback: handleDetachDoc, + }, + ] + : []), + { + label: t('Delete'), + isDisabled: !canUpdate, + icon: , + callback: deleteModal.open, + }, + ]; + + const { mutate: createChildrenDoc } = useCreateChildrenDoc({ + onSuccess: (doc) => { + onCreateSuccess?.(doc); + togglePanel(); + router.push(`/docs/${doc.id}`); + treeContext?.treeData.setSelectedNode(doc); + }, + }); + + const afterDelete = () => { + if (parentId) { + treeContext?.treeData.deleteNode(doc.id); + router.push(`/docs/${parentId}`); + } else if (doc.id === treeContext?.root?.id && !parentId) { + router.push(`/docs/`); + } else if (treeContext && treeContext.root) { + treeContext?.treeData.deleteNode(doc.id); + router.push(`/docs/${treeContext.root.id}`); + } + }; + + return ( + + + + { + e.stopPropagation(); + e.preventDefault(); + setIsOpen(!isOpen); + }} + iconName="more_horiz" + variant="filled" + $theme="primary" + $variation="600" + /> + + {canUpdate && ( + { + e.stopPropagation(); + e.preventDefault(); + + createChildrenDoc({ + parentId: doc.id, + }); + }} + color="primary" + > + + + )} + + {deleteModal.isOpen && ( + + )} + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/index.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/index.ts new file mode 100644 index 000000000..3fb57a348 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/index.ts @@ -0,0 +1 @@ +export * from './useTreeUtils'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useTreeUtils.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useTreeUtils.tsx new file mode 100644 index 000000000..086f5b6b3 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useTreeUtils.tsx @@ -0,0 +1,13 @@ +import { useTreeContext } from '@gouvfr-lasuite/ui-kit'; + +import { Doc } from '@/docs/doc-management'; + +export const useTreeUtils = (doc: Doc) => { + const treeContext = useTreeContext(); + + return { + isParent: doc.nb_accesses_ancestors <= 1, // it is a parent + isChild: doc.nb_accesses_ancestors > 1, // it is a child + isCurrentParent: treeContext?.root?.id === doc.id, // it can be a child but not for the current user + } as const; +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/index.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/index.ts new file mode 100644 index 000000000..608f00da5 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/index.ts @@ -0,0 +1,3 @@ +export * from './api'; +export * from './hooks'; +export * from './utils'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/utils.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/utils.ts new file mode 100644 index 000000000..f632c2cad --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/utils.ts @@ -0,0 +1,29 @@ +import { TreeViewDataType } from '@gouvfr-lasuite/ui-kit'; + +import { Doc, Role } from '../doc-management'; + +export const serializeDocToSubPage = (doc: Doc): Doc => { + return { ...doc, childrenCount: doc.numchild }; +}; + +export const subPageToTree = (children: Doc[]): TreeViewDataType[] => { + children.forEach((child) => { + child.childrenCount = child.numchild ?? 0; + subPageToTree(child.children ?? []); + }); + return children; +}; + +export const isOwnerOrAdmin = (doc: Doc): boolean => { + return doc.user_roles.some( + (role) => role === Role.OWNER || role === Role.ADMIN, + ); +}; + +export const canDrag = (doc: Doc): boolean => { + return isOwnerOrAdmin(doc); +}; + +export const canDrop = (doc: Doc): boolean => { + return isOwnerOrAdmin(doc); +}; diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelDocContent.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelDocContent.tsx index 505735780..928af5ec3 100644 --- a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelDocContent.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelDocContent.tsx @@ -1,14 +1,15 @@ -import { css } from 'styled-components'; +import { useTreeContext } from '@gouvfr-lasuite/ui-kit'; -import { Box, SeparatedSection } from '@/components'; -import { useCunninghamTheme } from '@/cunningham'; -import { useDocStore } from '@/docs/doc-management'; -import { SimpleDocItem } from '@/docs/docs-grid'; +import { Box } from '@/components'; +import { Doc, useDocStore } from '@/docs/doc-management'; +import { DocTree } from '@/features/docs/doc-tree/components/DocTree'; export const LeftPanelDocContent = () => { const { currentDoc } = useDocStore(); - const { spacingsTokens } = useCunninghamTheme(); - if (!currentDoc) { + + const tree = useTreeContext(); + + if (!currentDoc || !tree) { return null; } @@ -19,19 +20,9 @@ export const LeftPanelDocContent = () => { $css="width: 100%; overflow-y: auto; overflow-x: hidden;" className="--docs--left-panel-doc-content" > - - - - - - - + {tree.initialTargetId && ( + + )} ); }; diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx index 7177bc2c7..5733b0dff 100644 --- a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx @@ -1,19 +1,21 @@ import { Button } from '@openfun/cunningham-react'; -import { t } from 'i18next'; -import { useRouter } from 'next/navigation'; +import { useRouter } from 'next/router'; import { PropsWithChildren, useCallback, useState } from 'react'; import { Box, Icon, SeparatedSection } from '@/components'; -import { useCreateDoc } from '@/docs/doc-management'; -import { DocSearchModal } from '@/docs/doc-search'; +import { DocSearchModal, DocSearchTarget } from '@/docs/doc-search/'; import { useAuth } from '@/features/auth'; import { useCmdK } from '@/hook/useCmdK'; import { useLeftPanelStore } from '../stores'; +import { LeftPanelHeaderButton } from './LeftPanelHeaderButton'; + export const LeftPanelHeader = ({ children }: PropsWithChildren) => { const router = useRouter(); const { authenticated } = useAuth(); + const isDoc = router.pathname === '/docs/[id]'; + const [isSearchModalOpen, setIsSearchModalOpen] = useState(false); const openSearchModal = useCallback(() => { @@ -33,22 +35,11 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => { useCmdK(openSearchModal); const { togglePanel } = useLeftPanelStore(); - const { mutate: createDoc, isPending: isCreatingDoc } = useCreateDoc({ - onSuccess: (doc) => { - router.push(`/docs/${doc.id}`); - togglePanel(); - }, - }); - const goToHome = () => { - router.push('/'); + void router.push('/'); togglePanel(); }; - const createNewDoc = () => { - createDoc(); - }; - return ( <> @@ -80,17 +71,21 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => { /> )} - {authenticated && ( - - )} + + {authenticated && } {children} {isSearchModalOpen && ( - + )} ); diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeaderButton.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeaderButton.tsx new file mode 100644 index 000000000..7589f9554 --- /dev/null +++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeaderButton.tsx @@ -0,0 +1,73 @@ +import { useTreeContext } from '@gouvfr-lasuite/ui-kit'; +import { Button } from '@openfun/cunningham-react'; +import { useRouter } from 'next/router'; +import { useTranslation } from 'react-i18next'; + +import { Doc, useCreateDoc, useDocStore } from '@/features/docs'; +import { useCreateChildrenDoc } from '@/features/docs/doc-tree/api/useCreateChildren'; +import { isOwnerOrAdmin } from '@/features/docs/doc-tree/utils'; + +import { useLeftPanelStore } from '../stores'; + +export const LeftPanelHeaderButton = () => { + const router = useRouter(); + const isDoc = router.pathname === '/docs/[id]'; + + if (isDoc) { + return ; + } + + return ; +}; + +export const LeftPanelHeaderHomeButton = () => { + const router = useRouter(); + const { t } = useTranslation(); + const { togglePanel } = useLeftPanelStore(); + const { mutate: createDoc } = useCreateDoc({ + onSuccess: (doc) => { + void router.push(`/docs/${doc.id}`); + togglePanel(); + }, + }); + return ( + + ); +}; + +export const LeftPanelHeaderDocButton = () => { + const router = useRouter(); + const { currentDoc } = useDocStore(); + const { t } = useTranslation(); + const { togglePanel } = useLeftPanelStore(); + const treeContext = useTreeContext(); + const tree = treeContext?.treeData; + const { mutate: createChildrenDoc } = useCreateChildrenDoc({ + onSuccess: (doc) => { + tree?.addRootNode(doc); + tree?.selectNodeById(doc.id); + void router.push(`/docs/${doc.id}`); + togglePanel(); + }, + }); + + const onCreateDoc = () => { + if (treeContext && treeContext.root) { + createChildrenDoc({ + parentId: treeContext.root.id, + }); + } + }; + + return ( + + ); +}; diff --git a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx index 295672436..f391ad9e5 100644 --- a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx +++ b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx @@ -1,3 +1,4 @@ +import { TreeProvider } from '@gouvfr-lasuite/ui-kit'; import { Loader } from '@openfun/cunningham-react'; import { useQueryClient } from '@tanstack/react-query'; import Head from 'next/head'; @@ -7,14 +8,15 @@ import { useTranslation } from 'react-i18next'; import { Box, Icon, TextErrors } from '@/components'; import { DocEditor } from '@/docs/doc-editor'; +import { KEY_AUTH, setAuthUrl } from '@/features/auth'; import { Doc, KEY_DOC, useCollaboration, useDoc, useDocStore, -} from '@/docs/doc-management/'; -import { KEY_AUTH, setAuthUrl } from '@/features/auth'; +} from '@/features/docs/doc-management/'; +import { getDocChildren, subPageToTree } from '@/features/docs/doc-tree/'; import { MainLayout } from '@/layouts'; import { useBroadcastStore } from '@/stores'; import { NextPageWithLayout } from '@/types/next'; @@ -34,9 +36,17 @@ export function DocLayout() { - - - + { + const doc = await getDocChildren({ docId }); + return subPageToTree(doc.results); + }} + > + + + + ); } @@ -84,6 +94,12 @@ const DocPage = ({ id }: DocProps) => { setCurrentDoc(docQuery); }, [docQuery, setCurrentDoc, isFetching]); + useEffect(() => { + return () => { + setCurrentDoc(undefined); + }; + }, [setCurrentDoc]); + /** * We add a broadcast task to reset the query cache * when the document visibility changes. diff --git a/src/frontend/apps/impress/src/tests/utils.tsx b/src/frontend/apps/impress/src/tests/utils.tsx index b0d7c7ded..523fa01d5 100644 --- a/src/frontend/apps/impress/src/tests/utils.tsx +++ b/src/frontend/apps/impress/src/tests/utils.tsx @@ -1,3 +1,4 @@ +import { TreeProvider } from '@gouvfr-lasuite/ui-kit'; import { CunninghamProvider } from '@openfun/cunningham-react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { PropsWithChildren } from 'react'; @@ -14,8 +15,10 @@ export const AppWrapper = ({ children }: PropsWithChildren) => { }); return ( - - {children} - + + + {children} + + ); }; From f6e6fad7d25d13df1e514678977e124037ccdfb0 Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Thu, 27 Mar 2025 16:08:39 +0100 Subject: [PATCH 032/104] =?UTF-8?q?=E2=9C=A8(frontend)=20added=20new=20fea?= =?UTF-8?q?tures=20for=20document=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Created new files for managing subdocuments and detaching documents. - Refactored API request configuration to use an improved configuration type. - Removed unnecessary logs from the ModalConfirmDownloadUnsafe component. --- .../__tests__/app-impress/doc-tree.spec.ts | 40 ++++++++++++ src/frontend/apps/impress/src/api/helpers.tsx | 7 ++- .../ModalConfirmDownloadUnsafe.tsx | 1 - .../features/docs/doc-management/api/index.ts | 3 +- .../docs/doc-management/api/useDocs.tsx | 30 +++------ .../docs/doc-management/api/useSubDocs.tsx | 62 +++++++++++++++++++ .../features/docs/doc-management/types.tsx | 20 ++++-- .../features/docs/doc-tree/api/useDetach.tsx | 51 +++++++++++++++ .../components/DocTreeItemActions.tsx | 4 +- .../docs/doc-tree/hooks/useTreeUtils.tsx | 2 +- .../service-worker/plugins/ApiPlugin.ts | 1 + .../apps/impress/src/pages/globals.css | 8 --- 12 files changed, 190 insertions(+), 39 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-management/api/useSubDocs.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-tree/api/useDetach.tsx diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts index bf4b31349..07a927aa4 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts @@ -159,6 +159,46 @@ test.describe('Doc Tree', () => { `doc-sub-page-item-${firstSubPageJson.id}`, ); }); + + test('it detachs a document', async ({ page, browserName }) => { + await page.goto('/'); + const [docParent] = await createDoc( + page, + 'doc-tree-detach', + browserName, + 1, + ); + await verifyDocName(page, docParent); + + const [docChild] = await createDoc( + page, + 'doc-tree-detach-child', + browserName, + 1, + true, + ); + await verifyDocName(page, docChild); + + const docTree = page.getByTestId('doc-tree'); + const child = docTree + .getByRole('treeitem') + .locator('.--docs-sub-page-item') + .filter({ + hasText: docChild, + }); + await child.hover(); + const menu = child.getByText(`more_horiz`); + await menu.click(); + await page.getByText('Convert to doc').click(); + + await expect( + page.getByRole('textbox', { name: 'doc title input' }), + ).not.toHaveText(docChild); + + const header = page.locator('header').first(); + await header.locator('h2').getByText('Docs').click(); + await expect(page.getByText(docChild)).toBeVisible(); + }); }); test.describe('Doc Tree: Inheritance', () => { diff --git a/src/frontend/apps/impress/src/api/helpers.tsx b/src/frontend/apps/impress/src/api/helpers.tsx index e36b9d41b..cbc4d0b3c 100644 --- a/src/frontend/apps/impress/src/api/helpers.tsx +++ b/src/frontend/apps/impress/src/api/helpers.tsx @@ -21,6 +21,11 @@ export type DefinedInitialDataInfiniteOptionsAPI< TPageParam >; +export type InfiniteQueryConfig = Omit< + DefinedInitialDataInfiniteOptionsAPI, + 'queryKey' | 'initialData' | 'getNextPageParam' | 'initialPageParam' +>; + /** * Custom React hook that wraps React Query's `useInfiniteQuery` for paginated API requests. * @@ -38,7 +43,7 @@ export const useAPIInfiniteQuery = ['next'] }>( key: string, api: (props: T & { page: number }) => Promise, param: T, - queryConfig?: DefinedInitialDataInfiniteOptionsAPI, + queryConfig?: InfiniteQueryConfig, ) => { return useInfiniteQuery, QueryKey, number>({ initialPageParam: 1, diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/ModalConfirmDownloadUnsafe.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/ModalConfirmDownloadUnsafe.tsx index e38c8639c..3929175cb 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/ModalConfirmDownloadUnsafe.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteToolBar/ModalConfirmDownloadUnsafe.tsx @@ -32,7 +32,6 @@ export const ModalConfirmDownloadUnsafe = ({ aria-label={t('Download')} color="danger" onClick={() => { - console.log('onClick'); if (onConfirm) { void onConfirm(); } diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts b/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts index 11123c6bb..df4123600 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts @@ -1,8 +1,9 @@ export * from './useCreateDoc'; +export * from './useCreateFavoriteDoc'; export * from './useDeleteFavoriteDoc'; export * from './useDoc'; export * from './useDocOptions'; export * from './useDocs'; -export * from './useCreateFavoriteDoc'; +export * from './useSubDocs'; export * from './useUpdateDoc'; export * from './useUpdateDocLink'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx index 5f5636d57..88f385df5 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx @@ -8,22 +8,7 @@ import { useAPIInfiniteQuery, } from '@/api'; -import { Doc } from '../types'; - -export const isDocsOrdering = (data: string): data is DocsOrdering => { - return !!docsOrdering.find((validKey) => validKey === data); -}; - -const docsOrdering = [ - 'created_at', - '-created_at', - 'updated_at', - '-updated_at', - 'title', - '-title', -] as const; - -export type DocsOrdering = (typeof docsOrdering)[number]; +import { Doc, DocsOrdering } from '../types'; export type DocsParams = { page: number; @@ -33,26 +18,31 @@ export type DocsParams = { is_favorite?: boolean; }; -export type DocsResponse = APIList; -export const getDocs = async (params: DocsParams): Promise => { +export const constructParams = (params: DocsParams): URLSearchParams => { const searchParams = new URLSearchParams(); + if (params.page) { searchParams.set('page', params.page.toString()); } - if (params.ordering) { searchParams.set('ordering', params.ordering); } if (params.is_creator_me !== undefined) { searchParams.set('is_creator_me', params.is_creator_me.toString()); } - if (params.title && params.title.length > 0) { searchParams.set('title', params.title); } if (params.is_favorite !== undefined) { searchParams.set('is_favorite', params.is_favorite.toString()); } + + return searchParams; +}; + +export type DocsResponse = APIList; +export const getDocs = async (params: DocsParams): Promise => { + const searchParams = constructParams(params); const response = await fetchAPI(`documents/?${searchParams.toString()}`); if (!response.ok) { diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useSubDocs.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useSubDocs.tsx new file mode 100644 index 000000000..e76c8bc4e --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useSubDocs.tsx @@ -0,0 +1,62 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query'; + +import { + APIError, + InfiniteQueryConfig, + errorCauses, + fetchAPI, + useAPIInfiniteQuery, +} from '@/api'; + +import { DocsOrdering } from '../types'; + +import { DocsResponse, constructParams } from './useDocs'; + +export type SubDocsParams = { + page: number; + ordering?: DocsOrdering; + is_creator_me?: boolean; + title?: string; + is_favorite?: boolean; + parent_id: string; +}; + +export const getSubDocs = async ( + params: SubDocsParams, +): Promise => { + const searchParams = constructParams(params); + searchParams.set('parent_id', params.parent_id); + + const response: Response = await fetchAPI( + `documents/${params.parent_id}/descendants/?${searchParams.toString()}`, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to get the sub docs', + await errorCauses(response), + ); + } + + return response.json() as Promise; +}; + +export const KEY_LIST_SUB_DOC = 'sub-docs'; + +export function useSubDocs( + params: SubDocsParams, + queryConfig?: UseQueryOptions, +) { + return useQuery({ + queryKey: [KEY_LIST_SUB_DOC, params], + queryFn: () => getSubDocs(params), + ...queryConfig, + }); +} + +export const useInfiniteSubDocs = ( + params: SubDocsParams, + queryConfig?: InfiniteQueryConfig, +) => { + return useAPIInfiniteQuery(KEY_LIST_SUB_DOC, getSubDocs, params, queryConfig); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx index 2cc117b73..c09b9de17 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx @@ -37,19 +37,20 @@ export type Base64 = string; export interface Doc { id: string; title?: string; + children?: Doc[]; + childrenCount?: number; content: Base64; + created_at: string; creator: string; + depth: number; is_favorite: boolean; link_reach: LinkReach; link_role: LinkRole; - user_roles: Role[]; - created_at: string; - updated_at: string; nb_accesses_direct: number; nb_accesses_ancestors: number; - children?: Doc[]; - childrenCount?: number; numchild: number; + updated_at: string; + user_roles: Role[]; abilities: { accesses_manage: boolean; accesses_view: boolean; @@ -80,3 +81,12 @@ export enum DocDefaultFilter { MY_DOCS = 'my_docs', SHARED_WITH_ME = 'shared_with_me', } + +export type DocsOrdering = + | 'title' + | 'created_at' + | '-created_at' + | 'updated_at' + | '-updated_at' + | '-title' + | undefined; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDetach.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDetach.tsx new file mode 100644 index 000000000..8e261c492 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDetach.tsx @@ -0,0 +1,51 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +import { KEY_DOC, KEY_LIST_DOC } from '../../doc-management'; + +export type DetachDocParam = { + documentId: string; + rootId: string; +}; + +enum POSITION_MOVE { + FIRST_CHILD = 'first-child', + LAST_CHILD = 'last-child', + FIRST_SIBLING = 'first-sibling', + LAST_SIBLING = 'last-sibling', + LEFT = 'left', + RIGHT = 'right', +} + +export const detachDoc = async ({ + documentId, + rootId, +}: DetachDocParam): Promise => { + const response = await fetchAPI(`documents/${documentId}/move/`, { + method: 'POST', + body: JSON.stringify({ + target_document_id: rootId, + position: POSITION_MOVE.LAST_SIBLING, + }), + }); + + if (!response.ok) { + throw new APIError('Failed to move the doc', await errorCauses(response)); + } + + return response.json() as Promise; +}; + +export function useDetachDoc() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: detachDoc, + onSuccess: (_data, variables) => { + void queryClient.invalidateQueries({ queryKey: [KEY_LIST_DOC] }); + void queryClient.invalidateQueries({ + queryKey: [KEY_DOC, { id: variables.documentId }], + }); + }, + }); +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx index 785513649..ef8d5f19a 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx @@ -37,7 +37,7 @@ export const DocTreeItemActions = ({ const { togglePanel } = useLeftPanelStore(); const copyLink = useCopyDocLink(doc.id); const canUpdate = isOwnerOrAdmin(doc); - const { isChild } = useTreeUtils(doc); + const { isCurrentParent } = useTreeUtils(doc); const { mutate: detachDoc } = useDetachDoc(); const treeContext = useTreeContext(); @@ -66,7 +66,7 @@ export const DocTreeItemActions = ({ icon: , callback: copyLink, }, - ...(isChild + ...(!isCurrentParent ? [ { label: t('Convert to doc'), diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useTreeUtils.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useTreeUtils.tsx index 086f5b6b3..55ebff958 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useTreeUtils.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useTreeUtils.tsx @@ -8,6 +8,6 @@ export const useTreeUtils = (doc: Doc) => { return { isParent: doc.nb_accesses_ancestors <= 1, // it is a parent isChild: doc.nb_accesses_ancestors > 1, // it is a child - isCurrentParent: treeContext?.root?.id === doc.id, // it can be a child but not for the current user + isCurrentParent: treeContext?.root?.id === doc.id || doc.depth === 1, // it can be a child but not for the current user } as const; }; diff --git a/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts index 31df23bf4..3f3503a60 100644 --- a/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts +++ b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts @@ -172,6 +172,7 @@ export class ApiPlugin implements WorkboxPlugin { content: '', created_at: new Date().toISOString(), creator: 'dummy-id', + depth: 1, is_favorite: false, nb_accesses_direct: 1, nb_accesses_ancestors: 1, diff --git a/src/frontend/apps/impress/src/pages/globals.css b/src/frontend/apps/impress/src/pages/globals.css index c6bb8ac7a..b646185f4 100644 --- a/src/frontend/apps/impress/src/pages/globals.css +++ b/src/frontend/apps/impress/src/pages/globals.css @@ -68,11 +68,3 @@ main ::-webkit-scrollbar-thumb:hover, /* Support for IE. */ font-feature-settings: 'liga'; } - -[data-nextjs-dialog-overlay] { - display: none !important; -} - -nextjs-portal { - display: none; -} From 6e0bddb715346043ec7e4e4cb452b58b8949d877 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Mon, 31 Mar 2025 16:12:17 +0200 Subject: [PATCH 033/104] =?UTF-8?q?=F0=9F=94=A5(frontend)=20silent=20next.?= =?UTF-8?q?js=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The error modal since next.js 15 are quite intrusive. We decided to hide them. --- src/frontend/apps/impress/src/pages/globals.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/frontend/apps/impress/src/pages/globals.css b/src/frontend/apps/impress/src/pages/globals.css index b646185f4..c6bb8ac7a 100644 --- a/src/frontend/apps/impress/src/pages/globals.css +++ b/src/frontend/apps/impress/src/pages/globals.css @@ -68,3 +68,11 @@ main ::-webkit-scrollbar-thumb:hover, /* Support for IE. */ font-feature-settings: 'liga'; } + +[data-nextjs-dialog-overlay] { + display: none !important; +} + +nextjs-portal { + display: none; +} From 4fef197f8e2df728ea69d087daf97072e7b98a6d Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Wed, 2 Apr 2025 15:25:31 +0200 Subject: [PATCH 034/104] =?UTF-8?q?=E2=9C=8F=EF=B8=8F(frontend)=20child=20?= =?UTF-8?q?document=20with=20different=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We want to have a different wording when the child document has no title, so we can distinguish between the two cases. --- .../docs/doc-editor/components/DocEditor.tsx | 8 +------- .../docs/doc-export/components/ModalExport.tsx | 3 +-- .../docs/doc-header/components/DocTitle.tsx | 16 +++++++--------- .../doc-header/components/DocVersionHeader.tsx | 8 ++------ .../doc-management/components/ModalRemoveDoc.tsx | 2 +- .../docs/doc-management/hooks/useTrans.tsx | 7 ++++--- .../docs/doc-tree/components/DocSubPageItem.tsx | 2 +- .../docs/docs-grid/components/SimpleDocItem.tsx | 2 +- 8 files changed, 18 insertions(+), 30 deletions(-) diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx index 6f07096e6..5cf1db3b9 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx @@ -26,9 +26,7 @@ interface DocEditorProps { export const DocEditor = ({ doc, versionId }: DocEditorProps) => { const { isDesktop } = useResponsiveStore(); const isVersion = !!versionId && typeof versionId === 'string'; - const { colorsTokens } = useCunninghamTheme(); - const { provider } = useProviderStore(); if (!provider) { @@ -58,11 +56,7 @@ export const DocEditor = ({ doc, versionId }: DocEditorProps) => { $padding={{ horizontal: isDesktop ? '54px' : 'base' }} className="--docs--doc-editor-header" > - {isVersion ? ( - - ) : ( - - )} + {isVersion ? : } { const [format, setFormat] = useState( DocDownloadFormat.PDF, ); - const { untitledDocument } = useTrans(); - + const { untitledDocument } = useTrans(doc); const templateOptions = useMemo(() => { const templateOptions = (templates?.pages || []) .map((page) => diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx index 5175cfe7e..b9440e6e9 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx @@ -13,6 +13,7 @@ import { KEY_DOC, KEY_LIST_DOC, KEY_SUB_PAGE, + useDocStore, useTrans, useUpdateDoc, } from '@/docs/doc-management'; @@ -24,19 +25,16 @@ interface DocTitleProps { export const DocTitle = ({ doc }: DocTitleProps) => { if (!doc.abilities.partial_update) { - return ; + return ; } return ; }; -interface DocTitleTextProps { - title?: string; -} - -export const DocTitleText = ({ title }: DocTitleTextProps) => { +export const DocTitleText = () => { const { isMobile } = useResponsiveStore(); - const { untitledDocument } = useTrans(); + const { currentDoc } = useDocStore(); + const { untitledDocument } = useTrans(currentDoc); return ( { $size={isMobile ? 'h4' : 'h2'} $variation="1000" > - {title || untitledDocument} + {currentDoc?.title || untitledDocument} ); }; @@ -57,7 +55,7 @@ const DocTitleInput = ({ doc }: DocTitleProps) => { const { colorsTokens } = useCunninghamTheme(); const [titleDisplay, setTitleDisplay] = useState(doc.title); - const { untitledDocument } = useTrans(); + const { untitledDocument } = useTrans(doc); const { broadcast } = useBroadcastStore(); diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocVersionHeader.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocVersionHeader.tsx index fd8e91999..511b803c9 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocVersionHeader.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocVersionHeader.tsx @@ -5,11 +5,7 @@ import { useCunninghamTheme } from '@/cunningham'; import { DocTitleText } from './DocTitle'; -interface DocVersionHeaderProps { - title?: string; -} - -export const DocVersionHeader = ({ title }: DocVersionHeaderProps) => { +export const DocVersionHeader = () => { const { spacingsTokens } = useCunninghamTheme(); const { t } = useTranslation(); @@ -23,7 +19,7 @@ export const DocVersionHeader = ({ title }: DocVersionHeaderProps) => { aria-label={t('It is the document title')} className="--docs--doc-version-header" > - + diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx index f83b1aff8..f852e694e 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx @@ -29,7 +29,7 @@ export const ModalRemoveDoc = ({ const { toast } = useToastProvider(); const { push } = useRouter(); const pathname = usePathname(); - const { untitledDocument } = useTrans(); + const { untitledDocument } = useTrans(doc); const { mutate: removeDoc, diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useTrans.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useTrans.tsx index d97bd85ed..7664a10c3 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useTrans.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useTrans.tsx @@ -1,9 +1,10 @@ import { useTranslation } from 'react-i18next'; -import { Role } from '../types'; +import { Doc, Role } from '../types'; -export const useTrans = () => { +export const useTrans = (doc?: Doc) => { const { t } = useTranslation(); + const isChild = doc && doc.nb_accesses_ancestors > 1; const translatedRoles = { [Role.READER]: t('Reader'), @@ -16,7 +17,7 @@ export const useTrans = () => { transRole: (role: Role) => { return translatedRoles[role]; }, - untitledDocument: t('Untitled document'), + untitledDocument: isChild ? t('Untitled page') : t('Untitled document'), translatedRoles, }; }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx index 789686bae..8cb305fc3 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx @@ -34,7 +34,7 @@ type Props = TreeViewNodeProps; export const DocSubPageItem = (props: Props) => { const doc = props.node.data.value as Doc; const treeContext = useTreeContext(); - const { untitledDocument } = useTrans(); + const { untitledDocument } = useTrans(doc); const { node } = props; const { spacingsTokens } = useCunninghamTheme(); const [isHover, setIsHover] = useState(false); diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/SimpleDocItem.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/SimpleDocItem.tsx index 87c9387c9..3cc8108bc 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/SimpleDocItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/SimpleDocItem.tsx @@ -34,7 +34,7 @@ export const SimpleDocItem = ({ const { t } = useTranslation(); const { spacingsTokens } = useCunninghamTheme(); const { isDesktop } = useResponsiveStore(); - const { untitledDocument } = useTrans(); + const { untitledDocument } = useTrans(doc); return ( Date: Thu, 15 May 2025 08:57:53 +0200 Subject: [PATCH 035/104] =?UTF-8?q?=E2=9C=A8(frontend)=20add=20new=20SVG?= =?UTF-8?q?=20assets=20and=20skeleton=20loading=20styles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduced new SVG files for "desynchro" and "undo" icons to enhance the user interface. - Added a skeleton loading style in globals.css to improve the visual experience during content loading. --- .../docs/doc-share/assets/desynchro.svg | 5 +++++ .../features/docs/doc-share/assets/undo.svg | 5 +++++ .../apps/impress/src/pages/globals.css | 22 +++++++++++++++++++ 3 files changed, 32 insertions(+) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/assets/desynchro.svg create mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/assets/undo.svg diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/assets/desynchro.svg b/src/frontend/apps/impress/src/features/docs/doc-share/assets/desynchro.svg new file mode 100644 index 000000000..92cbf5dd5 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-share/assets/desynchro.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/assets/undo.svg b/src/frontend/apps/impress/src/features/docs/doc-share/assets/undo.svg new file mode 100644 index 000000000..4150522fd --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-share/assets/undo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/frontend/apps/impress/src/pages/globals.css b/src/frontend/apps/impress/src/pages/globals.css index c6bb8ac7a..d4a649c46 100644 --- a/src/frontend/apps/impress/src/pages/globals.css +++ b/src/frontend/apps/impress/src/pages/globals.css @@ -76,3 +76,25 @@ main ::-webkit-scrollbar-thumb:hover, nextjs-portal { display: none; } + + +.skeleton { + background: linear-gradient( + 100deg, + var(--c--theme--colors--greyscale-050) 30%, + var(--c--theme--colors--greyscale-100) 50%, + var(--c--theme--colors--greyscale-050) 70% + ); + background-size: 200% 100%; + animation: shimmer 2.5s infinite; + border-radius: 4px; +} + +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} \ No newline at end of file From 139c42f12fffad4988d3ba32d2ef376c63e0b401 Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Thu, 15 May 2025 08:59:16 +0200 Subject: [PATCH 036/104] =?UTF-8?q?=E2=9C=A8(backend)=20update=20Docker=20?= =?UTF-8?q?Hub=20workflow=20and=20fix=20migration=20conflict?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added 'feature/doc-dnd' branch to the Docker Hub workflow to support new feature development. - Created a new migration to add the 'has_deleted_children' field to the document model, enhancing the management of document states. --- .github/workflows/docker-hub.yml | 1 + ...c_and_more.py => 0022_remove_document_is_public_and_more.py} | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) rename src/backend/core/migrations/{0021_remove_document_is_public_and_more.py => 0022_remove_document_is_public_and_more.py} (80%) diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml index 5971fcfa7..dbaff0f0e 100644 --- a/.github/workflows/docker-hub.yml +++ b/.github/workflows/docker-hub.yml @@ -6,6 +6,7 @@ on: push: branches: - 'main' + - 'feature/doc-dnd' tags: - 'v*' pull_request: diff --git a/src/backend/core/migrations/0021_remove_document_is_public_and_more.py b/src/backend/core/migrations/0022_remove_document_is_public_and_more.py similarity index 80% rename from src/backend/core/migrations/0021_remove_document_is_public_and_more.py rename to src/backend/core/migrations/0022_remove_document_is_public_and_more.py index 97eaa4681..cfce2c5ed 100644 --- a/src/backend/core/migrations/0021_remove_document_is_public_and_more.py +++ b/src/backend/core/migrations/0022_remove_document_is_public_and_more.py @@ -5,7 +5,7 @@ class Migration(migrations.Migration): dependencies = [ - ("core", "0020_remove_is_public_add_field_attachments_and_duplicated_from"), + ("core", "0021_activate_unaccent_extension"), ] operations = [ From d943ba2d8f2d4aef4716d25acfabb2c800d6ee58 Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Mon, 19 May 2025 08:56:32 +0200 Subject: [PATCH 037/104] =?UTF-8?q?=E2=9C=A8(frontend)=20enhance=20documen?= =?UTF-8?q?t=20management=20types=20and=20utilities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updated the `Access` and `Doc` interfaces to include new properties for role management and document link reach. - Introduced utility functions to handle document link reach and role, improving the logic for determining access levels. - Refactored the `isOwnerOrAdmin` function to simplify role checks for document ownership and admin status. --- .../features/docs/doc-management/types.tsx | 29 ++++++++++++++++++- .../src/features/docs/doc-management/utils.ts | 26 +++++++++++++++++ .../src/features/docs/doc-tree/utils.ts | 5 ++-- .../docs/docs-grid/hooks/useDragAndDrop.tsx | 2 +- .../service-worker/plugins/ApiPlugin.ts | 15 ++++++++-- 5 files changed, 70 insertions(+), 7 deletions(-) diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx index c09b9de17..f2bdbc421 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx @@ -2,9 +2,16 @@ import { User } from '@/features/auth'; export interface Access { id: string; + max_ancestors_role: Role; role: Role; + max_role: Role; team: string; user: User; + document: { + id: string; + path: string; + depth: number; + }; abilities: { destroy: boolean; partial_update: boolean; @@ -21,10 +28,17 @@ export enum Role { OWNER = 'owner', } +export const RoleImportance = { + [Role.READER]: 1, + [Role.EDITOR]: 2, + [Role.ADMIN]: 3, + [Role.OWNER]: 4, +}; + export enum LinkReach { RESTRICTED = 'restricted', - PUBLIC = 'public', AUTHENTICATED = 'authenticated', + PUBLIC = 'public', } export enum LinkRole { @@ -43,13 +57,19 @@ export interface Doc { created_at: string; creator: string; depth: number; + path: string; is_favorite: boolean; link_reach: LinkReach; link_role: LinkRole; nb_accesses_direct: number; nb_accesses_ancestors: number; + computed_link_reach: LinkReach; + computed_link_role?: LinkRole; + ancestors_link_reach: LinkReach; + ancestors_link_role?: LinkRole; numchild: number; updated_at: string; + user_role: Role; user_roles: Role[]; abilities: { accesses_manage: boolean; @@ -73,9 +93,16 @@ export interface Doc { versions_destroy: boolean; versions_list: boolean; versions_retrieve: boolean; + link_select_options: LinkSelectOption; }; } +export interface LinkSelectOption { + public?: LinkRole[]; + authenticated?: LinkRole[]; + restricted?: LinkRole[]; +} + export enum DocDefaultFilter { ALL_DOCS = 'all_docs', MY_DOCS = 'my_docs', diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/utils.ts b/src/frontend/apps/impress/src/features/docs/doc-management/utils.ts index 2c229128e..abd7ec383 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/utils.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-management/utils.ts @@ -22,3 +22,29 @@ export const base64ToYDoc = (base64: string) => { export const base64ToBlocknoteXmlFragment = (base64: string) => { return base64ToYDoc(base64).getXmlFragment('document-store'); }; + +export const getDocLinkReach = (doc: Doc) => { + if (doc.computed_link_reach) { + return doc.computed_link_reach; + } + return doc.link_reach; +}; + +export const getDocLinkRole = (doc: Doc) => { + if (doc.computed_link_role) { + return doc.computed_link_role; + } + return doc.link_role; +}; + +export const docLinkIsDesync = (doc: Doc) => { + // If the document has no ancestors + if (!doc.ancestors_link_reach) { + return false; + } + + return ( + doc.computed_link_reach !== doc.ancestors_link_reach || + doc.computed_link_role !== doc.ancestors_link_role + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/utils.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/utils.ts index f632c2cad..a35e94105 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/utils.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/utils.ts @@ -15,9 +15,8 @@ export const subPageToTree = (children: Doc[]): TreeViewDataType[] => { }; export const isOwnerOrAdmin = (doc: Doc): boolean => { - return doc.user_roles.some( - (role) => role === Role.OWNER || role === Role.ADMIN, - ); + const userRole = doc.user_role; + return userRole === Role.OWNER || userRole === Role.ADMIN; }; export const canDrag = (doc: Doc): boolean => { diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/hooks/useDragAndDrop.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/hooks/useDragAndDrop.tsx index b2a8030cb..0725f5aaf 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/hooks/useDragAndDrop.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/hooks/useDragAndDrop.tsx @@ -21,7 +21,7 @@ export function useDragAndDrop( const [selectedDoc, setSelectedDoc] = useState(); const [canDrop, setCanDrop] = useState(); - const canDrag = selectedDoc?.user_roles.some((role) => role === Role.OWNER); + const canDrag = selectedDoc?.user_role === Role.OWNER; const mouseSensor = useSensor(MouseSensor, { activationConstraint }); const touchSensor = useSensor(TouchSensor, { activationConstraint }); diff --git a/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts index 3f3503a60..ebb88f40c 100644 --- a/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts +++ b/src/frontend/apps/impress/src/features/service-worker/plugins/ApiPlugin.ts @@ -1,7 +1,7 @@ import { WorkboxPlugin } from 'workbox-core'; import { Doc, DocsResponse } from '@/docs/doc-management'; -import { LinkReach, LinkRole } from '@/docs/doc-management/types'; +import { LinkReach, LinkRole, Role } from '@/docs/doc-management/types'; import { DBRequest, DocsDB } from '../DocsDB'; import { RequestSerializer } from '../RequestSerializer'; @@ -200,10 +200,21 @@ export class ApiPlugin implements WorkboxPlugin { versions_destroy: true, versions_list: true, versions_retrieve: true, + link_select_options: { + public: [LinkRole.READER, LinkRole.EDITOR], + authenticated: [LinkRole.READER, LinkRole.EDITOR], + restricted: undefined, + }, }, link_reach: LinkReach.RESTRICTED, link_role: LinkRole.READER, - user_roles: [], + user_roles: [Role.OWNER], + user_role: Role.OWNER, + path: '', + computed_link_reach: LinkReach.RESTRICTED, + computed_link_role: LinkRole.READER, + ancestors_link_reach: LinkReach.RESTRICTED, + ancestors_link_role: undefined, }; await DocsDB.cacheResponse( From e16846b83913c2ba621ed91b58f0b14af2def701 Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Mon, 19 May 2025 09:01:33 +0200 Subject: [PATCH 038/104] =?UTF-8?q?=E2=9C=A8(frontend)=20refactor=20docume?= =?UTF-8?q?nt=20access=20API=20and=20remove=20infinite=20query?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Simplified the `getDocAccesses` function by removing pagination parameters. - Updated the `useDocAccesses` hook to reflect changes in the API response type. - Removed the `useDocAccessesInfinite` function to streamline document access management. --- .../docs/doc-share/api/useDocAccesses.tsx | 64 +++---------------- 1 file changed, 8 insertions(+), 56 deletions(-) diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccesses.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccesses.tsx index aa65e3f7b..962a248c4 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccesses.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/api/useDocAccesses.tsx @@ -1,13 +1,6 @@ -import { - DefinedInitialDataInfiniteOptions, - InfiniteData, - QueryKey, - UseQueryOptions, - useInfiniteQuery, - useQuery, -} from '@tanstack/react-query'; +import { UseQueryOptions, useQuery } from '@tanstack/react-query'; -import { APIError, APIList, errorCauses, fetchAPI } from '@/api'; +import { APIError, errorCauses, fetchAPI } from '@/api'; import { Access } from '@/docs/doc-management'; export type DocAccessesParam = { @@ -15,18 +8,13 @@ export type DocAccessesParam = { ordering?: string; }; -export type DocAccessesAPIParams = DocAccessesParam & { - page: number; -}; - -type AccessesResponse = APIList; +export type DocAccessesAPIParams = DocAccessesParam & {}; export const getDocAccesses = async ({ - page, docId, ordering, -}: DocAccessesAPIParams): Promise => { - let url = `documents/${docId}/accesses/?page=${page}`; +}: DocAccessesAPIParams): Promise => { + let url = `documents/${docId}/accesses/`; if (ordering) { url += '&ordering=' + ordering; @@ -41,54 +29,18 @@ export const getDocAccesses = async ({ ); } - return response.json() as Promise; + return (await response.json()) as Access[]; }; export const KEY_LIST_DOC_ACCESSES = 'docs-accesses'; export function useDocAccesses( params: DocAccessesAPIParams, - queryConfig?: UseQueryOptions, + queryConfig?: UseQueryOptions, ) { - return useQuery({ + return useQuery({ queryKey: [KEY_LIST_DOC_ACCESSES, params], queryFn: () => getDocAccesses(params), ...queryConfig, }); } - -/** - * @param param Used for infinite scroll pagination - * @param queryConfig - * @returns - */ -export function useDocAccessesInfinite( - param: DocAccessesParam, - queryConfig?: DefinedInitialDataInfiniteOptions< - AccessesResponse, - APIError, - InfiniteData, - QueryKey, - number - >, -) { - return useInfiniteQuery< - AccessesResponse, - APIError, - InfiniteData, - QueryKey, - number - >({ - initialPageParam: 1, - queryKey: [KEY_LIST_DOC_ACCESSES, param], - queryFn: ({ pageParam }) => - getDocAccesses({ - ...param, - page: pageParam, - }), - getNextPageParam(lastPage, allPages) { - return lastPage.next ? allPages.length + 1 : undefined; - }, - ...queryConfig, - }); -} From c879da742dee45d3bfc31b9b878115525311f228 Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Mon, 19 May 2025 09:02:47 +0200 Subject: [PATCH 039/104] =?UTF-8?q?=E2=9C=A8(frontend)=20enhance=20documen?= =?UTF-8?q?t=20sharing=20and=20visibility=20features?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added a new component `DocInheritedShareContent` to display inherited access information for documents. - Updated `DocShareModal` to include inherited share content when applicable. - Refactored `DocRoleDropdown` to improve role selection messaging based on inherited roles. - Enhanced `DocVisibility` to manage link reach and role updates more effectively, including handling desynchronization scenarios. - Improved `DocShareMemberItem` to accommodate inherited access logic and ensure proper role management. --- .../impress/src/components/DropdownMenu.tsx | 3 + .../quick-search/QuickSearchStyle.tsx | 4 +- .../docs/doc-header/components/DocHeader.tsx | 5 +- .../docs/doc-header/components/DocToolBox.tsx | 18 +- .../components/DocInheritedShareContent.tsx | 206 ++++++++++++++++++ .../doc-share/components/DocRoleDropdown.tsx | 40 +++- .../components/DocShareMemberItem.tsx | 33 ++- .../doc-share/components/DocShareModal.tsx | 85 ++++++-- .../doc-share/components/DocVisibility.tsx | 185 +++++++++++++--- .../doc-tree/components/DocSubPageItem.tsx | 9 +- .../docs/doc-tree/components/DocTree.tsx | 22 +- .../components/DocTreeItemActions.tsx | 20 +- .../components/DocGridContentList.tsx | 9 +- 13 files changed, 538 insertions(+), 101 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/components/DocInheritedShareContent.tsx diff --git a/src/frontend/apps/impress/src/components/DropdownMenu.tsx b/src/frontend/apps/impress/src/components/DropdownMenu.tsx index 5fc3d64be..5513ccb78 100644 --- a/src/frontend/apps/impress/src/components/DropdownMenu.tsx +++ b/src/frontend/apps/impress/src/components/DropdownMenu.tsx @@ -99,6 +99,9 @@ export const DropdownMenu = ({ $size="xs" $weight="bold" $padding={{ vertical: 'xs', horizontal: 'base' }} + $css={css` + white-space: pre-line; + `} > {topMessage} diff --git a/src/frontend/apps/impress/src/components/quick-search/QuickSearchStyle.tsx b/src/frontend/apps/impress/src/components/quick-search/QuickSearchStyle.tsx index b6fa0ad6a..99bbc57fa 100644 --- a/src/frontend/apps/impress/src/components/quick-search/QuickSearchStyle.tsx +++ b/src/frontend/apps/impress/src/components/quick-search/QuickSearchStyle.tsx @@ -65,9 +65,7 @@ export const QuickSearchStyle = createGlobalStyle` [cmdk-list] { - padding: 0 var(--c--theme--spacings--base) var(--c--theme--spacings--base) - var(--c--theme--spacings--base); - + flex:1; overflow-y: auto; overscroll-behavior: contain; diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx index cae723092..bd152eb7c 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx @@ -8,6 +8,7 @@ import { LinkReach, Role, currentDocRole, + getDocLinkReach, useIsCollaborativeEditable, useTrans, } from '@/docs/doc-management'; @@ -28,8 +29,8 @@ export const DocHeader = ({ doc }: DocHeaderProps) => { const { t } = useTranslation(); const { transRole } = useTrans(); const { isEditable } = useIsCollaborativeEditable(doc); - const docIsPublic = doc.link_reach === LinkReach.PUBLIC; - const docIsAuth = doc.link_reach === LinkReach.AUTHENTICATED; + const docIsPublic = getDocLinkReach(doc) === LinkReach.PUBLIC; + const docIsAuth = getDocLinkReach(doc) === LinkReach.AUTHENTICATED; return ( <> diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx index 3cdadfabb..3ff83d179 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx @@ -1,7 +1,8 @@ +import { useTreeContext } from '@gouvfr-lasuite/ui-kit'; import { Button, useModal } from '@openfun/cunningham-react'; import { useQueryClient } from '@tanstack/react-query'; import dynamic from 'next/dynamic'; -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -23,7 +24,20 @@ const DocToolBoxLicence = dynamic(() => export const DocToolBox = ({ doc }: DocToolBoxProps) => { const { t } = useTranslation(); - const hasAccesses = doc.nb_accesses_direct > 1 && doc.abilities.accesses_view; + const treeContext = useTreeContext(); + + /** + * Following the change where there is no default owner when adding a sub-page, + * we need to handle both the case where the doc is the root and the case of sub-pages. + */ + const hasAccesses = useMemo(() => { + if (treeContext?.root?.id === doc.id) { + return doc.nb_accesses_direct > 1 && doc.abilities.accesses_view; + } + + return doc.nb_accesses_direct >= 1 && doc.abilities.accesses_view; + }, [doc, treeContext?.root]); + const queryClient = useQueryClient(); const { spacingsTokens } = useCunninghamTheme(); diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocInheritedShareContent.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocInheritedShareContent.tsx new file mode 100644 index 000000000..5d8aef9ab --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocInheritedShareContent.tsx @@ -0,0 +1,206 @@ +import { Button, Modal, ModalSize, useModal } from '@openfun/cunningham-react'; +import { Fragment, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { createGlobalStyle } from 'styled-components'; + +import { Box, StyledLink, Text } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; + +import { + Access, + RoleImportance, + useDoc, + useDocStore, +} from '../../doc-management'; +import SimpleFileIcon from '../../docs-grid/assets/simple-document.svg'; + +import { DocShareMemberItem } from './DocShareMemberItem'; +const ShareModalStyle = createGlobalStyle` + .c__modal__title { + padding-bottom: 0 !important; + } + .c__modal__scroller { + padding: 15px 15px !important; + } +`; + +type Props = { + rawAccesses: Access[]; +}; + +const getMaxRoleBetweenAccesses = (access1: Access, access2: Access) => { + const role1 = access1.max_role; + const role2 = access2.max_role; + + const roleImportance1 = RoleImportance[role1]; + const roleImportance2 = RoleImportance[role2]; + + return roleImportance1 > roleImportance2 ? role1 : role2; +}; + +export const DocInheritedShareContent = ({ rawAccesses }: Props) => { + const { t } = useTranslation(); + const { spacingsTokens } = useCunninghamTheme(); + const { currentDoc } = useDocStore(); + + const inheritedData = useMemo(() => { + if (!currentDoc || rawAccesses.length === 0) { + return null; + } + + let parentId = null; + let parentPathLength = 0; + const members: Access[] = []; + + // Find the parent document with the longest path that is different from currentDoc + for (const access of rawAccesses) { + const docPath = access.document.path; + + // Skip if it's the current document + if (access.document.id === currentDoc.id) { + continue; + } + + const findIndex = members.findIndex( + (member) => member.user.id === access.user.id, + ); + if (findIndex === -1) { + members.push(access); + } else { + const accessToUpdate = members[findIndex]; + const currentRole = accessToUpdate.max_role; + const maxRole = getMaxRoleBetweenAccesses(accessToUpdate, access); + + if (maxRole !== currentRole) { + members[findIndex] = access; + } + } + + // Check if this document has a longer path than our current candidate + if (docPath && (!parentId || docPath.length > parentPathLength)) { + parentId = access.document.id; + parentPathLength = docPath.length; + } + } + + return { parentId, members }; + }, [currentDoc, rawAccesses]); + + // Check if accesses map is empty + const hasAccesses = rawAccesses.length > 0; + + if (!hasAccesses) { + return null; + } + + return ( + + + + {t('Inherited share')} + + + {inheritedData && ( + + )} + + + ); +}; + +type DocInheritedShareContentItemProps = { + accesses: Access[]; + document_id: string; +}; +export const DocInheritedShareContentItem = ({ + accesses, + document_id, +}: DocInheritedShareContentItemProps) => { + const { t } = useTranslation(); + const { spacingsTokens } = useCunninghamTheme(); + const { data: doc, error, isLoading } = useDoc({ id: document_id }); + const errorCode = error?.status; + + const accessModal = useModal(); + if ((!doc && !isLoading && !error) || (error && errorCode !== 403)) { + return null; + } + + return ( + <> + + + + + {isLoading ? ( + + + + + ) : ( + <> + + + {error && errorCode === 403 + ? t('You do not have permission to view this document') + : (doc?.title ?? t('Untitled document'))} + + + + {t('Members of this page have access')} + + + )} + + + {!isLoading && ( + + )} + + {accessModal.isOpen && ( + + + {t('Access inherited from the parent page')} + + + } + size={ModalSize.MEDIUM} + > + + + {accesses.map((access) => ( + + + + ))} + +
+ )} + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocRoleDropdown.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocRoleDropdown.tsx index 261eb5f6f..b62db0147 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocRoleDropdown.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocRoleDropdown.tsx @@ -1,3 +1,5 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; import { DropdownMenu, DropdownMenuOption, Text } from '@/components'; @@ -18,30 +20,48 @@ export const DocRoleDropdown = ({ onSelectRole, rolesAllowed, }: DocRoleDropdownProps) => { + const { t } = useTranslation(); const { transRole, translatedRoles } = useTrans(); - if (!canUpdate) { - return ( - - {transRole(currentRole)} - - ); - } + /** + * When there is a higher role, the rolesAllowed are truncated + * We display a message to indicate that there is a higher role + */ + const topMessage = useMemo(() => { + if (!canUpdate || !rolesAllowed || rolesAllowed.length === 0) { + return message; + } + + const allRoles = Object.keys(translatedRoles); + + if (rolesAllowed.length < allRoles.length) { + let result = message ? `${message}\n\n` : ''; + result += t('This user has access inherited from a parent page.'); + return result; + } + + return message; + }, [canUpdate, rolesAllowed, translatedRoles, message, t]); const roles: DropdownMenuOption[] = Object.keys(translatedRoles).map( (key) => { return { label: transRole(key as Role), callback: () => onSelectRole?.(key as Role), - disabled: rolesAllowed && !rolesAllowed.includes(key as Role), isSelected: currentRole === (key as Role), }; }, ); - + if (!canUpdate) { + return ( + + {transRole(currentRole)} + + ); + } return ( { +export const DocShareMemberItem = ({ + doc, + access, + isInherited = false, +}: Props) => { const { t } = useTranslation(); const queryClient = useQueryClient(); - const { isLastOwner, isOtherOwner } = useWhoAmI(access); + const { isLastOwner } = useWhoAmI(access); const { toast } = useToastProvider(); const { isDesktop } = useResponsiveStore(); @@ -39,6 +44,9 @@ export const DocShareMemberItem = ({ doc, access }: Props) => { const { mutate: updateDocAccess } = useUpdateDocAccess({ onSuccess: () => { + if (!doc) { + return; + } void queryClient.invalidateQueries({ queryKey: [KEY_SUB_PAGE, { id: doc.id }], }); @@ -52,6 +60,9 @@ export const DocShareMemberItem = ({ doc, access }: Props) => { const { mutate: removeDocAccess } = useDeleteDocAccess({ onSuccess: () => { + if (!doc) { + return; + } void queryClient.invalidateQueries({ queryKey: [KEY_SUB_PAGE, { id: doc.id }], }); @@ -64,6 +75,9 @@ export const DocShareMemberItem = ({ doc, access }: Props) => { }); const onUpdate = (newRole: Role) => { + if (!doc) { + return; + } updateDocAccess({ docId: doc.id, role: newRole, @@ -72,6 +86,9 @@ export const DocShareMemberItem = ({ doc, access }: Props) => { }; const onRemove = () => { + if (!doc) { + return; + } removeDocAccess({ accessId: access.id, docId: doc.id }); }; @@ -84,6 +101,10 @@ export const DocShareMemberItem = ({ doc, access }: Props) => { }, ]; + const canUpdate = isInherited + ? false + : (doc?.abilities.accesses_manage ?? false); + return ( { right={ - {isDesktop && doc.abilities.accesses_manage && ( + {isDesktop && canUpdate && ( { setInputValue(''); }; - const membersQuery = useDocAccessesInfinite({ + const { data: membersQuery } = useDocAccesses({ docId: doc.id, }); @@ -79,10 +80,10 @@ export const DocShareModal = ({ doc, onClose }: Props) => { ); const membersData: QuickSearchData = useMemo(() => { - const members = - membersQuery.data?.pages.flatMap((page) => page.results) || []; + const members: Access[] = + membersQuery?.filter((access) => access.document.id === doc.id) ?? []; - const count = membersQuery.data?.pages[0]?.count ?? 1; + const count = doc.nb_accesses_direct > 1 ? doc.nb_accesses_direct : 1; return { groupName: @@ -92,16 +93,8 @@ export const DocShareModal = ({ doc, onClose }: Props) => { count: count, }), elements: members, - endActions: membersQuery.hasNextPage - ? [ - { - content: , - onSelect: () => void membersQuery.fetchNextPage(), - }, - ] - : undefined, }; - }, [membersQuery, t]); + }, [membersQuery, doc.id, doc.nb_accesses_direct, t]); const onFilter = useDebouncedCallback((str: string) => { setUserQuery(str); @@ -129,6 +122,18 @@ export const DocShareModal = ({ doc, onClose }: Props) => { setListHeight(height); }; + const inheritedAccesses = useMemo(() => { + return ( + membersQuery?.filter((access) => access.document.id !== doc.id) ?? [] + ); + }, [membersQuery, doc.id]); + + // const rootDoc = treeContext?.root; + const isRootDoc = false; + + const showInheritedShareContent = + inheritedAccesses.length > 0 && showMemberSection && !isRootDoc; + return ( <> { loading={searchUsersQuery.isLoading} placeholder={t('Type a name or email')} > + {inheritedAccesses.length > 0 && + showInheritedShareContent && ( + access.document.id !== doc.id, + ) ?? [] + } + /> + )} {showMemberSection ? ( 0} membersData={membersData} /> ) : ( @@ -272,22 +288,29 @@ const QuickSearchInviteInputSection = ({ }, [onSelect, searchUsersRawData, t, userQuery]); return ( - } - /> + + } + /> + ); }; interface QuickSearchMemberSectionProps { doc: Doc; membersData: QuickSearchData; + hasInheritedShareContent?: boolean; } const QuickSearchMemberSection = ({ doc, membersData, + hasInheritedShareContent = false, }: QuickSearchMemberSectionProps) => { const { t } = useTranslation(); const { data, hasNextPage, fetchNextPage } = useDocInvitationsInfinite({ @@ -311,10 +334,25 @@ const QuickSearchMemberSection = ({ }; }, [data?.pages, fetchNextPage, hasNextPage, t]); + const showSeparator = + invitationsData.elements.length > 0 && membersData.elements.length > 0; + + if ( + invitationsData.elements.length === 0 && + membersData.elements.length === 0 + ) { + return null; + } + return ( <> + {hasInheritedShareContent && } {invitationsData.elements.length > 0 && ( - + ( @@ -324,7 +362,12 @@ const QuickSearchMemberSection = ({ )} - + {showSeparator && } + + ( diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocVisibility.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocVisibility.tsx index 14fa8cc1f..2e03d0654 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocVisibility.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocVisibility.tsx @@ -1,5 +1,9 @@ -import { VariantType, useToastProvider } from '@openfun/cunningham-react'; -import { useState } from 'react'; +import { + Button, + VariantType, + useToastProvider, +} from '@openfun/cunningham-react'; +import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -17,12 +21,17 @@ import { KEY_LIST_DOC, LinkReach, LinkRole, + docLinkIsDesync, + getDocLinkReach, useUpdateDocLink, } from '@/features/docs'; import { useResponsiveStore } from '@/stores'; import { useTranslatedShareSettings } from '../hooks/'; +import Desync from './../assets/desynchro.svg'; +import Undo from './../assets/undo.svg'; + interface DocVisibilityProps { doc: Doc; } @@ -33,11 +42,19 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => { const { isDesktop } = useResponsiveStore(); const { spacingsTokens, colorsTokens } = useCunninghamTheme(); const canManage = doc.abilities.accesses_manage; - const [linkReach, setLinkReach] = useState(doc.link_reach); - const [docLinkRole, setDocLinkRole] = useState(doc.link_role); + const [linkReach, setLinkReach] = useState(getDocLinkReach(doc)); + const [docLinkRole, setDocLinkRole] = useState( + doc.computed_link_role ?? LinkRole.READER, + ); + const { linkModeTranslations, linkReachChoices, linkReachTranslations } = useTranslatedShareSettings(); + const description = + docLinkRole === LinkRole.READER + ? linkReachChoices[linkReach].descriptionReadOnly + : linkReachChoices[linkReach].descriptionEdit; + const api = useUpdateDocLink({ onSuccess: () => { toast( @@ -51,38 +68,94 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => { listInvalideQueries: [KEY_LIST_DOC, KEY_DOC], }); - const updateReach = (link_reach: LinkReach) => { - api.mutate({ id: doc.id, link_reach }); - setLinkReach(link_reach); - }; + const updateReach = useCallback( + (link_reach: LinkReach, link_role?: LinkRole) => { + const params: { + id: string; + link_reach: LinkReach; + link_role?: LinkRole; + } = { + id: doc.id, + link_reach, + }; - const updateLinkRole = (link_role: LinkRole) => { - api.mutate({ id: doc.id, link_role }); - setDocLinkRole(link_role); - }; + api.mutate(params); + setLinkReach(link_reach); + if (link_role) { + params.link_role = link_role; + setDocLinkRole(link_role); + } + }, + [api, doc.id], + ); - const linkReachOptions: DropdownMenuOption[] = Object.keys( - linkReachTranslations, - ).map((key) => ({ - label: linkReachTranslations[key as LinkReach], - icon: linkReachChoices[key as LinkReach].icon, - callback: () => updateReach(key as LinkReach), - isSelected: linkReach === (key as LinkReach), - })); - - const linkMode: DropdownMenuOption[] = Object.keys(linkModeTranslations).map( - (key) => ({ - label: linkModeTranslations[key as LinkRole], - callback: () => updateLinkRole(key as LinkRole), - isSelected: docLinkRole === (key as LinkRole), - }), + const updateLinkRole = useCallback( + (link_role: LinkRole) => { + api.mutate({ id: doc.id, link_role }); + setDocLinkRole(link_role); + }, + [api, doc.id], ); - const showLinkRoleOptions = doc.link_reach !== LinkReach.RESTRICTED; - const description = - docLinkRole === LinkRole.READER - ? linkReachChoices[linkReach].descriptionReadOnly - : linkReachChoices[linkReach].descriptionEdit; + const linkReachOptions: DropdownMenuOption[] = useMemo(() => { + return Object.values(LinkReach).map((key) => { + const isDisabled = + doc.abilities.link_select_options[key as LinkReach] === undefined; + + return { + label: linkReachTranslations[key as LinkReach], + callback: () => updateReach(key as LinkReach), + isSelected: linkReach === (key as LinkReach), + disabled: isDisabled, + }; + }); + }, [doc, linkReach, linkReachTranslations, updateReach]); + + const haveDisabledOptions = linkReachOptions.some( + (option) => option.disabled, + ); + + const showLinkRoleOptions = doc.computed_link_reach !== LinkReach.RESTRICTED; + + const linkRoleOptions: DropdownMenuOption[] = useMemo(() => { + const options = doc.abilities.link_select_options[linkReach] ?? []; + return Object.values(LinkRole).map((key) => { + const isDisabled = !options.includes(key); + return { + label: linkModeTranslations[key], + callback: () => updateLinkRole(key), + isSelected: docLinkRole === key, + disabled: isDisabled, + }; + }); + }, [doc, docLinkRole, linkModeTranslations, updateLinkRole, linkReach]); + + const haveDisabledLinkRoleOptions = linkRoleOptions.some( + (option) => option.disabled, + ); + + const undoDesync = () => { + const params: { + id: string; + link_reach: LinkReach; + link_role?: LinkRole; + } = { + id: doc.id, + link_reach: doc.ancestors_link_reach, + }; + if (doc.ancestors_link_role) { + params.link_role = doc.ancestors_link_role; + } + api.mutate(params); + setLinkReach(doc.ancestors_link_reach); + if (doc.ancestors_link_role) { + setDocLinkRole(doc.ancestors_link_role); + } + }; + + const showDesync = useMemo(() => { + return docLinkIsDesync(doc); + }, [doc]); return ( { {t('Link parameters')} + {showDesync && ( + + + + + {t('Sharing rules differ from the parent page')} + + + {doc.abilities.accesses_manage && ( + + )} + + )} { `} disabled={!canManage} showArrow={true} + topMessage={ + haveDisabledOptions + ? t( + 'You cannot restrict access to a subpage relative to its parent page.', + ) + : undefined + } options={linkReachOptions} > @@ -145,7 +257,14 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => { diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx index 8cb305fc3..a49db979e 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx @@ -39,7 +39,6 @@ export const DocSubPageItem = (props: Props) => { const { spacingsTokens } = useCunninghamTheme(); const [isHover, setIsHover] = useState(false); - const spacing = spacingsTokens(); const router = useRouter(); const { togglePanel } = useLeftPanelStore(); @@ -74,8 +73,9 @@ export const DocSubPageItem = (props: Props) => { .then((allChildren) => { node.open(); - router.push(`/docs/${doc.id}`); + router.push(`/docs/${createdDoc.id}`); treeContext?.treeData.setChildren(node.data.value.id, allChildren); + treeContext?.treeData.setSelectedNode(createdDoc); togglePanel(); }) .catch(console.error); @@ -89,6 +89,7 @@ export const DocSubPageItem = (props: Props) => { treeContext?.treeData.addChild(node.data.value.id, newDoc); node.open(); router.push(`/docs/${createdDoc.id}`); + treeContext?.treeData.setSelectedNode(newDoc); togglePanel(); } }; @@ -115,7 +116,7 @@ export const DocSubPageItem = (props: Props) => { data-testid={`doc-sub-page-item-${props.node.data.value.id}`} $width="100%" $direction="row" - $gap={spacing['xs']} + $gap={spacingsTokens['xs']} role="button" tabIndex={0} $align="center" @@ -139,7 +140,7 @@ export const DocSubPageItem = (props: Props) => { {doc.title || untitledDocument} - {doc.nb_accesses_direct > 1 && ( + {doc.nb_accesses_direct >= 1 && ( { const { spacingsTokens } = useCunninghamTheme(); - const spacing = spacingsTokens(); + const treeContext = useTreeContext(); const { currentDoc } = useDocStore(); const router = useRouter(); @@ -134,11 +134,25 @@ export const DocTree = ({ initialTargetId }: DocTreeProps) => { } return ( - - + + { - onCreateSuccess?.(doc); - togglePanel(); - router.push(`/docs/${doc.id}`); - treeContext?.treeData.setSelectedNode(doc); + onSuccess: (newDoc) => { + onCreateSuccess?.(newDoc); }, }); const afterDelete = () => { if (parentId) { treeContext?.treeData.deleteNode(doc.id); - router.push(`/docs/${parentId}`); + void router.push(`/docs/${parentId}`); } else if (doc.id === treeContext?.root?.id && !parentId) { - router.push(`/docs/`); + void router.push(`/docs/`); } else if (treeContext && treeContext.root) { treeContext?.treeData.deleteNode(doc.id); - router.push(`/docs/${treeContext.root.id}`); + void router.push(`/docs/${treeContext.root.id}`); } }; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx index 6d03cac77..ea756924d 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx @@ -152,10 +152,11 @@ export const DraggableDocGridItem = ({ canDrag, updateCanDrop, }: DocGridItemProps) => { - const canDropItem = doc.user_roles.some( - (role) => - role === Role.ADMIN || role === Role.OWNER || role === Role.EDITOR, - ); + const userRole = doc.user_role; + const canDropItem = + userRole === Role.ADMIN || + userRole === Role.OWNER || + userRole === Role.EDITOR; return ( Date: Mon, 19 May 2025 09:03:00 +0200 Subject: [PATCH 040/104] =?UTF-8?q?=E2=9C=A8(frontend)=20enhance=20documen?= =?UTF-8?q?t=20sharing=20and=20access=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduced new utility functions for managing document sharing, including `searchUserToInviteToDoc`, `addMemberToDoc`, and `updateShareLink`. - Updated existing tests to verify inherited share access and link visibility features. - Refactored document access handling in tests to improve clarity and maintainability. - Added comprehensive tests for inherited share functionalities, ensuring proper role and access management for subpages. --- .../apps/e2e/__tests__/app-impress/common.ts | 83 ++++--- .../app-impress/doc-grid-dnd.spec.ts | 3 + .../__tests__/app-impress/doc-grid.spec.ts | 1 + .../__tests__/app-impress/doc-header.spec.ts | 8 +- .../app-impress/doc-inherited-share.spec.ts | 208 ++++++++++++++++++ .../app-impress/doc-member-list.spec.ts | 93 ++++---- .../__tests__/app-impress/doc-search.spec.ts | 5 +- .../e2e/__tests__/app-impress/share-utils.ts | 158 +++++++++++++ .../__tests__/app-impress/sub-pages-utils.ts | 76 +++++++ .../apps/impress/src/pages/globals.css | 4 +- 10 files changed, 566 insertions(+), 73 deletions(-) create mode 100644 src/frontend/apps/e2e/__tests__/app-impress/doc-inherited-share.spec.ts create mode 100644 src/frontend/apps/e2e/__tests__/app-impress/share-utils.ts create mode 100644 src/frontend/apps/e2e/__tests__/app-impress/sub-pages-utils.ts diff --git a/src/frontend/apps/e2e/__tests__/app-impress/common.ts b/src/frontend/apps/e2e/__tests__/app-impress/common.ts index 380c3c970..7d395d8a2 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/common.ts @@ -188,7 +188,26 @@ export const goToGridDoc = async ( return docTitle as string; }; -export const mockedDocument = async (page: Page, json: object) => { +export const updateDocTitle = async (page: Page, title: string) => { + const input = page.getByLabel('doc title input'); + await expect(input).toBeVisible(); + await expect(input).toHaveText(''); + await input.click(); + await input.fill(title); + await input.click(); + await verifyDocName(page, title); +}; + +export const getWaitForCreateDoc = (page: Page) => { + return page.waitForResponse( + (response) => + response.url().includes('/documents/') && + response.url().includes('/children/') && + response.request().method() === 'POST', + ); +}; + +export const mockedDocument = async (page: Page, data: object) => { await page.route('**/documents/**/', async (route) => { const request = route.request(); if ( @@ -203,7 +222,7 @@ export const mockedDocument = async (page: Page, json: object) => { id: 'mocked-document-id', content: '', title: 'Mocked document', - accesses: [], + path: '000000', abilities: { destroy: false, // Means not owner link_configuration: false, @@ -214,11 +233,21 @@ export const mockedDocument = async (page: Page, json: object) => { update: false, partial_update: false, // Means not editor retrieve: true, + link_select_options: { + public: ['reader', 'editor'], + authenticated: ['reader', 'editor'], + restricted: null, + }, }, link_reach: 'restricted', + computed_link_reach: 'restricted', + computed_link_role: 'reader', + ancestors_link_reach: null, + ancestors_link_role: null, created_at: '2021-09-01T09:00:00Z', + user_role: 'owner', user_roles: ['owner'], - ...json, + ...data, }, }); } else { @@ -291,30 +320,32 @@ export const mockedAccesses = async (page: Page, json?: object) => { request.url().includes('page=') ) { await route.fulfill({ - json: { - count: 1, - next: null, - previous: null, - results: [ - { - id: 'bc8bbbc5-a635-4f65-9817-fd1e9ec8ef87', - user: { - id: 'b4a21bb3-722e-426c-9f78-9d190eda641c', - email: 'test@accesses.test', - }, - team: '', - role: 'reader', - abilities: { - destroy: true, - update: true, - partial_update: true, - retrieve: true, - set_role_to: ['administrator', 'editor'], - }, - ...json, + json: [ + { + id: 'bc8bbbc5-a635-4f65-9817-fd1e9ec8ef87', + user: { + id: 'b4a21bb3-722e-426c-9f78-9d190eda641c', + email: 'test@accesses.test', }, - ], - }, + team: '', + max_ancestors_role: null, + max_role: 'reader', + role: 'reader', + document: { + id: 'mocked-document-id', + path: '000000', + depth: 1, + }, + abilities: { + destroy: true, + update: true, + partial_update: true, + retrieve: true, + set_role_to: ['administrator', 'editor'], + }, + ...json, + }, + ], }); } else { await route.continue(); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts index 924d3db5f..3bac538cf 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts @@ -212,6 +212,7 @@ const data = [ title: 'Can drop and drag', updated_at: '2025-03-14T14:45:27.699542Z', user_roles: ['owner'], + user_role: 'owner', }, { id: 'can-only-drop', @@ -260,6 +261,7 @@ const data = [ updated_at: '2025-03-14T14:45:27.699542Z', user_roles: ['editor'], + user_role: 'editor', }, { id: 'no-drop-and-no-drag', @@ -307,5 +309,6 @@ const data = [ title: 'No drop and no drag', updated_at: '2025-03-14T14:44:16.032774Z', user_roles: ['reader'], + user_role: 'reader', }, ]; diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts index 71083ef52..6a90eaf21 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts @@ -170,6 +170,7 @@ test.describe('Document grid item options', () => { link_reach: 'restricted', created_at: '2021-09-01T09:00:00Z', user_roles: ['editor'], + user_role: 'editor', }, ], }, diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts index ba33ddb0f..a0d4ebcf3 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts @@ -54,6 +54,7 @@ test.describe('Doc Header', () => { retrieve: true, }, link_reach: 'public', + computed_link_reach: 'public', created_at: '2021-09-01T09:00:00Z', }); @@ -135,6 +136,11 @@ test.describe('Doc Header', () => { versions_list: true, versions_retrieve: true, update: true, + link_select_options: { + public: ['reader', 'editor'], + authenticated: ['reader', 'editor'], + restricted: null, + }, partial_update: true, retrieve: true, }, @@ -160,7 +166,7 @@ test.describe('Doc Header', () => { await expect(shareModal).toBeVisible(); await expect(page.getByText('Share the document')).toBeVisible(); - await expect(page.getByPlaceholder('Type a name or email')).toBeVisible(); + // await expect(page.getByPlaceholder('Type a name or email')).toBeVisible(); const invitationCard = shareModal.getByLabel('List invitation card'); await expect(invitationCard).toBeVisible(); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-inherited-share.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-inherited-share.spec.ts new file mode 100644 index 000000000..f9e3e0a6c --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-inherited-share.spec.ts @@ -0,0 +1,208 @@ +import { expect, test } from '@playwright/test'; + +import { createDoc } from './common'; +import { + addMemberToDoc, + searchUserToInviteToDoc, + updateShareLink, + verifyLinkReachIsDisabled, + verifyLinkReachIsEnabled, + verifyLinkRoleIsDisabled, + verifyLinkRoleIsEnabled, + verifyMemberAddedToDoc, +} from './share-utils'; +import { createRootSubPage, createSubPageFromParent } from './sub-pages-utils'; + +test.describe('Inherited share accesses', () => { + test('Vérifie l’héritage des accès', async ({ page, browserName }) => { + await page.goto('/'); + const [titleParent] = await createDoc(page, 'root-doc', browserName, 1); + const docTree = page.getByTestId('doc-tree'); + + const addButton = page.getByRole('button', { name: 'New page' }); + // Wait for and intercept the POST request to create a new page + const responsePromise = page.waitForResponse( + (response) => + response.url().includes('/documents/') && + response.url().includes('/children/') && + response.request().method() === 'POST', + ); + await addButton.click(); + + const response = await responsePromise; + expect(response.ok()).toBeTruthy(); + const subPageJson = await response.json(); + + await expect(docTree).toBeVisible(); + const subPageItem = docTree + .getByTestId(`doc-sub-page-item-${subPageJson.id}`) + .first(); + + await expect(subPageItem).toBeVisible(); + await subPageItem.click(); + await page.getByRole('button', { name: 'Share' }).click(); + await expect(page.getByText('Inherited share')).toBeVisible(); + await expect(page.getByRole('link', { name: titleParent })).toBeVisible(); + await page.getByRole('button', { name: 'See access' }).click(); + await expect(page.getByText('Access inherited from the')).toBeVisible(); + const user = page.getByTestId( + `doc-share-member-row-user@${browserName}.e2e`, + ); + await expect(user).toBeVisible(); + await expect(user.getByText('E2E Chromium')).toBeVisible(); + await expect(user.getByText('Owner')).toBeVisible(); + }); + + test('Vérifie le message si il y a un accès hérité', async ({ + page, + browserName, + }) => { + await page.goto('/'); + await createDoc(page, 'root-doc', browserName, 1); + + // Search user to add + let users = await searchUserToInviteToDoc(page); + let userToAdd = users[0]; + + // Add user as Administrator in root doc + await addMemberToDoc(page, 'Administrator', [userToAdd]); + await verifyMemberAddedToDoc(page, userToAdd, 'Administrator'); + await page.getByRole('button', { name: 'OK' }).click(); + + // Create sub page + const { name: subPageName, item: subPageJson } = await createRootSubPage( + page, + browserName, + 'sub-page', + ); + + // Add user as Editor in sub page + users = await searchUserToInviteToDoc(page); + userToAdd = users[0]; + await addMemberToDoc(page, 'Editor', [userToAdd]); + const userRow = await verifyMemberAddedToDoc(page, userToAdd, 'Editor'); + await userRow.getByRole('button', { name: 'doc-role-dropdown' }).click(); + await page.getByText('This user has access').click(); + await userRow.click(); + await page.getByRole('button', { name: 'OK' }).click(); + + // Add new sub page to sub page + await createSubPageFromParent( + page, + browserName, + subPageJson.id, + 'sub-page-2', + ); + + // // Check sub page inherited share + await page.getByRole('button', { name: 'Share' }).click(); + await expect(page.getByText('Inherited share')).toBeVisible(); + await expect(page.getByRole('link', { name: subPageName })).toBeVisible(); + await page.getByRole('button', { name: 'See access' }).click(); + await expect(page.getByText('Access inherited from the')).toBeVisible(); + const user = page.getByTestId(`doc-share-member-row-${userToAdd.email}`); + await expect(user).toBeVisible(); + await expect(user.getByText('Administrator')).toBeVisible(); + }); +}); + +test.describe('Inherited share link', () => { + test('Vérifie si le lien est bien hérité', async ({ page, browserName }) => { + await page.goto('/'); + // Create root doc + await createDoc(page, 'root-doc', browserName, 1); + + // Update share link + await page.getByRole('button', { name: 'Share' }).click(); + await updateShareLink(page, 'Connected', 'Reading'); + await page.getByRole('button', { name: 'OK' }).click(); + + // Create sub page + await createRootSubPage(page, browserName, 'sub-page'); + + // // verify share link is restricted and reader + await page.getByRole('button', { name: 'Share' }).click(); + await expect(page.getByText('Inherited share')).toBeVisible(); + // await verifyShareLink(page, 'Connected', 'Reading'); + }); + + test('Vérification du message de warning lorsque les règles de partage diffèrent', async ({ + page, + browserName, + }) => { + await page.goto('/'); + // Create root doc + await createDoc(page, 'root-doc', browserName, 1); + + // Update share link + await page.getByRole('button', { name: 'Share' }).click(); + await updateShareLink(page, 'Connected', 'Reading'); + await page.getByRole('button', { name: 'OK' }).click(); + + // Create sub page + await createRootSubPage(page, browserName, 'sub-page'); + await page.getByRole('button', { name: 'Share' }).click(); + + // Update share link to public and edition + await updateShareLink(page, 'Public', 'Edition'); + await expect(page.getByText('Sharing rules differ from the')).toBeVisible(); + const restoreButton = page.getByRole('button', { name: 'Restore' }); + await expect(restoreButton).toBeVisible(); + await restoreButton.click(); + await expect( + page.getByText('The document visibility has been updated').first(), + ).toBeVisible(); + await expect(page.getByText('Sharing rules differ from the')).toBeHidden(); + }); + + test('Vérification des possibilités de liens hérités', async ({ + page, + browserName, + }) => { + await page.goto('/'); + // Create root doc + await createDoc(page, 'root-doc', browserName, 1); + + // Update share link + await page.getByRole('button', { name: 'Share' }).click(); + await updateShareLink(page, 'Connected', 'Reading'); + await page.getByRole('button', { name: 'OK' }).click(); + await expect( + page.getByText('Document accessible to any connected person'), + ).toBeVisible(); + + // Create sub page + const { item: subPageItem } = await createRootSubPage( + page, + browserName, + 'sub-page', + ); + await expect( + page.getByText('Document accessible to any connected person'), + ).toBeVisible(); + + // Update share link to public and edition + await page.getByRole('button', { name: 'Share' }).click(); + await verifyLinkReachIsDisabled(page, 'Private'); + await updateShareLink(page, 'Public', 'Edition'); + await page.getByRole('button', { name: 'OK' }).click(); + await expect(page.getByText('Public document')).toBeVisible(); + + // Create sub page + await createSubPageFromParent( + page, + browserName, + subPageItem.id, + 'sub-page-2', + ); + await expect(page.getByText('Public document')).toBeVisible(); + + // Verify share link and role + await page.getByRole('button', { name: 'Share' }).click(); + await verifyLinkReachIsDisabled(page, 'Private'); + await verifyLinkReachIsDisabled(page, 'Connected'); + await verifyLinkReachIsEnabled(page, 'Public'); + await verifyLinkRoleIsDisabled(page, 'Reading'); + await verifyLinkRoleIsEnabled(page, 'Edition'); + }); +}); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-member-list.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-member-list.spec.ts index 45f68b78c..3c2ed6b65 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-member-list.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-member-list.spec.ts @@ -8,47 +8,59 @@ test.beforeEach(async ({ page }) => { test.describe('Document list members', () => { test('it checks a big list of members', async ({ page }) => { - await page.route( - /.*\/documents\/.*\/accesses\/\?page=.*/, - async (route) => { - const request = route.request(); - const url = new URL(request.url()); - const pageId = url.searchParams.get('page') ?? '1'; - - const accesses = { - count: 40, - next: +pageId < 2 ? 'http://anything/?page=2' : undefined, - previous: null, - results: Array.from({ length: 20 }, (_, i) => ({ - id: `2ff1ec07-86c1-4534-a643-f41824a6c53a-${pageId}-${i}`, - user: { - id: `fc092149-cafa-4ffa-a29d-e4b18af751-${pageId}-${i}`, - email: `impress@impress.world-page-${pageId}-${i}`, - full_name: `Impress World Page ${pageId}-${i}`, - }, - team: '', - role: 'editor', - abilities: { - destroy: false, - partial_update: true, - set_role_to: [], - }, - })), - }; - - if (request.method().includes('GET')) { - await route.fulfill({ - json: accesses, - }); - } else { - await route.continue(); - } - }, - ); - const docTitle = await goToGridDoc(page); await verifyDocName(page, docTitle); + // Get the current URL and extract the last part + const currentUrl = page.url(); + console.log('Current URL:', currentUrl); + const currentDocId = (() => { + // Remove trailing slash if present + const cleanUrl = currentUrl.endsWith('/') + ? currentUrl.slice(0, -1) + : currentUrl; + + // Split by '/' and get the last part + return cleanUrl.split('/').pop() || ''; + })(); + + await page.route('**/documents/**/accesses/', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const pageId = url.searchParams.get('page') ?? '1'; + + const accesses = Array.from({ length: 20 }, (_, i) => ({ + id: `2ff1ec07-86c1-4534-a643-f41824a6c53a-${pageId}-${i}`, + document: { + id: currentDocId, + name: `Doc ${pageId}-${i}`, + path: `0000.${pageId}-${i}`, + }, + user: { + id: `fc092149-cafa-4ffa-a29d-e4b18af751-${pageId}-${i}`, + email: `impress@impress.world-page-${pageId}-${i}`, + full_name: `Impress World Page ${pageId}-${i}`, + }, + team: '', + role: 'editor', + max_ancestors_role: null, + max_role: 'editor', + abilities: { + destroy: false, + partial_update: true, + set_role_to: ['administrator', 'editor'], + }, + })); + + if (request.method().includes('GET')) { + await route.fulfill({ + json: accesses, + }); + } else { + await route.continue(); + } + }); + await page.getByRole('button', { name: 'Share' }).click(); const prefix = 'doc-share-member-row'; @@ -56,11 +68,6 @@ test.describe('Document list members', () => { const loadMore = page.getByTestId('load-more-members'); await expect(elements).toHaveCount(20); - await expect(page.getByText(`Impress World Page 1-16`)).toBeVisible(); - - await loadMore.click(); - await expect(elements).toHaveCount(40); - await expect(page.getByText(`Impress World Page 2-15`)).toBeVisible(); await expect(loadMore).toBeHidden(); }); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts index 73534b273..e9f606773 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts @@ -25,7 +25,10 @@ test.describe('Document search', () => { ); await verifyDocName(page, doc2Title); await page.goto('/'); - await page.getByRole('button', { name: 'search' }).click(); + await page + .getByTestId('left-panel-desktop') + .getByRole('button', { name: 'search' }) + .click(); await expect( page.getByRole('img', { name: 'No active search' }), diff --git a/src/frontend/apps/e2e/__tests__/app-impress/share-utils.ts b/src/frontend/apps/e2e/__tests__/app-impress/share-utils.ts new file mode 100644 index 000000000..91062731b --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/share-utils.ts @@ -0,0 +1,158 @@ +import { Locator, Page, expect } from '@playwright/test'; + +export type UserSearchResult = { + email: string; + full_name?: string | null; +}; + +export type Role = 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader'; +export type LinkReach = 'Private' | 'Connected' | 'Public'; +export type LinkRole = 'Reading' | 'Edition'; + +export const searchUserToInviteToDoc = async ( + page: Page, + inputFill?: string, +): Promise => { + const inputFillValue = inputFill ?? 'user '; + + const responsePromise = page.waitForResponse( + (response) => + response + .url() + .includes(`/users/?q=${encodeURIComponent(inputFillValue)}`) && + response.status() === 200, + ); + + await page.getByRole('button', { name: 'Share' }).click(); + const inputSearch = page.getByRole('combobox', { + name: 'Quick search input', + }); + await expect(inputSearch).toBeVisible(); + await inputSearch.fill(inputFillValue); + const response = await responsePromise; + const users = (await response.json()) as UserSearchResult[]; + return users; +}; + +export const addMemberToDoc = async ( + page: Page, + role: Role, + users: UserSearchResult[], +) => { + const list = page.getByTestId('doc-share-add-member-list'); + await expect(list).toBeHidden(); + const quickSearchContent = page.getByTestId('doc-share-quick-search'); + for (const user of users) { + await quickSearchContent + .getByTestId(`search-user-row-${user.email}`) + .click(); + } + + await list.getByLabel('doc-role-dropdown').click(); + await expect(page.getByLabel(role)).toBeVisible(); + await page.getByLabel(role).click(); + await page.getByRole('button', { name: 'Invite' }).click(); +}; + +export const verifyMemberAddedToDoc = async ( + page: Page, + user: UserSearchResult, + role: Role, +): Promise => { + const container = page.getByLabel('List members card'); + await expect(container).toBeVisible(); + const userRow = container.getByTestId(`doc-share-member-row-${user.email}`); + await expect(userRow).toBeVisible(); + await expect(userRow.getByText(role)).toBeVisible(); + await expect(userRow.getByText(user.full_name || user.email)).toBeVisible(); + return userRow; +}; + +export const updateShareLink = async ( + page: Page, + linkReach: LinkReach, + linkRole?: LinkRole | null, +) => { + await page.getByRole('button', { name: 'Visibility', exact: true }).click(); + await page.getByRole('menuitem', { name: linkReach }).click(); + + const visibilityUpdatedText = page + .getByText('The document visibility has been updated') + .first(); + + await expect(visibilityUpdatedText).toBeVisible(); + + if (linkRole) { + await page + .getByRole('button', { name: 'Visibility mode', exact: true }) + .click(); + await page.getByRole('menuitem', { name: linkRole }).click(); + await expect(visibilityUpdatedText).toBeVisible(); + } +}; + +export const verifyLinkReachIsDisabled = async ( + page: Page, + linkReach: LinkReach, +) => { + await page.getByRole('button', { name: 'Visibility', exact: true }).click(); + const item = page.getByRole('menuitem', { name: linkReach }); + await expect(item).toBeDisabled(); + await page.click('body'); +}; + +export const verifyLinkReachIsEnabled = async ( + page: Page, + linkReach: LinkReach, +) => { + await page.getByRole('button', { name: 'Visibility', exact: true }).click(); + const item = page.getByRole('menuitem', { name: linkReach }); + await expect(item).toBeEnabled(); + await page.click('body'); +}; + +export const verifyLinkRoleIsDisabled = async ( + page: Page, + linkRole: LinkRole, +) => { + await page + .getByRole('button', { name: 'Visibility mode', exact: true }) + .click(); + const item = page.getByRole('menuitem', { name: linkRole }); + await expect(item).toBeDisabled(); + await page.click('body'); +}; + +export const verifyLinkRoleIsEnabled = async ( + page: Page, + linkRole: LinkRole, +) => { + await page + .getByRole('button', { name: 'Visibility mode', exact: true }) + .click(); + const item = page.getByRole('menuitem', { name: linkRole }); + await expect(item).toBeEnabled(); + await page.click('body'); +}; + +export const verifyShareLink = async ( + page: Page, + linkReach: LinkReach, + linkRole?: LinkRole | null, +) => { + const visibilityDropdownButton = page.getByRole('button', { + name: 'Visibility', + exact: true, + }); + await expect(visibilityDropdownButton).toBeVisible(); + await expect(visibilityDropdownButton.getByText(linkReach)).toBeVisible(); + + if (linkRole) { + const visibilityModeButton = page.getByRole('button', { + name: 'Visibility mode', + exact: true, + }); + await expect(visibilityModeButton).toBeVisible(); + await expect(page.getByText(linkRole)).toBeVisible(); + } +}; diff --git a/src/frontend/apps/e2e/__tests__/app-impress/sub-pages-utils.ts b/src/frontend/apps/e2e/__tests__/app-impress/sub-pages-utils.ts new file mode 100644 index 000000000..ca0a78b60 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/sub-pages-utils.ts @@ -0,0 +1,76 @@ +import { Page, expect } from '@playwright/test'; + +import { getWaitForCreateDoc, randomName, updateDocTitle } from './common'; + +export const createRootSubPage = async ( + page: Page, + browserName: string, + docName: string, +) => { + // Get add button + const addButton = page.getByRole('button', { name: 'New page' }); + + // Get response + const responsePromise = getWaitForCreateDoc(page); + await addButton.click(); + const response = await responsePromise; + expect(response.ok()).toBeTruthy(); + const subPageJson = (await response.json()) as { id: string }; + + // Get doc tree + const docTree = page.getByTestId('doc-tree'); + await expect(docTree).toBeVisible(); + + // Get sub page item + const subPageItem = docTree + .getByTestId(`doc-sub-page-item-${subPageJson.id}`) + .first(); + await expect(subPageItem).toBeVisible(); + await subPageItem.click(); + + // Update sub page name + const randomDocs = randomName(docName, browserName, 1); + await updateDocTitle(page, randomDocs[0]); + + // Return sub page data + return { name: randomDocs[0], docTreeItem: subPageItem, item: subPageJson }; +}; + +export const createSubPageFromParent = async ( + page: Page, + browserName: string, + parentId: string, + subPageName: string, +) => { + // Get parent doc tree item + const parentDocTreeItem = page.getByTestId(`doc-sub-page-item-${parentId}`); + await expect(parentDocTreeItem).toBeVisible(); + await parentDocTreeItem.hover(); + + // Create sub page + const responsePromise = getWaitForCreateDoc(page); + await parentDocTreeItem.getByRole('button', { name: 'add_box' }).click(); + + // Get response + const response = await responsePromise; + expect(response.ok()).toBeTruthy(); + const subPageJson = (await response.json()) as { id: string }; + + // Get doc tree + const docTree = page.getByTestId('doc-tree'); + await expect(docTree).toBeVisible(); + + // Get sub page item + const subPageItem = docTree + .getByTestId(`doc-sub-page-item-${subPageJson.id}`) + .first(); + await expect(subPageItem).toBeVisible(); + await subPageItem.click(); + + // Update sub page name + const subPageTitle = randomName(subPageName, browserName, 1)[0]; + await updateDocTitle(page, subPageTitle); + + // Return sub page data + return { name: subPageTitle, docTreeItem: subPageItem, item: subPageJson }; +}; diff --git a/src/frontend/apps/impress/src/pages/globals.css b/src/frontend/apps/impress/src/pages/globals.css index d4a649c46..c3fbaabea 100644 --- a/src/frontend/apps/impress/src/pages/globals.css +++ b/src/frontend/apps/impress/src/pages/globals.css @@ -77,7 +77,6 @@ nextjs-portal { display: none; } - .skeleton { background: linear-gradient( 100deg, @@ -94,7 +93,8 @@ nextjs-portal { 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } -} \ No newline at end of file +} From c10ae983f760403923aa3347bd1122701b60d547 Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Tue, 20 May 2025 10:00:26 +0200 Subject: [PATCH 041/104] =?UTF-8?q?=E2=9C=A8(frontend)=20update=20test=20d?= =?UTF-8?q?escriptions=20for=20clarity=20and=20consistency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - update tests description - Corrected minor typos in test descriptions to enhance readability. - Ensured that all test cases clearly convey their purpose and expected outcomes. --- .../apps/e2e/__tests__/app-impress/common.ts | 15 ++++++++++++--- .../__tests__/app-impress/doc-grid-dnd.spec.ts | 6 +++--- .../e2e/__tests__/app-impress/doc-header.spec.ts | 5 ----- .../app-impress/doc-inherited-share.spec.ts | 10 +++++----- .../e2e/__tests__/app-impress/doc-search.spec.ts | 2 +- .../e2e/__tests__/app-impress/doc-tree.spec.ts | 2 +- .../features/docs/doc-tree/components/DocTree.tsx | 2 +- 7 files changed, 23 insertions(+), 19 deletions(-) diff --git a/src/frontend/apps/e2e/__tests__/app-impress/common.ts b/src/frontend/apps/e2e/__tests__/app-impress/common.ts index 7d395d8a2..0aa88f166 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/common.ts @@ -217,6 +217,9 @@ export const mockedDocument = async (page: Page, data: object) => { !request.url().includes('accesses') && !request.url().includes('invitations') ) { + const { abilities, ...rest } = data as unknown as { + abilities?: Record; + }; await route.fulfill({ json: { id: 'mocked-document-id', @@ -238,6 +241,7 @@ export const mockedDocument = async (page: Page, data: object) => { authenticated: ['reader', 'editor'], restricted: null, }, + ...abilities, }, link_reach: 'restricted', computed_link_reach: 'restricted', @@ -247,7 +251,7 @@ export const mockedDocument = async (page: Page, data: object) => { created_at: '2021-09-01T09:00:00Z', user_role: 'owner', user_roles: ['owner'], - ...data, + ...rest, }, }); } else { @@ -314,10 +318,10 @@ export const mockedInvitations = async (page: Page, json?: object) => { export const mockedAccesses = async (page: Page, json?: object) => { await page.route('**/accesses/**/', async (route) => { const request = route.request(); + console.log('oui'); if ( request.method().includes('GET') && - request.url().includes('accesses') && - request.url().includes('page=') + request.url().includes('accesses') ) { await route.fulfill({ json: [ @@ -341,6 +345,11 @@ export const mockedAccesses = async (page: Page, json?: object) => { update: true, partial_update: true, retrieve: true, + link_select_options: { + public: ['reader', 'editor'], + authenticated: ['reader', 'editor'], + restricted: null, + }, set_role_to: ['administrator', 'editor'], }, ...json, diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts index 3bac538cf..32c43c2fe 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts @@ -50,7 +50,7 @@ test.describe('Doc grid dnd', () => { await page.mouse.move( dropZoneBoundingBox.x + dropZoneBoundingBox.width / 2, dropZoneBoundingBox.y + dropZoneBoundingBox.height / 2, - { steps: 10 }, // Rendre le mouvement plus fluide + { steps: 10 }, // Make the movement smoother ); const dragOverlay = page.getByTestId('drag-doc-overlay'); @@ -62,7 +62,7 @@ test.describe('Doc grid dnd', () => { await expect(dragOverlay).toBeHidden(); }); - test('it checks cant drop when we have not the minimum role', async ({ + test("it checks can't drop when we have not the minimum role", async ({ page, }) => { await mockedListDocs(page, data); @@ -113,7 +113,7 @@ test.describe('Doc grid dnd', () => { await page.mouse.up(); }); - test('it checks cant drag when we have not the minimum role', async ({ + test("it checks can't drag when we have not the minimum role", async ({ page, }) => { await mockedListDocs(page, data); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts index a0d4ebcf3..b6fd33810 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts @@ -136,11 +136,6 @@ test.describe('Doc Header', () => { versions_list: true, versions_retrieve: true, update: true, - link_select_options: { - public: ['reader', 'editor'], - authenticated: ['reader', 'editor'], - restricted: null, - }, partial_update: true, retrieve: true, }, diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-inherited-share.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-inherited-share.spec.ts index f9e3e0a6c..8d48abc58 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-inherited-share.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-inherited-share.spec.ts @@ -14,7 +14,7 @@ import { import { createRootSubPage, createSubPageFromParent } from './sub-pages-utils'; test.describe('Inherited share accesses', () => { - test('Vérifie l’héritage des accès', async ({ page, browserName }) => { + test('it checks inherited accesses', async ({ page, browserName }) => { await page.goto('/'); const [titleParent] = await createDoc(page, 'root-doc', browserName, 1); const docTree = page.getByTestId('doc-tree'); @@ -53,7 +53,7 @@ test.describe('Inherited share accesses', () => { await expect(user.getByText('Owner')).toBeVisible(); }); - test('Vérifie le message si il y a un accès hérité', async ({ + test('it checks that the highest role is displayed', async ({ page, browserName, }) => { @@ -107,7 +107,7 @@ test.describe('Inherited share accesses', () => { }); test.describe('Inherited share link', () => { - test('Vérifie si le lien est bien hérité', async ({ page, browserName }) => { + test('it checks if the link is inherited', async ({ page, browserName }) => { await page.goto('/'); // Create root doc await createDoc(page, 'root-doc', browserName, 1); @@ -126,7 +126,7 @@ test.describe('Inherited share link', () => { // await verifyShareLink(page, 'Connected', 'Reading'); }); - test('Vérification du message de warning lorsque les règles de partage diffèrent', async ({ + test('it checks warning message when sharing rules differ', async ({ page, browserName, }) => { @@ -155,7 +155,7 @@ test.describe('Inherited share link', () => { await expect(page.getByText('Sharing rules differ from the')).toBeHidden(); }); - test('Vérification des possibilités de liens hérités', async ({ + test('it checks inherited link possibilities', async ({ page, browserName, }) => { diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts index e9f606773..9bdbe01ec 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts @@ -113,7 +113,7 @@ test.describe('Document search', () => { }); test.describe('Sub page search', () => { - test('it check the precense of filters in search modal', async ({ + test('it check the presence of filters in search modal', async ({ page, browserName, }) => { diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts index 07a927aa4..194427d53 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts @@ -160,7 +160,7 @@ test.describe('Doc Tree', () => { ); }); - test('it detachs a document', async ({ page, browserName }) => { + test('it detaches a document', async ({ page, browserName }) => { await page.goto('/'); const [docParent] = await createDoc( page, diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx index ea1066eb4..e1cd5e3bb 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx @@ -122,7 +122,7 @@ export const DocTree = ({ initialTargetId }: DocTreeProps) => { treeContext?.treeData.setSelectedNode(currentDoc); - // we don't need to run this effect on every change of treeContext.data bacause it cause an infinite loop + // we don't need to run this effect on every change of treeContext.data because it cause an infinite loop // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentDoc, rootNode?.id]); From a397689ff1dcf35f3380ae38499d0e90fdd1a321 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Mon, 2 Jun 2025 17:00:45 +0200 Subject: [PATCH 042/104] Add basic oauth flow --- src/backend/core/api/viewsets.py | 44 +++++++++++++++++++++++++++++++- src/backend/core/urls.py | 5 ++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 3230bd3ce..3ed0fff6c 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -5,7 +5,7 @@ import logging import uuid from collections import defaultdict -from urllib.parse import unquote, urlparse +from urllib.parse import unquote, urlparse, urlencode from django.conf import settings from django.contrib.postgres.aggregates import ArrayAgg @@ -19,6 +19,7 @@ from django.db.models.functions import Left, Length from django.http import Http404, StreamingHttpResponse from django.utils.functional import cached_property +from django.shortcuts import redirect from django.utils.text import capfirst, slugify from django.utils.translation import gettext_lazy as _ @@ -1817,3 +1818,44 @@ def _load_theme_customization(self): ) return theme_customization + +notion_client_id = "206d872b-594c-80de-94ff-003760c352e4" +notion_client_secret = "XXX" +notion_redirect_uri = "https://emersion.fr/notion-redirect" + +@drf.decorators.api_view() +def notion_import_redirect(request): + if "notion_token" in request.session: + return redirect("/api/v1.0/notion_import/run") + query = urlencode({ + "client_id": notion_client_id, + "response_type": "code", + "owner": "user", + "redirect_uri": notion_redirect_uri, + }) + return redirect("https://api.notion.com/v1/oauth/authorize?" + query) + +@drf.decorators.api_view() +def notion_import_callback(request): + code = request.GET.get("code") + resp = requests.post( + "https://api.notion.com/v1/oauth/token", + auth=requests.auth.HTTPBasicAuth(notion_client_id, notion_client_secret), + headers={"Accept": "application/json"}, + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": notion_redirect_uri, + }, + ) + resp.raise_for_status() + data = resp.json() + request.session["notion_token"] = data["access_token"] + return redirect("/api/v1.0/notion_import/run") + +#@drf.decorators.api_view(["POST"]) +@drf.decorators.api_view() +def notion_import_run(request): + if "notion_token" not in request.session: + raise drf.exceptions.PermissionDenied() + return drf.response.Response({"sava": "oui et toi ?"}) diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 054418954..4233cf315 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -52,6 +52,11 @@ r"^templates/(?P[0-9a-z-]*)/", include(template_related_router.urls), ), + path("notion_import/", include([ + path("redirect", viewsets.notion_import_redirect), + path("callback", viewsets.notion_import_callback), + path("run", viewsets.notion_import_run), + ])) ] ), ), From 252b87aeda140b9084dca34a34685a1ff9547d21 Mon Sep 17 00:00:00 2001 From: Baptiste Prevot Date: Mon, 2 Jun 2025 14:45:21 +0200 Subject: [PATCH 043/104] notion-schemas: add a framework for some schemas of the notion api. Warning : there's a gotcha in the framework, see READMEs in code Signed-off-by: Baptiste Prevot --- .../core/notion_schemas/notion_block.py | 104 ++++++++++++++++++ .../core/notion_schemas/notion_color.py | 21 ++++ .../core/notion_schemas/notion_page.py | 15 +++ .../core/notion_schemas/notion_rich_text.py | 65 +++++++++++ 4 files changed, 205 insertions(+) create mode 100644 src/backend/core/notion_schemas/notion_block.py create mode 100644 src/backend/core/notion_schemas/notion_color.py create mode 100644 src/backend/core/notion_schemas/notion_page.py create mode 100644 src/backend/core/notion_schemas/notion_rich_text.py diff --git a/src/backend/core/notion_schemas/notion_block.py b/src/backend/core/notion_schemas/notion_block.py new file mode 100644 index 000000000..0915757d5 --- /dev/null +++ b/src/backend/core/notion_schemas/notion_block.py @@ -0,0 +1,104 @@ +from datetime import datetime +from enum import StrEnum +from typing import Annotated, Any, Literal + +from pydantic import BaseModel, Discriminator, Field, model_validator + +from .notion_color import NotionColor +from .notion_rich_text import NotionRichText + +"""Usage: NotionBlock.model_validate(response.json())""" + + +class NotionBlock(BaseModel): + created_time: datetime + last_edited_time: datetime + archived: bool + specific: "NotionBlockSpecifics" + + @model_validator(mode="before") + @classmethod + def move_type_inward_and_rename(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + + assert "type" in data, "Type must be specified" + data_type = data.pop("type") + data["specific"] = data.pop(data_type) + data["specific"]["type"] = data_type + + return data + + +class NotionBlockType(StrEnum): + """https://developers.notion.com/reference/block""" + + BOOKMARK = "bookmark" + BREADCRUMB = "breadcrumb" + BULLETED_LIST_ITEM = "bulleted_list_item" + CALLOUT = "callout" + CHILD_DATABASE = "child_database" + CHILD_PAGE = "child_page" + COLUMN = "column" + COLUMN_LIST = "column_list" + DIVIDER = "divider" + EMBED = "embed" + EQUATION = "equation" + FILE = "file" + HEADING_1 = "heading_1" + HEADING_2 = "heading_2" + HEADING_3 = "heading_3" + IMAGE = "image" + LINK_PREVIEW = "link_preview" + LINK_TO_PAGE = "link_to_page" + NUMBERED_LIST_ITEM = "numbered_list_item" + PARAGRAPH = "paragraph" + PDF = "pdf" + QUOTE = "quote" + SYNCED_BLOCK = "synced_block" + TABLE = "table" + TABLE_OF_CONTENTS = "table_of_contents" + TABLE_ROW = "table_row" + TEMPLATE = "template" + TO_DO = "to_do" + TOGGLE = "toggle" + UNSUPPORTED = "unsupported" + VIDEO = "video" + + +class NotionBlockHeadingBase(BaseModel): + """https://developers.notion.com/reference/block#headings""" + + type: Literal[ + NotionBlockType.HEADING_1, NotionBlockType.HEADING_2, NotionBlockType.HEADING_3 + ] + rich_text: list[NotionRichText] + color: NotionColor + is_toggleable: bool = False + + +class NotionBlockHeading1(NotionBlockHeadingBase): + type: Literal[NotionBlockType.HEADING_1] = NotionBlockType.HEADING_1 + + +class NotionBlockHeading2(NotionBlockHeadingBase): + type: Literal[NotionBlockType.HEADING_2] = NotionBlockType.HEADING_2 + + +class NotionBlockHeading3(NotionBlockHeadingBase): + type: Literal[NotionBlockType.HEADING_3] = NotionBlockType.HEADING_3 + + +class NotionParagraph(BaseModel): + """https://developers.notion.com/reference/block#paragraph""" + + type: Literal[NotionBlockType.PARAGRAPH] = NotionBlockType.PARAGRAPH + rich_text: list[NotionRichText] + color: NotionColor + children: list["NotionBlock"] = Field(default_factory=list) + + +NotionBlockSpecifics = Annotated[ + NotionBlockHeading1 | NotionBlockHeading2 | NotionBlockHeading3, + Discriminator(discriminator="type"), +] diff --git a/src/backend/core/notion_schemas/notion_color.py b/src/backend/core/notion_schemas/notion_color.py new file mode 100644 index 000000000..4e65a8809 --- /dev/null +++ b/src/backend/core/notion_schemas/notion_color.py @@ -0,0 +1,21 @@ +from enum import StrEnum + + +class NotionColor(StrEnum): + DEFAULT = "default" + BLUE = "blue" + BLUE_BACKGROUND = "blue_background" + BROWN = "brown" + BROWN_BACKGROUND = "brown_background" + GRAY = "gray" + GRAY_BACKGROUND = "gray_background" + GREEN = "green" + GREEN_BACKGROUND = "green_background" + ORANGE = "orange" + ORANGE_BACKGROUND = "orange_background" + YELLOW = "yellow" + YELLOW_BACKGROUND = "yellow_background" + PINK = "pink" + PINK_BACKGROUND = "pink_background" + PURPLE = "purple" + PURPLE_BACKGROUND = "purple_background" diff --git a/src/backend/core/notion_schemas/notion_page.py b/src/backend/core/notion_schemas/notion_page.py new file mode 100644 index 000000000..741c689de --- /dev/null +++ b/src/backend/core/notion_schemas/notion_page.py @@ -0,0 +1,15 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class NotionFile(BaseModel): ... + + +class NotionPage(BaseModel): + id: str + created_time: datetime + last_edited_time: datetime + archived: bool + icon: NotionFile + cover: NotionFile diff --git a/src/backend/core/notion_schemas/notion_rich_text.py b/src/backend/core/notion_schemas/notion_rich_text.py new file mode 100644 index 000000000..c22e67a81 --- /dev/null +++ b/src/backend/core/notion_schemas/notion_rich_text.py @@ -0,0 +1,65 @@ +from enum import StrEnum +from typing import Annotated, Any, Literal + +from pydantic import BaseModel, Discriminator, model_validator + +from .notion_color import NotionColor + + +class NotionRichTextAnnotation(BaseModel): + """https://developers.notion.com/reference/rich-text#the-annotation-object""" + + bold: bool = False + italic: bool = False + strikethrough: bool = False + underline: bool = False + code: bool = False + color: NotionColor = NotionColor.DEFAULT + + +class NotionRichText(BaseModel): + """https://developers.notion.com/reference/rich-text, not a block""" + + annotations: NotionRichTextAnnotation + plain_text: str + href: str | None = None + specific: "NotionRichTextSpecifics" + + @model_validator(mode="before") + @classmethod + def move_type_inward_and_rename(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + + assert "type" in data, "Type must be specified" + data_type = data.pop("type") + data["specific"] = data.pop(data_type) + data["specific"]["type"] = data_type + + return data + + +class NotionRichTextType(StrEnum): + TEXT = "text" + MENTION = "mention" + EQUATION = "equation" + + +class NotionRichTextText(BaseModel): + type: Literal[NotionRichTextType.TEXT] = NotionRichTextType.TEXT + + +class NotionRichTextMention(BaseModel): + type: Literal[NotionRichTextType.MENTION] = NotionRichTextType.MENTION + # Mention + + +class NotionRichTextEquation(BaseModel): + type: Literal[NotionRichTextType.EQUATION] = NotionRichTextType.EQUATION + expression: str # LaTeX expression + + +NotionRichTextSpecifics = Annotated[ + NotionRichTextText | NotionRichTextMention | NotionRichTextEquation, + Discriminator(discriminator="type"), +] From e3523e7e70e9176aee2b0d7aef4a2a3665f87cc0 Mon Sep 17 00:00:00 2001 From: Clara Ni Date: Mon, 2 Jun 2025 18:06:21 +0200 Subject: [PATCH 044/104] Add import_notion service --- src/backend/core/api/viewsets.py | 4 +- src/backend/core/services/notion_import.py | 128 +++++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 src/backend/core/services/notion_import.py diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 3ed0fff6c..1c3524a26 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -35,6 +35,7 @@ from core import authentication, choices, enums, models from core.services.ai_services import AIService from core.services.collaboration_services import CollaborationService +from core.services.notion_import import import_notion from core.utils import extract_attachments, filter_descendants from . import permissions, serializers, utils @@ -1853,9 +1854,10 @@ def notion_import_callback(request): request.session["notion_token"] = data["access_token"] return redirect("/api/v1.0/notion_import/run") -#@drf.decorators.api_view(["POST"]) +# @drf.decorators.api_view(["POST"]) @drf.decorators.api_view() def notion_import_run(request): if "notion_token" not in request.session: raise drf.exceptions.PermissionDenied() + import_notion(request.session["notion_token"]) return drf.response.Response({"sava": "oui et toi ?"}) diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py new file mode 100644 index 000000000..89a905a89 --- /dev/null +++ b/src/backend/core/services/notion_import.py @@ -0,0 +1,128 @@ +from enum import Enum +import requests + + +class PageType(Enum): + PAGE = "page" + DATABASE = "database" + + +class Page: + def __init__(self, type, id, name): + self.type = type + self.id = id + self.name = name + + def __repr__(self): + return f"\n Page(type={self.type}, id='{self.id}', name='{self.name}')" + + +def search_notion(token: str, start_cursor: str): + response = requests.post( + "https://api.notion.com/v1/search", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Notion-Version": "2022-06-28", + "start_cursor": start_cursor if start_cursor else None, + "value": "page", + }, + ) + + if response.status_code == 200: + print("✅ Requête réussie !") + return response.json() + else: + print(f"❌ Erreur lors de la requête : {response.status_code}") + print(response.text) + + +def fetch_root_pages(token: str): + pages = [] + cursor = None + has_more = True + + while has_more: + response = search_notion(token, start_cursor=cursor) + + for item in response["results"]: + if item.get("parent", {}).get("type") == "workspace": + obj_type = item["object"] + if obj_type == "page": + page_type = PageType.PAGE + rich_texts = next( + ( + prop["title"] + for prop in item["properties"].values() + if prop["type"] == "title" + ), + [], + ) + else: + page_type = PageType.DATABASE + rich_texts = item.title + + pages.append( + Page( + type=page_type, + id=item["id"], + name="".join( + rich_text["plain_text"] for rich_text in rich_texts + ), + ) + ) + + has_more = response.get("has_more", False) + cursor = response.get("next_cursor") + + return pages + + +def fetch_blocks(token: str, block_id: str): + response = requests.get( + f"https://api.notion.com/v1/blocks/{block_id}/children", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Notion-Version": "2022-06-28", + }, + ) + + if response.status_code == 200: + return response.json() + else: + print(f"❌ Erreur lors de la requête : {response.status_code}") + print(response.text) + + +def fetch_block_children(token: str, block_id: str): + blocks = [] + cursor = None + has_more = True + + while has_more: + response = fetch_blocks(token, block_id) + + blocks.extend(response["results"]) + + has_more = response.get("has_more", False) + cursor = response.get("next_cursor") + + children = [] + for block in blocks: + if block["has_children"]: + response = fetch_block_children(token, block["id"]) + children.extend(response) + + blocks.extend(children) + return blocks + + +def import_notion(token: str): + """Recursively imports all Notion pages and blocks accessible using the given token.""" + root_pages = fetch_root_pages(token) + for root_page in root_pages: + page_content = fetch_block_children(token, root_page.id) + print(f"Page {root_page.id}") + print(page_content) + print() From 01544f62639699e147777f1e899cc40820a2662f Mon Sep 17 00:00:00 2001 From: Baptiste Prevot Date: Mon, 2 Jun 2025 18:11:48 +0200 Subject: [PATCH 045/104] notion-schemas: add some more schemas --- .../core/notion_schemas/notion_block.py | 115 +++++++++++++++--- .../core/notion_schemas/notion_rich_text.py | 5 +- 2 files changed, 104 insertions(+), 16 deletions(-) diff --git a/src/backend/core/notion_schemas/notion_block.py b/src/backend/core/notion_schemas/notion_block.py index 0915757d5..64577d69e 100644 --- a/src/backend/core/notion_schemas/notion_block.py +++ b/src/backend/core/notion_schemas/notion_block.py @@ -2,7 +2,7 @@ from enum import StrEnum from typing import Annotated, Any, Literal -from pydantic import BaseModel, Discriminator, Field, model_validator +from pydantic import BaseModel, Discriminator, Field, ValidationError, model_validator from .notion_color import NotionColor from .notion_rich_text import NotionRichText @@ -22,10 +22,12 @@ def move_type_inward_and_rename(cls, data: Any) -> Any: if not isinstance(data, dict): return data - assert "type" in data, "Type must be specified" + if "type" not in data: + raise ValidationError("Type must be specified") + data_type = data.pop("type") data["specific"] = data.pop(data_type) - data["specific"]["type"] = data_type + data["specific"]["block_type"] = data_type return data @@ -39,6 +41,7 @@ class NotionBlockType(StrEnum): CALLOUT = "callout" CHILD_DATABASE = "child_database" CHILD_PAGE = "child_page" + CODE = "code" COLUMN = "column" COLUMN_LIST = "column_list" DIVIDER = "divider" @@ -66,10 +69,10 @@ class NotionBlockType(StrEnum): VIDEO = "video" -class NotionBlockHeadingBase(BaseModel): +class NotionHeadingBase(BaseModel): """https://developers.notion.com/reference/block#headings""" - type: Literal[ + block_type: Literal[ NotionBlockType.HEADING_1, NotionBlockType.HEADING_2, NotionBlockType.HEADING_3 ] rich_text: list[NotionRichText] @@ -77,28 +80,112 @@ class NotionBlockHeadingBase(BaseModel): is_toggleable: bool = False -class NotionBlockHeading1(NotionBlockHeadingBase): - type: Literal[NotionBlockType.HEADING_1] = NotionBlockType.HEADING_1 +class NotionHeading1(NotionHeadingBase): + block_type: Literal[NotionBlockType.HEADING_1] = NotionBlockType.HEADING_1 -class NotionBlockHeading2(NotionBlockHeadingBase): - type: Literal[NotionBlockType.HEADING_2] = NotionBlockType.HEADING_2 +class NotionHeading2(NotionHeadingBase): + block_type: Literal[NotionBlockType.HEADING_2] = NotionBlockType.HEADING_2 -class NotionBlockHeading3(NotionBlockHeadingBase): - type: Literal[NotionBlockType.HEADING_3] = NotionBlockType.HEADING_3 +class NotionHeading3(NotionHeadingBase): + block_type: Literal[NotionBlockType.HEADING_3] = NotionBlockType.HEADING_3 class NotionParagraph(BaseModel): """https://developers.notion.com/reference/block#paragraph""" - type: Literal[NotionBlockType.PARAGRAPH] = NotionBlockType.PARAGRAPH + block_type: Literal[NotionBlockType.PARAGRAPH] = NotionBlockType.PARAGRAPH + rich_text: list[NotionRichText] + color: NotionColor + children: list["NotionBlock"] = Field(default_factory=list) + + +class NotionBulletedListItem(BaseModel): + """https://developers.notion.com/reference/block#bulleted-list-item""" + + block_type: Literal[NotionBlockType.BULLETED_LIST_ITEM] = ( + NotionBlockType.BULLETED_LIST_ITEM + ) + rich_text: list[NotionRichText] + color: NotionColor + children: list["NotionBlock"] = Field(default_factory=list) + + +class NotionNumberedListItem(BaseModel): + """https://developers.notion.com/reference/block#numbered-list-item""" + + block_type: Literal[NotionBlockType.NUMBERED_LIST_ITEM] = ( + NotionBlockType.NUMBERED_LIST_ITEM + ) rich_text: list[NotionRichText] color: NotionColor children: list["NotionBlock"] = Field(default_factory=list) +class NotionCode(BaseModel): + """https://developers.notion.com/reference/block#code""" + + block_type: Literal[NotionBlockType.CODE] = NotionBlockType.CODE + caption: list[NotionRichText] + rich_text: list[NotionRichText] + language: str # Actually an enum + + +class NotionDivider(BaseModel): + """https://developers.notion.com/reference/block#divider""" + + block_type: Literal[NotionBlockType.DIVIDER] = NotionBlockType.DIVIDER + + +class NotionEmbed(BaseModel): + """https://developers.notion.com/reference/block#embed""" + + block_type: Literal[NotionBlockType.EMBED] = NotionBlockType.EMBED + url: str + + +class NotionFileType(StrEnum): + FILE = "file" + EXTERNAL = "external" + FILE_UPLOAD = "file_upload" + + +class NotionFile(BaseModel): + # FIXME: this is actually another occurrence of type discriminating + """https://developers.notion.com/reference/block#file""" + + block_type: Literal[NotionBlockType.FILE] = NotionBlockType.FILE + caption: list[NotionRichText] + type: NotionFileType + ... + + +class NotionImage(BaseModel): + """https://developers.notion.com/reference/block#image""" + + block_type: Literal[NotionBlockType.IMAGE] = NotionBlockType.IMAGE + # FIXME: this actually contains a file reference which will be defined for the above, but with the "image" attribute + + +class NotionLinkPreview(BaseModel): + """https://developers.notion.com/reference/block#link-preview""" + + block_type: Literal[NotionBlockType.LINK_PREVIEW] = NotionBlockType.LINK_PREVIEW + url: str + + NotionBlockSpecifics = Annotated[ - NotionBlockHeading1 | NotionBlockHeading2 | NotionBlockHeading3, - Discriminator(discriminator="type"), + NotionHeading1 + | NotionHeading2 + | NotionHeading3 + | NotionParagraph + | NotionNumberedListItem + | NotionBulletedListItem + | NotionCode + | NotionDivider + | NotionEmbed + | NotionFile + | NotionImage, + Discriminator(discriminator="block_type"), ] diff --git a/src/backend/core/notion_schemas/notion_rich_text.py b/src/backend/core/notion_schemas/notion_rich_text.py index c22e67a81..0e777e456 100644 --- a/src/backend/core/notion_schemas/notion_rich_text.py +++ b/src/backend/core/notion_schemas/notion_rich_text.py @@ -1,7 +1,7 @@ from enum import StrEnum from typing import Annotated, Any, Literal -from pydantic import BaseModel, Discriminator, model_validator +from pydantic import BaseModel, Discriminator, ValidationError, model_validator from .notion_color import NotionColor @@ -31,7 +31,8 @@ def move_type_inward_and_rename(cls, data: Any) -> Any: if not isinstance(data, dict): return data - assert "type" in data, "Type must be specified" + if "type" not in data: + raise ValidationError("Type must be specified") data_type = data.pop("type") data["specific"] = data.pop(data_type) data["specific"]["type"] = data_type From 974bb86a6877fe8a5a31a3dfed3849cc6f0ab40f Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Mon, 2 Jun 2025 19:36:33 +0200 Subject: [PATCH 046/104] Add blocks converter to y-provider --- .../core/services/converter_services.py | 42 +++++++++++++++++ src/backend/impress/settings.py | 5 ++ .../src/handlers/convertBlocksHandler.ts | 47 +++++++++++++++++++ .../servers/y-provider/src/handlers/index.ts | 1 + src/frontend/servers/y-provider/src/routes.ts | 1 + .../y-provider/src/servers/appServer.ts | 3 ++ 6 files changed, 99 insertions(+) create mode 100644 src/frontend/servers/y-provider/src/handlers/convertBlocksHandler.ts diff --git a/src/backend/core/services/converter_services.py b/src/backend/core/services/converter_services.py index 5213bac86..633c0ba2e 100644 --- a/src/backend/core/services/converter_services.py +++ b/src/backend/core/services/converter_services.py @@ -76,3 +76,45 @@ def convert_markdown(self, text): ) from err return document_content + + def convert_blocks(self, blocks): + """Convert a list of blocks into our internal format using an external microservice.""" + + print('BONJOUR') + print(settings.Y_PROVIDER_API_BASE_URL) + try: + response = requests.post( + f"{settings.Y_PROVIDER_API_BASE_URL}{settings.BLOCKS_CONVERSION_API_ENDPOINT}/", + json={ + "blocks": blocks, + }, + headers={ + "Authorization": self.auth_header, + "Content-Type": "application/json", + }, + timeout=settings.CONVERSION_API_TIMEOUT, + verify=settings.CONVERSION_API_SECURE, + ) + response.raise_for_status() + conversion_response = response.json() + + except requests.RequestException as err: + raise ServiceUnavailableError( + "Failed to connect to conversion service", + ) from err + + except ValueError as err: + raise InvalidResponseError( + "Could not parse conversion service response" + ) from err + + try: + document_content = conversion_response[ + settings.CONVERSION_API_CONTENT_FIELD + ] + except KeyError as err: + raise MissingContentError( + f"Response missing required field: {settings.CONVERSION_API_CONTENT_FIELD}" + ) from err + + return document_content diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 571d7052d..7093aad6d 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -628,6 +628,11 @@ class Base(Configuration): environ_name="CONVERSION_API_ENDPOINT", environ_prefix=None, ) + BLOCKS_CONVERSION_API_ENDPOINT = values.Value( + default="convert-blocks", + environ_name="BLOCKS_CONVERSION_API_ENDPOINT", + environ_prefix=None, + ) CONVERSION_API_CONTENT_FIELD = values.Value( default="content", environ_name="CONVERSION_API_CONTENT_FIELD", diff --git a/src/frontend/servers/y-provider/src/handlers/convertBlocksHandler.ts b/src/frontend/servers/y-provider/src/handlers/convertBlocksHandler.ts new file mode 100644 index 000000000..38af60934 --- /dev/null +++ b/src/frontend/servers/y-provider/src/handlers/convertBlocksHandler.ts @@ -0,0 +1,47 @@ +//import { PartialBlock } from '@blocknote/core'; +import { ServerBlockNoteEditor } from '@blocknote/server-util'; +import { Request, Response } from 'express'; +import * as Y from 'yjs'; + +import { logger, toBase64 } from '@/utils'; + +interface ConversionRequest { + blocks: any; // TODO: PartialBlock +} + +interface ConversionResponse { + content: string; +} + +interface ErrorResponse { + error: string; +} + +export const convertBlocksHandler = async ( + req: Request< + object, + ConversionResponse | ErrorResponse, + ConversionRequest, + object + >, + res: Response, +) => { + const blocks = req.body?.blocks; + if (!blocks) { + res.status(400).json({ error: 'Invalid request: missing content' }); + return; + } + + try { + const editor = ServerBlockNoteEditor.create(); + + // Create a Yjs Document from blocks, and encode it as a base64 string + const yDocument = editor.blocksToYDoc(blocks, 'document-store'); + const content = toBase64(Y.encodeStateAsUpdate(yDocument)); + + res.status(200).json({ content }); + } catch (e) { + logger('conversion failed:', e); + res.status(500).json({ error: 'An error occurred' }); + } +}; diff --git a/src/frontend/servers/y-provider/src/handlers/index.ts b/src/frontend/servers/y-provider/src/handlers/index.ts index 75bd7f7bb..167493a30 100644 --- a/src/frontend/servers/y-provider/src/handlers/index.ts +++ b/src/frontend/servers/y-provider/src/handlers/index.ts @@ -1,3 +1,4 @@ export * from './collaborationResetConnectionsHandler'; export * from './collaborationWSHandler'; export * from './convertMarkdownHandler'; +export * from './convertBlocksHandler'; diff --git a/src/frontend/servers/y-provider/src/routes.ts b/src/frontend/servers/y-provider/src/routes.ts index 98803b87f..7b8d289bb 100644 --- a/src/frontend/servers/y-provider/src/routes.ts +++ b/src/frontend/servers/y-provider/src/routes.ts @@ -2,4 +2,5 @@ export const routes = { COLLABORATION_WS: '/collaboration/ws/', COLLABORATION_RESET_CONNECTIONS: '/collaboration/api/reset-connections/', CONVERT_MARKDOWN: '/api/convert-markdown/', + CONVERT_BLOCKS: '/api/convert-blocks/', }; diff --git a/src/frontend/servers/y-provider/src/servers/appServer.ts b/src/frontend/servers/y-provider/src/servers/appServer.ts index 5c035db79..2f99db5b1 100644 --- a/src/frontend/servers/y-provider/src/servers/appServer.ts +++ b/src/frontend/servers/y-provider/src/servers/appServer.ts @@ -9,6 +9,7 @@ import { collaborationResetConnectionsHandler, collaborationWSHandler, convertMarkdownHandler, + convertBlocksHandler, } from '../handlers'; import { corsMiddleware, httpSecurity, wsSecurity } from '../middlewares'; import { routes } from '../routes'; @@ -51,6 +52,8 @@ export const initServer = () => { */ app.post(routes.CONVERT_MARKDOWN, httpSecurity, convertMarkdownHandler); + app.post(routes.CONVERT_BLOCKS, httpSecurity, convertBlocksHandler); + Sentry.setupExpressErrorHandler(app); app.get('/ping', (req, res) => { From f2c575443b1e465aa7d059aef59b48a8bccd0732 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Mon, 2 Jun 2025 19:38:00 +0200 Subject: [PATCH 047/104] wip: add document creation code --- src/backend/core/api/viewsets.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 1c3524a26..55ba6a858 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -35,6 +35,7 @@ from core import authentication, choices, enums, models from core.services.ai_services import AIService from core.services.collaboration_services import CollaborationService +from core.services.converter_services import YdocConverter from core.services.notion_import import import_notion from core.utils import extract_attachments, filter_descendants @@ -1859,5 +1860,28 @@ def notion_import_callback(request): def notion_import_run(request): if "notion_token" not in request.session: raise drf.exceptions.PermissionDenied() - import_notion(request.session["notion_token"]) + + import_notion(request.session['notion_token']) + + #document_content = YdocConverter().convert_blocks([ + # { + # "type": "paragraph", + # "content": "Bonjour à toustes zé à toussent", + # }, + #]) + + #obj = models.Document.add_root( + # depth=1, + # creator=request.user, + # title="J'aime les courgettes", + # link_reach=models.LinkReachChoices.RESTRICTED, + # content=document_content, + #) + + #models.DocumentAccess.objects.create( + # document=obj, + # user=request.user, + # role=models.RoleChoices.OWNER, + #) + return drf.response.Response({"sava": "oui et toi ?"}) From 53e41bd61e8626910327619c073fc3703ea12468 Mon Sep 17 00:00:00 2001 From: Baptiste Prevot Date: Mon, 2 Jun 2025 19:13:16 +0200 Subject: [PATCH 048/104] notion-import: use schemas --- .../core/notion_schemas/notion_block.py | 6 +- .../core/notion_schemas/notion_page.py | 21 ++- .../core/notion_schemas/notion_rich_text.py | 2 + src/backend/core/services/notion_import.py | 129 ++++++++---------- 4 files changed, 77 insertions(+), 81 deletions(-) diff --git a/src/backend/core/notion_schemas/notion_block.py b/src/backend/core/notion_schemas/notion_block.py index 64577d69e..2afb51b25 100644 --- a/src/backend/core/notion_schemas/notion_block.py +++ b/src/backend/core/notion_schemas/notion_block.py @@ -11,10 +11,13 @@ class NotionBlock(BaseModel): + id: str created_time: datetime last_edited_time: datetime archived: bool specific: "NotionBlockSpecifics" + has_children: bool + children: list["NotionBlock"] = Field(init=False, default_factory=list) @model_validator(mode="before") @classmethod @@ -72,9 +75,6 @@ class NotionBlockType(StrEnum): class NotionHeadingBase(BaseModel): """https://developers.notion.com/reference/block#headings""" - block_type: Literal[ - NotionBlockType.HEADING_1, NotionBlockType.HEADING_2, NotionBlockType.HEADING_3 - ] rich_text: list[NotionRichText] color: NotionColor is_toggleable: bool = False diff --git a/src/backend/core/notion_schemas/notion_page.py b/src/backend/core/notion_schemas/notion_page.py index 741c689de..47a01258c 100644 --- a/src/backend/core/notion_schemas/notion_page.py +++ b/src/backend/core/notion_schemas/notion_page.py @@ -2,14 +2,27 @@ from pydantic import BaseModel +from .notion_rich_text import NotionRichText + class NotionFile(BaseModel): ... class NotionPage(BaseModel): id: str - created_time: datetime - last_edited_time: datetime archived: bool - icon: NotionFile - cover: NotionFile + + # created_time: datetime + # last_edited_time: datetime + # icon: NotionFile + # cover: NotionFile + + properties: dict # This is a very messy dict, with some RichText somewhere + + def get_title(self) -> str | None: + title_property: dict | None = self.properties.get("title") + if title_property is None: + return None + + rich_text = title_property["title"] # This could be parsed using NotionRichText + return rich_text["plain_text"] diff --git a/src/backend/core/notion_schemas/notion_rich_text.py b/src/backend/core/notion_schemas/notion_rich_text.py index 0e777e456..4d209d665 100644 --- a/src/backend/core/notion_schemas/notion_rich_text.py +++ b/src/backend/core/notion_schemas/notion_rich_text.py @@ -48,6 +48,8 @@ class NotionRichTextType(StrEnum): class NotionRichTextText(BaseModel): type: Literal[NotionRichTextType.TEXT] = NotionRichTextType.TEXT + content: str + link: str | None class NotionRichTextMention(BaseModel): diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index 89a905a89..d7873bd66 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -1,128 +1,109 @@ +import logging +from dataclasses import dataclass from enum import Enum -import requests +from typing import Any +import requests +from pydantic import TypeAdapter +from requests import Session -class PageType(Enum): - PAGE = "page" - DATABASE = "database" +from ..notion_schemas.notion_block import NotionBlock +from ..notion_schemas.notion_page import NotionPage +logger = logging.getLogger(__name__) -class Page: - def __init__(self, type, id, name): - self.type = type - self.id = id - self.name = name - def __repr__(self): - return f"\n Page(type={self.type}, id='{self.id}', name='{self.name}')" +def build_notion_session(token: str) -> Session: + session = Session() + session.headers = { + "Authorization": f"Bearer {token}", + "Notion-Version": "2022-06-28", + } + return session -def search_notion(token: str, start_cursor: str): - response = requests.post( +def search_notion(session: Session, start_cursor: str) -> dict[str, Any]: + response = session.post( "https://api.notion.com/v1/search", - headers={ - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - "Notion-Version": "2022-06-28", + json={ "start_cursor": start_cursor if start_cursor else None, "value": "page", }, ) if response.status_code == 200: - print("✅ Requête réussie !") + logger.info("✅ Requête réussie !") return response.json() else: - print(f"❌ Erreur lors de la requête : {response.status_code}") - print(response.text) + logger.error(f"❌ Erreur lors de la requête : {response.status_code}") + logger.debug(response.text) + raise ValueError -def fetch_root_pages(token: str): +def fetch_root_pages(session: Session) -> list[NotionPage]: pages = [] - cursor = None + cursor = "" has_more = True while has_more: - response = search_notion(token, start_cursor=cursor) + response = search_notion(session, start_cursor=cursor) for item in response["results"]: - if item.get("parent", {}).get("type") == "workspace": - obj_type = item["object"] - if obj_type == "page": - page_type = PageType.PAGE - rich_texts = next( - ( - prop["title"] - for prop in item["properties"].values() - if prop["type"] == "title" - ), - [], - ) - else: - page_type = PageType.DATABASE - rich_texts = item.title - - pages.append( - Page( - type=page_type, - id=item["id"], - name="".join( - rich_text["plain_text"] for rich_text in rich_texts - ), - ) - ) + if item.get("parent", {}).get("type") != "workspace": + continue + + assert item["object"] == "page" + + pages.append(NotionPage.model_validate(item)) has_more = response.get("has_more", False) - cursor = response.get("next_cursor") + cursor = response.get("next_cursor", "") return pages -def fetch_blocks(token: str, block_id: str): - response = requests.get( +def fetch_blocks(session: Session, block_id: str, start_cursor: str) -> dict[str, Any]: + response = session.get( f"https://api.notion.com/v1/blocks/{block_id}/children", - headers={ - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - "Notion-Version": "2022-06-28", + params={ + "start_cursor": start_cursor if start_cursor else None, }, ) if response.status_code == 200: return response.json() else: - print(f"❌ Erreur lors de la requête : {response.status_code}") - print(response.text) + logger.debug(response.text) + raise ValueError(f"❌ Erreur lors de la requête : {response.status_code}") -def fetch_block_children(token: str, block_id: str): - blocks = [] - cursor = None +def fetch_block_children(session: Session, block_id: str) -> list[NotionBlock]: + blocks: list[NotionBlock] = [] + cursor = "" has_more = True while has_more: - response = fetch_blocks(token, block_id) + response = fetch_blocks(session, block_id, cursor) - blocks.extend(response["results"]) + blocks.extend( + TypeAdapter(list[NotionBlock]).validate_python(response["results"]) + ) has_more = response.get("has_more", False) - cursor = response.get("next_cursor") + cursor = response.get("next_cursor", "") - children = [] for block in blocks: - if block["has_children"]: - response = fetch_block_children(token, block["id"]) - children.extend(response) + if block.has_children: + block.children = fetch_block_children(session, block.id) - blocks.extend(children) return blocks def import_notion(token: str): """Recursively imports all Notion pages and blocks accessible using the given token.""" - root_pages = fetch_root_pages(token) - for root_page in root_pages: - page_content = fetch_block_children(token, root_page.id) - print(f"Page {root_page.id}") - print(page_content) - print() + session = build_notion_session(token) + root_pages = fetch_root_pages(session) + for page in root_pages: + blocks = fetch_block_children(session, page.id) + logger.info(f"Page {page.get_title()} (id {page.id})") + logger.info(blocks) From 63039bee4433f980e2e4432d65fec37b9b22bc63 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 3 Jun 2025 00:25:25 +0200 Subject: [PATCH 049/104] Fix ValueError in NotionPage.get_title() --- src/backend/core/notion_schemas/notion_page.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/core/notion_schemas/notion_page.py b/src/backend/core/notion_schemas/notion_page.py index 47a01258c..eec379f1f 100644 --- a/src/backend/core/notion_schemas/notion_page.py +++ b/src/backend/core/notion_schemas/notion_page.py @@ -24,5 +24,5 @@ def get_title(self) -> str | None: if title_property is None: return None - rich_text = title_property["title"] # This could be parsed using NotionRichText + rich_text = title_property["title"][0] # This could be parsed using NotionRichText return rich_text["plain_text"] From 7fad79f1100d67aa5030be8d3a05371522123e72 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 3 Jun 2025 00:26:06 +0200 Subject: [PATCH 050/104] Remove awkward debugging log --- src/backend/core/services/converter_services.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/backend/core/services/converter_services.py b/src/backend/core/services/converter_services.py index 633c0ba2e..47d9cf8ac 100644 --- a/src/backend/core/services/converter_services.py +++ b/src/backend/core/services/converter_services.py @@ -80,8 +80,6 @@ def convert_markdown(self, text): def convert_blocks(self, blocks): """Convert a list of blocks into our internal format using an external microservice.""" - print('BONJOUR') - print(settings.Y_PROVIDER_API_BASE_URL) try: response = requests.post( f"{settings.Y_PROVIDER_API_BASE_URL}{settings.BLOCKS_CONVERSION_API_ENDPOINT}/", From 6bebe672d1222b0ef1160086d99bfb5e1d5e1407 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 3 Jun 2025 00:26:38 +0200 Subject: [PATCH 051/104] Fix 400 in Notion search --- src/backend/core/services/notion_import.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index d7873bd66..c7f0d2820 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -23,12 +23,16 @@ def build_notion_session(token: str) -> Session: def search_notion(session: Session, start_cursor: str) -> dict[str, Any]: + req_data = {} + if start_cursor: + req_data = { + "start_cursor": start_cursor, + "value": "page", + } + response = session.post( "https://api.notion.com/v1/search", - json={ - "start_cursor": start_cursor if start_cursor else None, - "value": "page", - }, + json=req_data, ) if response.status_code == 200: From 3f31453a762796c080bfc1d00af3b6af03de7e15 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 3 Jun 2025 00:27:21 +0200 Subject: [PATCH 052/104] Simplify Notion API error handling --- src/backend/core/services/notion_import.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index c7f0d2820..ea941bcaa 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -35,13 +35,11 @@ def search_notion(session: Session, start_cursor: str) -> dict[str, Any]: json=req_data, ) - if response.status_code == 200: - logger.info("✅ Requête réussie !") - return response.json() - else: - logger.error(f"❌ Erreur lors de la requête : {response.status_code}") - logger.debug(response.text) - raise ValueError + if response.status_code != 200: + print(response.json()) + + response.raise_for_status() + return response.json() def fetch_root_pages(session: Session) -> list[NotionPage]: @@ -74,11 +72,8 @@ def fetch_blocks(session: Session, block_id: str, start_cursor: str) -> dict[str }, ) - if response.status_code == 200: - return response.json() - else: - logger.debug(response.text) - raise ValueError(f"❌ Erreur lors de la requête : {response.status_code}") + response.raise_for_status() + return response.json() def fetch_block_children(session: Session, block_id: str) -> list[NotionBlock]: From 2f0ef4562fed19e53b2879f817d8f32c96f79e61 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 3 Jun 2025 00:28:05 +0200 Subject: [PATCH 053/104] Create one document per root Notion page --- src/backend/core/api/viewsets.py | 45 +++++++++++----------- src/backend/core/services/notion_import.py | 3 +- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 55ba6a858..e3f593946 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1861,27 +1861,28 @@ def notion_import_run(request): if "notion_token" not in request.session: raise drf.exceptions.PermissionDenied() - import_notion(request.session['notion_token']) - - #document_content = YdocConverter().convert_blocks([ - # { - # "type": "paragraph", - # "content": "Bonjour à toustes zé à toussent", - # }, - #]) - - #obj = models.Document.add_root( - # depth=1, - # creator=request.user, - # title="J'aime les courgettes", - # link_reach=models.LinkReachChoices.RESTRICTED, - # content=document_content, - #) - - #models.DocumentAccess.objects.create( - # document=obj, - # user=request.user, - # role=models.RoleChoices.OWNER, - #) + pages = import_notion(request.session['notion_token']) + + document_content = YdocConverter().convert_blocks([ + { + "type": "paragraph", + "content": "Bonjour à toustes zé à toussent", + }, + ]) + + for page in pages: + obj = models.Document.add_root( + depth=1, + creator=request.user, + title=page.get_title() or "J'aime les courgettes", + link_reach=models.LinkReachChoices.RESTRICTED, + content=document_content, + ) + + models.DocumentAccess.objects.create( + document=obj, + user=request.user, + role=models.RoleChoices.OWNER, + ) return drf.response.Response({"sava": "oui et toi ?"}) diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index ea941bcaa..5f3047d00 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -98,7 +98,7 @@ def fetch_block_children(session: Session, block_id: str) -> list[NotionBlock]: return blocks -def import_notion(token: str): +def import_notion(token: str) -> list[NotionPage]: """Recursively imports all Notion pages and blocks accessible using the given token.""" session = build_notion_session(token) root_pages = fetch_root_pages(session) @@ -106,3 +106,4 @@ def import_notion(token: str): blocks = fetch_block_children(session, page.id) logger.info(f"Page {page.get_title()} (id {page.id})") logger.info(blocks) + return root_pages From b7db0b3ae87436c99e0c26d31e86d59edf63e479 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 3 Jun 2025 00:49:06 +0200 Subject: [PATCH 054/104] Add super dumb block converter --- src/backend/core/api/viewsets.py | 9 ++------ src/backend/core/services/notion_import.py | 25 ++++++++++++++++++++-- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index e3f593946..8ef294cbd 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1863,14 +1863,9 @@ def notion_import_run(request): pages = import_notion(request.session['notion_token']) - document_content = YdocConverter().convert_blocks([ - { - "type": "paragraph", - "content": "Bonjour à toustes zé à toussent", - }, - ]) + for page, blocks in pages: + document_content = YdocConverter().convert_blocks(blocks) - for page in pages: obj = models.Document.add_root( depth=1, creator=request.user, diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index 5f3047d00..4019211ea 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -98,12 +98,33 @@ def fetch_block_children(session: Session, block_id: str) -> list[NotionBlock]: return blocks -def import_notion(token: str) -> list[NotionPage]: +def convert_block(block: NotionBlock) -> Any: + match type(block): + case NotionParagraph: + return { + "type": "paragraph", + "content": block.rich_text[0].plain_text # TODO: handle multiple + } + + +def convert_block_list(blocks: list[NotionBlock]) -> Any: + converted_blocks = [] + for block in blocks: + converted_block = convert_block(block) + if converted_block == None: + continue + converted_blocks.append(converted_block) + return converted_blocks + + +def import_notion(token: str) -> list[(NotionPage, Any)]: """Recursively imports all Notion pages and blocks accessible using the given token.""" session = build_notion_session(token) root_pages = fetch_root_pages(session) + pages_and_blocks = [] for page in root_pages: blocks = fetch_block_children(session, page.id) logger.info(f"Page {page.get_title()} (id {page.id})") logger.info(blocks) - return root_pages + pages_and_blocks.append((page, convert_blocks(blocks))) + return pages_and_blocks From 7bae379b0200a055d5848427f1db9f5db7467c2d Mon Sep 17 00:00:00 2001 From: Baptiste Prevot Date: Tue, 3 Jun 2025 00:51:53 +0200 Subject: [PATCH 055/104] notion-schemas: add catcah-all unsupported block type --- .../core/notion_schemas/notion_block.py | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/backend/core/notion_schemas/notion_block.py b/src/backend/core/notion_schemas/notion_block.py index 2afb51b25..4394098e9 100644 --- a/src/backend/core/notion_schemas/notion_block.py +++ b/src/backend/core/notion_schemas/notion_block.py @@ -175,6 +175,32 @@ class NotionLinkPreview(BaseModel): url: str +class NotionBlockUnsupported(BaseModel): + """FIXME: Maybe https://github.com/pydantic/pydantic/discussions/4928#discussioncomment-13079554 would be better""" + + block_type: Literal[ + NotionBlockType.BOOKMARK, + NotionBlockType.BREADCRUMB, + NotionBlockType.CALLOUT, + NotionBlockType.CHILD_DATABASE, + NotionBlockType.CHILD_PAGE, + NotionBlockType.COLUMN, + NotionBlockType.COLUMN_LIST, + NotionBlockType.EQUATION, + NotionBlockType.LINK_TO_PAGE, + NotionBlockType.PDF, + NotionBlockType.QUOTE, + NotionBlockType.SYNCED_BLOCK, + NotionBlockType.TABLE, + NotionBlockType.TABLE_OF_CONTENTS, + NotionBlockType.TABLE_ROW, + NotionBlockType.TEMPLATE, + NotionBlockType.TO_DO, + NotionBlockType.TOGGLE, + NotionBlockType.VIDEO, + ] + + NotionBlockSpecifics = Annotated[ NotionHeading1 | NotionHeading2 @@ -186,6 +212,7 @@ class NotionLinkPreview(BaseModel): | NotionDivider | NotionEmbed | NotionFile - | NotionImage, + | NotionImage + | NotionBlockUnsupported, Discriminator(discriminator="block_type"), ] From 4955ccf269c676e7cf2138d97e2c9226581105ab Mon Sep 17 00:00:00 2001 From: Baptiste Prevot Date: Tue, 3 Jun 2025 00:54:56 +0200 Subject: [PATCH 056/104] just add some colors --- src/backend/core/notion_schemas/notion_color.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/backend/core/notion_schemas/notion_color.py b/src/backend/core/notion_schemas/notion_color.py index 4e65a8809..881a6de94 100644 --- a/src/backend/core/notion_schemas/notion_color.py +++ b/src/backend/core/notion_schemas/notion_color.py @@ -19,3 +19,5 @@ class NotionColor(StrEnum): PINK_BACKGROUND = "pink_background" PURPLE = "purple" PURPLE_BACKGROUND = "purple_background" + RED = "red" + RED_BACKGROUND = "red_background" From 216c55fac1688e3473019ca5852d73d336583858 Mon Sep 17 00:00:00 2001 From: Baptiste Prevot Date: Tue, 3 Jun 2025 00:58:51 +0200 Subject: [PATCH 057/104] just add a link type --- src/backend/core/notion_schemas/notion_rich_text.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/backend/core/notion_schemas/notion_rich_text.py b/src/backend/core/notion_schemas/notion_rich_text.py index 4d209d665..036a57d86 100644 --- a/src/backend/core/notion_schemas/notion_rich_text.py +++ b/src/backend/core/notion_schemas/notion_rich_text.py @@ -1,7 +1,7 @@ from enum import StrEnum from typing import Annotated, Any, Literal -from pydantic import BaseModel, Discriminator, ValidationError, model_validator +from pydantic import BaseModel, Discriminator, Field, ValidationError, model_validator from .notion_color import NotionColor @@ -46,10 +46,14 @@ class NotionRichTextType(StrEnum): EQUATION = "equation" +class NotionLink(BaseModel): + url: str + + class NotionRichTextText(BaseModel): type: Literal[NotionRichTextType.TEXT] = NotionRichTextType.TEXT content: str - link: str | None + link: NotionLink | None class NotionRichTextMention(BaseModel): From 74bcea77ace01b343facee96d75e1c00c93cd47b Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 3 Jun 2025 01:01:25 +0200 Subject: [PATCH 058/104] Fix typo lol --- src/backend/core/services/notion_import.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index 4019211ea..40e9e7b14 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -126,5 +126,5 @@ def import_notion(token: str) -> list[(NotionPage, Any)]: blocks = fetch_block_children(session, page.id) logger.info(f"Page {page.get_title()} (id {page.id})") logger.info(blocks) - pages_and_blocks.append((page, convert_blocks(blocks))) + pages_and_blocks.append((page, convert_block_list(blocks))) return pages_and_blocks From 1c7371ac14f9ea9752a5653756a86ffeb41191a3 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 3 Jun 2025 01:06:19 +0200 Subject: [PATCH 059/104] It's not a match --- src/backend/core/services/notion_import.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index 40e9e7b14..d5943a5cb 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -7,7 +7,7 @@ from pydantic import TypeAdapter from requests import Session -from ..notion_schemas.notion_block import NotionBlock +from ..notion_schemas.notion_block import NotionBlock, NotionParagraph from ..notion_schemas.notion_page import NotionPage logger = logging.getLogger(__name__) @@ -99,12 +99,15 @@ def fetch_block_children(session: Session, block_id: str) -> list[NotionBlock]: def convert_block(block: NotionBlock) -> Any: - match type(block): - case NotionParagraph: - return { - "type": "paragraph", - "content": block.rich_text[0].plain_text # TODO: handle multiple - } + if isinstance(block.specific, NotionParagraph): + content = "" + if len(block.specific.rich_text) > 0: + # TODO: handle multiple of these + content = block.specific.rich_text[0].plain_text + return { + "type": "paragraph", + "content": content, + } def convert_block_list(blocks: list[NotionBlock]) -> Any: From c8380391a07c9a67394fe1a5adb0cb06d70bf4a8 Mon Sep 17 00:00:00 2001 From: Baptiste Prevot Date: Tue, 3 Jun 2025 01:07:32 +0200 Subject: [PATCH 060/104] fixup --- src/backend/core/notion_schemas/notion_block.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/backend/core/notion_schemas/notion_block.py b/src/backend/core/notion_schemas/notion_block.py index 4394098e9..689d257da 100644 --- a/src/backend/core/notion_schemas/notion_block.py +++ b/src/backend/core/notion_schemas/notion_block.py @@ -198,6 +198,7 @@ class NotionBlockUnsupported(BaseModel): NotionBlockType.TO_DO, NotionBlockType.TOGGLE, NotionBlockType.VIDEO, + NotionBlockType.UNSUPPORTED, ] From b5e3f1aac38a20af61971530d98b46b7f718cfa5 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 3 Jun 2025 01:11:08 +0200 Subject: [PATCH 061/104] Unionize all of these rich folks --- src/backend/core/services/notion_import.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index d5943a5cb..41d1ca4a4 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -101,9 +101,8 @@ def fetch_block_children(session: Session, block_id: str) -> list[NotionBlock]: def convert_block(block: NotionBlock) -> Any: if isinstance(block.specific, NotionParagraph): content = "" - if len(block.specific.rich_text) > 0: - # TODO: handle multiple of these - content = block.specific.rich_text[0].plain_text + for rich_text in block.specific.rich_text: + content += rich_text.plain_text return { "type": "paragraph", "content": content, From 6aabba6bc26b1bd5540a3456ea57c695b613ebb8 Mon Sep 17 00:00:00 2001 From: Baptiste Prevot Date: Tue, 3 Jun 2025 01:19:04 +0200 Subject: [PATCH 062/104] notion-import: tidy up Signed-off-by: Baptiste Prevot --- src/backend/core/api/viewsets.py | 26 +++++++++++------- .../core/notion_schemas/notion_block.py | 1 + src/backend/core/services/notion_import.py | 27 ++++++++++--------- 3 files changed, 31 insertions(+), 23 deletions(-) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 8ef294cbd..ebc2bd6b7 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -5,7 +5,7 @@ import logging import uuid from collections import defaultdict -from urllib.parse import unquote, urlparse, urlencode +from urllib.parse import unquote, urlencode, urlparse from django.conf import settings from django.contrib.postgres.aggregates import ArrayAgg @@ -18,8 +18,8 @@ from django.db.models.expressions import RawSQL from django.db.models.functions import Left, Length from django.http import Http404, StreamingHttpResponse -from django.utils.functional import cached_property from django.shortcuts import redirect +from django.utils.functional import cached_property from django.utils.text import capfirst, slugify from django.utils.translation import gettext_lazy as _ @@ -1821,22 +1821,27 @@ def _load_theme_customization(self): return theme_customization + notion_client_id = "206d872b-594c-80de-94ff-003760c352e4" notion_client_secret = "XXX" notion_redirect_uri = "https://emersion.fr/notion-redirect" + @drf.decorators.api_view() def notion_import_redirect(request): if "notion_token" in request.session: return redirect("/api/v1.0/notion_import/run") - query = urlencode({ - "client_id": notion_client_id, - "response_type": "code", - "owner": "user", - "redirect_uri": notion_redirect_uri, - }) + query = urlencode( + { + "client_id": notion_client_id, + "response_type": "code", + "owner": "user", + "redirect_uri": notion_redirect_uri, + } + ) return redirect("https://api.notion.com/v1/oauth/authorize?" + query) + @drf.decorators.api_view() def notion_import_callback(request): code = request.GET.get("code") @@ -1855,15 +1860,16 @@ def notion_import_callback(request): request.session["notion_token"] = data["access_token"] return redirect("/api/v1.0/notion_import/run") + # @drf.decorators.api_view(["POST"]) @drf.decorators.api_view() def notion_import_run(request): if "notion_token" not in request.session: raise drf.exceptions.PermissionDenied() - pages = import_notion(request.session['notion_token']) + pages_and_blocks = import_notion(request.session["notion_token"]) - for page, blocks in pages: + for page, blocks in pages_and_blocks: document_content = YdocConverter().convert_blocks(blocks) obj = models.Document.add_root( diff --git a/src/backend/core/notion_schemas/notion_block.py b/src/backend/core/notion_schemas/notion_block.py index 689d257da..dcb14e576 100644 --- a/src/backend/core/notion_schemas/notion_block.py +++ b/src/backend/core/notion_schemas/notion_block.py @@ -18,6 +18,7 @@ class NotionBlock(BaseModel): specific: "NotionBlockSpecifics" has_children: bool children: list["NotionBlock"] = Field(init=False, default_factory=list) + # This is not part of the API response, but is used to store children blocks @model_validator(mode="before") @classmethod diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index 41d1ca4a4..1b73a4719 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -98,18 +98,19 @@ def fetch_block_children(session: Session, block_id: str) -> list[NotionBlock]: return blocks -def convert_block(block: NotionBlock) -> Any: - if isinstance(block.specific, NotionParagraph): - content = "" - for rich_text in block.specific.rich_text: - content += rich_text.plain_text - return { - "type": "paragraph", - "content": content, - } - - -def convert_block_list(blocks: list[NotionBlock]) -> Any: +def convert_block(block: NotionBlock) -> dict[str, Any] | None: + match block.specific: + case NotionParagraph(): + content = "" + for rich_text in block.specific.rich_text: + content += rich_text.plain_text + return { + "type": "paragraph", + "content": content, + } + + +def convert_block_list(blocks: list[NotionBlock]) -> list[dict[str, Any]]: converted_blocks = [] for block in blocks: converted_block = convert_block(block) @@ -119,7 +120,7 @@ def convert_block_list(blocks: list[NotionBlock]) -> Any: return converted_blocks -def import_notion(token: str) -> list[(NotionPage, Any)]: +def import_notion(token: str) -> list[tuple[NotionPage, list[dict[str, Any]]]]: """Recursively imports all Notion pages and blocks accessible using the given token.""" session = build_notion_session(token) root_pages = fetch_root_pages(session) From c8d44e846a48e86a2b66c466dd457d62afc3b976 Mon Sep 17 00:00:00 2001 From: Baptiste Prevot Date: Tue, 3 Jun 2025 01:24:36 +0200 Subject: [PATCH 063/104] notion-schemas: better unsupported objects --- .../core/notion_schemas/notion_block.py | 62 +++++++++---------- 1 file changed, 28 insertions(+), 34 deletions(-) diff --git a/src/backend/core/notion_schemas/notion_block.py b/src/backend/core/notion_schemas/notion_block.py index dcb14e576..04d2ab746 100644 --- a/src/backend/core/notion_schemas/notion_block.py +++ b/src/backend/core/notion_schemas/notion_block.py @@ -179,42 +179,36 @@ class NotionLinkPreview(BaseModel): class NotionBlockUnsupported(BaseModel): """FIXME: Maybe https://github.com/pydantic/pydantic/discussions/4928#discussioncomment-13079554 would be better""" - block_type: Literal[ - NotionBlockType.BOOKMARK, - NotionBlockType.BREADCRUMB, - NotionBlockType.CALLOUT, - NotionBlockType.CHILD_DATABASE, - NotionBlockType.CHILD_PAGE, - NotionBlockType.COLUMN, - NotionBlockType.COLUMN_LIST, - NotionBlockType.EQUATION, - NotionBlockType.LINK_TO_PAGE, - NotionBlockType.PDF, - NotionBlockType.QUOTE, - NotionBlockType.SYNCED_BLOCK, - NotionBlockType.TABLE, - NotionBlockType.TABLE_OF_CONTENTS, - NotionBlockType.TABLE_ROW, - NotionBlockType.TEMPLATE, - NotionBlockType.TO_DO, - NotionBlockType.TOGGLE, - NotionBlockType.VIDEO, - NotionBlockType.UNSUPPORTED, - ] + block_type: str + raw: dict[str, Any] | None = None + + @model_validator(mode="before") + @classmethod + def put_all_in_raw(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + + if "raw" not in data: + data["raw"] = data.copy() + + return data NotionBlockSpecifics = Annotated[ - NotionHeading1 - | NotionHeading2 - | NotionHeading3 - | NotionParagraph - | NotionNumberedListItem - | NotionBulletedListItem - | NotionCode - | NotionDivider - | NotionEmbed - | NotionFile - | NotionImage + Annotated[ + NotionHeading1 + | NotionHeading2 + | NotionHeading3 + | NotionParagraph + | NotionNumberedListItem + | NotionBulletedListItem + | NotionCode + | NotionDivider + | NotionEmbed + | NotionFile + | NotionImage, + Discriminator(discriminator="block_type"), + ] | NotionBlockUnsupported, - Discriminator(discriminator="block_type"), + Field(union_mode="left_to_right"), ] From 0c86a9b09ab6db9175dbdaf95f0882f8efc658cf Mon Sep 17 00:00:00 2001 From: Nicolas Ritouet Date: Sun, 22 Jun 2025 10:58:54 +0200 Subject: [PATCH 064/104] Add import button --- .../impress/src/components/DropdownMenu.tsx | 5 +- .../left-panel/components/LeftPanelHeader.tsx | 67 ++++++++++++++++--- 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/src/frontend/apps/impress/src/components/DropdownMenu.tsx b/src/frontend/apps/impress/src/components/DropdownMenu.tsx index 5513ccb78..d2a722316 100644 --- a/src/frontend/apps/impress/src/components/DropdownMenu.tsx +++ b/src/frontend/apps/impress/src/components/DropdownMenu.tsx @@ -13,6 +13,7 @@ export type DropdownMenuOption = { danger?: boolean; isSelected?: boolean; disabled?: boolean; + padding?: BoxProps['$padding']; show?: boolean; }; @@ -129,7 +130,9 @@ export const DropdownMenu = ({ $justify="space-between" $background={colorsTokens['greyscale-000']} $color={colorsTokens['primary-600']} - $padding={{ vertical: 'xs', horizontal: 'base' }} + $padding={ + option.padding ?? { vertical: 'xs', horizontal: 'base' } + } $width="100%" $gap={spacingsTokens['base']} $css={css` diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx index 5733b0dff..b13f690bc 100644 --- a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx @@ -1,16 +1,16 @@ import { Button } from '@openfun/cunningham-react'; -import { useRouter } from 'next/router'; +import { t } from 'i18next'; +import { useRouter } from 'next/navigation'; import { PropsWithChildren, useCallback, useState } from 'react'; -import { Box, Icon, SeparatedSection } from '@/components'; -import { DocSearchModal, DocSearchTarget } from '@/docs/doc-search/'; +import { Box, DropdownMenu, Icon, SeparatedSection } from '@/components'; +import { useCreateDoc } from '@/docs/doc-management'; +import { DocSearchModal } from '@/docs/doc-search'; import { useAuth } from '@/features/auth'; import { useCmdK } from '@/hook/useCmdK'; import { useLeftPanelStore } from '../stores'; -import { LeftPanelHeaderButton } from './LeftPanelHeaderButton'; - export const LeftPanelHeader = ({ children }: PropsWithChildren) => { const router = useRouter(); const { authenticated } = useAuth(); @@ -35,11 +35,32 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => { useCmdK(openSearchModal); const { togglePanel } = useLeftPanelStore(); + const { mutate: createDoc, isPending: isCreatingDoc } = useCreateDoc({ + onSuccess: (doc) => { + router.push(`/docs/${doc.id}`); + togglePanel(); + }, + }); + const goToHome = () => { - void router.push('/'); + router.push('/'); togglePanel(); }; + const createNewDoc = () => { + createDoc(); + }; + + const handleImportFilesystem = () => { + // TODO: Implement filesystem import + }; + + const handleImportNotion = () => { + const baseApiUrl = process.env.NEXT_PUBLIC_API_ORIGIN; + const notionAuthUrl = `${baseApiUrl}/api/v1.0/notion_import/redirect`; + window.location.href = notionAuthUrl; + }; + return ( <> @@ -71,8 +92,38 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => { /> )} - - {authenticated && } + {authenticated && ( + + + + )} {children} From 7eea4818454b25ac5eda9ce06bc27a88cf7daf69 Mon Sep 17 00:00:00 2001 From: Baptiste Prevot Date: Tue, 3 Jun 2025 01:37:56 +0200 Subject: [PATCH 065/104] notion-schemas: add tables --- .../core/notion_schemas/notion_block.py | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/backend/core/notion_schemas/notion_block.py b/src/backend/core/notion_schemas/notion_block.py index 04d2ab746..b22258528 100644 --- a/src/backend/core/notion_schemas/notion_block.py +++ b/src/backend/core/notion_schemas/notion_block.py @@ -176,9 +176,25 @@ class NotionLinkPreview(BaseModel): url: str -class NotionBlockUnsupported(BaseModel): - """FIXME: Maybe https://github.com/pydantic/pydantic/discussions/4928#discussioncomment-13079554 would be better""" +class NotionTable(BaseModel): + """https://developers.notion.com/reference/block#table + + The children of this block are NotionTableRow blocks.""" + + block_type: Literal[NotionBlockType.TABLE] = NotionBlockType.TABLE + table_width: int + has_column_header: bool + has_row_header: bool + +class NotionTableRow(BaseModel): + """https://developers.notion.com/reference/block#table-row""" + + block_type: Literal[NotionBlockType.TABLE_ROW] = NotionBlockType.TABLE_ROW + cells: list[list[NotionRichText]] # Each cell is a list of rich text objects + + +class NotionBlockUnsupported(BaseModel): block_type: str raw: dict[str, Any] | None = None @@ -206,7 +222,10 @@ def put_all_in_raw(cls, data: Any) -> Any: | NotionDivider | NotionEmbed | NotionFile - | NotionImage, + | NotionImage + | NotionLinkPreview + | NotionTable + | NotionTableRow, Discriminator(discriminator="block_type"), ] | NotionBlockUnsupported, From 04965202a67f1ea84398bc8fd6c04f8f0195f292 Mon Sep 17 00:00:00 2001 From: Baptiste Prevot Date: Tue, 3 Jun 2025 01:43:57 +0200 Subject: [PATCH 066/104] notion-schemas: blocks: add child-page and video --- .../core/notion_schemas/notion_block.py | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/backend/core/notion_schemas/notion_block.py b/src/backend/core/notion_schemas/notion_block.py index b22258528..de39f1a7d 100644 --- a/src/backend/core/notion_schemas/notion_block.py +++ b/src/backend/core/notion_schemas/notion_block.py @@ -169,6 +169,13 @@ class NotionImage(BaseModel): # FIXME: this actually contains a file reference which will be defined for the above, but with the "image" attribute +class NotionVideo(BaseModel): + """https://developers.notion.com/reference/block#video""" + + block_type: Literal[NotionBlockType.VIDEO] = NotionBlockType.VIDEO + # FIXME: this actually contains a file reference which will be defined for the above, but with the "video" attribute + + class NotionLinkPreview(BaseModel): """https://developers.notion.com/reference/block#link-preview""" @@ -194,6 +201,15 @@ class NotionTableRow(BaseModel): cells: list[list[NotionRichText]] # Each cell is a list of rich text objects +class NotionChildPage(BaseModel): + """https://developers.notion.com/reference/block#child-page + + My guess is that the actual child page is a child of this block ? We don't have the id...""" + + block_type: Literal[NotionBlockType.CHILD_PAGE] = NotionBlockType.CHILD_PAGE + title: str + + class NotionBlockUnsupported(BaseModel): block_type: str raw: dict[str, Any] | None = None @@ -223,9 +239,11 @@ def put_all_in_raw(cls, data: Any) -> Any: | NotionEmbed | NotionFile | NotionImage + | NotionVideo | NotionLinkPreview | NotionTable - | NotionTableRow, + | NotionTableRow + | NotionChildPage, Discriminator(discriminator="block_type"), ] | NotionBlockUnsupported, From 70c283d4c96d0804fce3b1cdf66600b0efa511c6 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 3 Jun 2025 09:44:00 +0200 Subject: [PATCH 067/104] Don't reuse token in redirect endpoint --- src/backend/core/api/viewsets.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index ebc2bd6b7..072488fd4 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1829,8 +1829,6 @@ def _load_theme_customization(self): @drf.decorators.api_view() def notion_import_redirect(request): - if "notion_token" in request.session: - return redirect("/api/v1.0/notion_import/run") query = urlencode( { "client_id": notion_client_id, From 4699870e688a146226d2f6971ee53b2f5f7bfda4 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 3 Jun 2025 09:51:32 +0200 Subject: [PATCH 068/104] Move Notion API details to settings --- src/backend/core/api/viewsets.py | 13 ++++--------- src/backend/impress/settings.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 072488fd4..2f3ffebe8 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1822,19 +1822,14 @@ def _load_theme_customization(self): return theme_customization -notion_client_id = "206d872b-594c-80de-94ff-003760c352e4" -notion_client_secret = "XXX" -notion_redirect_uri = "https://emersion.fr/notion-redirect" - - @drf.decorators.api_view() def notion_import_redirect(request): query = urlencode( { - "client_id": notion_client_id, + "client_id": settings.NOTION_CLIENT_ID, "response_type": "code", "owner": "user", - "redirect_uri": notion_redirect_uri, + "redirect_uri": settings.NOTION_REDIRECT_URI, } ) return redirect("https://api.notion.com/v1/oauth/authorize?" + query) @@ -1845,12 +1840,12 @@ def notion_import_callback(request): code = request.GET.get("code") resp = requests.post( "https://api.notion.com/v1/oauth/token", - auth=requests.auth.HTTPBasicAuth(notion_client_id, notion_client_secret), + auth=requests.auth.HTTPBasicAuth(settings.NOTION_CLIENT_ID, settings.NOTION_CLIENT_SECRET), headers={"Accept": "application/json"}, data={ "grant_type": "authorization_code", "code": code, - "redirect_uri": notion_redirect_uri, + "redirect_uri": settings.NOTION_REDIRECT_URI, }, ) resp.raise_for_status() diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 7093aad6d..12c12cc3a 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -649,6 +649,22 @@ class Base(Configuration): environ_prefix=None, ) + NOTION_CLIENT_ID = values.Value( + default=None, + environ_name="NOTION_CLIENT_ID", + environ_prefix=None, + ) + NOTION_CLIENT_SECRET = values.Value( + default=None, + environ_name="NOTION_CLIENT_SECRET", + environ_prefix=None, + ) + NOTION_REDIRECT_URI = values.Value( + default=None, + environ_name="NOTION_REDIRECT_URI", + environ_prefix=None, + ) + # Logging # We want to make it easy to log to console but by default we log production # to Sentry and don't want to log to console. From 979bc07383209e1d23309ed01b5536b813ea482a Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 3 Jun 2025 10:08:04 +0200 Subject: [PATCH 069/104] Introduce ImportedDocument --- src/backend/core/api/viewsets.py | 8 ++++---- src/backend/core/services/notion_import.py | 15 ++++++++++----- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 2f3ffebe8..66f5f9e4a 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1860,15 +1860,15 @@ def notion_import_run(request): if "notion_token" not in request.session: raise drf.exceptions.PermissionDenied() - pages_and_blocks = import_notion(request.session["notion_token"]) + imported_docs = import_notion(request.session["notion_token"]) - for page, blocks in pages_and_blocks: - document_content = YdocConverter().convert_blocks(blocks) + for imported_doc in imported_docs: + document_content = YdocConverter().convert_blocks(imported_doc.blocks) obj = models.Document.add_root( depth=1, creator=request.user, - title=page.get_title() or "J'aime les courgettes", + title=imported_doc.page.get_title() or "J'aime les courgettes", link_reach=models.LinkReachChoices.RESTRICTED, content=document_content, ) diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index 1b73a4719..f8b677d30 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -4,7 +4,7 @@ from typing import Any import requests -from pydantic import TypeAdapter +from pydantic import BaseModel, TypeAdapter from requests import Session from ..notion_schemas.notion_block import NotionBlock, NotionParagraph @@ -120,14 +120,19 @@ def convert_block_list(blocks: list[NotionBlock]) -> list[dict[str, Any]]: return converted_blocks -def import_notion(token: str) -> list[tuple[NotionPage, list[dict[str, Any]]]]: +class ImportedDocument(BaseModel): + page: NotionPage + blocks: list[dict[str, Any]] + + +def import_notion(token: str) -> list[ImportedDocument]: """Recursively imports all Notion pages and blocks accessible using the given token.""" session = build_notion_session(token) root_pages = fetch_root_pages(session) - pages_and_blocks = [] + docs = [] for page in root_pages: blocks = fetch_block_children(session, page.id) logger.info(f"Page {page.get_title()} (id {page.id})") logger.info(blocks) - pages_and_blocks.append((page, convert_block_list(blocks))) - return pages_and_blocks + docs.append(ImportedDocument(page=page, blocks=convert_block_list(blocks))) + return docs From 4b56e6c4f323aa729f05fefef6896990d724711f Mon Sep 17 00:00:00 2001 From: Clara Ni Date: Tue, 3 Jun 2025 10:39:44 +0200 Subject: [PATCH 070/104] handle heading blocks --- src/backend/core/services/notion_import.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index f8b677d30..55de1bf5a 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -7,7 +7,13 @@ from pydantic import BaseModel, TypeAdapter from requests import Session -from ..notion_schemas.notion_block import NotionBlock, NotionParagraph +from ..notion_schemas.notion_block import ( + NotionBlock, + NotionParagraph, + NotionHeading1, + NotionHeading2, + NotionHeading3, +) from ..notion_schemas.notion_page import NotionPage logger = logging.getLogger(__name__) @@ -108,6 +114,14 @@ def convert_block(block: NotionBlock) -> dict[str, Any] | None: "type": "paragraph", "content": content, } + case NotionHeading1() | NotionHeading2() | NotionHeading3(): + content = "" + for rich_text in block.specific.rich_text: + content += rich_text.plain_text + return { + "type": "heading", + "content": content, + } def convert_block_list(blocks: list[NotionBlock]) -> list[dict[str, Any]]: From d1d85efd010e969024d2021a1fa2997bb7f7f3ee Mon Sep 17 00:00:00 2001 From: Baptiste Prevot Date: Tue, 3 Jun 2025 01:52:56 +0200 Subject: [PATCH 071/104] notion-import: tidy parsing --- src/backend/core/services/notion_import.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index 55de1bf5a..d212dc8c2 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -9,12 +9,13 @@ from ..notion_schemas.notion_block import ( NotionBlock, - NotionParagraph, NotionHeading1, NotionHeading2, NotionHeading3, + NotionParagraph, ) from ..notion_schemas.notion_page import NotionPage +from ..notion_schemas.notion_rich_text import NotionRichText logger = logging.getLogger(__name__) @@ -104,23 +105,21 @@ def fetch_block_children(session: Session, block_id: str) -> list[NotionBlock]: return blocks +def convert_rich_texts(rich_texts: list[NotionRichText]) -> str: + return "".join(rich_text.plain_text for rich_text in rich_texts) + + def convert_block(block: NotionBlock) -> dict[str, Any] | None: match block.specific: case NotionParagraph(): - content = "" - for rich_text in block.specific.rich_text: - content += rich_text.plain_text return { "type": "paragraph", - "content": content, + "content": convert_rich_texts(block.specific.rich_text), } case NotionHeading1() | NotionHeading2() | NotionHeading3(): - content = "" - for rich_text in block.specific.rich_text: - content += rich_text.plain_text return { "type": "heading", - "content": content, + "content": convert_rich_texts(block.specific.rich_text), } From 721a888f998c32b00d2fa36c2769253bbfe2d086 Mon Sep 17 00:00:00 2001 From: Baptiste Prevot Date: Tue, 3 Jun 2025 02:03:11 +0200 Subject: [PATCH 072/104] notion-import: handle dividers --- src/backend/core/services/notion_import.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index d212dc8c2..29ace550a 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -9,6 +9,7 @@ from ..notion_schemas.notion_block import ( NotionBlock, + NotionDivider, NotionHeading1, NotionHeading2, NotionHeading3, @@ -120,6 +121,13 @@ def convert_block(block: NotionBlock) -> dict[str, Any] | None: return { "type": "heading", "content": convert_rich_texts(block.specific.rich_text), + "level": block.specific.block_type.value.split("_")[ + -1 + ], # e.g., "1", "2", or "3" + } + case NotionDivider(): + return { + "type": "divider", } From 35e8ef4dfeaf0a4ae4f861099e60222f43e5f203 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 3 Jun 2025 11:34:59 +0200 Subject: [PATCH 073/104] Add support for child pages --- src/backend/core/api/viewsets.py | 55 +++++++++++----- .../core/notion_schemas/notion_page.py | 40 +++++++++++- src/backend/core/services/notion_import.py | 65 +++++++++++++++---- 3 files changed, 132 insertions(+), 28 deletions(-) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 66f5f9e4a..c0da159fb 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1854,6 +1854,44 @@ def notion_import_callback(request): return redirect("/api/v1.0/notion_import/run") +def _import_notion_child_page(imported_doc, parent_doc, user, imported_docs_by_page_id): + document_content = YdocConverter().convert_blocks(imported_doc.blocks) + + obj = parent_doc.add_child( + creator=user, + title=imported_doc.page.get_title() or "J'aime les carottes", + content=document_content, + ) + + imported_docs_by_page_id[imported_doc.page.id] = obj + + for child in imported_doc.children: + _import_notion_child_page(child, obj, user, imported_docs_by_page_id) + + +def _import_notion_root_page(imported_doc, user, imported_docs_by_page_id): + document_content = YdocConverter().convert_blocks(imported_doc.blocks) + + obj = models.Document.add_root( + depth=1, + creator=user, + title=imported_doc.page.get_title() or "J'aime les courgettes", + link_reach=models.LinkReachChoices.RESTRICTED, + content=document_content, + ) + + models.DocumentAccess.objects.create( + document=obj, + user=user, + role=models.RoleChoices.OWNER, + ) + + imported_docs_by_page_id[imported_doc.page.id] = obj + + for child in imported_doc.children: + _import_notion_child_page(child, obj, user, imported_docs_by_page_id) + + # @drf.decorators.api_view(["POST"]) @drf.decorators.api_view() def notion_import_run(request): @@ -1862,21 +1900,8 @@ def notion_import_run(request): imported_docs = import_notion(request.session["notion_token"]) + imported_docs_by_page_id = {} for imported_doc in imported_docs: - document_content = YdocConverter().convert_blocks(imported_doc.blocks) - - obj = models.Document.add_root( - depth=1, - creator=request.user, - title=imported_doc.page.get_title() or "J'aime les courgettes", - link_reach=models.LinkReachChoices.RESTRICTED, - content=document_content, - ) - - models.DocumentAccess.objects.create( - document=obj, - user=request.user, - role=models.RoleChoices.OWNER, - ) + _import_notion_root_page(imported_doc, request.user, imported_docs_by_page_id) return drf.response.Response({"sava": "oui et toi ?"}) diff --git a/src/backend/core/notion_schemas/notion_page.py b/src/backend/core/notion_schemas/notion_page.py index eec379f1f..8493631ce 100644 --- a/src/backend/core/notion_schemas/notion_page.py +++ b/src/backend/core/notion_schemas/notion_page.py @@ -1,6 +1,8 @@ from datetime import datetime +from enum import StrEnum +from typing import Annotated, Any, Literal -from pydantic import BaseModel +from pydantic import BaseModel, Discriminator, Field, ValidationError, model_validator from .notion_rich_text import NotionRichText @@ -8,9 +10,45 @@ class NotionFile(BaseModel): ... +class NotionParentType(StrEnum): + DATABASE = "database_id" + PAGE = "page_id" + WORKSPACE = "workspace" + BLOCK = "block_id" + + +class NotionParentDatabase(BaseModel): + type: Literal[NotionParentType.DATABASE] = NotionParentType.DATABASE + database_id: str + + +class NotionParentPage(BaseModel): + type: Literal[NotionParentType.PAGE] = NotionParentType.PAGE + page_id: str + + +class NotionParentWorkspace(BaseModel): + type: Literal[NotionParentType.WORKSPACE] = NotionParentType.WORKSPACE + + +class NotionParentBlock(BaseModel): + type: Literal[NotionParentType.BLOCK] = NotionParentType.BLOCK + block_id: str + + +NotionParent = Annotated[ + NotionParentDatabase + | NotionParentPage + | NotionParentWorkspace + | NotionParentBlock, + Discriminator(discriminator="type"), +] + + class NotionPage(BaseModel): id: str archived: bool + parent: NotionParent # created_time: datetime # last_edited_time: datetime diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index 29ace550a..515bcb412 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -14,8 +14,9 @@ NotionHeading2, NotionHeading3, NotionParagraph, + NotionChildPage, ) -from ..notion_schemas.notion_page import NotionPage +from ..notion_schemas.notion_page import NotionPage, NotionParentWorkspace, NotionParentBlock, NotionParentPage from ..notion_schemas.notion_rich_text import NotionRichText logger = logging.getLogger(__name__) @@ -50,7 +51,7 @@ def search_notion(session: Session, start_cursor: str) -> dict[str, Any]: return response.json() -def fetch_root_pages(session: Session) -> list[NotionPage]: +def fetch_all_pages(session: Session) -> list[NotionPage]: pages = [] cursor = "" has_more = True @@ -59,9 +60,6 @@ def fetch_root_pages(session: Session) -> list[NotionPage]: response = search_notion(session, start_cursor=cursor) for item in response["results"]: - if item.get("parent", {}).get("type") != "workspace": - continue - assert item["object"] == "page" pages.append(NotionPage.model_validate(item)) @@ -143,17 +141,60 @@ def convert_block_list(blocks: list[NotionBlock]) -> list[dict[str, Any]]: class ImportedDocument(BaseModel): page: NotionPage - blocks: list[dict[str, Any]] + blocks: list[dict[str, Any]] = [] + children: list["ImportedDocument"] = [] + +def find_page(id: str, pages: list[NotionPage]): + for page in all_pages: + if page.id == id: + return page + return None + +def find_block_child_page(block_id: str, all_pages: list[NotionPage]): + for page in all_pages: + if isinstance(page.parent, NotionParentBlock) and page.parent.block_id == block_id: + return page + return None + + +def convert_child_pages(session: Session, parent: NotionPage, blocks: list[NotionBlock], all_pages: list[NotionPage]) -> list[ImportedDocument]: + children = [] + + for page in all_pages: + if isinstance(page.parent, NotionParentPage) and page.parent.page_id == parent.id: + children.append(import_page(session, page, all_pages)) + + for block in blocks: + if not isinstance(block.specific, NotionChildPage): + continue + + # TODO + #parent_page = find_block_child_page(block.id, all_pages) + #if parent_page == None: + # logger.warning(f"Cannot find parent of block {block.id}") + # continue + #children.append(import_page(session, parent_page, all_pages)) + + return children + + +def import_page(session: Session, page: NotionPage, all_pages: list[NotionPage]) -> ImportedDocument: + blocks = fetch_block_children(session, page.id) + logger.info(f"Page {page.get_title()} (id {page.id})") + logger.info(blocks) + return ImportedDocument( + page=page, + blocks=convert_block_list(blocks), + children=convert_child_pages(session, page, blocks, all_pages), + ) def import_notion(token: str) -> list[ImportedDocument]: """Recursively imports all Notion pages and blocks accessible using the given token.""" session = build_notion_session(token) - root_pages = fetch_root_pages(session) + all_pages = fetch_all_pages(session) docs = [] - for page in root_pages: - blocks = fetch_block_children(session, page.id) - logger.info(f"Page {page.get_title()} (id {page.id})") - logger.info(blocks) - docs.append(ImportedDocument(page=page, blocks=convert_block_list(blocks))) + for page in all_pages: + if isinstance(page.parent, NotionParentWorkspace): + docs.append(import_page(session, page, all_pages)) return docs From cf343c6a2e15e041983262b0f32abe859f35b2ae Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 3 Jun 2025 11:41:17 +0200 Subject: [PATCH 074/104] Add DocumentAccess for child docs, just in case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No idea if it works… --- src/backend/core/api/viewsets.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index c0da159fb..040d93d88 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1863,6 +1863,12 @@ def _import_notion_child_page(imported_doc, parent_doc, user, imported_docs_by_p content=document_content, ) + models.DocumentAccess.objects.create( + document=obj, + user=user, + role=models.RoleChoices.OWNER, + ) + imported_docs_by_page_id[imported_doc.page.id] = obj for child in imported_doc.children: From 48ba52bb5939d72036b30cb56021d6dc996699c6 Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 3 Jun 2025 11:51:50 +0200 Subject: [PATCH 075/104] Introduce NotionFile --- .../core/notion_schemas/notion_block.py | 3 +- .../core/notion_schemas/notion_file.py | 28 +++++++++++++++++++ .../core/notion_schemas/notion_page.py | 4 +-- 3 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 src/backend/core/notion_schemas/notion_file.py diff --git a/src/backend/core/notion_schemas/notion_block.py b/src/backend/core/notion_schemas/notion_block.py index de39f1a7d..9942364e9 100644 --- a/src/backend/core/notion_schemas/notion_block.py +++ b/src/backend/core/notion_schemas/notion_block.py @@ -6,6 +6,7 @@ from .notion_color import NotionColor from .notion_rich_text import NotionRichText +from .notion_file import NotionFile """Usage: NotionBlock.model_validate(response.json())""" @@ -166,7 +167,7 @@ class NotionImage(BaseModel): """https://developers.notion.com/reference/block#image""" block_type: Literal[NotionBlockType.IMAGE] = NotionBlockType.IMAGE - # FIXME: this actually contains a file reference which will be defined for the above, but with the "image" attribute + file: NotionFile class NotionVideo(BaseModel): diff --git a/src/backend/core/notion_schemas/notion_file.py b/src/backend/core/notion_schemas/notion_file.py new file mode 100644 index 000000000..59b4a9737 --- /dev/null +++ b/src/backend/core/notion_schemas/notion_file.py @@ -0,0 +1,28 @@ +from enum import StrEnum +from typing import Annotated, Literal + +from pydantic import BaseModel, Discriminator + +class NotionFileType(StrEnum): + HOSTED = "file" + UPLOAD = "file_upload" + EXTERNAL = "external" + +class NotionFileHosted(BaseModel): + type: Literal[NotionFileType.HOSTED] = NotionFileType.HOSTED + file: dict # TODO + +class NotionFileUpload(BaseModel): + type: Literal[NotionFileType.UPLOAD] = NotionFileType.UPLOAD + file_upload: dict # TODO + +class NotionFileExternal(BaseModel): + type: Literal[NotionFileType.EXTERNAL] = NotionFileType.EXTERNAL + external: dict # TODO + +NotionFile = Annotated[ + NotionFileHosted + | NotionFileUpload + | NotionFileExternal, + Discriminator(discriminator="type"), +] diff --git a/src/backend/core/notion_schemas/notion_page.py b/src/backend/core/notion_schemas/notion_page.py index 8493631ce..2aa0b0e58 100644 --- a/src/backend/core/notion_schemas/notion_page.py +++ b/src/backend/core/notion_schemas/notion_page.py @@ -5,9 +5,7 @@ from pydantic import BaseModel, Discriminator, Field, ValidationError, model_validator from .notion_rich_text import NotionRichText - - -class NotionFile(BaseModel): ... +from .notion_file import NotionFile class NotionParentType(StrEnum): From fb277088ee53b696f8ec6d94bd95e5c2d8d084d7 Mon Sep 17 00:00:00 2001 From: Baptiste Prevot Date: Tue, 3 Jun 2025 03:51:03 +0200 Subject: [PATCH 076/104] notion-import: add table & fix converter error message --- .../core/notion_schemas/notion_block.py | 4 +- .../core/services/converter_services.py | 5 +- src/backend/core/services/notion_import.py | 113 ++++++++++++++++-- .../src/handlers/convertBlocksHandler.ts | 2 +- 4 files changed, 108 insertions(+), 16 deletions(-) diff --git a/src/backend/core/notion_schemas/notion_block.py b/src/backend/core/notion_schemas/notion_block.py index 9942364e9..bbc3caaa0 100644 --- a/src/backend/core/notion_schemas/notion_block.py +++ b/src/backend/core/notion_schemas/notion_block.py @@ -211,7 +211,7 @@ class NotionChildPage(BaseModel): title: str -class NotionBlockUnsupported(BaseModel): +class NotionUnsupported(BaseModel): block_type: str raw: dict[str, Any] | None = None @@ -247,6 +247,6 @@ def put_all_in_raw(cls, data: Any) -> Any: | NotionChildPage, Discriminator(discriminator="block_type"), ] - | NotionBlockUnsupported, + | NotionUnsupported, Field(union_mode="left_to_right"), ] diff --git a/src/backend/core/services/converter_services.py b/src/backend/core/services/converter_services.py index 47d9cf8ac..7fa603a11 100644 --- a/src/backend/core/services/converter_services.py +++ b/src/backend/core/services/converter_services.py @@ -93,7 +93,10 @@ def convert_blocks(self, blocks): timeout=settings.CONVERSION_API_TIMEOUT, verify=settings.CONVERSION_API_SECURE, ) - response.raise_for_status() + if not response.ok: + raise ValueError( + f"Conversion service returned an error: {response.status_code} - {response.text}" + ) conversion_response = response.json() except requests.RequestException as err: diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index 515bcb412..5ce4d5741 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -1,3 +1,4 @@ +import json import logging from dataclasses import dataclass from enum import Enum @@ -9,14 +10,22 @@ from ..notion_schemas.notion_block import ( NotionBlock, + NotionChildPage, NotionDivider, NotionHeading1, NotionHeading2, NotionHeading3, NotionParagraph, - NotionChildPage, + NotionTable, + NotionTableRow, + NotionUnsupported, +) +from ..notion_schemas.notion_page import ( + NotionPage, + NotionParentBlock, + NotionParentPage, + NotionParentWorkspace, ) -from ..notion_schemas.notion_page import NotionPage, NotionParentWorkspace, NotionParentBlock, NotionParentPage from ..notion_schemas.notion_rich_text import NotionRichText logger = logging.getLogger(__name__) @@ -60,7 +69,9 @@ def fetch_all_pages(session: Session) -> list[NotionPage]: response = search_notion(session, start_cursor=cursor) for item in response["results"]: - assert item["object"] == "page" + if item["object"] != "page": + logger.warning(f"Skipping non-page object: {item['object']}") + continue pages.append(NotionPage.model_validate(item)) @@ -123,9 +134,72 @@ def convert_block(block: NotionBlock) -> dict[str, Any] | None: -1 ], # e.g., "1", "2", or "3" } - case NotionDivider(): + # case NotionDivider(): + # return { + # "type": "divider", + # } + case NotionTable(): + rows: list[NotionTableRow] = [child.specific for child in block.children] # type: ignore # I don't know how to assert properly + if len(rows) == 0: + return { + "type": "paragraph", + "content": "Empty table ?!", + } + + n_columns = len( + rows[0].cells + ) # I'll assume all rows have the same number of cells + if n_columns == 0: + return { + "type": "paragraph", + "content": "Empty row ?!", + } + if not all(len(row.cells) == n_columns for row in rows): + return { + "type": "paragraph", + "content": "Rows have different number of cells ?!", + } + return { + "type": "table", + "content": { + "type": "tableContent", + "columnWidths": [1000 / n_columns for _ in range(n_columns)], + "headerRows": int(block.specific.has_column_header), + "headerColumns": int(block.specific.has_row_header), + "props": { + "textColor": "default", + }, + "rows": [ + { + "cells": [ + { + "type": "tableCell", + "content": [ + { + "type": "text", + "text": convert_rich_texts(cell), + "styles": {}, + } + ], + } + for cell in row.cells + ] + } + for row in rows + ], + }, + } + + case NotionUnsupported(): + str_raw = json.dumps(block.specific.raw, indent=2) + return { + "type": "paragraph", + "content": f"This should be a {block.specific.block_type}, not yet supported in docs", + } + case _: return { - "type": "divider", + "type": "paragraph", + "content": f"This should be a {block.specific.block_type}, not yet handled by the importer", } @@ -144,24 +218,37 @@ class ImportedDocument(BaseModel): blocks: list[dict[str, Any]] = [] children: list["ImportedDocument"] = [] + def find_page(id: str, pages: list[NotionPage]): for page in all_pages: if page.id == id: return page return None + def find_block_child_page(block_id: str, all_pages: list[NotionPage]): for page in all_pages: - if isinstance(page.parent, NotionParentBlock) and page.parent.block_id == block_id: + if ( + isinstance(page.parent, NotionParentBlock) + and page.parent.block_id == block_id + ): return page return None -def convert_child_pages(session: Session, parent: NotionPage, blocks: list[NotionBlock], all_pages: list[NotionPage]) -> list[ImportedDocument]: +def convert_child_pages( + session: Session, + parent: NotionPage, + blocks: list[NotionBlock], + all_pages: list[NotionPage], +) -> list[ImportedDocument]: children = [] for page in all_pages: - if isinstance(page.parent, NotionParentPage) and page.parent.page_id == parent.id: + if ( + isinstance(page.parent, NotionParentPage) + and page.parent.page_id == parent.id + ): children.append(import_page(session, page, all_pages)) for block in blocks: @@ -169,16 +256,18 @@ def convert_child_pages(session: Session, parent: NotionPage, blocks: list[Notio continue # TODO - #parent_page = find_block_child_page(block.id, all_pages) - #if parent_page == None: + # parent_page = find_block_child_page(block.id, all_pages) + # if parent_page == None: # logger.warning(f"Cannot find parent of block {block.id}") # continue - #children.append(import_page(session, parent_page, all_pages)) + # children.append(import_page(session, parent_page, all_pages)) return children -def import_page(session: Session, page: NotionPage, all_pages: list[NotionPage]) -> ImportedDocument: +def import_page( + session: Session, page: NotionPage, all_pages: list[NotionPage] +) -> ImportedDocument: blocks = fetch_block_children(session, page.id) logger.info(f"Page {page.get_title()} (id {page.id})") logger.info(blocks) diff --git a/src/frontend/servers/y-provider/src/handlers/convertBlocksHandler.ts b/src/frontend/servers/y-provider/src/handlers/convertBlocksHandler.ts index 38af60934..05665c3b6 100644 --- a/src/frontend/servers/y-provider/src/handlers/convertBlocksHandler.ts +++ b/src/frontend/servers/y-provider/src/handlers/convertBlocksHandler.ts @@ -42,6 +42,6 @@ export const convertBlocksHandler = async ( res.status(200).json({ content }); } catch (e) { logger('conversion failed:', e); - res.status(500).json({ error: 'An error occurred' }); + res.status(500).json({ error: String(e) }); } }; From f2248ccbdbf6ca3f8319d8f2560d8b4df1172696 Mon Sep 17 00:00:00 2001 From: Clara Ni Date: Tue, 3 Jun 2025 12:22:49 +0200 Subject: [PATCH 077/104] add FRONTEND_URL to env settings --- env.d/development/common.dist | 1 + src/backend/impress/settings.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/env.d/development/common.dist b/env.d/development/common.dist index 4b1389bf4..75e7460fa 100644 --- a/env.d/development/common.dist +++ b/env.d/development/common.dist @@ -64,3 +64,4 @@ COLLABORATION_WS_URL=ws://localhost:4444/collaboration/ws/ # Frontend FRONTEND_THEME=default +FRONTEND_URL=http://localhost:3000 diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 12c12cc3a..2bb3b6f18 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -415,6 +415,9 @@ class Base(Configuration): ) # Frontend + FRONTEND_URL = values.Value( + None, environ_name="FRONTEND_URL", environ_prefix=None + ) FRONTEND_THEME = values.Value( None, environ_name="FRONTEND_THEME", environ_prefix=None ) From 2f6880959a828ec83e8456daafbbe748a1d7df8e Mon Sep 17 00:00:00 2001 From: Clara Ni Date: Tue, 3 Jun 2025 12:49:28 +0200 Subject: [PATCH 078/104] Add a loading page during import --- src/backend/core/api/viewsets.py | 5 +-- .../doc-management/api/useImportNotion.tsx | 33 +++++++++++++++ .../features/service-worker/service-worker.ts | 1 + .../apps/impress/src/i18n/translations.json | 10 +++++ .../impress/src/pages/import-notion/index.tsx | 42 +++++++++++++++++++ 5 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-management/api/useImportNotion.tsx create mode 100644 src/frontend/apps/impress/src/pages/import-notion/index.tsx diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 040d93d88..ebca6bae8 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1851,7 +1851,7 @@ def notion_import_callback(request): resp.raise_for_status() data = resp.json() request.session["notion_token"] = data["access_token"] - return redirect("/api/v1.0/notion_import/run") + return redirect(f"{settings.FRONTEND_URL}/import-notion/") def _import_notion_child_page(imported_doc, parent_doc, user, imported_docs_by_page_id): @@ -1898,8 +1898,7 @@ def _import_notion_root_page(imported_doc, user, imported_docs_by_page_id): _import_notion_child_page(child, obj, user, imported_docs_by_page_id) -# @drf.decorators.api_view(["POST"]) -@drf.decorators.api_view() +@drf.decorators.api_view(["POST"]) def notion_import_run(request): if "notion_token" not in request.session: raise drf.exceptions.PermissionDenied() diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useImportNotion.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useImportNotion.tsx new file mode 100644 index 000000000..a7234e960 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useImportNotion.tsx @@ -0,0 +1,33 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +import { KEY_LIST_DOC } from './useDocs'; + +export const importNotion = async (): Promise => { + const response = await fetchAPI('notion_import/run', { + method: 'POST', + }); + + if (!response.ok) { + throw new APIError( + 'Failed to import the Notion', + await errorCauses(response), + ); + } +}; + +export function useImportNotion() { + const router = useRouter(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: importNotion, + onSuccess: () => { + void queryClient.resetQueries({ + queryKey: [KEY_LIST_DOC], + }); + router.push('/'); + }, + }); +} diff --git a/src/frontend/apps/impress/src/features/service-worker/service-worker.ts b/src/frontend/apps/impress/src/features/service-worker/service-worker.ts index b4db83f6d..42772df3d 100644 --- a/src/frontend/apps/impress/src/features/service-worker/service-worker.ts +++ b/src/frontend/apps/impress/src/features/service-worker/service-worker.ts @@ -112,6 +112,7 @@ const precacheResources = [ '/accessibility/', '/legal-notice/', '/personal-data-cookies/', + '/import-notion', FALLBACK.offline, FALLBACK.images, FALLBACK.docs, diff --git a/src/frontend/apps/impress/src/i18n/translations.json b/src/frontend/apps/impress/src/i18n/translations.json index fb906dea0..1e04f59a7 100644 --- a/src/frontend/apps/impress/src/i18n/translations.json +++ b/src/frontend/apps/impress/src/i18n/translations.json @@ -166,6 +166,7 @@ "No text selected": "Kein Text ausgewählt", "No versions": "Keine Versionen", "Nothing exceptional, no special privileges related to a .gouv.fr.": "Nichts Außergewöhnliches, keine besonderen Privilegien im Zusammenhang mit .gouv.fr.", + "Notion import in progress...": "Notion-Import in Arbeit...", "OK": "OK", "Offline ?!": "Offline?!", "Only invited people can access": "Nur eingeladene Personen haben Zugriff", @@ -182,6 +183,7 @@ "Pin document icon": "Pinne das Dokumentenlogo an", "Pinned documents": "Angepinnte Dokumente", "Please download it only if it comes from a trusted source.": "Bitte laden Sie es nur herunter, wenn es von einer vertrauenswürdigen Quelle stammt.", + "Please stay on this page and be patient": "Bitte bleiben Sie auf dieser Seite und haben Sie Geduld", "Private": "Privat", "Proconnect Login": "Proconnect-Anmeldung", "Public": "Öffentlich", @@ -399,6 +401,7 @@ "No text selected": "No hay texto seleccionado", "No versions": "No hay versiones", "Nothing exceptional, no special privileges related to a .gouv.fr.": "Nada excepcional, no hay privilegios especiales relacionados con un .gouv.fr.", + "Notion import in progress...": "Importación de Notion en curso...", "OK": "Ok", "Offline ?!": "¿¡Sin conexión!?", "Only invited people can access": "Solo las personas invitadas pueden acceder", @@ -415,6 +418,7 @@ "Pin document icon": "Icono para marcar el documento como favorito", "Pinned documents": "Documentos favoritos", "Please download it only if it comes from a trusted source.": "Por favor, descárguelo solo si viene de una fuente de confianza.", + "Please stay on this page and be patient": "Rimanete su questa pagina e siate pazienti", "Private": "Privado", "Proconnect Login": "Iniciar sesión ProConnect", "Public": "Público", @@ -624,6 +628,7 @@ "No text selected": "Aucun texte sélectionné", "No versions": "Aucune version", "Nothing exceptional, no special privileges related to a .gouv.fr.": "Rien d'exceptionnel, pas de privilèges spéciaux liés à un .gouv.fr.", + "Notion import in progress...": "Import Notion en cours...", "OK": "OK", "Offline ?!": "Hors-ligne ?!", "Only invited people can access": "Seules les personnes invitées peuvent accéder", @@ -640,6 +645,7 @@ "Pin document icon": "Icône épingler un document", "Pinned documents": "Documents épinglés", "Please download it only if it comes from a trusted source.": "Veuillez le télécharger uniquement s'il provient d'une source fiable.", + "Please stay on this page and be patient": "Merci de rester sur cette page et de patienter un peu", "Private": "Privé", "Proconnect Login": "Login Proconnect", "Public": "Public", @@ -828,6 +834,7 @@ "No text selected": "Non è stato selezionato nessun testo", "No versions": "Nessuna versione", "Nothing exceptional, no special privileges related to a .gouv.fr.": "Niente di eccezionale, nessun privilegio speciale legato a un .gouv.fr.", + "Notion import in progress...": "Importazione di nozioni in corso...", "OK": "OK", "Offline ?!": "Offline ?!", "Only invited people can access": "Solo le persone invitate possono accedere", @@ -844,6 +851,7 @@ "Pin document icon": "Icona \"fissa documento\"", "Pinned documents": "Documenti fissati", "Please download it only if it comes from a trusted source.": "Per favore scaricalo solo se proviene da una fonte attendibile", + "Please stay on this page and be patient": "Rimanete su questa pagina e siate pazienti", "Private": "Privato", "Public": "Pubblico", "Public document": "Documento pubblico", @@ -1033,6 +1041,7 @@ "No text selected": "Geen tekst geselecteerd", "No versions": "Geen versies", "Nothing exceptional, no special privileges related to a .gouv.fr.": "Niets uitzonderlijk, geen speciale privileges gerelateerd aan een .gouv.fr.", + "Notion import in progress...": "Notion import bezig...", "OK": "Ok", "Offline ?!": "Offline ?!", "Only invited people can access": "Alleen uitgenodigde gebruikers hebben toegang", @@ -1049,6 +1058,7 @@ "Pin document icon": "Document icoon vastzetten", "Pinned documents": "Vastgepinde documenten", "Please download it only if it comes from a trusted source.": "Alleen downloaden als het van een vertrouwde bron komt.", + "Please stay on this page and be patient": "Blijf op deze pagina en heb geduld", "Private": "Privé", "Proconnect Login": "Login", "Public": "Publiek", diff --git a/src/frontend/apps/impress/src/pages/import-notion/index.tsx b/src/frontend/apps/impress/src/pages/import-notion/index.tsx new file mode 100644 index 000000000..098cb3068 --- /dev/null +++ b/src/frontend/apps/impress/src/pages/import-notion/index.tsx @@ -0,0 +1,42 @@ +import { Loader } from '@openfun/cunningham-react'; +import { ReactElement, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Box, Text } from '@/components'; +import { useImportNotion } from '@/features/docs/doc-management/api/useImportNotion'; +import { MainLayout } from '@/layouts'; +import { NextPageWithLayout } from '@/types/next'; + +const Page: NextPageWithLayout = () => { + const { t } = useTranslation(); + + const { mutate: importNotion } = useImportNotion(); + + useEffect(() => { + importNotion(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + {t('Notion import in progress...')} + + + {t('Please stay on this page and be patient')} + + + + ); +}; + +Page.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default Page; From a9ed9170cb5eb42dd65e886fb69574ed20d46171 Mon Sep 17 00:00:00 2001 From: Nicolas Ritouet Date: Tue, 3 Jun 2025 12:57:43 +0200 Subject: [PATCH 079/104] Ajout support Bullet list and Number list --- src/backend/core/services/notion_import.py | 30 ++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index 5ce4d5741..3a6cc74c1 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -19,6 +19,8 @@ NotionTable, NotionTableRow, NotionUnsupported, + NotionBulletedListItem, + NotionNumberedListItem, ) from ..notion_schemas.notion_page import ( NotionPage, @@ -41,11 +43,19 @@ def build_notion_session(token: str) -> Session: def search_notion(session: Session, start_cursor: str) -> dict[str, Any]: - req_data = {} + req_data = { + "filter": { + "value": "page", + "property": "object", + }, + } if start_cursor: req_data = { "start_cursor": start_cursor, - "value": "page", + "filter": { + "value": "page", + "property": "object", + }, } response = session.post( @@ -189,6 +199,22 @@ def convert_block(block: NotionBlock) -> dict[str, Any] | None: ], }, } + case NotionBulletedListItem(): + content = "" + for rich_text in block.specific.rich_text: + content += rich_text.plain_text + return { + "type": "bulletListItem", + "content": content, + } + case NotionNumberedListItem(): + content = "" + for rich_text in block.specific.rich_text: + content += rich_text.plain_text + return { + "type": "numberedListItem", + "content": content, + } case NotionUnsupported(): str_raw = json.dumps(block.specific.raw, indent=2) From 9284879db28349124fc4726582ed9d4c3cac606a Mon Sep 17 00:00:00 2001 From: Thibault Guisnet Date: Tue, 3 Jun 2025 13:50:19 +0200 Subject: [PATCH 080/104] add format text --- src/backend/core/services/notion_import.py | 40 +++++++++++++++++++--- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index 3a6cc74c1..43b4d65f1 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -28,7 +28,7 @@ NotionParentPage, NotionParentWorkspace, ) -from ..notion_schemas.notion_rich_text import NotionRichText +from ..notion_schemas.notion_rich_text import NotionRichText, NotionRichTextAnnotation logger = logging.getLogger(__name__) @@ -125,16 +125,27 @@ def fetch_block_children(session: Session, block_id: str) -> list[NotionBlock]: return blocks -def convert_rich_texts(rich_texts: list[NotionRichText]) -> str: - return "".join(rich_text.plain_text for rich_text in rich_texts) +def convert_rich_texts(rich_texts: list[NotionRichText]) -> list[dict[str, Any]]: + content = [] + for rich_text in rich_texts: + stylestab = convert_annotations(rich_text.annotations) + content.append( + { + "type" : "text", + "text" : rich_text.plain_text, + "styles" : stylestab, + } + ) + return content def convert_block(block: NotionBlock) -> dict[str, Any] | None: match block.specific: case NotionParagraph(): + content = convert_rich_texts(block.specific.rich_text) return { "type": "paragraph", - "content": convert_rich_texts(block.specific.rich_text), + "content": content, } case NotionHeading1() | NotionHeading2() | NotionHeading3(): return { @@ -227,6 +238,27 @@ def convert_block(block: NotionBlock) -> dict[str, Any] | None: "type": "paragraph", "content": f"This should be a {block.specific.block_type}, not yet handled by the importer", } + + +def convert_annotations(annotations: NotionRichTextAnnotation) -> dict[str, str]: + res = {} + if annotations.bold: + res["bold"] = "true" + if annotations.italic: + res["italic"] = "true" + if annotations.underline: + res["underline"] = "true" + if annotations.strikethrough: + res["strike"] = "true" + if annotations.color: + if '_' in str(annotations.color): + tmp = str(annotations.color) + res["backgroundColor"] = tmp[:tmp.rfind("_")].lower() + else: + res["textColor"] = str(annotations.color).lower() + return res + + def convert_block_list(blocks: list[NotionBlock]) -> list[dict[str, Any]]: From 41f44bed96249ad73f392138ee1d996eb0f90b45 Mon Sep 17 00:00:00 2001 From: Baptiste Prevot Date: Tue, 3 Jun 2025 05:16:00 +0200 Subject: [PATCH 081/104] tidy --- .../core/notion_schemas/notion_file.py | 15 +++-- .../core/notion_schemas/notion_page.py | 16 ++--- src/backend/core/services/notion_import.py | 67 ++++++------------- 3 files changed, 36 insertions(+), 62 deletions(-) diff --git a/src/backend/core/notion_schemas/notion_file.py b/src/backend/core/notion_schemas/notion_file.py index 59b4a9737..7b1a11f39 100644 --- a/src/backend/core/notion_schemas/notion_file.py +++ b/src/backend/core/notion_schemas/notion_file.py @@ -3,26 +3,29 @@ from pydantic import BaseModel, Discriminator + class NotionFileType(StrEnum): HOSTED = "file" UPLOAD = "file_upload" EXTERNAL = "external" + class NotionFileHosted(BaseModel): type: Literal[NotionFileType.HOSTED] = NotionFileType.HOSTED - file: dict # TODO + file: dict # TODO + class NotionFileUpload(BaseModel): type: Literal[NotionFileType.UPLOAD] = NotionFileType.UPLOAD - file_upload: dict # TODO + file_upload: dict # TODO + class NotionFileExternal(BaseModel): type: Literal[NotionFileType.EXTERNAL] = NotionFileType.EXTERNAL - external: dict # TODO + external: dict # TODO + NotionFile = Annotated[ - NotionFileHosted - | NotionFileUpload - | NotionFileExternal, + NotionFileHosted | NotionFileUpload | NotionFileExternal, Discriminator(discriminator="type"), ] diff --git a/src/backend/core/notion_schemas/notion_page.py b/src/backend/core/notion_schemas/notion_page.py index 2aa0b0e58..b014b3423 100644 --- a/src/backend/core/notion_schemas/notion_page.py +++ b/src/backend/core/notion_schemas/notion_page.py @@ -1,11 +1,7 @@ -from datetime import datetime from enum import StrEnum -from typing import Annotated, Any, Literal +from typing import Annotated, Literal -from pydantic import BaseModel, Discriminator, Field, ValidationError, model_validator - -from .notion_rich_text import NotionRichText -from .notion_file import NotionFile +from pydantic import BaseModel, Discriminator class NotionParentType(StrEnum): @@ -35,10 +31,7 @@ class NotionParentBlock(BaseModel): NotionParent = Annotated[ - NotionParentDatabase - | NotionParentPage - | NotionParentWorkspace - | NotionParentBlock, + NotionParentDatabase | NotionParentPage | NotionParentWorkspace | NotionParentBlock, Discriminator(discriminator="type"), ] @@ -60,5 +53,6 @@ def get_title(self) -> str | None: if title_property is None: return None - rich_text = title_property["title"][0] # This could be parsed using NotionRichText + # This could be parsed using NotionRichText + rich_text = title_property["title"][0] return rich_text["plain_text"] diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index 43b4d65f1..448c6d122 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -1,26 +1,23 @@ import json import logging -from dataclasses import dataclass -from enum import Enum from typing import Any -import requests from pydantic import BaseModel, TypeAdapter from requests import Session from ..notion_schemas.notion_block import ( NotionBlock, + NotionBulletedListItem, NotionChildPage, NotionDivider, NotionHeading1, NotionHeading2, NotionHeading3, + NotionNumberedListItem, NotionParagraph, NotionTable, NotionTableRow, NotionUnsupported, - NotionBulletedListItem, - NotionNumberedListItem, ) from ..notion_schemas.notion_page import ( NotionPage, @@ -44,10 +41,10 @@ def build_notion_session(token: str) -> Session: def search_notion(session: Session, start_cursor: str) -> dict[str, Any]: req_data = { - "filter": { - "value": "page", - "property": "object", - }, + "filter": { + "value": "page", + "property": "object", + }, } if start_cursor: req_data = { @@ -131,9 +128,9 @@ def convert_rich_texts(rich_texts: list[NotionRichText]) -> list[dict[str, Any]] stylestab = convert_annotations(rich_text.annotations) content.append( { - "type" : "text", - "text" : rich_text.plain_text, - "styles" : stylestab, + "type": "text", + "text": rich_text.plain_text, + "styles": stylestab, } ) return content @@ -184,24 +181,20 @@ def convert_block(block: NotionBlock) -> dict[str, Any] | None: "type": "table", "content": { "type": "tableContent", - "columnWidths": [1000 / n_columns for _ in range(n_columns)], + "columnWidths": [ + 1000 / n_columns for _ in range(n_columns) + ], # TODO "headerRows": int(block.specific.has_column_header), "headerColumns": int(block.specific.has_row_header), "props": { - "textColor": "default", + "textColor": "default", # TODO }, "rows": [ { "cells": [ { "type": "tableCell", - "content": [ - { - "type": "text", - "text": convert_rich_texts(cell), - "styles": {}, - } - ], + "content": convert_rich_texts(cell), } for cell in row.cells ] @@ -211,20 +204,14 @@ def convert_block(block: NotionBlock) -> dict[str, Any] | None: }, } case NotionBulletedListItem(): - content = "" - for rich_text in block.specific.rich_text: - content += rich_text.plain_text return { "type": "bulletListItem", - "content": content, + "content": convert_rich_texts(block.specific.rich_text), } case NotionNumberedListItem(): - content = "" - for rich_text in block.specific.rich_text: - content += rich_text.plain_text return { "type": "numberedListItem", - "content": content, + "content": convert_rich_texts(block.specific.rich_text), } case NotionUnsupported(): @@ -238,7 +225,7 @@ def convert_block(block: NotionBlock) -> dict[str, Any] | None: "type": "paragraph", "content": f"This should be a {block.specific.block_type}, not yet handled by the importer", } - + def convert_annotations(annotations: NotionRichTextAnnotation) -> dict[str, str]: res = {} @@ -250,15 +237,12 @@ def convert_annotations(annotations: NotionRichTextAnnotation) -> dict[str, str] res["underline"] = "true" if annotations.strikethrough: res["strike"] = "true" - if annotations.color: - if '_' in str(annotations.color): - tmp = str(annotations.color) - res["backgroundColor"] = tmp[:tmp.rfind("_")].lower() - else: - res["textColor"] = str(annotations.color).lower() - return res - + if "_" in annotations.color: + res["backgroundColor"] = annotations.color.split("_")[0].lower() + else: + res["textColor"] = annotations.color.lower() + return res def convert_block_list(blocks: list[NotionBlock]) -> list[dict[str, Any]]: @@ -277,13 +261,6 @@ class ImportedDocument(BaseModel): children: list["ImportedDocument"] = [] -def find_page(id: str, pages: list[NotionPage]): - for page in all_pages: - if page.id == id: - return page - return None - - def find_block_child_page(block_id: str, all_pages: list[NotionPage]): for page in all_pages: if ( From 10b85ecad3499269fcac7b9ced424274e6266e5d Mon Sep 17 00:00:00 2001 From: Baptiste Prevot Date: Tue, 3 Jun 2025 05:31:26 +0200 Subject: [PATCH 082/104] notion-import: handle sub list items --- src/backend/core/notion_schemas/notion_block.py | 13 ++++++++++++- src/backend/core/services/notion_import.py | 2 ++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/backend/core/notion_schemas/notion_block.py b/src/backend/core/notion_schemas/notion_block.py index bbc3caaa0..3f265ad79 100644 --- a/src/backend/core/notion_schemas/notion_block.py +++ b/src/backend/core/notion_schemas/notion_block.py @@ -5,8 +5,8 @@ from pydantic import BaseModel, Discriminator, Field, ValidationError, model_validator from .notion_color import NotionColor -from .notion_rich_text import NotionRichText from .notion_file import NotionFile +from .notion_rich_text import NotionRichText """Usage: NotionBlock.model_validate(response.json())""" @@ -125,6 +125,16 @@ class NotionNumberedListItem(BaseModel): children: list["NotionBlock"] = Field(default_factory=list) +class NotionToDo(BaseModel): + """https://developers.notion.com/reference/block#to-do""" + + block_type: Literal[NotionBlockType.TO_DO] = NotionBlockType.TO_DO + rich_text: list[NotionRichText] + checked: bool + color: NotionColor + children: list["NotionBlock"] = Field(default_factory=list) + + class NotionCode(BaseModel): """https://developers.notion.com/reference/block#code""" @@ -235,6 +245,7 @@ def put_all_in_raw(cls, data: Any) -> Any: | NotionParagraph | NotionNumberedListItem | NotionBulletedListItem + | NotionToDo | NotionCode | NotionDivider | NotionEmbed diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index 448c6d122..fc373b99e 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -207,11 +207,13 @@ def convert_block(block: NotionBlock) -> dict[str, Any] | None: return { "type": "bulletListItem", "content": convert_rich_texts(block.specific.rich_text), + "children": convert_block_list(block.children), } case NotionNumberedListItem(): return { "type": "numberedListItem", "content": convert_rich_texts(block.specific.rich_text), + "children": convert_block_list(block.children), } case NotionUnsupported(): From 36fbf65d122586fe56fcb5662fb53c8c5be039f0 Mon Sep 17 00:00:00 2001 From: Clara Ni Date: Tue, 3 Jun 2025 15:07:10 +0200 Subject: [PATCH 083/104] convert_block returns now list of dict --- src/backend/core/services/notion_import.py | 76 ++++++++++++---------- 1 file changed, 42 insertions(+), 34 deletions(-) diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index fc373b99e..416e6ce5e 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -136,22 +136,26 @@ def convert_rich_texts(rich_texts: list[NotionRichText]) -> list[dict[str, Any]] return content -def convert_block(block: NotionBlock) -> dict[str, Any] | None: +def convert_block(block: NotionBlock) -> list[dict[str, Any]] | None: match block.specific: case NotionParagraph(): content = convert_rich_texts(block.specific.rich_text) - return { - "type": "paragraph", - "content": content, - } + return [ + { + "type": "paragraph", + "content": content, + } + ] case NotionHeading1() | NotionHeading2() | NotionHeading3(): - return { - "type": "heading", - "content": convert_rich_texts(block.specific.rich_text), - "level": block.specific.block_type.value.split("_")[ - -1 - ], # e.g., "1", "2", or "3" - } + return [ + { + "type": "heading", + "content": convert_rich_texts(block.specific.rich_text), + "level": block.specific.block_type.value.split("_")[ + -1 + ], # e.g., "1", "2", or "3" + } + ] # case NotionDivider(): # return { # "type": "divider", @@ -159,25 +163,27 @@ def convert_block(block: NotionBlock) -> dict[str, Any] | None: case NotionTable(): rows: list[NotionTableRow] = [child.specific for child in block.children] # type: ignore # I don't know how to assert properly if len(rows) == 0: - return { - "type": "paragraph", - "content": "Empty table ?!", - } + return [ + { + "type": "paragraph", + "content": "Empty table ?!", + } + ] n_columns = len( rows[0].cells ) # I'll assume all rows have the same number of cells if n_columns == 0: - return { + return [{ "type": "paragraph", "content": "Empty row ?!", - } + }] if not all(len(row.cells) == n_columns for row in rows): - return { + return [{ "type": "paragraph", "content": "Rows have different number of cells ?!", - } - return { + }] + return [{ "type": "table", "content": { "type": "tableContent", @@ -202,31 +208,33 @@ def convert_block(block: NotionBlock) -> dict[str, Any] | None: for row in rows ], }, - } + }] case NotionBulletedListItem(): - return { + return [{ "type": "bulletListItem", "content": convert_rich_texts(block.specific.rich_text), "children": convert_block_list(block.children), - } + }] case NotionNumberedListItem(): - return { + return [{ "type": "numberedListItem", "content": convert_rich_texts(block.specific.rich_text), "children": convert_block_list(block.children), - } + }] case NotionUnsupported(): str_raw = json.dumps(block.specific.raw, indent=2) - return { - "type": "paragraph", - "content": f"This should be a {block.specific.block_type}, not yet supported in docs", - } + return [ + { + "type": "paragraph", + "content": f"This should be a {block.specific.block_type}, not yet supported in docs", + } + ] case _: - return { + return [{ "type": "paragraph", "content": f"This should be a {block.specific.block_type}, not yet handled by the importer", - } + }] def convert_annotations(annotations: NotionRichTextAnnotation) -> dict[str, str]: @@ -251,9 +259,9 @@ def convert_block_list(blocks: list[NotionBlock]) -> list[dict[str, Any]]: converted_blocks = [] for block in blocks: converted_block = convert_block(block) - if converted_block == None: + if len(converted_block) == 0: continue - converted_blocks.append(converted_block) + converted_blocks.extend(converted_block) return converted_blocks From 0c815f2de0c4d12cb251434f9a459acb2ff7d272 Mon Sep 17 00:00:00 2001 From: Clara Ni Date: Tue, 3 Jun 2025 15:07:58 +0200 Subject: [PATCH 084/104] handle columns and columns list --- src/backend/core/notion_schemas/notion_block.py | 14 ++++++++++++++ src/backend/core/services/notion_import.py | 10 ++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/backend/core/notion_schemas/notion_block.py b/src/backend/core/notion_schemas/notion_block.py index 3f265ad79..4ac1f67d0 100644 --- a/src/backend/core/notion_schemas/notion_block.py +++ b/src/backend/core/notion_schemas/notion_block.py @@ -212,6 +212,18 @@ class NotionTableRow(BaseModel): cells: list[list[NotionRichText]] # Each cell is a list of rich text objects +class NotionColumnList(BaseModel): + """https://developers.notion.com/reference/block#column-list-and-column""" + + block_type: Literal[NotionBlockType.COLUMN_LIST] = NotionBlockType.COLUMN_LIST + + +class NotionColumn(BaseModel): + """https://developers.notion.com/reference/block#column-list-and-column""" + + block_type: Literal[NotionBlockType.COLUMN] = NotionBlockType.COLUMN + + class NotionChildPage(BaseModel): """https://developers.notion.com/reference/block#child-page @@ -247,6 +259,8 @@ def put_all_in_raw(cls, data: Any) -> Any: | NotionBulletedListItem | NotionToDo | NotionCode + | NotionColumn + | NotionColumnList | NotionDivider | NotionEmbed | NotionFile diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index 416e6ce5e..581d6e6de 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -9,6 +9,8 @@ NotionBlock, NotionBulletedListItem, NotionChildPage, + NotionColumn, + NotionColumnList, NotionDivider, NotionHeading1, NotionHeading2, @@ -138,6 +140,14 @@ def convert_rich_texts(rich_texts: list[NotionRichText]) -> list[dict[str, Any]] def convert_block(block: NotionBlock) -> list[dict[str, Any]] | None: match block.specific: + case NotionColumnList(): + columns_content = [] + for column in block.children: + columns_content.extend(convert_block(column)) + return columns_content + case NotionColumn(): + return [convert_block(child_content)[0] for child_content in block.children] + case NotionParagraph(): content = convert_rich_texts(block.specific.rich_text) return [ From 15c3ca8b1ee4303a0b051a3a7f268ccfdff02ff1 Mon Sep 17 00:00:00 2001 From: Baptiste Prevot Date: Tue, 3 Jun 2025 05:44:38 +0200 Subject: [PATCH 085/104] notion-import: handle notion todos --- src/backend/core/services/notion_import.py | 123 +++++++++++---------- 1 file changed, 67 insertions(+), 56 deletions(-) diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index 581d6e6de..60234e224 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -19,6 +19,7 @@ NotionParagraph, NotionTable, NotionTableRow, + NotionToDo, NotionUnsupported, ) from ..notion_schemas.notion_page import ( @@ -138,7 +139,7 @@ def convert_rich_texts(rich_texts: list[NotionRichText]) -> list[dict[str, Any]] return content -def convert_block(block: NotionBlock) -> list[dict[str, Any]] | None: +def convert_block(block: NotionBlock) -> list[dict[str, Any]]: match block.specific: case NotionColumnList(): columns_content = [] @@ -167,9 +168,7 @@ def convert_block(block: NotionBlock) -> list[dict[str, Any]] | None: } ] # case NotionDivider(): - # return { - # "type": "divider", - # } + # return {"type": "divider", "properties": {}} case NotionTable(): rows: list[NotionTableRow] = [child.specific for child in block.children] # type: ignore # I don't know how to assert properly if len(rows) == 0: @@ -184,54 +183,67 @@ def convert_block(block: NotionBlock) -> list[dict[str, Any]] | None: rows[0].cells ) # I'll assume all rows have the same number of cells if n_columns == 0: - return [{ - "type": "paragraph", - "content": "Empty row ?!", - }] + return [{"type": "paragraph", "content": "Empty row ?!"}] if not all(len(row.cells) == n_columns for row in rows): - return [{ - "type": "paragraph", - "content": "Rows have different number of cells ?!", - }] - return [{ - "type": "table", - "content": { - "type": "tableContent", - "columnWidths": [ - 1000 / n_columns for _ in range(n_columns) - ], # TODO - "headerRows": int(block.specific.has_column_header), - "headerColumns": int(block.specific.has_row_header), - "props": { - "textColor": "default", # TODO + return [ + { + "type": "paragraph", + "content": "Rows have different number of cells ?!", + } + ] + return [ + { + "type": "table", + "content": { + "type": "tableContent", + "columnWidths": [ + 1000 / n_columns for _ in range(n_columns) + ], # TODO + "headerRows": int(block.specific.has_column_header), + "headerColumns": int(block.specific.has_row_header), + "props": { + "textColor": "default", # TODO + }, + "rows": [ + { + "cells": [ + { + "type": "tableCell", + "content": convert_rich_texts(cell), + } + for cell in row.cells + ] + } + for row in rows + ], }, - "rows": [ - { - "cells": [ - { - "type": "tableCell", - "content": convert_rich_texts(cell), - } - for cell in row.cells - ] - } - for row in rows - ], - }, - }] + } + ] case NotionBulletedListItem(): - return [{ - "type": "bulletListItem", - "content": convert_rich_texts(block.specific.rich_text), - "children": convert_block_list(block.children), - }] + return [ + { + "type": "bulletListItem", + "content": convert_rich_texts(block.specific.rich_text), + "children": convert_block_list(block.children), + } + ] case NotionNumberedListItem(): - return [{ - "type": "numberedListItem", - "content": convert_rich_texts(block.specific.rich_text), - "children": convert_block_list(block.children), - }] - + return [ + { + "type": "numberedListItem", + "content": convert_rich_texts(block.specific.rich_text), + "children": convert_block_list(block.children), + } + ] + case NotionToDo(): + return [ + { + "type": "checkListItem", + "content": convert_rich_texts(block.specific.rich_text), + "checked": block.specific.checked, + "children": convert_block_list(block.children), + } + ] case NotionUnsupported(): str_raw = json.dumps(block.specific.raw, indent=2) return [ @@ -241,10 +253,12 @@ def convert_block(block: NotionBlock) -> list[dict[str, Any]] | None: } ] case _: - return [{ - "type": "paragraph", - "content": f"This should be a {block.specific.block_type}, not yet handled by the importer", - }] + return [ + { + "type": "paragraph", + "content": f"This should be a {block.specific.block_type}, not yet handled by the importer", + } + ] def convert_annotations(annotations: NotionRichTextAnnotation) -> dict[str, str]: @@ -268,10 +282,7 @@ def convert_annotations(annotations: NotionRichTextAnnotation) -> dict[str, str] def convert_block_list(blocks: list[NotionBlock]) -> list[dict[str, Any]]: converted_blocks = [] for block in blocks: - converted_block = convert_block(block) - if len(converted_block) == 0: - continue - converted_blocks.extend(converted_block) + converted_blocks.extend(convert_block(block)) return converted_blocks From 578409e34ed0c6cc0488a49973a440a57a165de8 Mon Sep 17 00:00:00 2001 From: Baptiste Prevot Date: Tue, 3 Jun 2025 05:51:22 +0200 Subject: [PATCH 086/104] fixup --- src/backend/core/services/notion_import.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index 60234e224..e46c24417 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -245,12 +245,15 @@ def convert_block(block: NotionBlock) -> list[dict[str, Any]]: } ] case NotionUnsupported(): - str_raw = json.dumps(block.specific.raw, indent=2) return [ { "type": "paragraph", "content": f"This should be a {block.specific.block_type}, not yet supported in docs", - } + }, + # { + # "type": "quote", + # "content": json.dumps(block.specific.raw, indent=2), + # }, ] case _: return [ From 3c938ae6f7e49ece12cd6fdcc6df7cb96baf514a Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 3 Jun 2025 15:21:58 +0200 Subject: [PATCH 087/104] Handle uploaded images --- src/backend/core/api/viewsets.py | 32 +++++++++++--- .../core/notion_schemas/notion_block.py | 21 +++++---- src/backend/core/services/notion_import.py | 44 +++++++++++++++++-- 3 files changed, 75 insertions(+), 22 deletions(-) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index ebca6bae8..79594694c 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1854,13 +1854,30 @@ def notion_import_callback(request): return redirect(f"{settings.FRONTEND_URL}/import-notion/") -def _import_notion_child_page(imported_doc, parent_doc, user, imported_docs_by_page_id): - document_content = YdocConverter().convert_blocks(imported_doc.blocks) +def _import_notion_doc_content(imported_doc, obj, user): + for att in imported_doc.attachments: + extra_args = { + "Metadata": { + "owner": str(user.id), + "status": enums.DocumentAttachmentStatus.READY, # TODO + }, + } + file_id = uuid.uuid4() + key = f"{obj.key_base}/{enums.ATTACHMENTS_FOLDER:s}/{file_id!s}.raw" + with requests.get(att.file.file["url"], stream=True) as resp: + default_storage.connection.meta.client.upload_fileobj( + resp.raw, default_storage.bucket_name, key + ) + obj.attachments.append(key) + att.block["props"]["url"] = f"{settings.MEDIA_BASE_URL}{settings.MEDIA_URL}{key}" + + obj.content = YdocConverter().convert_blocks(imported_doc.blocks) + obj.save() +def _import_notion_child_page(imported_doc, parent_doc, user, imported_docs_by_page_id): obj = parent_doc.add_child( creator=user, title=imported_doc.page.get_title() or "J'aime les carottes", - content=document_content, ) models.DocumentAccess.objects.create( @@ -1869,6 +1886,8 @@ def _import_notion_child_page(imported_doc, parent_doc, user, imported_docs_by_p role=models.RoleChoices.OWNER, ) + _import_notion_doc_content(imported_doc, obj, user) + imported_docs_by_page_id[imported_doc.page.id] = obj for child in imported_doc.children: @@ -1876,14 +1895,11 @@ def _import_notion_child_page(imported_doc, parent_doc, user, imported_docs_by_p def _import_notion_root_page(imported_doc, user, imported_docs_by_page_id): - document_content = YdocConverter().convert_blocks(imported_doc.blocks) - obj = models.Document.add_root( depth=1, creator=user, title=imported_doc.page.get_title() or "J'aime les courgettes", link_reach=models.LinkReachChoices.RESTRICTED, - content=document_content, ) models.DocumentAccess.objects.create( @@ -1892,13 +1908,15 @@ def _import_notion_root_page(imported_doc, user, imported_docs_by_page_id): role=models.RoleChoices.OWNER, ) + _import_notion_doc_content(imported_doc, obj, user) + imported_docs_by_page_id[imported_doc.page.id] = obj for child in imported_doc.children: _import_notion_child_page(child, obj, user, imported_docs_by_page_id) -@drf.decorators.api_view(["POST"]) +@drf.decorators.api_view(["GET", "POST"]) # TODO: drop GET (used for testing) def notion_import_run(request): if "notion_token" not in request.session: raise drf.exceptions.PermissionDenied() diff --git a/src/backend/core/notion_schemas/notion_block.py b/src/backend/core/notion_schemas/notion_block.py index 4ac1f67d0..30b710ae8 100644 --- a/src/backend/core/notion_schemas/notion_block.py +++ b/src/backend/core/notion_schemas/notion_block.py @@ -157,20 +157,12 @@ class NotionEmbed(BaseModel): url: str -class NotionFileType(StrEnum): - FILE = "file" - EXTERNAL = "external" - FILE_UPLOAD = "file_upload" - - -class NotionFile(BaseModel): +class NotionBlockFile(BaseModel): # FIXME: this is actually another occurrence of type discriminating """https://developers.notion.com/reference/block#file""" block_type: Literal[NotionBlockType.FILE] = NotionBlockType.FILE - caption: list[NotionRichText] - type: NotionFileType - ... + # TODO: NotionFile class NotionImage(BaseModel): @@ -179,6 +171,13 @@ class NotionImage(BaseModel): block_type: Literal[NotionBlockType.IMAGE] = NotionBlockType.IMAGE file: NotionFile + @model_validator(mode="before") + @classmethod + def move_type_inward_and_rename(cls, data: Any) -> Any: + if not isinstance(data, dict): + return data + return { "block_type": "image", "file": data } + class NotionVideo(BaseModel): """https://developers.notion.com/reference/block#video""" @@ -263,7 +262,7 @@ def put_all_in_raw(cls, data: Any) -> Any: | NotionColumnList | NotionDivider | NotionEmbed - | NotionFile + | NotionBlockFile | NotionImage | NotionVideo | NotionLinkPreview diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index e46c24417..a85539e58 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -16,6 +16,7 @@ NotionHeading2, NotionHeading3, NotionNumberedListItem, + NotionImage, NotionParagraph, NotionTable, NotionTableRow, @@ -28,7 +29,9 @@ NotionParentPage, NotionParentWorkspace, ) +from ..notion_schemas.notion_page import NotionPage, NotionParentWorkspace, NotionParentBlock, NotionParentPage from ..notion_schemas.notion_rich_text import NotionRichText, NotionRichTextAnnotation +from ..notion_schemas.notion_file import NotionFileHosted, NotionFileExternal logger = logging.getLogger(__name__) @@ -139,7 +142,34 @@ def convert_rich_texts(rich_texts: list[NotionRichText]) -> list[dict[str, Any]] return content -def convert_block(block: NotionBlock) -> list[dict[str, Any]]: +class ImportedAttachment(BaseModel): + block: Any + file: NotionFileHosted + + +def convert_image(image: NotionImage, attachments: list[ImportedAttachment]): + # TODO: NotionFileUpload + match image.file: + case NotionFileExternal(): + return [{ + "type": "image", + "props": { + "url": image.file.external["url"], + }, + }] + case NotionFileHosted(): + block = { + "type": "image", + "props": { + "url": "about:blank", # populated later on + }, + } + attachments.append(ImportedAttachment(block=block, file=image.file)) + + return [block] + + +def convert_block(block: NotionBlock, attachments: list[ImportedAttachment]) -> list[dict[str, Any]]: match block.specific: case NotionColumnList(): columns_content = [] @@ -157,6 +187,8 @@ def convert_block(block: NotionBlock) -> list[dict[str, Any]]: "content": content, } ] + case NotionImage(): + return convert_image(block.specific, attachments) case NotionHeading1() | NotionHeading2() | NotionHeading3(): return [ { @@ -282,10 +314,10 @@ def convert_annotations(annotations: NotionRichTextAnnotation) -> dict[str, str] return res -def convert_block_list(blocks: list[NotionBlock]) -> list[dict[str, Any]]: +def convert_block_list(blocks: list[NotionBlock], attachments: list[ImportedAttachment]) -> list[dict[str, Any]]: converted_blocks = [] for block in blocks: - converted_blocks.extend(convert_block(block)) + converted_blocks.extend(convert_block(block, attachments)) return converted_blocks @@ -293,6 +325,7 @@ class ImportedDocument(BaseModel): page: NotionPage blocks: list[dict[str, Any]] = [] children: list["ImportedDocument"] = [] + attachments: list[ImportedAttachment] = [] def find_block_child_page(block_id: str, all_pages: list[NotionPage]): @@ -340,10 +373,13 @@ def import_page( blocks = fetch_block_children(session, page.id) logger.info(f"Page {page.get_title()} (id {page.id})") logger.info(blocks) + attachments = [] + converted_blocks = convert_block_list(blocks, attachments) return ImportedDocument( page=page, - blocks=convert_block_list(blocks), + blocks=converted_blocks, children=convert_child_pages(session, page, blocks, all_pages), + attachments=attachments, ) From b3eb0ff948e4beec1c55d64b60d34dbd50a5e8cd Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 3 Jun 2025 15:30:16 +0200 Subject: [PATCH 088/104] Fix missing arg in convert_block() --- src/backend/core/services/notion_import.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index a85539e58..8a107f840 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -174,10 +174,10 @@ def convert_block(block: NotionBlock, attachments: list[ImportedAttachment]) -> case NotionColumnList(): columns_content = [] for column in block.children: - columns_content.extend(convert_block(column)) + columns_content.extend(convert_block(column, attachments)) return columns_content case NotionColumn(): - return [convert_block(child_content)[0] for child_content in block.children] + return [convert_block(child_content, attachments)[0] for child_content in block.children] case NotionParagraph(): content = convert_rich_texts(block.specific.rich_text) From 33f21ff02d74b9edb261299dd44163d02b35a5e5 Mon Sep 17 00:00:00 2001 From: Baptiste Prevot Date: Tue, 3 Jun 2025 06:06:25 +0200 Subject: [PATCH 089/104] tidy --- src/backend/core/services/notion_import.py | 40 ++++++++++++++-------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index 8a107f840..f1b823cfd 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -15,23 +15,22 @@ NotionHeading1, NotionHeading2, NotionHeading3, - NotionNumberedListItem, NotionImage, + NotionNumberedListItem, NotionParagraph, NotionTable, NotionTableRow, NotionToDo, NotionUnsupported, ) +from ..notion_schemas.notion_file import NotionFileExternal, NotionFileHosted from ..notion_schemas.notion_page import ( NotionPage, NotionParentBlock, NotionParentPage, NotionParentWorkspace, ) -from ..notion_schemas.notion_page import NotionPage, NotionParentWorkspace, NotionParentBlock, NotionParentPage from ..notion_schemas.notion_rich_text import NotionRichText, NotionRichTextAnnotation -from ..notion_schemas.notion_file import NotionFileHosted, NotionFileExternal logger = logging.getLogger(__name__) @@ -147,29 +146,37 @@ class ImportedAttachment(BaseModel): file: NotionFileHosted -def convert_image(image: NotionImage, attachments: list[ImportedAttachment]): +def convert_image( + image: NotionImage, attachments: list[ImportedAttachment] +) -> list[dict[str, Any]]: # TODO: NotionFileUpload match image.file: case NotionFileExternal(): - return [{ - "type": "image", - "props": { - "url": image.file.external["url"], - }, - }] + return [ + { + "type": "image", + "props": { + "url": image.file.external["url"], + }, + } + ] case NotionFileHosted(): block = { "type": "image", "props": { - "url": "about:blank", # populated later on + "url": "about:blank", # populated later on }, } attachments.append(ImportedAttachment(block=block, file=image.file)) return [block] + case _: + return [{"paragraph": {"content": "Unsupported image type"}}] -def convert_block(block: NotionBlock, attachments: list[ImportedAttachment]) -> list[dict[str, Any]]: +def convert_block( + block: NotionBlock, attachments: list[ImportedAttachment] +) -> list[dict[str, Any]]: match block.specific: case NotionColumnList(): columns_content = [] @@ -177,7 +184,10 @@ def convert_block(block: NotionBlock, attachments: list[ImportedAttachment]) -> columns_content.extend(convert_block(column, attachments)) return columns_content case NotionColumn(): - return [convert_block(child_content, attachments)[0] for child_content in block.children] + return [ + convert_block(child_content, attachments)[0] + for child_content in block.children + ] case NotionParagraph(): content = convert_rich_texts(block.specific.rich_text) @@ -314,7 +324,9 @@ def convert_annotations(annotations: NotionRichTextAnnotation) -> dict[str, str] return res -def convert_block_list(blocks: list[NotionBlock], attachments: list[ImportedAttachment]) -> list[dict[str, Any]]: +def convert_block_list( + blocks: list[NotionBlock], attachments: list[ImportedAttachment] +) -> list[dict[str, Any]]: converted_blocks = [] for block in blocks: converted_blocks.extend(convert_block(block, attachments)) From 9337e4262d6d41c580dff156b60ea9387d85ce4e Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 3 Jun 2025 15:38:48 +0200 Subject: [PATCH 090/104] Struggle update --- src/backend/core/services/notion_import.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index f1b823cfd..e80ac3d7b 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -369,12 +369,12 @@ def convert_child_pages( if not isinstance(block.specific, NotionChildPage): continue - # TODO - # parent_page = find_block_child_page(block.id, all_pages) - # if parent_page == None: - # logger.warning(f"Cannot find parent of block {block.id}") - # continue - # children.append(import_page(session, parent_page, all_pages)) + # TODO: doesn't work, never finds the child + child_page = find_block_child_page(block.id, all_pages) + if child_page == None: + logger.warning(f"Cannot find child page of block {block.id}") + continue + children.append(import_page(session, child_page, all_pages)) return children From 6c276c27ec598cf9b1d61a2b0ff1c2b6dd822861 Mon Sep 17 00:00:00 2001 From: Baptiste Prevot Date: Tue, 3 Jun 2025 06:09:50 +0200 Subject: [PATCH 091/104] fix --- src/backend/core/notion_schemas/notion_block.py | 11 ++++++++++- src/backend/core/services/notion_import.py | 14 +++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/backend/core/notion_schemas/notion_block.py b/src/backend/core/notion_schemas/notion_block.py index 30b710ae8..490f2c634 100644 --- a/src/backend/core/notion_schemas/notion_block.py +++ b/src/backend/core/notion_schemas/notion_block.py @@ -144,6 +144,15 @@ class NotionCode(BaseModel): language: str # Actually an enum +class NotionCallout(BaseModel): + """https://developers.notion.com/reference/block#callout""" + + block_type: Literal[NotionBlockType.CALLOUT] = NotionBlockType.CALLOUT + rich_text: list[NotionRichText] + # icon: Any # could be an emoji or an image + color: NotionColor + + class NotionDivider(BaseModel): """https://developers.notion.com/reference/block#divider""" @@ -176,7 +185,7 @@ class NotionImage(BaseModel): def move_type_inward_and_rename(cls, data: Any) -> Any: if not isinstance(data, dict): return data - return { "block_type": "image", "file": data } + return {"block_type": "image", "file": data} class NotionVideo(BaseModel): diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index e80ac3d7b..26eaab502 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -8,6 +8,7 @@ from ..notion_schemas.notion_block import ( NotionBlock, NotionBulletedListItem, + NotionCallout, NotionChildPage, NotionColumn, NotionColumnList, @@ -211,6 +212,13 @@ def convert_block( ] # case NotionDivider(): # return {"type": "divider", "properties": {}} + case NotionCallout(): + return [ + { + "type": "comment", + "content": convert_rich_texts(block.specific.rich_text), + } + ] case NotionTable(): rows: list[NotionTableRow] = [child.specific for child in block.children] # type: ignore # I don't know how to assert properly if len(rows) == 0: @@ -266,7 +274,7 @@ def convert_block( { "type": "bulletListItem", "content": convert_rich_texts(block.specific.rich_text), - "children": convert_block_list(block.children), + "children": convert_block_list(block.children, attachments), } ] case NotionNumberedListItem(): @@ -274,7 +282,7 @@ def convert_block( { "type": "numberedListItem", "content": convert_rich_texts(block.specific.rich_text), - "children": convert_block_list(block.children), + "children": convert_block_list(block.children, attachments), } ] case NotionToDo(): @@ -283,7 +291,7 @@ def convert_block( "type": "checkListItem", "content": convert_rich_texts(block.specific.rich_text), "checked": block.specific.checked, - "children": convert_block_list(block.children), + "children": convert_block_list(block.children, attachments), } ] case NotionUnsupported(): From ac6742d82729d0cf76955873a80650b3aef409cb Mon Sep 17 00:00:00 2001 From: Baptiste Prevot Date: Tue, 3 Jun 2025 06:14:53 +0200 Subject: [PATCH 092/104] notion-schemas: handle callouts --- src/backend/core/notion_schemas/notion_block.py | 3 ++- src/backend/core/services/notion_import.py | 9 ++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/backend/core/notion_schemas/notion_block.py b/src/backend/core/notion_schemas/notion_block.py index 490f2c634..9ba1312fb 100644 --- a/src/backend/core/notion_schemas/notion_block.py +++ b/src/backend/core/notion_schemas/notion_block.py @@ -277,7 +277,8 @@ def put_all_in_raw(cls, data: Any) -> Any: | NotionLinkPreview | NotionTable | NotionTableRow - | NotionChildPage, + | NotionChildPage + | NotionCallout, Discriminator(discriminator="block_type"), ] | NotionUnsupported, diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index 26eaab502..974bb2200 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -215,8 +215,11 @@ def convert_block( case NotionCallout(): return [ { - "type": "comment", + "type": "quote", "content": convert_rich_texts(block.specific.rich_text), + "props": { + "backgroundColor": "yellow", # TODO: use the callout color + }, } ] case NotionTable(): @@ -380,8 +383,8 @@ def convert_child_pages( # TODO: doesn't work, never finds the child child_page = find_block_child_page(block.id, all_pages) if child_page == None: - logger.warning(f"Cannot find child page of block {block.id}") - continue + logger.warning(f"Cannot find child page of block {block.id}") + continue children.append(import_page(session, child_page, all_pages)) return children From da02423dbf31e818d8f27cb6faf1a35447dd7015 Mon Sep 17 00:00:00 2001 From: Thibault Guisnet Date: Tue, 3 Jun 2025 15:46:51 +0200 Subject: [PATCH 093/104] add partial links --- src/backend/core/services/notion_import.py | 25 +++++++++++++++------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index 974bb2200..6fd525ba4 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -131,14 +131,23 @@ def fetch_block_children(session: Session, block_id: str) -> list[NotionBlock]: def convert_rich_texts(rich_texts: list[NotionRichText]) -> list[dict[str, Any]]: content = [] for rich_text in rich_texts: - stylestab = convert_annotations(rich_text.annotations) - content.append( - { - "type": "text", - "text": rich_text.plain_text, - "styles": stylestab, - } - ) + if rich_text.href: + content.append( + { + "type" : "link", + "content" : rich_text.plain_text, + "href" : rich_text.href, + } + ) + else : + stylestab = convert_annotations(rich_text.annotations) + content.append( + { + "type" : "text", + "text" : rich_text.plain_text, + "styles" : stylestab, + } + ) return content From adc60295863f6756fdfe20383d58503612c722e5 Mon Sep 17 00:00:00 2001 From: Baptiste Prevot Date: Tue, 3 Jun 2025 06:32:19 +0200 Subject: [PATCH 094/104] notion-schemas: handle code blocks --- src/backend/core/services/notion_import.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index 6fd525ba4..98db9d75a 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -10,6 +10,7 @@ NotionBulletedListItem, NotionCallout, NotionChildPage, + NotionCode, NotionColumn, NotionColumnList, NotionDivider, @@ -306,6 +307,16 @@ def convert_block( "children": convert_block_list(block.children, attachments), } ] + case NotionCode(): + return [ + { + "type": "codeBlock", + "content": "".join( + rich_text.plain_text for rich_text in block.specific.rich_text + ), + "props": {"language": block.specific.language}, + } + ] case NotionUnsupported(): return [ { From 81ef2e717041fc8682135b73c0ad4b76705eb498 Mon Sep 17 00:00:00 2001 From: Baptiste Prevot Date: Tue, 3 Jun 2025 06:51:11 +0200 Subject: [PATCH 095/104] notion-schemas: handle bookmarks --- .../core/notion_schemas/notion_block.py | 14 +++++++-- src/backend/core/services/notion_import.py | 29 ++++++++++++++----- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/backend/core/notion_schemas/notion_block.py b/src/backend/core/notion_schemas/notion_block.py index 9ba1312fb..74e7ea489 100644 --- a/src/backend/core/notion_schemas/notion_block.py +++ b/src/backend/core/notion_schemas/notion_block.py @@ -182,7 +182,7 @@ class NotionImage(BaseModel): @model_validator(mode="before") @classmethod - def move_type_inward_and_rename(cls, data: Any) -> Any: + def move_file_type_inward_and_rename(cls, data: Any) -> Any: if not isinstance(data, dict): return data return {"block_type": "image", "file": data} @@ -202,6 +202,14 @@ class NotionLinkPreview(BaseModel): url: str +class NotionBookmark(BaseModel): + """https://developers.notion.com/reference/block#bookmark""" + + block_type: Literal[NotionBlockType.BOOKMARK] = NotionBlockType.BOOKMARK + url: str + caption: list[NotionRichText] = Field(default_factory=list) + + class NotionTable(BaseModel): """https://developers.notion.com/reference/block#table @@ -278,7 +286,9 @@ def put_all_in_raw(cls, data: Any) -> Any: | NotionTable | NotionTableRow | NotionChildPage - | NotionCallout, + | NotionCallout + | NotionLinkPreview + | NotionBookmark, Discriminator(discriminator="block_type"), ] | NotionUnsupported, diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index 98db9d75a..3a32300c5 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -7,6 +7,7 @@ from ..notion_schemas.notion_block import ( NotionBlock, + NotionBookmark, NotionBulletedListItem, NotionCallout, NotionChildPage, @@ -135,18 +136,18 @@ def convert_rich_texts(rich_texts: list[NotionRichText]) -> list[dict[str, Any]] if rich_text.href: content.append( { - "type" : "link", - "content" : rich_text.plain_text, - "href" : rich_text.href, + "type": "link", + "content": rich_text.plain_text, + "href": rich_text.href, } ) - else : + else: stylestab = convert_annotations(rich_text.annotations) content.append( { - "type" : "text", - "text" : rich_text.plain_text, - "styles" : stylestab, + "type": "text", + "text": rich_text.plain_text, + "styles": stylestab, } ) return content @@ -317,6 +318,20 @@ def convert_block( "props": {"language": block.specific.language}, } ] + case NotionBookmark(): + caption = convert_rich_texts(block.specific.caption) or block.specific.url + return [ + { + "type": "paragraph", + "content": [ + { + "type": "link", + "content": caption, + "href": block.specific.url, + }, + ], + } + ] case NotionUnsupported(): return [ { From 70451bae1a895b1c8ad8819c004ef08b87870c49 Mon Sep 17 00:00:00 2001 From: Baptiste Prevot Date: Tue, 3 Jun 2025 06:55:06 +0200 Subject: [PATCH 096/104] notion-schemas: fix heading handling --- src/backend/core/services/notion_import.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index 3a32300c5..a3db40f92 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -216,9 +216,11 @@ def convert_block( { "type": "heading", "content": convert_rich_texts(block.specific.rich_text), - "level": block.specific.block_type.value.split("_")[ - -1 - ], # e.g., "1", "2", or "3" + "props": { + "level": block.specific.block_type.value.split("_")[ + -1 + ], # e.g., "1", "2", or "3" + }, } ] # case NotionDivider(): From 931055009726eaaa599e21e622998a6c1981a266 Mon Sep 17 00:00:00 2001 From: Baptiste Prevot Date: Tue, 3 Jun 2025 07:01:05 +0200 Subject: [PATCH 097/104] notion-schemas: fix default table width --- src/backend/core/services/notion_import.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index a3db40f92..062e5d88f 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -257,14 +257,15 @@ def convert_block( "content": "Rows have different number of cells ?!", } ] + SEEMINGLY_DEFAULT_WIDTH = 128 return [ { "type": "table", "content": { "type": "tableContent", "columnWidths": [ - 1000 / n_columns for _ in range(n_columns) - ], # TODO + SEEMINGLY_DEFAULT_WIDTH for _ in range(n_columns) + ], "headerRows": int(block.specific.has_column_header), "headerColumns": int(block.specific.has_row_header), "props": { From e69ce24a6cdd10511bc399c43436bc1aeb4885fb Mon Sep 17 00:00:00 2001 From: Thibault Guisnet Date: Tue, 3 Jun 2025 17:26:25 +0200 Subject: [PATCH 098/104] improve links --- src/backend/core/services/notion_import.py | 25 +++++++++++----------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index 062e5d88f..6951621c1 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -136,23 +136,24 @@ def convert_rich_texts(rich_texts: list[NotionRichText]) -> list[dict[str, Any]] if rich_text.href: content.append( { - "type": "link", - "content": rich_text.plain_text, - "href": rich_text.href, - } - ) - else: - stylestab = convert_annotations(rich_text.annotations) - content.append( - { - "type": "text", - "text": rich_text.plain_text, - "styles": stylestab, + "type" : "link", + "content" : [convert_rich_text(rich_text)], + "href" : rich_text.href, } ) + else : + content.append(convert_rich_text(rich_text)) return content +def convert_rich_text(rich_text: NotionRichText) -> dict[str, Any]: + return { + "type" : "text", + "text" : rich_text.plain_text, + "styles" : convert_annotations(rich_text.annotations), + } + + class ImportedAttachment(BaseModel): block: Any file: NotionFileHosted From 3d9547d6fbcf99630c56f9c635d4838e40d6646d Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 3 Jun 2025 16:45:24 +0200 Subject: [PATCH 099/104] C'est le WIP maintenant --- src/backend/core/api/viewsets.py | 40 +++++++++++++++---- .../core/notion_schemas/notion_page.py | 3 ++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 79594694c..b165a1d69 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -36,7 +36,7 @@ from core.services.ai_services import AIService from core.services.collaboration_services import CollaborationService from core.services.converter_services import YdocConverter -from core.services.notion_import import import_notion +from core.services.notion_import import build_notion_session, fetch_all_pages, import_page from core.utils import extract_attachments, filter_descendants from . import permissions, serializers, utils @@ -1916,15 +1916,41 @@ def _import_notion_root_page(imported_doc, user, imported_docs_by_page_id): _import_notion_child_page(child, obj, user, imported_docs_by_page_id) -@drf.decorators.api_view(["GET", "POST"]) # TODO: drop GET (used for testing) -def notion_import_run(request): - if "notion_token" not in request.session: - raise drf.exceptions.PermissionDenied() +def _generate_notion_progress(root_pages, page_statuses): + raw = json.dumps([{ + "title": page.get_title(), + "status": page_statuses[page.id], + } for page in root_pages]) + return f"data: {raw}\n\n" + + +def _notion_import_event_stream(request): + session = build_notion_session(request.session["notion_token"]) + all_pages = fetch_all_pages(session) + root_pages = [page for page in all_pages if page.is_root()] + + page_statuses = {} + for page in root_pages: + page_statuses[page.id] = "pending" - imported_docs = import_notion(request.session["notion_token"]) + yield _generate_notion_progress(root_pages, page_statuses) + + imported_docs = [] + for page in root_pages: + imported_docs.append(import_page(session, page, all_pages)) + page_statuses[page.id] = "fetched" + yield _generate_notion_progress(root_pages, page_statuses) imported_docs_by_page_id = {} for imported_doc in imported_docs: _import_notion_root_page(imported_doc, request.user, imported_docs_by_page_id) + page_statuses[imported_doc.page.id] = "imported" + yield _generate_notion_progress(root_pages, page_statuses) + +@drf.decorators.api_view(["GET", "POST"]) # TODO: drop GET (used for testing) +def notion_import_run(request): + if "notion_token" not in request.session: + raise drf.exceptions.PermissionDenied() - return drf.response.Response({"sava": "oui et toi ?"}) + #return drf.response.Response({"sava": "oui et toi ?"}) + return StreamingHttpResponse(_notion_import_event_stream(request), content_type='text/event-stream') diff --git a/src/backend/core/notion_schemas/notion_page.py b/src/backend/core/notion_schemas/notion_page.py index b014b3423..4d98856c5 100644 --- a/src/backend/core/notion_schemas/notion_page.py +++ b/src/backend/core/notion_schemas/notion_page.py @@ -56,3 +56,6 @@ def get_title(self) -> str | None: # This could be parsed using NotionRichText rich_text = title_property["title"][0] return rich_text["plain_text"] + + def is_root(self): + return isinstance(self.parent, NotionParentWorkspace) From 42f42fc37f16dd90bf1f5b2ef2047ee74d4c361f Mon Sep 17 00:00:00 2001 From: Simon Ser Date: Tue, 3 Jun 2025 17:03:39 +0200 Subject: [PATCH 100/104] Disable content negotiation --- src/backend/core/api/viewsets.py | 22 ++++++++++++++++------ src/backend/core/urls.py | 2 +- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index b165a1d69..323f5590c 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -1947,10 +1947,20 @@ def _notion_import_event_stream(request): page_statuses[imported_doc.page.id] = "imported" yield _generate_notion_progress(root_pages, page_statuses) -@drf.decorators.api_view(["GET", "POST"]) # TODO: drop GET (used for testing) -def notion_import_run(request): - if "notion_token" not in request.session: - raise drf.exceptions.PermissionDenied() - #return drf.response.Response({"sava": "oui et toi ?"}) - return StreamingHttpResponse(_notion_import_event_stream(request), content_type='text/event-stream') +class IgnoreClientContentNegotiation(drf.negotiation.BaseContentNegotiation): + def select_parser(self, request, parsers): + return parsers[0] + + def select_renderer(self, request, renderers, format_suffix): + return (renderers[0], renderers[0].media_type) + +class NotionImportRunView(drf.views.APIView): + content_negotiation_class = IgnoreClientContentNegotiation + + def get(self, request, format=None): + if "notion_token" not in request.session: + raise drf.exceptions.PermissionDenied() + + #return drf.response.Response({"sava": "oui et toi ?"}) + return StreamingHttpResponse(_notion_import_event_stream(request), content_type='text/event-stream') diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index 4233cf315..7c0f25943 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -55,7 +55,7 @@ path("notion_import/", include([ path("redirect", viewsets.notion_import_redirect), path("callback", viewsets.notion_import_callback), - path("run", viewsets.notion_import_run), + path("run", viewsets.NotionImportRunView.as_view()), ])) ] ), From 116a7e35c52d9d0d4d321a79074b5055715b6bf8 Mon Sep 17 00:00:00 2001 From: Clara Ni Date: Tue, 3 Jun 2025 17:11:59 +0200 Subject: [PATCH 101/104] add eventSource in useImportNotion --- .../doc-management/api/useImportNotion.tsx | 85 ++++++++++++++----- .../impress/src/pages/import-notion/index.tsx | 12 ++- 2 files changed, 68 insertions(+), 29 deletions(-) diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useImportNotion.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useImportNotion.tsx index a7234e960..e24479d0c 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useImportNotion.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useImportNotion.tsx @@ -1,33 +1,74 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; -import { APIError, errorCauses, fetchAPI } from '@/api'; +import { baseApiUrl } from '@/api'; -import { KEY_LIST_DOC } from './useDocs'; +type ImportState = { + title: string; + status: 'pending' | 'fetched' | 'imported'; +}[]; -export const importNotion = async (): Promise => { - const response = await fetchAPI('notion_import/run', { - method: 'POST', - }); +const computeSuccessPercentage = (importState?: ImportState) => { + if (!importState) { + return 0; + } + if (!importState.length) { + return 100; + } - if (!response.ok) { - throw new APIError( - 'Failed to import the Notion', - await errorCauses(response), - ); + let fetchedFiles = 0; + let importedFiles = 0; + + for (const file of importState) { + if (file.status === 'fetched') { + fetchedFiles += 1; + } else if (file.status === 'imported') { + fetchedFiles += 1; + importedFiles += 1; + } } + + const filesNb = importState.length; + + return Math.round(((fetchedFiles + importedFiles) / (2 * filesNb)) * 100); }; export function useImportNotion() { const router = useRouter(); - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: importNotion, - onSuccess: () => { - void queryClient.resetQueries({ - queryKey: [KEY_LIST_DOC], - }); - router.push('/'); - }, - }); + + const [importState, setImportState] = useState(); + + useEffect(() => { + // send the request with an Event Source + const eventSource = new EventSource( + `${baseApiUrl('1.0')}notion_import/run`, + { + withCredentials: true, + }, + ); + + eventSource.onmessage = (event) => { + console.log('hello', event.data); + const files = JSON.parse(event.data as string) as ImportState; + + // si tous les fichiers sont chargés, rediriger vers la home page + if (files.some((file) => file.status === 'imported')) { + eventSource.close(); + router.push('/'); + } + + // mettre à jour le state d'import + setImportState(files); + }; + + return () => { + eventSource.close(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { + importState, + percentageValue: computeSuccessPercentage(importState), + }; } diff --git a/src/frontend/apps/impress/src/pages/import-notion/index.tsx b/src/frontend/apps/impress/src/pages/import-notion/index.tsx index 098cb3068..a0301b1f6 100644 --- a/src/frontend/apps/impress/src/pages/import-notion/index.tsx +++ b/src/frontend/apps/impress/src/pages/import-notion/index.tsx @@ -1,5 +1,5 @@ import { Loader } from '@openfun/cunningham-react'; -import { ReactElement, useEffect } from 'react'; +import { ReactElement } from 'react'; import { useTranslation } from 'react-i18next'; import { Box, Text } from '@/components'; @@ -10,12 +10,7 @@ import { NextPageWithLayout } from '@/types/next'; const Page: NextPageWithLayout = () => { const { t } = useTranslation(); - const { mutate: importNotion } = useImportNotion(); - - useEffect(() => { - importNotion(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const { percentageValue } = useImportNotion(); return ( { {t('Please stay on this page and be patient')} + + {percentageValue}% + ); }; From 96e36e615106190b47e77788aebd3ef1f77942bc Mon Sep 17 00:00:00 2001 From: Clara Ni Date: Tue, 3 Jun 2025 17:40:48 +0200 Subject: [PATCH 102/104] fancy progress bar --- src/frontend/apps/impress/src/i18n/translations.json | 3 +++ .../apps/impress/src/pages/import-notion/index.tsx | 9 ++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/frontend/apps/impress/src/i18n/translations.json b/src/frontend/apps/impress/src/i18n/translations.json index 1e04f59a7..9860fae69 100644 --- a/src/frontend/apps/impress/src/i18n/translations.json +++ b/src/frontend/apps/impress/src/i18n/translations.json @@ -629,6 +629,9 @@ "No versions": "Aucune version", "Nothing exceptional, no special privileges related to a .gouv.fr.": "Rien d'exceptionnel, pas de privilèges spéciaux liés à un .gouv.fr.", "Notion import in progress...": "Import Notion en cours...", + "Notion import fetched": "🔄 Page Notion récupérée", + "Notion import imported": "✅️ Importé", + "Notion import pending": "⏸️ En attente", "OK": "OK", "Offline ?!": "Hors-ligne ?!", "Only invited people can access": "Seules les personnes invitées peuvent accéder", diff --git a/src/frontend/apps/impress/src/pages/import-notion/index.tsx b/src/frontend/apps/impress/src/pages/import-notion/index.tsx index a0301b1f6..1685f1a8c 100644 --- a/src/frontend/apps/impress/src/pages/import-notion/index.tsx +++ b/src/frontend/apps/impress/src/pages/import-notion/index.tsx @@ -10,7 +10,7 @@ import { NextPageWithLayout } from '@/types/next'; const Page: NextPageWithLayout = () => { const { t } = useTranslation(); - const { percentageValue } = useImportNotion(); + const { importState, percentageValue } = useImportNotion(); return ( { {percentageValue}% + + {importState?.map((page) => ( + {`${page.title} - ${t(`Notion import ${page.status}`)}`} + ))} + ); }; From b1d52cc5e6ac95ac4c0b35638b5463e986ea4e76 Mon Sep 17 00:00:00 2001 From: Baptiste Prevot Date: Tue, 3 Jun 2025 08:24:59 +0200 Subject: [PATCH 103/104] notion-import: handle child page blocks --- src/backend/core/services/notion_import.py | 159 +++++++++++++-------- 1 file changed, 102 insertions(+), 57 deletions(-) diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index 6951621c1..e99b19ce0 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -2,7 +2,7 @@ import logging from typing import Any -from pydantic import BaseModel, TypeAdapter +from pydantic import BaseModel, Field, TypeAdapter from requests import Session from ..notion_schemas.notion_block import ( @@ -136,21 +136,21 @@ def convert_rich_texts(rich_texts: list[NotionRichText]) -> list[dict[str, Any]] if rich_text.href: content.append( { - "type" : "link", - "content" : [convert_rich_text(rich_text)], - "href" : rich_text.href, + "type": "link", + "content": [convert_rich_text(rich_text)], + "href": rich_text.href, # FIXME: if it was a notion link, we should convert it to a link to the document } ) - else : + else: content.append(convert_rich_text(rich_text)) return content def convert_rich_text(rich_text: NotionRichText) -> dict[str, Any]: return { - "type" : "text", - "text" : rich_text.plain_text, - "styles" : convert_annotations(rich_text.annotations), + "type": "text", + "text": rich_text.plain_text, + "styles": convert_annotations(rich_text.annotations), } @@ -159,6 +159,11 @@ class ImportedAttachment(BaseModel): file: NotionFileHosted +class ImportedChildPage(BaseModel): + child_page_block: NotionBlock + block_to_update: Any + + def convert_image( image: NotionImage, attachments: list[ImportedAttachment] ) -> list[dict[str, Any]]: @@ -188,17 +193,21 @@ def convert_image( def convert_block( - block: NotionBlock, attachments: list[ImportedAttachment] + block: NotionBlock, + attachments: list[ImportedAttachment], + child_page_blocks: list[ImportedChildPage], ) -> list[dict[str, Any]]: match block.specific: case NotionColumnList(): columns_content = [] for column in block.children: - columns_content.extend(convert_block(column, attachments)) + columns_content.extend( + convert_block(column, attachments, child_page_blocks) + ) return columns_content case NotionColumn(): return [ - convert_block(child_content, attachments)[0] + convert_block(child_content, attachments, child_page_blocks)[0] for child_content in block.children ] @@ -225,7 +234,7 @@ def convert_block( } ] # case NotionDivider(): - # return {"type": "divider", "properties": {}} + # return [{"type": "divider"}] case NotionCallout(): return [ { @@ -292,7 +301,11 @@ def convert_block( { "type": "bulletListItem", "content": convert_rich_texts(block.specific.rich_text), - "children": convert_block_list(block.children, attachments), + "children": convert_block_list( + block.children, + attachments, + child_page_blocks, + ), } ] case NotionNumberedListItem(): @@ -300,7 +313,11 @@ def convert_block( { "type": "numberedListItem", "content": convert_rich_texts(block.specific.rich_text), - "children": convert_block_list(block.children, attachments), + "children": convert_block_list( + block.children, + attachments, + child_page_blocks, + ), } ] case NotionToDo(): @@ -309,7 +326,11 @@ def convert_block( "type": "checkListItem", "content": convert_rich_texts(block.specific.rich_text), "checked": block.specific.checked, - "children": convert_block_list(block.children, attachments), + "children": convert_block_list( + block.children, + attachments, + child_page_blocks, + ), } ] case NotionCode(): @@ -336,6 +357,22 @@ def convert_block( ], } ] + case NotionChildPage(): + # TODO: convert to a link + res = { + "type": "paragraph", + "content": [ + { + "type": "link", + "content": f"Child page: {block.specific.title}", + "href": "about:blank", # populated later on + }, + ], + } + child_page_blocks.append( + ImportedChildPage(child_page_block=block, block_to_update=res) + ) + return [res] case NotionUnsupported(): return [ { @@ -375,19 +412,22 @@ def convert_annotations(annotations: NotionRichTextAnnotation) -> dict[str, str] def convert_block_list( - blocks: list[NotionBlock], attachments: list[ImportedAttachment] + blocks: list[NotionBlock], + attachments: list[ImportedAttachment], + child_page_blocks: list[ImportedChildPage], ) -> list[dict[str, Any]]: converted_blocks = [] for block in blocks: - converted_blocks.extend(convert_block(block, attachments)) + converted_blocks.extend(convert_block(block, attachments, child_page_blocks)) return converted_blocks class ImportedDocument(BaseModel): page: NotionPage - blocks: list[dict[str, Any]] = [] - children: list["ImportedDocument"] = [] - attachments: list[ImportedAttachment] = [] + blocks: list[dict[str, Any]] = Field(default_factory=list) + children: list["ImportedDocument"] = Field(default_factory=list) + attachments: list[ImportedAttachment] = Field(default_factory=list) + child_page_blocks: list[ImportedChildPage] = Field(default_factory=list) def find_block_child_page(block_id: str, all_pages: list[NotionPage]): @@ -400,48 +440,30 @@ def find_block_child_page(block_id: str, all_pages: list[NotionPage]): return None -def convert_child_pages( - session: Session, - parent: NotionPage, - blocks: list[NotionBlock], - all_pages: list[NotionPage], -) -> list[ImportedDocument]: - children = [] - - for page in all_pages: - if ( - isinstance(page.parent, NotionParentPage) - and page.parent.page_id == parent.id - ): - children.append(import_page(session, page, all_pages)) - - for block in blocks: - if not isinstance(block.specific, NotionChildPage): - continue - - # TODO: doesn't work, never finds the child - child_page = find_block_child_page(block.id, all_pages) - if child_page == None: - logger.warning(f"Cannot find child page of block {block.id}") - continue - children.append(import_page(session, child_page, all_pages)) - - return children - - def import_page( - session: Session, page: NotionPage, all_pages: list[NotionPage] + session: Session, + page: NotionPage, + child_page_blocs_ids_to_parent_page_ids: dict[str, str], ) -> ImportedDocument: blocks = fetch_block_children(session, page.id) logger.info(f"Page {page.get_title()} (id {page.id})") logger.info(blocks) - attachments = [] - converted_blocks = convert_block_list(blocks, attachments) + attachments: list[ImportedAttachment] = [] + + child_page_blocks: list[ImportedChildPage] = [] + + converted_blocks = convert_block_list(blocks, attachments, child_page_blocks) + + for child_page_block in child_page_blocks: + child_page_blocs_ids_to_parent_page_ids[ + child_page_block.child_page_block.id + ] = page.id + return ImportedDocument( page=page, blocks=converted_blocks, - children=convert_child_pages(session, page, blocks, all_pages), attachments=attachments, + child_page_blocks=child_page_blocks, ) @@ -449,8 +471,31 @@ def import_notion(token: str) -> list[ImportedDocument]: """Recursively imports all Notion pages and blocks accessible using the given token.""" session = build_notion_session(token) all_pages = fetch_all_pages(session) - docs = [] + docs_by_page_id: dict[str, ImportedDocument] = {} + child_page_blocs_ids_to_parent_page_ids: dict[str, str] = {} for page in all_pages: - if isinstance(page.parent, NotionParentWorkspace): - docs.append(import_page(session, page, all_pages)) - return docs + docs_by_page_id[page.id] = import_page( + session, page, child_page_blocs_ids_to_parent_page_ids + ) + + root_pages = [] + for page in all_pages: + if isinstance(page.parent, NotionParentPage): + docs_by_page_id[page.parent.page_id].children.append( + docs_by_page_id[page.id] + ) + elif isinstance(page.parent, NotionParentBlock): + parent_page_id = child_page_blocs_ids_to_parent_page_ids.get(page.id) + if parent_page_id: + docs_by_page_id[parent_page_id].children.append( + docs_by_page_id[page.id] + ) + else: + logger.warning( + f"Page {page.id} has a parent block, but no parent page found." + ) + elif isinstance(page.parent, NotionParentWorkspace): + # This is a root page, not a child of another page + root_pages.append(docs_by_page_id[page.id]) + + return root_pages From 7af6e8dd9ac9fea38a82c6b698b95dc765a0ff46 Mon Sep 17 00:00:00 2001 From: Baptiste Prevot Date: Tue, 3 Jun 2025 09:10:37 +0200 Subject: [PATCH 104/104] notion-import: adapt child page block to progress stream --- src/backend/core/api/viewsets.py | 95 +++++++++++++++------- src/backend/core/services/notion_import.py | 45 ++++------ 2 files changed, 79 insertions(+), 61 deletions(-) diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 323f5590c..ae0223188 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -36,9 +36,16 @@ from core.services.ai_services import AIService from core.services.collaboration_services import CollaborationService from core.services.converter_services import YdocConverter -from core.services.notion_import import build_notion_session, fetch_all_pages, import_page +from core.services.notion_import import ( + ImportedDocument, + build_notion_session, + fetch_all_pages, + import_page, + link_child_page_to_parent, +) from core.utils import extract_attachments, filter_descendants +from ..notion_schemas.notion_page import NotionPage from . import permissions, serializers, utils from .filters import DocumentFilter, ListDocumentFilter @@ -1840,7 +1847,9 @@ def notion_import_callback(request): code = request.GET.get("code") resp = requests.post( "https://api.notion.com/v1/oauth/token", - auth=requests.auth.HTTPBasicAuth(settings.NOTION_CLIENT_ID, settings.NOTION_CLIENT_SECRET), + auth=requests.auth.HTTPBasicAuth( + settings.NOTION_CLIENT_ID, settings.NOTION_CLIENT_SECRET + ), headers={"Accept": "application/json"}, data={ "grant_type": "authorization_code", @@ -1859,7 +1868,7 @@ def _import_notion_doc_content(imported_doc, obj, user): extra_args = { "Metadata": { "owner": str(user.id), - "status": enums.DocumentAttachmentStatus.READY, # TODO + "status": enums.DocumentAttachmentStatus.READY, # TODO }, } file_id = uuid.uuid4() @@ -1869,12 +1878,15 @@ def _import_notion_doc_content(imported_doc, obj, user): resp.raw, default_storage.bucket_name, key ) obj.attachments.append(key) - att.block["props"]["url"] = f"{settings.MEDIA_BASE_URL}{settings.MEDIA_URL}{key}" + att.block["props"]["url"] = ( + f"{settings.MEDIA_BASE_URL}{settings.MEDIA_URL}{key}" + ) obj.content = YdocConverter().convert_blocks(imported_doc.blocks) obj.save() -def _import_notion_child_page(imported_doc, parent_doc, user, imported_docs_by_page_id): + +def _import_notion_child_page(imported_doc, parent_doc, user, imported_ids): obj = parent_doc.add_child( creator=user, title=imported_doc.page.get_title() or "J'aime les carottes", @@ -1888,13 +1900,13 @@ def _import_notion_child_page(imported_doc, parent_doc, user, imported_docs_by_p _import_notion_doc_content(imported_doc, obj, user) - imported_docs_by_page_id[imported_doc.page.id] = obj + imported_ids.append(imported_doc.page.id) for child in imported_doc.children: - _import_notion_child_page(child, obj, user, imported_docs_by_page_id) + _import_notion_child_page(child, obj, user, imported_ids) -def _import_notion_root_page(imported_doc, user, imported_docs_by_page_id): +def _import_notion_root_page(imported_doc, user) -> list[str]: obj = models.Document.add_root( depth=1, creator=user, @@ -1908,44 +1920,64 @@ def _import_notion_root_page(imported_doc, user, imported_docs_by_page_id): role=models.RoleChoices.OWNER, ) - _import_notion_doc_content(imported_doc, obj, user) + imported_ids = [imported_doc.page.id] - imported_docs_by_page_id[imported_doc.page.id] = obj + _import_notion_doc_content(imported_doc, obj, user) for child in imported_doc.children: - _import_notion_child_page(child, obj, user, imported_docs_by_page_id) + _import_notion_child_page(child, obj, user, imported_ids) + + return imported_ids -def _generate_notion_progress(root_pages, page_statuses): - raw = json.dumps([{ - "title": page.get_title(), - "status": page_statuses[page.id], - } for page in root_pages]) +def _generate_notion_progress( + all_pages: list[NotionPage], page_statuses: dict[str, str] +) -> str: + raw = json.dumps( + [ + { + "title": page.get_title(), + "status": page_statuses[page.id], + } + for page in all_pages + ] + ) return f"data: {raw}\n\n" def _notion_import_event_stream(request): session = build_notion_session(request.session["notion_token"]) all_pages = fetch_all_pages(session) - root_pages = [page for page in all_pages if page.is_root()] page_statuses = {} - for page in root_pages: + for page in all_pages: page_statuses[page.id] = "pending" - yield _generate_notion_progress(root_pages, page_statuses) + yield _generate_notion_progress(all_pages, page_statuses) - imported_docs = [] - for page in root_pages: - imported_docs.append(import_page(session, page, all_pages)) + docs_by_page_id: dict[str, ImportedDocument] = {} + child_page_blocs_ids_to_parent_page_ids: dict[str, str] = {} + + for page in all_pages: + docs_by_page_id[page.id] = import_page( + session, page, child_page_blocs_ids_to_parent_page_ids + ) page_statuses[page.id] = "fetched" - yield _generate_notion_progress(root_pages, page_statuses) + yield _generate_notion_progress(all_pages, page_statuses) + + for page in all_pages: + link_child_page_to_parent( + page, docs_by_page_id, child_page_blocs_ids_to_parent_page_ids + ) - imported_docs_by_page_id = {} - for imported_doc in imported_docs: - _import_notion_root_page(imported_doc, request.user, imported_docs_by_page_id) - page_statuses[imported_doc.page.id] = "imported" - yield _generate_notion_progress(root_pages, page_statuses) + root_docs = [doc for doc in docs_by_page_id.values() if doc.page.is_root()] + + for root_doc in root_docs: + imported_ids = _import_notion_root_page(root_doc, request.user) + for imported_id in imported_ids: + page_statuses[imported_id] = "imported" + + yield _generate_notion_progress(all_pages, page_statuses) class IgnoreClientContentNegotiation(drf.negotiation.BaseContentNegotiation): @@ -1955,6 +1987,7 @@ def select_parser(self, request, parsers): def select_renderer(self, request, renderers, format_suffix): return (renderers[0], renderers[0].media_type) + class NotionImportRunView(drf.views.APIView): content_negotiation_class = IgnoreClientContentNegotiation @@ -1962,5 +1995,7 @@ def get(self, request, format=None): if "notion_token" not in request.session: raise drf.exceptions.PermissionDenied() - #return drf.response.Response({"sava": "oui et toi ?"}) - return StreamingHttpResponse(_notion_import_event_stream(request), content_type='text/event-stream') + # return drf.response.Response({"sava": "oui et toi ?"}) + return StreamingHttpResponse( + _notion_import_event_stream(request), content_type="text/event-stream" + ) diff --git a/src/backend/core/services/notion_import.py b/src/backend/core/services/notion_import.py index e99b19ce0..7af2c26f6 100644 --- a/src/backend/core/services/notion_import.py +++ b/src/backend/core/services/notion_import.py @@ -467,35 +467,18 @@ def import_page( ) -def import_notion(token: str) -> list[ImportedDocument]: - """Recursively imports all Notion pages and blocks accessible using the given token.""" - session = build_notion_session(token) - all_pages = fetch_all_pages(session) - docs_by_page_id: dict[str, ImportedDocument] = {} - child_page_blocs_ids_to_parent_page_ids: dict[str, str] = {} - for page in all_pages: - docs_by_page_id[page.id] = import_page( - session, page, child_page_blocs_ids_to_parent_page_ids - ) - - root_pages = [] - for page in all_pages: - if isinstance(page.parent, NotionParentPage): - docs_by_page_id[page.parent.page_id].children.append( - docs_by_page_id[page.id] +def link_child_page_to_parent( + page: NotionPage, + docs_by_page_id: dict[str, ImportedDocument], + child_page_blocs_ids_to_parent_page_ids: dict[str, str], +): + if isinstance(page.parent, NotionParentPage): + docs_by_page_id[page.parent.page_id].children.append(docs_by_page_id[page.id]) + elif isinstance(page.parent, NotionParentBlock): + parent_page_id = child_page_blocs_ids_to_parent_page_ids.get(page.id) + if parent_page_id: + docs_by_page_id[parent_page_id].children.append(docs_by_page_id[page.id]) + else: + logger.warning( + f"Page {page.id} has a parent block, but no parent page found." ) - elif isinstance(page.parent, NotionParentBlock): - parent_page_id = child_page_blocs_ids_to_parent_page_ids.get(page.id) - if parent_page_id: - docs_by_page_id[parent_page_id].children.append( - docs_by_page_id[page.id] - ) - else: - logger.warning( - f"Page {page.id} has a parent block, but no parent page found." - ) - elif isinstance(page.parent, NotionParentWorkspace): - # This is a root page, not a child of another page - root_pages.append(docs_by_page_id[page.id]) - - return root_pages