diff --git a/antarest/core/exceptions.py b/antarest/core/exceptions.py index dddcda014c..af5639b5ff 100644 --- a/antarest/core/exceptions.py +++ b/antarest/core/exceptions.py @@ -343,6 +343,16 @@ def __init__(self, is_variant: bool) -> None: super().__init__(HTTPStatus.EXPECTATION_FAILED, "Upgrade not supported for parent of variants") +class FileDeletionNotAllowed(HTTPException): + """ + Exception raised when deleting a file or a folder which isn't inside the 'User' folder. + """ + + def __init__(self, message: str) -> None: + msg = f"Raw deletion failed because {message}" + super().__init__(HTTPStatus.FORBIDDEN, msg) + + class ReferencedObjectDeletionNotAllowed(HTTPException): """ Exception raised when a binding constraint is not allowed to be deleted because it references diff --git a/antarest/study/service.py b/antarest/study/service.py index 2bff77b1ec..24cbe730f9 100644 --- a/antarest/study/service.py +++ b/antarest/study/service.py @@ -35,6 +35,7 @@ BadEditInstructionException, ChildNotFoundError, CommandApplicationError, + FileDeletionNotAllowed, IncorrectPathError, NotAManagedStudyException, ReferencedObjectDeletionNotAllowed, @@ -47,7 +48,7 @@ ) from antarest.core.filetransfer.model import FileDownloadTaskDTO from antarest.core.filetransfer.service import FileTransferManager -from antarest.core.interfaces.cache import ICache +from antarest.core.interfaces.cache import CacheConstants, ICache from antarest.core.interfaces.eventbus import Event, EventType, IEventBus from antarest.core.jwt import DEFAULT_ADMIN_USER, JWTGroup, JWTUser from antarest.core.model import JSON, SUB_JSON, PermissionInfo, PublicMode, StudyPermissionType @@ -130,6 +131,7 @@ from antarest.study.storage.rawstudy.model.filesystem.matrix.matrix import MatrixFrequency from antarest.study.storage.rawstudy.model.filesystem.matrix.output_series_matrix import OutputSeriesMatrix from antarest.study.storage.rawstudy.model.filesystem.raw_file_node import RawFileNode +from antarest.study.storage.rawstudy.model.filesystem.root.user.user import User from antarest.study.storage.rawstudy.raw_study_service import RawStudyService from antarest.study.storage.storage_service import StudyStorageService from antarest.study.storage.study_download_utils import StudyDownloader, get_output_variables_information @@ -2640,3 +2642,39 @@ def asserts_no_thermal_in_binding_constraints( if ref_bcs: binding_ids = [bc.id for bc in ref_bcs] raise ReferencedObjectDeletionNotAllowed(cluster_id, binding_ids, object_type="Cluster") + + def delete_file_or_folder(self, study_id: str, path: str, current_user: JWTUser) -> None: + """ + Deletes a file or a folder of the study. + The data must be located inside the 'User' folder. + Also, it can not be inside the 'expansion' folder. + + Args: + study_id: UUID of the concerned study + path: Path corresponding to the resource to be deleted + current_user: User that called the endpoint + + Raises: + FileDeletionNotAllowed: if the path does not comply with the above rules + """ + study = self.get_study(study_id) + assert_permission(current_user, study, StudyPermissionType.WRITE) + + url = [item for item in path.split("/") if item] + if len(url) < 2 or url[0] != "user": + raise FileDeletionNotAllowed(f"the targeted data isn't inside the 'User' folder: {path}") + + study_tree = self.storage_service.raw_study_service.get_raw(study, True).tree + user_node = t.cast(User, study_tree.get_node(["user"])) + if url[1] in [file.filename for file in user_node.registered_files]: + raise FileDeletionNotAllowed(f"you are not allowed to delete this resource : {path}") + + try: + user_node.delete(url[1:]) + except ChildNotFoundError as e: + raise FileDeletionNotAllowed("the given path doesn't exist") from e + + # update cache + cache_id = f"{CacheConstants.RAW_STUDY}/{study.id}" + updated_tree = study_tree.get() + self.storage_service.get_storage(study).cache.put(cache_id, updated_tree) # type: ignore diff --git a/antarest/study/web/raw_studies_blueprint.py b/antarest/study/web/raw_studies_blueprint.py index dc8554e55d..177f071668 100644 --- a/antarest/study/web/raw_studies_blueprint.py +++ b/antarest/study/web/raw_studies_blueprint.py @@ -191,6 +191,21 @@ def get_study( ).encode("utf-8") return Response(content=json_response, media_type="application/json") + @bp.delete( + "/studies/{uuid}/raw", + tags=[APITag.study_raw_data], + summary="Delete files or folders located inside the 'User' folder", + response_model=None, + ) + def delete_file( + uuid: str, + path: str = Param("/", examples=["user/wind_solar/synthesis_windSolar.xlsx"]), # type: ignore + current_user: JWTUser = Depends(auth.get_current_user), + ) -> t.Any: + uuid = sanitize_uuid(uuid) + logger.info(f"Deleting path {path} inside study {uuid}", extra={"user": current_user.id}) + study_service.delete_file_or_folder(uuid, path, current_user) + @bp.get( "/studies/{uuid}/areas/aggregate/mc-ind/{output_id}", tags=[APITag.study_raw_data], diff --git a/tests/integration/raw_studies_blueprint/test_fetch_raw_data.py b/tests/integration/raw_studies_blueprint/test_fetch_raw_data.py index 2b5fe6de4a..8859a56469 100644 --- a/tests/integration/raw_studies_blueprint/test_fetch_raw_data.py +++ b/tests/integration/raw_studies_blueprint/test_fetch_raw_data.py @@ -264,3 +264,66 @@ def test_get_study( headers=headers, ) assert res.status_code == 200, f"Error for path={path} and depth={depth}" + + +def test_delete_raw(client: TestClient, user_access_token: str, internal_study_id: str) -> None: + client.headers = {"Authorization": f"Bearer {user_access_token}"} + + # ============================= + # SET UP + NOMINAL CASES + # ============================= + + content = io.BytesIO(b"This is the end!") + file_1_path = "user/file_1.txt" + file_2_path = "user/folder/file_2.txt" + file_3_path = "user/folder_2/file_3.txt" + for f in [file_1_path, file_2_path, file_3_path]: + # Creates a file / folder inside user folder. + res = client.put( + f"/v1/studies/{internal_study_id}/raw", params={"path": f, "create_missing": True}, files={"file": content} + ) + assert res.status_code == 204, res.json() + + # Deletes the file / folder + if f == file_2_path: + f = "user/folder" + res = client.delete(f"/v1/studies/{internal_study_id}/raw?path={f}") + assert res.status_code == 200 + # Asserts it doesn't exist anymore + res = client.get(f"/v1/studies/{internal_study_id}/raw?path={f}") + assert res.status_code == 404 + assert "not a child of" in res.json()["description"] + + # checks debug view + res = client.get(f"/v1/studies/{internal_study_id}/raw?path=&depth=-1") + assert res.status_code == 200 + tree = res.json()["user"] + if f == file_3_path: + # asserts the folder that wasn't deleted is still here. + assert list(tree.keys()) == ["expansion", "folder_2"] + assert tree["folder_2"] == {} + else: + # asserts deleted files cannot be seen inside the debug view + assert list(tree.keys()) == ["expansion"] + + # ============================= + # ERRORS + # ============================= + + # try to delete expansion folder + res = client.delete(f"/v1/studies/{internal_study_id}/raw?path=/user/expansion") + assert res.status_code == 403 + assert res.json()["exception"] == "FileDeletionNotAllowed" + assert "you are not allowed to delete this resource" in res.json()["description"] + + # try to delete a file which isn't inside the 'User' folder + res = client.delete(f"/v1/studies/{internal_study_id}/raw?path=/input/thermal") + assert res.status_code == 403 + assert res.json()["exception"] == "FileDeletionNotAllowed" + assert "the targeted data isn't inside the 'User' folder" in res.json()["description"] + + # With a path that doesn't exist + res = client.delete(f"/v1/studies/{internal_study_id}/raw?path=user/fake_folder/fake_file.txt") + assert res.status_code == 403 + assert res.json()["exception"] == "FileDeletionNotAllowed" + assert "the given path doesn't exist" in res.json()["description"]