Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(debug): allow files and folder deletion inside debug view #2147

Merged
merged 7 commits into from
Sep 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions antarest/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 39 additions & 1 deletion antarest/study/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
BadEditInstructionException,
ChildNotFoundError,
CommandApplicationError,
FileDeletionNotAllowed,
IncorrectPathError,
NotAManagedStudyException,
ReferencedObjectDeletionNotAllowed,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
15 changes: 15 additions & 0 deletions antarest/study/web/raw_studies_blueprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
63 changes: 63 additions & 0 deletions tests/integration/raw_studies_blueprint/test_fetch_raw_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
113 changes: 99 additions & 14 deletions webapp/src/components/App/Singlestudy/explore/Debug/Data/Folder.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,35 @@
import {
Divider,
IconButton,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
ListSubheader,
Menu,
MenuItem,
} from "@mui/material";
import FolderIcon from "@mui/icons-material/Folder";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import DeleteIcon from "@mui/icons-material/Delete";
import {
getFileIcon,
getFileType,
type TreeFolder,
type DataCompProps,
isFolder,
} from "../utils";
import { Fragment } from "react";
import { Fragment, useState } from "react";
import EmptyView from "../../../../../common/page/SimpleContent";
import { useTranslation } from "react-i18next";
import { Filename, Menubar } from "./styles";
import UploadFileButton from "../../../../../common/buttons/UploadFileButton";
import ConfirmationDialog from "../../../../../common/dialogs/ConfirmationDialog";
import useConfirm from "../../../../../../hooks/useConfirm";
import { deleteFile } from "../../../../../../services/api/studies/raw";
import useEnqueueErrorSnackbar from "../../../../../../hooks/useEnqueueErrorSnackbar";
import { toError } from "../../../../../../utils/fnUtils";

function Folder(props: DataCompProps) {
const {
Expand All @@ -35,6 +44,13 @@ function Folder(props: DataCompProps) {

const { t } = useTranslation();
const replaceFile = useConfirm();
const removeFile = useConfirm();
const [menuData, setMenuData] = useState<null | {
anchorEl: HTMLElement;
filePath: string;
}>(null);
const enqueueErrorSnackbar = useEnqueueErrorSnackbar();

const treeFolder = treeData as TreeFolder;
const list = Object.entries(treeFolder);

Expand All @@ -53,6 +69,27 @@ function Folder(props: DataCompProps) {
}
};

const handleMenuClose = () => {
setMenuData(null);
};

const handleDeleteClick = () => {
handleMenuClose();

removeFile.showConfirm().then((confirm) => {
const filePath = menuData?.filePath;
if (confirm && filePath) {
deleteFile({ studyId, path: filePath })
.then((res) => {
reloadTreeData();
})
.catch((err) => {
enqueueErrorSnackbar("Delete failed", toError(err));
});
}
});
};

////////////////////////////////////////////////////////////////
// JSX
////////////////////////////////////////////////////////////////
Expand Down Expand Up @@ -94,21 +131,45 @@ function Folder(props: DataCompProps) {

return (
<Fragment key={filename}>
<ListItemButton
onClick={() =>
setSelectedFile({
fileType,
filename,
filePath: `${filePath}/${filename}`,
treeData: data,
})
<ListItem
secondaryAction={
<IconButton
edge="end"
size="small"
onClick={(event) => {
setMenuData({
anchorEl: event.currentTarget,
filePath: `${filePath}/${filename}`,
});
}}
>
<MoreVertIcon />
</IconButton>
}
disablePadding
>
<ListItemIcon>
<Icon />
</ListItemIcon>
<ListItemText primary={filename} />
</ListItemButton>
<ListItemButton
onClick={() =>
setSelectedFile({
fileType,
filename,
filePath: `${filePath}/${filename}`,
treeData: data,
})
}
>
<ListItemIcon>
<Icon />
</ListItemIcon>
<ListItemText
title={filename}
primary={filename}
primaryTypographyProps={{
sx: { overflow: "hidden", textOverflow: "ellipsis" },
}}
/>
</ListItemButton>
</ListItem>
{!isLast && <Divider variant="fullWidth" />}
</Fragment>
);
Expand All @@ -117,6 +178,18 @@ function Folder(props: DataCompProps) {
<EmptyView title={t("study.debug.folder.empty")} icon={FolderIcon} />
)}
</List>
{/* Items menu */}
<Menu
anchorEl={menuData?.anchorEl}
open={!!menuData}
onClose={handleMenuClose}
>
<MenuItem onClick={handleDeleteClick}>
<DeleteIcon sx={{ mr: 1 }} fontSize="small" />
Delete
</MenuItem>
</Menu>
{/* Confim file replacement */}
<ConfirmationDialog
title={t("study.debug.folder.upload.replaceFileConfirm.title")}
confirmButtonText={t("global.replace")}
Expand All @@ -128,6 +201,18 @@ function Folder(props: DataCompProps) {
>
{t("study.debug.folder.upload.replaceFileConfirm.message")}
</ConfirmationDialog>
{/* Confim file deletion */}
<ConfirmationDialog
titleIcon={DeleteIcon}
confirmButtonText={t("global.delete")}
cancelButtonText={t("global.cancel")}
maxWidth="xs"
open={removeFile.isPending}
onConfirm={removeFile.yes}
onCancel={removeFile.no}
>
Delete the file?
</ConfirmationDialog>
</>
);
}
Expand Down
15 changes: 14 additions & 1 deletion webapp/src/services/api/studies/raw/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import client from "../../client";
import type { DownloadMatrixParams, ImportFileParams } from "./types";
import type {
DeleteFileParams,
DownloadMatrixParams,
ImportFileParams,
} from "./types";

export async function downloadMatrix(params: DownloadMatrixParams) {
const { studyId, ...queryParams } = params;
const url = `v1/studies/${studyId}/raw/download`;

const res = await client.get<Blob>(url, {
params: queryParams,
responseType: "blob",
Expand All @@ -16,6 +21,7 @@ export async function importFile(params: ImportFileParams) {
const { studyId, file, onUploadProgress, ...queryParams } = params;
const url = `v1/studies/${studyId}/raw`;
const body = { file };

await client.putForm<void>(url, body, {
params: {
...queryParams,
Expand All @@ -24,3 +30,10 @@ export async function importFile(params: ImportFileParams) {
onUploadProgress,
});
}

export async function deleteFile(params: DeleteFileParams) {
const { studyId, path } = params;
const url = `v1/studies/${studyId}/raw`;

await client.delete<void>(url, { params: { path } });
}
5 changes: 5 additions & 0 deletions webapp/src/services/api/studies/raw/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,8 @@ export interface ImportFileParams {
createMissing?: boolean;
onUploadProgress?: AxiosRequestConfig["onUploadProgress"];
}

export interface DeleteFileParams {
studyId: StudyMetadata["id"];
path: string;
}
Loading