diff --git a/antarest/study/business/model/user_model.py b/antarest/study/business/model/user_model.py index 2301feb689..74eb58cf73 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 + files: list[str] + directories: list["FolderTree"] + + +class UserResourcesTree(AntaresBaseModel): + 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 5c1bf99486..40cbf7c714 100644 --- a/antarest/study/business/user_resources_management.py +++ b/antarest/study/business/user_resources_management.py @@ -10,33 +10,69 @@ # # 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, UserResourceDataCreation, UserResourceDataRemoval +from antarest.core.exceptions import UserResourceNotFound +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 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 + + 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) + + 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(self, study: StudyInterface) -> UserResourcesTree: user_resources = study.get_study_dao().get_all_user_resources() - 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) 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 782844bac9..205e333e6b 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,11 +2366,11 @@ 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) + 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 ba5a1bcee1..a4c033f1ff 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"} @@ -38,8 +38,23 @@ 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"] - + expected_structure = { + "directories": [ + { + "name": "my", + "files": [], + "directories": [ + { + "name": "folder", + "files": [], + "directories": [], + } + ], + } + ], + "files": [], + } + assert res.json() == expected_structure # Create a file with a specific content content = b"specific content" res = client.put( @@ -52,7 +67,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") @@ -66,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") @@ -82,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 @@ -96,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