From bbd48d002bafb6496800b1214839a57a6cc01a92 Mon Sep 17 00:00:00 2001
From: Samir Kamal <1954121+skamril@users.noreply.github.com>
Date: Thu, 21 Nov 2024 17:06:12 +0100
Subject: [PATCH 1/5] feat(ui-utils): add `validatePath` function in validation
---
webapp/src/utils/validation/array.ts | 1 -
webapp/src/utils/validation/number.ts | 3 +-
webapp/src/utils/validation/string.ts | 104 +++++++++++++++++++++++++-
3 files changed, 103 insertions(+), 5 deletions(-)
diff --git a/webapp/src/utils/validation/array.ts b/webapp/src/utils/validation/array.ts
index d0edd43a15..98c7b50251 100644
--- a/webapp/src/utils/validation/array.ts
+++ b/webapp/src/utils/validation/array.ts
@@ -29,7 +29,6 @@ interface ArrayValidationOptions {
* validateArray([1, 2, 3], { allowDuplicate: false }); // true
* validateArray([1, 1, 2, 3], { allowDuplicate: false }); // Error message
*
- *
* @example
With currying.
* const fn = validateArray({ allowDuplicate: false });
* fn([1, 2, 3]); // true
diff --git a/webapp/src/utils/validation/number.ts b/webapp/src/utils/validation/number.ts
index fd96c1502e..f975ef29ba 100644
--- a/webapp/src/utils/validation/number.ts
+++ b/webapp/src/utils/validation/number.ts
@@ -28,11 +28,10 @@ interface NumberValidationOptions {
* validateNumber(5, { min: 0, max: 10 }); // true
* validateNumber(9, { min: 10, max: 20 }); // Error message
*
- *
* @example With currying.
* const fn = validateNumber({ min: 0, max: 10 });
* fn(5); // true
- * fn(11); // Error message
+ * fn(9); // Error message
*
* @param value - The number to validate.
* @param [options] - Configuration options for validation.
diff --git a/webapp/src/utils/validation/string.ts b/webapp/src/utils/validation/string.ts
index bd9bdef1e8..bf47d6775c 100644
--- a/webapp/src/utils/validation/string.ts
+++ b/webapp/src/utils/validation/string.ts
@@ -33,6 +33,15 @@ interface StringValidationOptions {
* Validates the input string against a variety of checks including length restrictions,
* character validations, and uniqueness against provided arrays of existing and excluded values.
*
+ * @example
+ * validateString("foo", { allowSpaces: false }); // true
+ * validateNumber("foo bar", { allowSpaces: false }); // Error message
+ *
+ * @example With currying.
+ * const fn = validateString({ allowSpaces: false });
+ * fn("foo"); // true
+ * fn("foo bar"); // Error message
+ *
* @param value - The string to validate. Leading and trailing spaces will be trimmed.
* @param options - Configuration options for validation. (Optional)
* @param [options.existingValues=[]] - An array of strings to check against for duplicates. Comparison is case-insensitive by default.
@@ -49,7 +58,22 @@ interface StringValidationOptions {
export function validateString(
value: string,
options?: StringValidationOptions,
-): ValidationReturn {
+): ValidationReturn;
+
+export function validateString(
+ options?: StringValidationOptions,
+): (value: string) => ValidationReturn;
+
+export function validateString(
+ valueOrOpts?: string | StringValidationOptions,
+ options: StringValidationOptions = {},
+): ValidationReturn | ((value: string) => ValidationReturn) {
+ if (typeof valueOrOpts !== "string") {
+ return (v: string) => validateString(v, valueOrOpts);
+ }
+
+ const value = valueOrOpts;
+
const {
existingValues = [],
excludedValues = [],
@@ -60,7 +84,7 @@ export function validateString(
editedValue = "",
minLength = 0,
maxLength = 255,
- } = options || {};
+ } = options;
const trimmedValue = value.trim();
@@ -183,3 +207,79 @@ function generatePattern(
allowSpecialChars && specialChars ? escapeSpecialChars(specialChars) : "";
return basePattern + spacePattern + specialCharsPattern + "]*$";
}
+
+interface PathValidationOptions {
+ allowToStartWithSlash?: boolean;
+ allowToEndWithSlash?: boolean;
+ allowEmpty?: boolean;
+}
+
+/**
+ * Validates a path against specified criteria.
+ *
+ * @example
+ * validatePath("foo/bar", { allowToEndWithSlash: false }); // true
+ * validatePath("foo/bar/", { allowToEndWithSlash: false }); // Error message
+ *
+ * @example With currying.
+ * const fn = validateString({ allowToEndWithSlash: false });
+ * fn("foo/bar"); // true
+ * fn("foo/bar/"); // Error message
+ *
+ * @param path - The string to validate.
+ * @param options - Configuration options for validation. (Optional)
+ * @param [options.allowToStartWithSlash=true] - Indicates if the path is allowed to start with a '/'.
+ * @param [options.allowToEndWithSlash=true] - Indicates if the path is allowed to end with a '/'.
+ * @param [options.allowEmpty=false] - Indicates if an empty path is allowed.
+ * @returns True if validation is successful, or a localized error message if it fails.
+ */
+export function validatePath(
+ path: string,
+ options?: PathValidationOptions,
+): ValidationReturn;
+
+export function validatePath(
+ options?: PathValidationOptions,
+): (value: string) => ValidationReturn;
+
+export function validatePath(
+ pathOrOpts?: string | PathValidationOptions,
+ options: PathValidationOptions = {},
+): ValidationReturn | ((value: string) => ValidationReturn) {
+ if (typeof pathOrOpts !== "string") {
+ return (v: string) => validatePath(v, pathOrOpts);
+ }
+
+ const path = pathOrOpts;
+
+ const {
+ allowToStartWithSlash = true,
+ allowToEndWithSlash = true,
+ allowEmpty = false,
+ } = options;
+
+ if (!path) {
+ return allowEmpty ? true : t("form.field.required");
+ }
+
+ if (!allowToStartWithSlash && path.startsWith("/")) {
+ return t("form.field.path.startWithSlashNotAllowed");
+ }
+
+ if (!allowToEndWithSlash && path.endsWith("/")) {
+ return t("form.field.path.endWithSlashNotAllowed");
+ }
+
+ if (
+ path
+ .replace(/^\//, "") // Remove first "/" if present
+ .replace(/\/$/, "") // Remove last "/" if present
+ .split("/")
+ .map((v) => v.trim())
+ .includes("")
+ ) {
+ return t("form.field.path.invalid");
+ }
+
+ return true;
+}
From e7496836c5088ae7b3c35dce14e928d6655ad4a6 Mon Sep 17 00:00:00 2001
From: Samir Kamal <1954121+skamril@users.noreply.github.com>
Date: Thu, 21 Nov 2024 17:10:56 +0100
Subject: [PATCH 2/5] fix(ui-studies): small bottom part not visible with
scroll
---
.../App/Studies/StudiesList/index.tsx | 7 ++-----
webapp/src/components/App/Studies/StudyTree.tsx | 17 ++++++++++++-----
2 files changed, 14 insertions(+), 10 deletions(-)
diff --git a/webapp/src/components/App/Studies/StudiesList/index.tsx b/webapp/src/components/App/Studies/StudiesList/index.tsx
index a6c412e625..6d90785471 100644
--- a/webapp/src/components/App/Studies/StudiesList/index.tsx
+++ b/webapp/src/components/App/Studies/StudiesList/index.tsx
@@ -39,10 +39,7 @@ import { FixedSizeGrid, GridOnScrollProps } from "react-window";
import { v4 as uuidv4 } from "uuid";
import { AxiosError } from "axios";
import { StudyMetadata } from "../../../../common/types";
-import {
- STUDIES_HEIGHT_HEADER,
- STUDIES_LIST_HEADER_HEIGHT,
-} from "../../../../theme";
+import { STUDIES_LIST_HEADER_HEIGHT } from "../../../../theme";
import {
setStudyScrollPosition,
StudiesSortConf,
@@ -184,7 +181,7 @@ function StudiesList(props: StudiesListProps) {
return (
{
- return children.map((elm) => {
- const id = parentId ? `${parentId}/${elm.name}` : elm.name;
+ return children.map((child) => {
+ const id = parentId ? `${parentId}/${child.name}` : child.name;
return (
handleTreeItemClick(id)}
>
- {buildTree(elm.children, id)}
+ {buildTree(child.children, id)}
);
});
@@ -60,7 +60,14 @@ function StudyTree() {
{buildTree([studiesTree])}
From 68c49fac25305f082062671e4860bad3b54892c5 Mon Sep 17 00:00:00 2001
From: Samir Kamal <1954121+skamril@users.noreply.github.com>
Date: Thu, 21 Nov 2024 17:11:56 +0100
Subject: [PATCH 3/5] refactor(ui-studies): function to build tree
---
webapp/src/components/App/Studies/utils.ts | 75 +++++++++++-----------
1 file changed, 37 insertions(+), 38 deletions(-)
diff --git a/webapp/src/components/App/Studies/utils.ts b/webapp/src/components/App/Studies/utils.ts
index dc2d1c5396..3f2ff61564 100644
--- a/webapp/src/components/App/Studies/utils.ts
+++ b/webapp/src/components/App/Studies/utils.ts
@@ -20,46 +20,45 @@ export interface StudyTreeNode {
children: StudyTreeNode[];
}
-const nodeProcess = (
- tree: StudyTreeNode,
- path: string[],
- folderPath: string,
-): void => {
- const { children } = tree;
- if (path.length === 1) {
- return;
- }
- const element = path.pop() || "";
- const index = children.findIndex(
- (elm: StudyTreeNode) => elm.name === element,
- );
- const newFolderPath = `${folderPath}/${element}`;
- if (index < 0) {
- children.push({ name: element, children: [], path: newFolderPath });
- nodeProcess(
- children[children.length - 1] as StudyTreeNode,
- path,
- newFolderPath,
- );
- } else {
- nodeProcess(children[index] as StudyTreeNode, path, newFolderPath);
- }
-};
-
-export const buildStudyTree = (studies: StudyMetadata[]): StudyTreeNode => {
+/**
+ * Builds a tree structure from a list of study metadata.
+ *
+ * @param studies - Array of study metadata objects.
+ * @returns A tree structure representing the studies.
+ */
+export function buildStudyTree(studies: StudyMetadata[]) {
const tree: StudyTreeNode = { name: "root", children: [], path: "" };
- let path: string[] = [];
+
for (const study of studies) {
- if (study.folder !== undefined && study.folder !== null) {
- path = [
- study.workspace,
- ...(study.folder as string).split("/").filter((elm) => elm !== ""),
- ];
- } else {
- path = [study.workspace];
+ const path =
+ typeof study.folder === "string"
+ ? [study.workspace, ...study.folder.split("/").filter(Boolean)]
+ : [study.workspace];
+
+ let current = tree;
+
+ for (let i = 0; i < path.length; i++) {
+ // Skip the last folder, as it represents the study itself
+ if (i === path.length - 1) {
+ break;
+ }
+
+ const folderName = path[i];
+ let child = current.children.find((child) => child.name === folderName);
+
+ if (!child) {
+ child = {
+ name: folderName,
+ children: [],
+ path: current.path ? `${current.path}/${folderName}` : folderName,
+ };
+
+ current.children.push(child);
+ }
+
+ current = child;
}
- path.reverse();
- nodeProcess(tree, path, "");
}
+
return tree;
-};
+}
From 373f8942f0f16df93daee3e9335a6abe01de8556 Mon Sep 17 00:00:00 2001
From: Samir Kamal <1954121+skamril@users.noreply.github.com>
Date: Thu, 21 Nov 2024 17:12:53 +0100
Subject: [PATCH 4/5] feat(ui-studies): update validation in MoveStudyDialog
ANT-2390
---
webapp/public/locales/en/main.json | 8 +-
webapp/public/locales/fr/main.json | 8 +-
.../App/Studies/MoveStudyDialog.tsx | 96 +++++++++++--------
webapp/src/services/api/study.ts | 13 ++-
4 files changed, 71 insertions(+), 54 deletions(-)
diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json
index df948d3920..98abe54461 100644
--- a/webapp/public/locales/en/main.json
+++ b/webapp/public/locales/en/main.json
@@ -78,6 +78,7 @@
"global.status": "Status",
"global.semicolon": "Semicolon",
"global.language": "Language",
+ "global.path": "Path",
"global.time.hourly": "Hourly",
"global.time.daily": "Daily",
"global.time.weekly": "Weekly",
@@ -154,6 +155,9 @@
"form.field.requireUppercase": "Must contain at least one uppercase letter.",
"form.field.requireDigit": "Must contain at least one digit.",
"form.field.requireSpecialChars": "Must contain at least one special character.",
+ "form.field.path.startWithSlashNotAllowed": "Path must not start with a '/'",
+ "form.field.path.endWithSlashNotAllowed": "Path must not end with a '/'",
+ "form.field.path.invalid": "Invalid path",
"matrix.graphSelector": "Columns",
"matrix.message.importHint": "Click or drag and drop a matrix here",
"matrix.importNewMatrix": "Import a new matrix",
@@ -620,7 +624,6 @@
"studies.error.loadStudy": "Failed to load study",
"studies.error.runStudy": "Failed to run study",
"studies.error.scanFolder": "Failed to start folder scan",
- "studies.error.moveStudy": "Failed to move study {{study}}",
"studies.error.saveData": "Failed to save data",
"studies.error.copyStudy": "Failed to copy study",
"studies.error.import": "Failed to import Study ({{uploadFile}})",
@@ -631,11 +634,10 @@
"studies.error.createStudy": "Failed to create Study {{studyname}}",
"studies.success.saveData": "Data saved with success",
"studies.success.scanFolder": "Folder scan started",
- "studies.success.moveStudy": "Study {{study}} was successfully moved to {{folder}}",
+ "studies.success.moveStudy": "Study '{{study}}' was successfully moved to '{{path}}'",
"studies.success.createStudy": "Study {{studyname}} created successfully",
"studies.studylaunched": "{{studyname}} launched!",
"studies.copySuffix": "Copy",
- "studies.folder": "Folder",
"studies.filters.strictfolder": "Show only direct folder children",
"studies.scanFolder": "Scan folder",
"studies.moveStudy": "Move",
diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json
index 3dacacb6e0..8e64c877be 100644
--- a/webapp/public/locales/fr/main.json
+++ b/webapp/public/locales/fr/main.json
@@ -78,6 +78,7 @@
"global.status": "Statut",
"global.semicolon": "Point-virgule",
"global.language": "Langue",
+ "global.path": "Chemin",
"global.time.hourly": "Horaire",
"global.time.daily": "Journalier",
"global.time.weekly": "Hebdomadaire",
@@ -154,6 +155,9 @@
"form.field.requireUppercase": "Doit contenir au moins une lettre majuscule.",
"form.field.requireDigit": "Doit contenir au moins un chiffre.",
"form.field.requireSpecialChars": "Doit contenir au moins un caractère spécial.",
+ "form.field.path.startWithSlashNotAllowed": "Le chemin ne doit pas commencer par un '/'",
+ "form.field.path.endWithSlashNotAllowed": "Le chemin ne doit pas finir par un '/'",
+ "form.field.path.invalid": "Chemin invalide",
"matrix.graphSelector": "Colonnes",
"matrix.message.importHint": "Cliquer ou glisser une matrice ici",
"matrix.importNewMatrix": "Import d'une nouvelle matrice",
@@ -620,7 +624,6 @@
"studies.error.loadStudy": "Échec du chargement de l'étude",
"studies.error.runStudy": "Échec du lancement de l'étude",
"studies.error.scanFolder": "Échec du lancement du scan",
- "studies.error.moveStudy": "Échec du déplacement de l'étude {{study}}",
"studies.error.saveData": "Erreur lors de la sauvegarde des données",
"studies.error.copyStudy": "Erreur lors de la copie de l'étude",
"studies.error.import": "L'import de l'étude a échoué ({{uploadFile}})",
@@ -631,11 +634,10 @@
"studies.error.createStudy": "Erreur lors de la création de l'étude {{studyname}}",
"studies.success.saveData": "Donnée sauvegardée avec succès",
"studies.success.scanFolder": "L'analyse du dossier a commencé",
- "studies.success.moveStudy": "L'étude {{study}} a été déplacée avec succès vers {{folder}}",
+ "studies.success.moveStudy": "L'étude \"{{study}}\" a été déplacée avec succès vers \"{{path}}\"",
"studies.success.createStudy": "L'étude {{studyname}} a été crée avec succès",
"studies.studylaunched": "{{studyname}} lancé(s) !",
"studies.copySuffix": "Copie",
- "studies.folder": "Dossier",
"studies.filters.strictfolder": "Afficher uniquement les descendants directs",
"studies.scanFolder": "Scanner le dossier",
"studies.moveStudy": "Déplacer",
diff --git a/webapp/src/components/App/Studies/MoveStudyDialog.tsx b/webapp/src/components/App/Studies/MoveStudyDialog.tsx
index e3698438a5..ff9687f8af 100644
--- a/webapp/src/components/App/Studies/MoveStudyDialog.tsx
+++ b/webapp/src/components/App/Studies/MoveStudyDialog.tsx
@@ -13,17 +13,40 @@
*/
import { DialogProps } from "@mui/material";
-import TextField from "@mui/material/TextField";
import { useSnackbar } from "notistack";
-import * as R from "ramda";
import { useTranslation } from "react-i18next";
-import { usePromise } from "react-use";
import { StudyMetadata } from "../../../common/types";
-import useEnqueueErrorSnackbar from "../../../hooks/useEnqueueErrorSnackbar";
import { moveStudy } from "../../../services/api/study";
-import { isStringEmpty } from "../../../services/utils";
import FormDialog from "../../common/dialogs/FormDialog";
import { SubmitHandlerPlus } from "../../common/Form/types";
+import StringFE from "@/components/common/fieldEditors/StringFE";
+import * as R from "ramda";
+import { validatePath } from "@/utils/validation/string";
+
+function formalizePath(
+ path: string | undefined,
+ studyId?: StudyMetadata["id"],
+) {
+ const trimmedPath = path?.trim();
+
+ if (!trimmedPath) {
+ return "";
+ }
+
+ const pathArray = trimmedPath.split("/").filter(Boolean);
+
+ if (studyId) {
+ const lastFolder = R.last(pathArray);
+
+ // The API automatically add the study ID to a not empty path when moving a study.
+ // So we need to remove it from the display path.
+ if (lastFolder === studyId) {
+ return pathArray.slice(0, -1).join("/");
+ }
+ }
+
+ return pathArray.join("/");
+}
interface Props extends DialogProps {
study: StudyMetadata;
@@ -33,36 +56,35 @@ interface Props extends DialogProps {
function MoveStudyDialog(props: Props) {
const { study, open, onClose } = props;
const [t] = useTranslation();
- const mounted = usePromise();
const { enqueueSnackbar } = useSnackbar();
- const enqueueErrorSnackbar = useEnqueueErrorSnackbar();
+
const defaultValues = {
- folder: R.join("/", R.dropLast(1, R.split("/", study.folder || ""))),
+ path: formalizePath(study.folder, study.id),
};
////////////////////////////////////////////////////////////////
// Event Handlers
////////////////////////////////////////////////////////////////
- const handleSubmit = async (
+ const handleSubmit = (data: SubmitHandlerPlus) => {
+ const path = formalizePath(data.values.path);
+ return moveStudy(study.id, path);
+ };
+
+ const handleSubmitSuccessful = (
data: SubmitHandlerPlus,
) => {
- const { folder } = data.values;
- try {
- await mounted(moveStudy(study.id, folder));
- enqueueSnackbar(
- t("studies.success.moveStudy", { study: study.name, folder }),
- {
- variant: "success",
- },
- );
- onClose();
- } catch (e) {
- enqueueErrorSnackbar(
- t("studies.error.moveStudy", { study: study.name }),
- e as Error,
- );
- }
+ onClose();
+
+ enqueueSnackbar(
+ t("studies.success.moveStudy", {
+ study: study.name,
+ path: data.values.path || "/", // Empty path move the study to the root
+ }),
+ {
+ variant: "success",
+ },
+ );
};
////////////////////////////////////////////////////////////////
@@ -74,27 +96,19 @@ function MoveStudyDialog(props: Props) {
open={open}
config={{ defaultValues }}
onSubmit={handleSubmit}
+ onSubmitSuccessful={handleSubmitSuccessful}
onCancel={onClose}
>
- {(formObj) => (
- (
+ {
- return !isStringEmpty(value);
- },
- })}
/>
)}
diff --git a/webapp/src/services/api/study.ts b/webapp/src/services/api/study.ts
index 8750b177f4..53d4a67925 100644
--- a/webapp/src/services/api/study.ts
+++ b/webapp/src/services/api/study.ts
@@ -13,7 +13,7 @@
*/
import { AxiosRequestConfig } from "axios";
-import { isBoolean, trimCharsStart } from "ramda-adjunct";
+import * as RA from "ramda-adjunct";
import client from "./client";
import {
FileStudyTreeConfigDTO,
@@ -135,7 +135,7 @@ export const editStudy = async (
depth = 1,
): Promise => {
let formattedData: unknown = data;
- if (isBoolean(data)) {
+ if (RA.isBoolean(data)) {
formattedData = JSON.stringify(data);
}
const res = await client.post(
@@ -163,11 +163,10 @@ export const copyStudy = async (
return res.data;
};
-export const moveStudy = async (sid: string, folder: string): Promise => {
- const folderWithId = trimCharsStart("/", `${folder.trim()}/${sid}`);
- await client.put(
- `/v1/studies/${sid}/move?folder_dest=${encodeURIComponent(folderWithId)}`,
- );
+export const moveStudy = async (studyId: string, folder: string) => {
+ await client.put(`/v1/studies/${studyId}/move`, null, {
+ params: { folder_dest: folder },
+ });
};
export const archiveStudy = async (sid: string): Promise => {
From 6d454f4aac26d3c4b09230333307d0d81e94802a Mon Sep 17 00:00:00 2001
From: belthlemar
Date: Thu, 5 Dec 2024 11:40:04 +0100
Subject: [PATCH 5/5] feat(move): adapt back-end code and add tests
---
antarest/study/service.py | 6 ++-
.../variantstudy/variant_study_service.py | 2 +-
.../studies_blueprint/test_move.py | 51 +++++++++++++++++++
tests/integration/test_integration.py | 3 +-
4 files changed, 59 insertions(+), 3 deletions(-)
create mode 100644 tests/integration/studies_blueprint/test_move.py
diff --git a/antarest/study/service.py b/antarest/study/service.py
index 7e1198c6b7..71d4bc3881 100644
--- a/antarest/study/service.py
+++ b/antarest/study/service.py
@@ -1073,11 +1073,15 @@ def copy_task(notifier: ITaskNotifier) -> TaskResult:
return task_or_study_id
- def move_study(self, study_id: str, new_folder: str, params: RequestParameters) -> None:
+ def move_study(self, study_id: str, folder_dest: str, params: RequestParameters) -> None:
study = self.get_study(study_id)
assert_permission(params.user, study, StudyPermissionType.WRITE)
if not is_managed(study):
raise NotAManagedStudyException(study_id)
+ if folder_dest:
+ new_folder = folder_dest.rstrip("/") + f"/{study.id}"
+ else:
+ new_folder = None
study.folder = new_folder
self.repository.save(study, update_modification_date=False)
self.event_bus.push(
diff --git a/antarest/study/storage/variantstudy/variant_study_service.py b/antarest/study/storage/variantstudy/variant_study_service.py
index a57b7f1b2f..37493be684 100644
--- a/antarest/study/storage/variantstudy/variant_study_service.py
+++ b/antarest/study/storage/variantstudy/variant_study_service.py
@@ -610,7 +610,7 @@ def create_variant_study(self, uuid: str, name: str, params: RequestParameters)
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
version=study.version,
- folder=(re.sub(f"/?{study.id}", "", study.folder) if study.folder is not None else None),
+ folder=(re.sub(study.id, new_id, study.folder) if study.folder is not None else None),
groups=study.groups, # Create inherit_group boolean
owner_id=params.user.impersonator if params.user else None,
snapshot=None,
diff --git a/tests/integration/studies_blueprint/test_move.py b/tests/integration/studies_blueprint/test_move.py
new file mode 100644
index 0000000000..83defc9aad
--- /dev/null
+++ b/tests/integration/studies_blueprint/test_move.py
@@ -0,0 +1,51 @@
+# Copyright (c) 2024, RTE (https://www.rte-france.com)
+#
+# See AUTHORS.txt
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# SPDX-License-Identifier: MPL-2.0
+#
+# This file is part of the Antares project.
+
+from starlette.testclient import TestClient
+
+
+class TestMove:
+ def test_move_endpoint(self, client: TestClient, internal_study_id: str, user_access_token: str) -> None:
+ client.headers = {"Authorization": f"Bearer {user_access_token}"}
+
+ res = client.post("/v1/studies?name=study_test")
+ assert res.status_code == 201
+ study_id = res.json()
+
+ # asserts move with a given folder adds the /study_id at the end of the path
+ res = client.put(f"/v1/studies/{study_id}/move", params={"folder_dest": "folder1"})
+ res.raise_for_status()
+ res = client.get(f"/v1/studies/{study_id}")
+ assert res.json()["folder"] == f"folder1/{study_id}"
+
+ # asserts move to a folder with //// removes the unwanted `/`
+ res = client.put(f"/v1/studies/{study_id}/move", params={"folder_dest": "folder2///////"})
+ res.raise_for_status()
+ res = client.get(f"/v1/studies/{study_id}")
+ assert res.json()["folder"] == f"folder2/{study_id}"
+
+ # asserts the created variant has the same parent folder
+ res = client.post(f"/v1/studies/{study_id}/variants?name=Variant1")
+ variant_id = res.json()
+ res = client.get(f"/v1/studies/{variant_id}")
+ assert res.json()["folder"] == f"folder2/{variant_id}"
+
+ # asserts move doesn't work on un-managed studies
+ res = client.put(f"/v1/studies/{internal_study_id}/move", params={"folder_dest": "folder1"})
+ assert res.status_code == 422
+ assert res.json()["exception"] == "NotAManagedStudyException"
+
+ # asserts users can put back a study at the root folder
+ res = client.put(f"/v1/studies/{study_id}/move", params={"folder_dest": ""})
+ res.raise_for_status()
+ res = client.get(f"/v1/studies/{study_id}")
+ assert res.json()["folder"] is None
diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py
index 27cb8cf78a..0f691d733f 100644
--- a/tests/integration/test_integration.py
+++ b/tests/integration/test_integration.py
@@ -207,7 +207,8 @@ def test_main(client: TestClient, admin_access_token: str) -> None:
headers={"Authorization": f'Bearer {george_credentials["access_token"]}'},
)
assert len(res.json()) == 3
- assert filter(lambda s: s["id"] == copied.json(), res.json().values()).__next__()["folder"] == "foo/bar"
+ moved_study = filter(lambda s: s["id"] == copied.json(), res.json().values()).__next__()
+ assert moved_study["folder"] == f"foo/bar/{moved_study['id']}"
# Study delete
client.delete(