From 0d30e61c3cd1098c2ab5cf670af9db8d155770c4 Mon Sep 17 00:00:00 2001 From: belthlemar Date: Mon, 24 Aug 2026 16:58:28 +0200 Subject: [PATCH 01/10] introduce new model --- antarest/study/business/model/user_model.py | 11 +++++++++ .../business/user_resources_management.py | 23 +++++++++++++++++-- antarest/study/web/study_data_blueprint.py | 6 ++--- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/antarest/study/business/model/user_model.py b/antarest/study/business/model/user_model.py index 2301feb689..8449676112 100644 --- a/antarest/study/business/model/user_model.py +++ b/antarest/study/business/model/user_model.py @@ -41,3 +41,14 @@ def _validate_coherence(self) -> Self: class UserResourceDataRemoval(AntaresBaseModel): path: str + + +class FolderTree(AntaresBaseModel): + name: str + directories: list["FolderTree"] + files: list[str] + + +class UserResourcesTree(AntaresBaseModel): + directories: list[FolderTree] + files: list[str] diff --git a/antarest/study/business/user_resources_management.py b/antarest/study/business/user_resources_management.py index 5c1bf99486..c798b09b97 100644 --- a/antarest/study/business/user_resources_management.py +++ b/antarest/study/business/user_resources_management.py @@ -11,7 +11,12 @@ # This file is part of the Antares project. from pathlib import PurePosixPath -from antarest.study.business.model.user_model import ResourceType, UserResourceDataCreation, UserResourceDataRemoval +from antarest.study.business.model.user_model import ( + ResourceType, + UserResourceDataCreation, + UserResourceDataRemoval, + UserResourcesTree, +) from antarest.study.business.study_interface import StudyInterface from antarest.study.storage.variantstudy.model.command.remove_user_resource import RemoveUserResource from antarest.study.storage.variantstudy.model.command.replace_user_resource import ReplaceUserResource @@ -22,8 +27,22 @@ class UserResourcesManager: def __init__(self, command_context: CommandContext) -> None: self._command_context = command_context - def get_all_user_resources_paths(self, study: StudyInterface) -> list[str]: + def get_all_user_resources_paths(self, study: StudyInterface) -> UserResourcesTree: user_resources = study.get_study_dao().get_all_user_resources() + # result = {} + + """" + class FolderTree(AntaresBaseModel): + name: str + directories: list["FolderTree"] + files: list[str] + + class UserResourcesTree(AntaresBaseModel): + directories: list[FolderTree] + files: list[str] + + """ + sorted_resources = sorted(user_resources, key=lambda res: res.path) return [res.path.as_posix() for res in sorted_resources] diff --git a/antarest/study/web/study_data_blueprint.py b/antarest/study/web/study_data_blueprint.py index 782844bac9..4b86eeab3b 100644 --- a/antarest/study/web/study_data_blueprint.py +++ b/antarest/study/web/study_data_blueprint.py @@ -128,7 +128,7 @@ ThermalClusterCreation, ThermalClusterUpdate, ) -from antarest.study.business.model.user_model import ResourceType +from antarest.study.business.model.user_model import ResourceType, UserResourcesTree from antarest.study.business.table_mode_management import TableDataDTO, TableModeType from antarest.study.model import CommentsDto from antarest.study.storage.rawstudy.model.filesystem.config.identifier import transform_name_to_id @@ -2366,8 +2366,8 @@ def get_study_data( """ return study_service.get_study_data(study_id) - @bp.get("/studies/{uuid}/user-resources", summary="Fetches paths of all user resources for a given study") - def get_all_user_resources(study_service: StudyServiceDep, uuid: UuidStr) -> list[str]: + @bp.get("/studies/{uuid}/user-resources", summary="Fetches tree structure of all user resources for a given study") + def get_all_user_resources(study_service: StudyServiceDep, uuid: UuidStr) -> UserResourcesTree: study = study_service.check_study_access(uuid, StudyPermissionType.READ) study_interface = study_service.get_study_interface(study) return study_service.user_resources_manager.get_all_user_resources_paths(study_interface) From db89eaf704b81c1c02a92c6a901807b30f0e0398 Mon Sep 17 00:00:00 2001 From: belthlemar Date: Mon, 24 Aug 2026 17:02:20 +0200 Subject: [PATCH 02/10] add chatgpt code --- .../business/user_resources_management.py | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/antarest/study/business/user_resources_management.py b/antarest/study/business/user_resources_management.py index c798b09b97..6dfa3e00b8 100644 --- a/antarest/study/business/user_resources_management.py +++ b/antarest/study/business/user_resources_management.py @@ -10,6 +10,7 @@ # # This file is part of the Antares project. from pathlib import PurePosixPath +from typing import Any from antarest.study.business.model.user_model import ( ResourceType, @@ -23,28 +24,37 @@ from antarest.study.storage.variantstudy.model.command_context import CommandContext +def _build_tree(resources: list[UserResourceDataCreation]) -> UserResourcesTree: + root: dict[str, Any] = {"directories": [], "files": []} + + for resource in resources: + parts = resource.path.parts + current = root + + for part in parts[:-1]: + directory = next( + (d for d in current["directories"] if d["name"] == part), + None, + ) + if directory is None: + directory = {"name": part, "directories": [], "files": []} + current["directories"].append(directory) + + current = directory + + if resource.resource_type == ResourceType.FILE: + current["files"].append(parts[-1]) + + return UserResourcesTree.model_validate(root) + + class UserResourcesManager: def __init__(self, command_context: CommandContext) -> None: self._command_context = command_context def get_all_user_resources_paths(self, study: StudyInterface) -> UserResourcesTree: user_resources = study.get_study_dao().get_all_user_resources() - # result = {} - - """" - class FolderTree(AntaresBaseModel): - name: str - directories: list["FolderTree"] - files: list[str] - - class UserResourcesTree(AntaresBaseModel): - directories: list[FolderTree] - files: list[str] - - """ - - sorted_resources = sorted(user_resources, key=lambda res: res.path) - return [res.path.as_posix() for res in sorted_resources] + return _build_tree(user_resources) def get_user_resource(self, study: StudyInterface, path: PurePosixPath) -> bytes: return study.get_study_dao().get_user_resource(path) From b6e1ce89083528c3b746f5d355f240611efb8a65 Mon Sep 17 00:00:00 2001 From: belthlemar Date: Mon, 24 Aug 2026 17:03:41 +0200 Subject: [PATCH 03/10] start rewriting test --- tests/integration/study_data_blueprint/test_user_resources.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/study_data_blueprint/test_user_resources.py b/tests/integration/study_data_blueprint/test_user_resources.py index ba5a1bcee1..26332ebf3f 100644 --- a/tests/integration/study_data_blueprint/test_user_resources.py +++ b/tests/integration/study_data_blueprint/test_user_resources.py @@ -28,7 +28,7 @@ def test_nominal_case(client: TestClient, user_access_token: str, storage_mode: # Fetches all user resources. Should be empty res = client.get(f"/v1/studies/{study_id}/user-resources") assert res.status_code == 200 - assert res.json() == [] + assert res.json() == {"directories": [], "files": []} # Create a folder params = {"path": "my/folder", "resource_type": "folder"} From 77f590b8d90da86da431caa409c0dc6f91f718c8 Mon Sep 17 00:00:00 2001 From: belthlemar Date: Mon, 24 Aug 2026 17:04:50 +0200 Subject: [PATCH 04/10] typo --- antarest/study/business/user_resources_management.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/antarest/study/business/user_resources_management.py b/antarest/study/business/user_resources_management.py index 6dfa3e00b8..a9479df0cf 100644 --- a/antarest/study/business/user_resources_management.py +++ b/antarest/study/business/user_resources_management.py @@ -32,10 +32,7 @@ def _build_tree(resources: list[UserResourceDataCreation]) -> UserResourcesTree: current = root for part in parts[:-1]: - directory = next( - (d for d in current["directories"] if d["name"] == part), - None, - ) + directory = next((d for d in current["directories"] if d["name"] == part), None) if directory is None: directory = {"name": part, "directories": [], "files": []} current["directories"].append(directory) From bc251b721e3c4337c166380676e3891039259559 Mon Sep 17 00:00:00 2001 From: belthlemar Date: Mon, 24 Aug 2026 17:11:23 +0200 Subject: [PATCH 05/10] fix ai code --- antarest/study/business/model/user_model.py | 4 ++-- .../study/business/user_resources_management.py | 7 +++++-- .../study_data_blueprint/test_user_resources.py | 16 ++++++++++++++-- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/antarest/study/business/model/user_model.py b/antarest/study/business/model/user_model.py index 8449676112..74eb58cf73 100644 --- a/antarest/study/business/model/user_model.py +++ b/antarest/study/business/model/user_model.py @@ -45,10 +45,10 @@ class UserResourceDataRemoval(AntaresBaseModel): class FolderTree(AntaresBaseModel): name: str - directories: list["FolderTree"] files: list[str] + directories: list["FolderTree"] class UserResourcesTree(AntaresBaseModel): - directories: list[FolderTree] files: list[str] + directories: list[FolderTree] diff --git a/antarest/study/business/user_resources_management.py b/antarest/study/business/user_resources_management.py index a9479df0cf..51bee53f15 100644 --- a/antarest/study/business/user_resources_management.py +++ b/antarest/study/business/user_resources_management.py @@ -39,9 +39,12 @@ def _build_tree(resources: list[UserResourceDataCreation]) -> UserResourcesTree: current = directory - if resource.resource_type == ResourceType.FILE: - current["files"].append(parts[-1]) + name = parts[-1] + if resource.resource_type == ResourceType.FILE: + current["files"].append(name) + else: + current["directories"].append({"name": name, "directories": [], "files": []}) return UserResourcesTree.model_validate(root) diff --git a/tests/integration/study_data_blueprint/test_user_resources.py b/tests/integration/study_data_blueprint/test_user_resources.py index 26332ebf3f..bc5757ff9e 100644 --- a/tests/integration/study_data_blueprint/test_user_resources.py +++ b/tests/integration/study_data_blueprint/test_user_resources.py @@ -38,8 +38,20 @@ def test_nominal_case(client: TestClient, user_access_token: str, storage_mode: # Fetch all resources. Should contain the folder res = client.get(f"/v1/studies/{study_id}/user-resources") assert res.status_code == 200 - assert res.json() == ["my/folder"] - + assert res.json() == { + "directories": [ + { + "name": "my", + "files": [], + "directories": { + "name": "folder", + "files": [], + "directories": [], + }, + } + ], + "files": [], + } # Create a file with a specific content content = b"specific content" res = client.put( From 3c6edf0a6b2617e44ec9b4150c33bf46853dc555 Mon Sep 17 00:00:00 2001 From: belthlemar Date: Mon, 24 Aug 2026 17:12:33 +0200 Subject: [PATCH 06/10] typo --- .../study_data_blueprint/test_user_resources.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/integration/study_data_blueprint/test_user_resources.py b/tests/integration/study_data_blueprint/test_user_resources.py index bc5757ff9e..fce81c0c8c 100644 --- a/tests/integration/study_data_blueprint/test_user_resources.py +++ b/tests/integration/study_data_blueprint/test_user_resources.py @@ -43,11 +43,13 @@ def test_nominal_case(client: TestClient, user_access_token: str, storage_mode: { "name": "my", "files": [], - "directories": { - "name": "folder", - "files": [], - "directories": [], - }, + "directories": [ + { + "name": "folder", + "files": [], + "directories": [], + } + ], } ], "files": [], From 3482e88cbd2d4374d16c1c95589cbcb73e60e3af Mon Sep 17 00:00:00 2001 From: belthlemar Date: Mon, 24 Aug 2026 17:14:11 +0200 Subject: [PATCH 07/10] co --- .../study_data_blueprint/test_user_resources.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/integration/study_data_blueprint/test_user_resources.py b/tests/integration/study_data_blueprint/test_user_resources.py index fce81c0c8c..58ebb53a05 100644 --- a/tests/integration/study_data_blueprint/test_user_resources.py +++ b/tests/integration/study_data_blueprint/test_user_resources.py @@ -66,7 +66,22 @@ def test_nominal_case(client: TestClient, user_access_token: str, storage_mode: # Fetch all resources. Should contain the file res = client.get(f"/v1/studies/{study_id}/user-resources") assert res.status_code == 200 - assert res.json() == ["my/file", "my/folder"] + assert res.json() == { + "directories": [ + { + "name": "my", + "files": ["file"], + "directories": [ + { + "name": "folder", + "files": [], + "directories": [], + } + ], + } + ], + "files": [], + } # Fetch the content of the created file res = client.get(f"/v1/studies/{study_id}/user-resources/content?path=my/file") From 7e91a6aabb474653984e377269a36a6b5a33f2a2 Mon Sep 17 00:00:00 2001 From: belthlemar Date: Mon, 24 Aug 2026 17:15:23 +0200 Subject: [PATCH 08/10] c --- .../study_data_blueprint/test_user_resources.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/integration/study_data_blueprint/test_user_resources.py b/tests/integration/study_data_blueprint/test_user_resources.py index 58ebb53a05..7843e1b281 100644 --- a/tests/integration/study_data_blueprint/test_user_resources.py +++ b/tests/integration/study_data_blueprint/test_user_resources.py @@ -38,7 +38,7 @@ def test_nominal_case(client: TestClient, user_access_token: str, storage_mode: # Fetch all resources. Should contain the folder res = client.get(f"/v1/studies/{study_id}/user-resources") assert res.status_code == 200 - assert res.json() == { + expected_structure = { "directories": [ { "name": "my", @@ -54,6 +54,7 @@ def test_nominal_case(client: TestClient, user_access_token: str, storage_mode: ], "files": [], } + assert res.json() == expected_structure # Create a file with a specific content content = b"specific content" res = client.put( @@ -95,14 +96,14 @@ def test_nominal_case(client: TestClient, user_access_token: str, storage_mode: # Fetch all resources. Should contain the folder only res = client.get(f"/v1/studies/{study_id}/user-resources") assert res.status_code == 200 - assert res.json() == ["my/folder"] + assert res.json() == expected_structure # Create a folder "my". Should be a no-op as it already exists. res = client.put(f"/v1/studies/{study_id}/user-resources", params={"path": "my", "resource_type": "folder"}) assert res.status_code == 200 res = client.get(f"/v1/studies/{study_id}/user-resources") assert res.status_code == 200 - assert res.json() == ["my/folder"] + assert res.json() == expected_structure # Delete the folder res = client.delete(f"/v1/studies/{study_id}/user-resources?path=my/folder") From c441baaa85c102b976bf58390e52ea6432414bb0 Mon Sep 17 00:00:00 2001 From: belthlemar Date: Mon, 24 Aug 2026 17:16:18 +0200 Subject: [PATCH 09/10] change endpoint structure in the test --- tests/integration/study_data_blueprint/test_user_resources.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/study_data_blueprint/test_user_resources.py b/tests/integration/study_data_blueprint/test_user_resources.py index 7843e1b281..72344178c1 100644 --- a/tests/integration/study_data_blueprint/test_user_resources.py +++ b/tests/integration/study_data_blueprint/test_user_resources.py @@ -112,7 +112,7 @@ def test_nominal_case(client: TestClient, user_access_token: str, storage_mode: # Fetch all resources. Should still contain the parent folder "my" res = client.get(f"/v1/studies/{study_id}/user-resources") assert res.status_code == 200 - assert res.json() == ["my"] + assert res.json() == {"directories": [{"name": "my", "files": [], "directories": []}], "files": []} ########################## # Error cases From f3c58f2dd7f8b5b6576118b863adffc67b148538 Mon Sep 17 00:00:00 2001 From: belthlemar Date: Mon, 24 Aug 2026 17:25:09 +0200 Subject: [PATCH 10/10] raise a 404 instead of a 500 --- .../business/user_resources_management.py | 21 ++++++++++++------- antarest/study/web/study_data_blueprint.py | 2 +- .../test_user_resources.py | 1 + 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/antarest/study/business/user_resources_management.py b/antarest/study/business/user_resources_management.py index 51bee53f15..40cbf7c714 100644 --- a/antarest/study/business/user_resources_management.py +++ b/antarest/study/business/user_resources_management.py @@ -12,6 +12,7 @@ from pathlib import PurePosixPath from typing import Any +from antarest.core.exceptions import UserResourceNotFound from antarest.study.business.model.user_model import ( ResourceType, UserResourceDataCreation, @@ -52,7 +53,7 @@ class UserResourcesManager: def __init__(self, command_context: CommandContext) -> None: self._command_context = command_context - def get_all_user_resources_paths(self, study: StudyInterface) -> UserResourcesTree: + def get_all_user_resources(self, study: StudyInterface) -> UserResourcesTree: user_resources = study.get_study_dao().get_all_user_resources() return _build_tree(user_resources) @@ -60,12 +61,18 @@ def get_user_resource(self, study: StudyInterface, path: PurePosixPath) -> bytes return study.get_study_dao().get_user_resource(path) def delete_user_resource(self, study: StudyInterface, path: PurePosixPath) -> None: - command = RemoveUserResource( - data=UserResourceDataRemoval(path=path.as_posix()), - command_context=self._command_context, - study_version=study.version, - ) - study.add_commands([command]) + # First, we need to check if the resource exists + for resource in study.get_study_dao().get_all_user_resources(): + if resource.path.is_relative_to(path): + # Remove the existing resource + command = RemoveUserResource( + data=UserResourceDataRemoval(path=path.as_posix()), + command_context=self._command_context, + study_version=study.version, + ) + study.add_commands([command]) + return + raise UserResourceNotFound(path.as_posix()) def replace_user_resource( self, study: StudyInterface, resource_type: ResourceType, path: PurePosixPath, content: bytes | None diff --git a/antarest/study/web/study_data_blueprint.py b/antarest/study/web/study_data_blueprint.py index 4b86eeab3b..205e333e6b 100644 --- a/antarest/study/web/study_data_blueprint.py +++ b/antarest/study/web/study_data_blueprint.py @@ -2370,7 +2370,7 @@ def get_study_data( def get_all_user_resources(study_service: StudyServiceDep, uuid: UuidStr) -> UserResourcesTree: study = study_service.check_study_access(uuid, StudyPermissionType.READ) study_interface = study_service.get_study_interface(study) - return study_service.user_resources_manager.get_all_user_resources_paths(study_interface) + return study_service.user_resources_manager.get_all_user_resources(study_interface) @bp.get( "/studies/{uuid}/user-resources/content", diff --git a/tests/integration/study_data_blueprint/test_user_resources.py b/tests/integration/study_data_blueprint/test_user_resources.py index 72344178c1..a4c033f1ff 100644 --- a/tests/integration/study_data_blueprint/test_user_resources.py +++ b/tests/integration/study_data_blueprint/test_user_resources.py @@ -126,6 +126,7 @@ def test_nominal_case(client: TestClient, user_access_token: str, storage_mode: # Deletes a fake user resource. Should fail res = client.delete(f"/v1/studies/{study_id}/user-resources?path=fake/path/to/file") + assert res.status_code == 404 description = res.json()["description"] assert ( "User resources not found: 'fake/path/to/file'" in description