From f5553f3c9c055fd0ecd47d5941a19f5b052f5f61 Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Fri, 7 Aug 2026 13:01:37 +0200 Subject: [PATCH 01/32] export study --- .../server/controller/StudyController.java | 18 +- .../dto/studyexport/CaseExportInfos.java | 19 ++ .../dto/studyexport/NodeTreeExportInfos.java | 26 +++ .../studyexport/RootNetworkExportInfos.java | 21 ++ .../dto/studyexport/TreeExportInfos.java | 20 ++ .../server/error/StudyBusinessErrorCode.java | 3 +- .../study/server/service/CaseService.java | 9 + .../service/StudyExportArchiveService.java | 205 ++++++++++++++++++ .../study/server/service/StudyService.java | 54 ++++- 9 files changed, 369 insertions(+), 6 deletions(-) create mode 100644 src/main/java/org/gridsuite/study/server/dto/studyexport/CaseExportInfos.java create mode 100644 src/main/java/org/gridsuite/study/server/dto/studyexport/NodeTreeExportInfos.java create mode 100644 src/main/java/org/gridsuite/study/server/dto/studyexport/RootNetworkExportInfos.java create mode 100644 src/main/java/org/gridsuite/study/server/dto/studyexport/TreeExportInfos.java create mode 100644 src/main/java/org/gridsuite/study/server/service/StudyExportArchiveService.java diff --git a/src/main/java/org/gridsuite/study/server/controller/StudyController.java b/src/main/java/org/gridsuite/study/server/controller/StudyController.java index 4e4ab1d402..42c06225da 100644 --- a/src/main/java/org/gridsuite/study/server/controller/StudyController.java +++ b/src/main/java/org/gridsuite/study/server/controller/StudyController.java @@ -32,6 +32,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.util.Pair; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -63,6 +64,7 @@ public class StudyController { private final RemoteServicesInspector remoteServicesInspector; private final RootNetworkService rootNetworkService; private final RebuildNodeService rebuildNodeService; + private final StudyExportArchiveService studyExportArchiveService; public StudyController(StudyService studyService, NetworkService networkStoreService, @@ -72,7 +74,8 @@ public StudyController(StudyService studyService, CaseService caseService, RemoteServicesInspector remoteServicesInspector, RootNetworkService rootNetworkService, - RebuildNodeService rebuildNodeService) { + RebuildNodeService rebuildNodeService, + StudyExportArchiveService studyExportArchiveService) { this.studyService = studyService; this.networkModificationTreeService = networkModificationTreeService; this.networkStoreService = networkStoreService; @@ -82,6 +85,7 @@ public StudyController(StudyService studyService, this.remoteServicesInspector = remoteServicesInspector; this.rootNetworkService = rootNetworkService; this.rebuildNodeService = rebuildNodeService; + this.studyExportArchiveService = studyExportArchiveService; } @InitBinder @@ -1600,4 +1604,16 @@ public ResponseEntity> getAllComputationsStatus(@Pa @Parameter(description = "Node UUID") @PathVariable("nodeUuid") UUID nodeUuid) { return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(studyService.getAllComputationsStatus(studyUuid, rootNetworkUuid, nodeUuid)); } + + @GetMapping(value = "/studies/{studyUuid}/export", produces = "application/gzip") + @Operation(summary = "Export a study as a gzip archive") + @ApiResponse(responseCode = "200", description = "The study archive as gzip") + @ApiResponse(responseCode = "404", description = "Study or root network not found") + public ResponseEntity exportStudyArchive(@PathVariable("studyUuid") UUID studyUuid, + @RequestHeader(HEADER_USER_ID) String userId) { + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + studyUuid + ".gz"); + headers.add(HttpHeaders.CONTENT_TYPE, "application/gzip"); + return ResponseEntity.ok().headers(headers).body(studyExportArchiveService.exportStudyArchive(studyUuid, userId)); + } } diff --git a/src/main/java/org/gridsuite/study/server/dto/studyexport/CaseExportInfos.java b/src/main/java/org/gridsuite/study/server/dto/studyexport/CaseExportInfos.java new file mode 100644 index 0000000000..64d820f57e --- /dev/null +++ b/src/main/java/org/gridsuite/study/server/dto/studyexport/CaseExportInfos.java @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * 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/. + */ +package org.gridsuite.study.server.dto.studyexport; + +import java.util.UUID; + +/** + * @author Ghazwa Rehili + */ +public record CaseExportInfos( + UUID uuid, + String name +) { +} + diff --git a/src/main/java/org/gridsuite/study/server/dto/studyexport/NodeTreeExportInfos.java b/src/main/java/org/gridsuite/study/server/dto/studyexport/NodeTreeExportInfos.java new file mode 100644 index 0000000000..c32b032b3b --- /dev/null +++ b/src/main/java/org/gridsuite/study/server/dto/studyexport/NodeTreeExportInfos.java @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * 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/. + */ +package org.gridsuite.study.server.dto.studyexport; + +import org.gridsuite.study.server.networkmodificationtree.dto.BuildStatus; +import org.gridsuite.study.server.networkmodificationtree.entities.NetworkModificationNodeType; + +import java.util.List; +import java.util.UUID; + +/** + * @author Ghazwa Rehili + */ +public record NodeTreeExportInfos( + String name, + String type, + UUID modificationGroupUuid, + BuildStatus buildStatus, + NetworkModificationNodeType nodeType, + List children +) { +} diff --git a/src/main/java/org/gridsuite/study/server/dto/studyexport/RootNetworkExportInfos.java b/src/main/java/org/gridsuite/study/server/dto/studyexport/RootNetworkExportInfos.java new file mode 100644 index 0000000000..d8c1b5b5af --- /dev/null +++ b/src/main/java/org/gridsuite/study/server/dto/studyexport/RootNetworkExportInfos.java @@ -0,0 +1,21 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * 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/. + */ +package org.gridsuite.study.server.dto.studyexport; + +import java.util.Map; + +/** + * @author Ghazwa Rehili + */ +public record RootNetworkExportInfos( + String name, + String tag, + String caseFormat, + CaseExportInfos caseInfos, + Map importParameters +) { +} diff --git a/src/main/java/org/gridsuite/study/server/dto/studyexport/TreeExportInfos.java b/src/main/java/org/gridsuite/study/server/dto/studyexport/TreeExportInfos.java new file mode 100644 index 0000000000..4aee1a1f29 --- /dev/null +++ b/src/main/java/org/gridsuite/study/server/dto/studyexport/TreeExportInfos.java @@ -0,0 +1,20 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * 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/. + */ +package org.gridsuite.study.server.dto.studyexport; + +import java.util.List; +import java.util.UUID; + +/** + * @author Ghazwa Rehili + */ +public record TreeExportInfos( + UUID studyUuid, + List rootNetworks, + NodeTreeExportInfos nodeTree +) { +} diff --git a/src/main/java/org/gridsuite/study/server/error/StudyBusinessErrorCode.java b/src/main/java/org/gridsuite/study/server/error/StudyBusinessErrorCode.java index 609f411055..16cee39672 100644 --- a/src/main/java/org/gridsuite/study/server/error/StudyBusinessErrorCode.java +++ b/src/main/java/org/gridsuite/study/server/error/StudyBusinessErrorCode.java @@ -33,7 +33,8 @@ public enum StudyBusinessErrorCode implements BusinessErrorCode { TOO_MANY_MAP_CARDS("study.tooManyMapCards"), ELEMENT_ALREADY_EXISTS("study.elementAlreadyExists"), UNPROCESSABLE_IMPORT_PARAMETER("study.unprocessableImportParameter"), - MAX_OPERATION_TYPE_EXCEEDED("study.maxOperationTypeExceeded"); + MAX_OPERATION_TYPE_EXCEEDED("study.maxOperationTypeExceeded"), + EXPORT_STUDY_ERROR("study.exportStudyError"); private final String value; diff --git a/src/main/java/org/gridsuite/study/server/service/CaseService.java b/src/main/java/org/gridsuite/study/server/service/CaseService.java index 1779459a42..03996030ea 100644 --- a/src/main/java/org/gridsuite/study/server/service/CaseService.java +++ b/src/main/java/org/gridsuite/study/server/service/CaseService.java @@ -17,6 +17,7 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; @@ -94,4 +95,12 @@ public UUID createCase(String caseKey, String contentType) { return restTemplate.exchange(caseServerBaseUri + path, HttpMethod.POST, null, UUID.class).getBody(); } + + public ResponseEntity getCaseContent(UUID caseUuid) { + String path = UriComponentsBuilder.fromPath(DELIMITER + CASE_API_VERSION + "/cases/{caseUuid}") + .buildAndExpand(caseUuid) + .toUriString(); + + return restTemplate.exchange(caseServerBaseUri + path, HttpMethod.GET, null, byte[].class); + } } diff --git a/src/main/java/org/gridsuite/study/server/service/StudyExportArchiveService.java b/src/main/java/org/gridsuite/study/server/service/StudyExportArchiveService.java new file mode 100644 index 0000000000..734563e0f7 --- /dev/null +++ b/src/main/java/org/gridsuite/study/server/service/StudyExportArchiveService.java @@ -0,0 +1,205 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * 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/. + */ +package org.gridsuite.study.server.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.gridsuite.study.server.dto.RootNetworkInfos; +import org.gridsuite.study.server.dto.networkexport.PermissionType; +import org.gridsuite.study.server.dto.studyexport.TreeExportInfos; +import org.gridsuite.study.server.error.StudyException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Lazy; +import org.springframework.core.io.InputStreamResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.FileAttribute; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Stream; +import java.util.zip.GZIPInputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.gridsuite.study.server.error.StudyBusinessErrorCode.EXPORT_STUDY_ERROR; + +/** + * @author Ghazwa Rehili + */ +@Service +public class StudyExportArchiveService { + private static final Logger LOGGER = LoggerFactory.getLogger(StudyExportArchiveService.class); + + private final StudyService studyService; + private final RootNetworkService rootNetworkService; + private final CaseService caseService; + private final DirectoryService directoryService; + private final ObjectMapper objectMapper; + private final StudyExportArchiveService self; + + public StudyExportArchiveService(@Lazy StudyExportArchiveService self, StudyService studyService, RootNetworkService rootNetworkService, + CaseService caseService, DirectoryService directoryService, ObjectMapper objectMapper) { + this.self = self; + this.studyService = studyService; + this.rootNetworkService = rootNetworkService; + this.caseService = caseService; + this.directoryService = directoryService; + this.objectMapper = objectMapper; + } + + /** + * Export a study as a gzip archive + * @param studyUuid the study UUID + * @param userId the requesting user, checked for read access to the study + * @return InputStreamResource containing the zip archive + */ + public InputStreamResource exportStudyArchive(UUID studyUuid, String userId) { + directoryService.checkPermission(List.of(studyUuid), null, userId, PermissionType.READ, false); + Path tempDir = createTempWorkDir(studyUuid); + Path zipFile = null; + try { + List rootNetworkInfosList = self.loadRootNetworkInfosAndWriteTree(studyUuid, tempDir); + Path casesDir = Files.createDirectories(tempDir.resolve("cases")); + for (RootNetworkInfos rootNetworkInfos : rootNetworkInfosList) { + UUID caseUuid = rootNetworkInfos.getCaseInfos().getCaseUuid(); + String caseName = rootNetworkInfos.getCaseInfos().getCaseName(); + exportCaseFile(caseUuid, caseName, casesDir); + } + zipFile = createTempExportFile(studyUuid); + try (OutputStream fos = Files.newOutputStream(zipFile); + ZipOutputStream zipOut = new ZipOutputStream(fos)) { + writeZipEntries(tempDir, zipOut); + } + InputStream stream = Files.newInputStream(zipFile, StandardOpenOption.DELETE_ON_CLOSE); + zipFile = null; + return new InputStreamResource(stream); + } catch (IOException e) { + throw new StudyException(EXPORT_STUDY_ERROR, "Failed to export study: " + studyUuid); + } finally { + try { + deleteDirectory(tempDir); + } catch (IOException e) { + LOGGER.warn("Failed to clean up temp export directory {} for study {}", tempDir, studyUuid, e); + } + if (zipFile != null) { + try { + Files.deleteIfExists(zipFile); + } catch (IOException e) { + LOGGER.warn("Failed to delete temp zip file {} for study {}", zipFile, studyUuid, e); + } + } + } + } + + @Transactional(readOnly = true) + protected List loadRootNetworkInfosAndWriteTree(UUID studyUuid, Path tempDir) throws IOException { + TreeExportInfos treeExportInfos = studyService.exportStudy(studyUuid); + List rootNetworkInfosList = rootNetworkService.getRootNetworkInfosWithLinksInfos(studyUuid); + Path studyJsonPath = tempDir.resolve("tree.json"); + objectMapper.writerWithDefaultPrettyPrinter().writeValue(studyJsonPath.toFile(), treeExportInfos); + return rootNetworkInfosList; + } + + private Path createTempWorkDir(UUID studyUuid) { + try { + FileAttribute> attr = + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------")); + return Files.createTempDirectory("study-export-" + studyUuid, attr); + } catch (IOException e) { + throw new StudyException(EXPORT_STUDY_ERROR, "Failed to create temp directory for study: " + studyUuid); + } + } + + private Path createTempExportFile(UUID studyUuid) { + try { + FileAttribute> attr = + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")); + return Files.createTempFile("study-export-" + studyUuid, ".zip", attr); + } catch (IOException e) { + throw new StudyException(EXPORT_STUDY_ERROR, "Failed to create temp file for study: " + studyUuid); + } + } + + /** + * Export a case file from case-server + */ + private void exportCaseFile(UUID caseUuid, String caseName, Path casesDir) throws IOException { + ResponseEntity response = caseService.getCaseContent(caseUuid); + byte[] body = response.getBody(); + if (body != null) { + Path caseDir = casesDir.resolve(caseUuid.toString()); + Files.createDirectories(caseDir); + String contentEncoding = response.getHeaders().getFirst(HttpHeaders.CONTENT_ENCODING); + if ("gzip".equalsIgnoreCase(contentEncoding)) { + body = decompressGzip(body); + } + Path caseFile = caseDir.resolve(caseName); + Files.write(caseFile, body); + } + } + + private static byte[] decompressGzip(byte[] data) throws IOException { + try (GZIPInputStream gzipIn = new GZIPInputStream(new ByteArrayInputStream(data)); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + gzipIn.transferTo(out); + return out.toByteArray(); + } + } + + /** + * Write directory contents to zip archive + */ + private void writeZipEntries(Path directory, ZipOutputStream zipOut) throws IOException { + try (Stream paths = Files.walk(directory)) { + paths.filter(Files::isRegularFile).forEach(file -> { + try { + Path relativePath = directory.relativize(file); + String entryName = relativePath.toString().replace('\\', '/'); + ZipEntry entry = new ZipEntry(entryName); + entry.setSize(Files.size(file)); + zipOut.putNextEntry(entry); + try (InputStream in = Files.newInputStream(file)) { + in.transferTo(zipOut); + } + zipOut.closeEntry(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } + } + + /** + * Recursively delete a directory + */ + private void deleteDirectory(Path directory) throws IOException { + if (Files.exists(directory)) { + try (Stream paths = Files.walk(directory)) { + paths.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.delete(path); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } catch (UncheckedIOException e) { + throw e.getCause(); + } + } + } +} diff --git a/src/main/java/org/gridsuite/study/server/service/StudyService.java b/src/main/java/org/gridsuite/study/server/service/StudyService.java index e0882f8547..bf62186db2 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyService.java @@ -28,16 +28,17 @@ import org.gridsuite.study.server.dto.networkexport.NodeExportInfos; import org.gridsuite.study.server.dto.networkexport.PermissionType; import org.gridsuite.study.server.dto.sequence.NodeSequenceType; +import org.gridsuite.study.server.dto.studyexport.CaseExportInfos; +import org.gridsuite.study.server.dto.studyexport.NodeTreeExportInfos; +import org.gridsuite.study.server.dto.studyexport.RootNetworkExportInfos; +import org.gridsuite.study.server.dto.studyexport.TreeExportInfos; import org.gridsuite.study.server.dto.workflow.AbstractWorkflowInfos; import org.gridsuite.study.server.dto.workflow.RerunLoadFlowInfos; import org.gridsuite.study.server.elasticsearch.EquipmentInfosService; import org.gridsuite.study.server.elasticsearch.StudyInfosService; import org.gridsuite.study.server.error.StudyException; import org.gridsuite.study.server.networkmodificationtree.dto.*; -import org.gridsuite.study.server.networkmodificationtree.entities.NetworkModificationNodeInfoEntity; -import org.gridsuite.study.server.networkmodificationtree.entities.NodeEntity; -import org.gridsuite.study.server.networkmodificationtree.entities.NodeType; -import org.gridsuite.study.server.networkmodificationtree.entities.RootNetworkNodeInfoEntity; +import org.gridsuite.study.server.networkmodificationtree.entities.*; import org.gridsuite.study.server.notification.NotificationService; import org.gridsuite.study.server.notification.dto.NetworkImpactsInfos; import org.gridsuite.study.server.repository.*; @@ -3019,4 +3020,49 @@ public void assertOnQuotasAvailability(ComputationType computationType, String u public Boolean getOperationQuotaStatus() { return shouldCheckOperationQuotas; } + + @Transactional(readOnly = true) + public TreeExportInfos exportStudy(UUID studyUuid) { + assertIsStudyExist(studyUuid); + List rootNetworkInfosList = rootNetworkService.getRootNetworkInfosWithLinksInfos(studyUuid); + if (rootNetworkInfosList.isEmpty()) { + throw new StudyException(NOT_FOUND, "No root network found for study " + studyUuid); + } + List rootNetworks = rootNetworkInfosList.stream().map(this::toRootNetworkExportInfos).toList(); + AbstractNode rootNode = networkModificationTreeService.getStudyTree(studyUuid, null); + NodeTreeExportInfos nodeTree = rootNode != null ? toNodeTreeExportInfos(rootNode) : null; + return new TreeExportInfos(studyUuid, rootNetworks, nodeTree); + } + + private RootNetworkExportInfos toRootNetworkExportInfos(RootNetworkInfos rootNetworkInfos) { + return new RootNetworkExportInfos( + rootNetworkInfos.getName(), + rootNetworkInfos.getTag(), + rootNetworkInfos.getCaseInfos().getCaseFormat(), + new CaseExportInfos(rootNetworkInfos.getCaseInfos().getCaseUuid(), rootNetworkInfos.getCaseInfos().getCaseName()), + rootNetworkInfos.getImportParameters() + ); + } + + private NodeTreeExportInfos toNodeTreeExportInfos(AbstractNode node) { + List children = CollectionUtils.emptyIfNull(node.getChildren()).stream().map(this::toNodeTreeExportInfos).toList(); + UUID modificationGroupUuid = null; + BuildStatus buildStatus = BuildStatus.NOT_BUILT; + NetworkModificationNodeType nodeType = NetworkModificationNodeType.CONSTRUCTION; + if (node instanceof NetworkModificationNode modificationNode) { + modificationGroupUuid = modificationNode.getModificationGroupUuid(); + nodeType = modificationNode.getNodeType(); + if (modificationNode.getNodeBuildStatus() != null) { + buildStatus = modificationNode.getNodeBuildStatus().getGlobalBuildStatus(); + } + } + return new NodeTreeExportInfos( + node.getName(), + node.getType().name(), + modificationGroupUuid, + buildStatus, + nodeType, + children + ); + } } From c22592dd0eb27317ca2932284e841afcd8e38f64 Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Fri, 7 Aug 2026 13:51:57 +0200 Subject: [PATCH 02/32] add TU --- .../TreeExportArchiveTest.java | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 src/test/java/org/gridsuite/study/server/studycontroller/TreeExportArchiveTest.java diff --git a/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportArchiveTest.java b/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportArchiveTest.java new file mode 100644 index 0000000000..b4d71dacda --- /dev/null +++ b/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportArchiveTest.java @@ -0,0 +1,96 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * 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/. + */ +package org.gridsuite.study.server.studycontroller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.tomakehurst.wiremock.client.WireMock; +import org.gridsuite.study.server.dto.networkexport.PermissionType; +import org.gridsuite.study.server.dto.studyexport.TreeExportInfos; +import org.gridsuite.study.server.utils.wiremock.WireMockUtilsCriteria; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MvcResult; + +import java.io.ByteArrayInputStream; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import static org.gridsuite.study.server.StudyConstants.HEADER_USER_ID; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public class TreeExportArchiveTest extends StudyTestBase { + + @Autowired + private ObjectMapper objectMapper; + + @Test + void testExportStudyArchive() throws Exception { + // Create a study + UUID studyUuid = createStudyWithStubs("testUser", CASE_UUID); + ReflectionTestUtils.setField(caseService, "caseServerBaseUri", wireMockServer.baseUrl()); + // Stub the read-permission check on the study + wireMockStubs.directoryServer.stubCheckPermission(List.of(studyUuid), null, "testUser", PermissionType.READ, false, HttpStatus.OK.value()); + // Stub the case content download used during export + wireMockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo("/v1/cases/" + CASE_UUID)) + .willReturn(WireMock.aResponse().withStatus(200) + .withHeader("Content-Type", "application/octet-stream") + .withBody("dummy case content".getBytes()))); + // Export as archive + MvcResult result = mockMvc.perform(get("/v1/studies/{studyUuid}/export", studyUuid).header(HEADER_USER_ID, "testUser")) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", "attachment; filename=" + studyUuid + ".gz")) + .andExpect(header().string("Content-Type", "application/gzip")) + .andReturn(); + // Verify the response contains data + byte[] archiveContent = result.getResponse().getContentAsByteArray(); + assertNotNull(archiveContent); + assertTrue(archiveContent.length > 0); + TreeExportInfos exportInfos = null; + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(archiveContent))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if ("tree.json".equals(entry.getName())) { + exportInfos = objectMapper.readValue(zis.readAllBytes(), TreeExportInfos.class); + break; + } + } + } + // Verify export structure + assertNotNull(exportInfos); + assertEquals(studyUuid, exportInfos.studyUuid()); + assertNotNull(exportInfos.rootNetworks()); + assertEquals(1, exportInfos.rootNetworks().size()); + assertNotNull(exportInfos.rootNetworks().getFirst().importParameters()); + assertNotNull(exportInfos.nodeTree()); + assertEquals("ROOT", exportInfos.nodeTree().type()); + assertNotNull(exportInfos.nodeTree().children()); + assertEquals(1, exportInfos.nodeTree().children().size()); + // Verify the case content download call + WireMockUtilsCriteria.verifyGetRequest(wireMockServer, "/v1/cases/" + CASE_UUID, false, Map.of(), 1); + wireMockStubs.directoryServer.verifyCheckPermission(List.of(studyUuid), null, PermissionType.READ, false); + } + + @Test + void testExportStudyArchiveFailNoPermission() throws Exception { + UUID studyUuid = createStudyWithStubs("testUser", CASE_UUID); + wireMockStubs.directoryServer.stubCheckPermission(List.of(studyUuid), null, "testUser", PermissionType.READ, false, HttpStatus.FORBIDDEN.value()); + + mockMvc.perform(get("/v1/studies/{studyUuid}/export", studyUuid).header(HEADER_USER_ID, "testUser")) + .andExpect(status().isForbidden()) + .andExpect(header().string("Content-Disposition", nullValue())); + wireMockStubs.directoryServer.verifyCheckPermission(List.of(studyUuid), null, PermissionType.READ, false); + } +} From 84ff55cd9d8483ca1b2d420ea427ec0e3a00faeb Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Fri, 7 Aug 2026 15:03:04 +0200 Subject: [PATCH 03/32] enhance coverage code --- .../TreeExportArchiveTest.java | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportArchiveTest.java b/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportArchiveTest.java index b4d71dacda..b599b9acbd 100644 --- a/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportArchiveTest.java +++ b/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportArchiveTest.java @@ -12,21 +12,32 @@ import org.gridsuite.study.server.dto.studyexport.TreeExportInfos; import org.gridsuite.study.server.utils.wiremock.WireMockUtilsCriteria; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatcher; +import org.mockito.MockedStatic; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MvcResult; import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import static org.gridsuite.study.server.StudyConstants.HEADER_USER_ID; import static org.hamcrest.Matchers.nullValue; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mockStatic; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -93,4 +104,46 @@ void testExportStudyArchiveFailNoPermission() throws Exception { .andExpect(header().string("Content-Disposition", nullValue())); wireMockStubs.directoryServer.verifyCheckPermission(List.of(studyUuid), null, PermissionType.READ, false); } + + @Test + void testExportStudyArchiveFailToDeleteTempZipFile() throws Exception { + // Create a study + UUID studyUuid = createStudyWithStubs("testUser", CASE_UUID); + ReflectionTestUtils.setField(caseService, "caseServerBaseUri", wireMockServer.baseUrl()); + wireMockStubs.directoryServer.stubCheckPermission(List.of(studyUuid), null, "testUser", PermissionType.READ, false, HttpStatus.OK.value()); + wireMockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo("/v1/cases/" + CASE_UUID)) + .willReturn(WireMock.aResponse().withStatus(200).withHeader("Content-Type", "application/octet-stream") + .withBody("dummy case content".getBytes()))); + // Capture the real zip file path as it is matched, so the test can clean it up itself: + // the service's own Files.deleteIfExists call on this path is mocked to fail below. + AtomicReference capturedZipFile = new AtomicReference<>(); + ArgumentMatcher isStudyZipFile = path -> { + boolean matches = path.getFileName().toString().startsWith("study-export-" + studyUuid) + && path.getFileName().toString().endsWith(".zip"); + if (matches) { + capturedZipFile.set(path); + } + return matches; + }; + try { + try (MockedStatic mockedFiles = mockStatic(Files.class, CALLS_REAL_METHODS)) { + mockedFiles.when(() -> Files.newInputStream(argThat(isStudyZipFile), eq(StandardOpenOption.DELETE_ON_CLOSE))) + .thenThrow(new IOException("Simulated failure opening exported zip stream")); + mockedFiles.when(() -> Files.deleteIfExists(argThat(isStudyZipFile))) + .thenThrow(new IOException("Simulated failure deleting temp zip file")); + + mockMvc.perform(get("/v1/studies/{studyUuid}/export", studyUuid).header(HEADER_USER_ID, "testUser")) + .andExpect(status().isInternalServerError()) + .andExpect(header().string("Content-Disposition", nullValue())); + assertNotNull(capturedZipFile.get(), "the mocked zip file path was never matched"); + } + } finally { + Path zipFile = capturedZipFile.get(); + if (zipFile != null) { + Files.deleteIfExists(zipFile); + } + } + WireMockUtilsCriteria.verifyGetRequest(wireMockServer, "/v1/cases/" + CASE_UUID, false, Map.of(), 1); + wireMockStubs.directoryServer.verifyCheckPermission(List.of(studyUuid), null, PermissionType.READ, false); + } } From aa595a31516ee343aaad3d55cc132015f3d9dbae Mon Sep 17 00:00:00 2001 From: Etienne Homer Date: Fri, 7 Aug 2026 17:19:51 +0200 Subject: [PATCH 04/32] rework exportStudyArchive() Signed-off-by: Etienne Homer --- .../service/StudyExportArchiveService.java | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/gridsuite/study/server/service/StudyExportArchiveService.java b/src/main/java/org/gridsuite/study/server/service/StudyExportArchiveService.java index 734563e0f7..1fab7ecb0b 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyExportArchiveService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyExportArchiveService.java @@ -9,6 +9,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.gridsuite.study.server.dto.RootNetworkInfos; import org.gridsuite.study.server.dto.networkexport.PermissionType; +import org.gridsuite.study.server.dto.studyexport.RootNetworkExportInfos; import org.gridsuite.study.server.dto.studyexport.TreeExportInfos; import org.gridsuite.study.server.error.StudyException; import org.slf4j.Logger; @@ -73,11 +74,13 @@ public InputStreamResource exportStudyArchive(UUID studyUuid, String userId) { Path tempDir = createTempWorkDir(studyUuid); Path zipFile = null; try { - List rootNetworkInfosList = self.loadRootNetworkInfosAndWriteTree(studyUuid, tempDir); + TreeExportInfos treeExportInfos = studyService.exportStudy(studyUuid); + Path studyJsonPath = tempDir.resolve("tree.json"); + objectMapper.writerWithDefaultPrettyPrinter().writeValue(studyJsonPath.toFile(), treeExportInfos); Path casesDir = Files.createDirectories(tempDir.resolve("cases")); - for (RootNetworkInfos rootNetworkInfos : rootNetworkInfosList) { - UUID caseUuid = rootNetworkInfos.getCaseInfos().getCaseUuid(); - String caseName = rootNetworkInfos.getCaseInfos().getCaseName(); + for (RootNetworkExportInfos rootNetworkInfos : treeExportInfos.rootNetworks()) { + UUID caseUuid = rootNetworkInfos.caseInfos().uuid(); + String caseName = rootNetworkInfos.caseInfos().name(); exportCaseFile(caseUuid, caseName, casesDir); } zipFile = createTempExportFile(studyUuid); @@ -106,15 +109,6 @@ public InputStreamResource exportStudyArchive(UUID studyUuid, String userId) { } } - @Transactional(readOnly = true) - protected List loadRootNetworkInfosAndWriteTree(UUID studyUuid, Path tempDir) throws IOException { - TreeExportInfos treeExportInfos = studyService.exportStudy(studyUuid); - List rootNetworkInfosList = rootNetworkService.getRootNetworkInfosWithLinksInfos(studyUuid); - Path studyJsonPath = tempDir.resolve("tree.json"); - objectMapper.writerWithDefaultPrettyPrinter().writeValue(studyJsonPath.toFile(), treeExportInfos); - return rootNetworkInfosList; - } - private Path createTempWorkDir(UUID studyUuid) { try { FileAttribute> attr = From db1e926d330adbfbddd203664abe3332280f3eb8 Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Fri, 7 Aug 2026 17:29:05 +0200 Subject: [PATCH 05/32] code review remarks --- .../server/controller/StudyController.java | 28 ++++++++++--------- .../studyexport/RootNetworkExportInfos.java | 5 ++-- ...veService.java => StudyExportService.java} | 23 ++++++--------- .../study/server/service/StudyService.java | 5 ++-- 4 files changed, 28 insertions(+), 33 deletions(-) rename src/main/java/org/gridsuite/study/server/service/{StudyExportArchiveService.java => StudyExportService.java} (88%) diff --git a/src/main/java/org/gridsuite/study/server/controller/StudyController.java b/src/main/java/org/gridsuite/study/server/controller/StudyController.java index 42c06225da..b4816eec79 100644 --- a/src/main/java/org/gridsuite/study/server/controller/StudyController.java +++ b/src/main/java/org/gridsuite/study/server/controller/StudyController.java @@ -32,16 +32,14 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.util.Pair; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; +import org.springframework.http.*; import org.springframework.lang.NonNull; import org.springframework.util.CollectionUtils; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.annotation.*; import java.beans.PropertyEditorSupport; +import java.nio.charset.StandardCharsets; import java.util.*; import static org.gridsuite.study.server.StudyConstants.*; @@ -64,7 +62,7 @@ public class StudyController { private final RemoteServicesInspector remoteServicesInspector; private final RootNetworkService rootNetworkService; private final RebuildNodeService rebuildNodeService; - private final StudyExportArchiveService studyExportArchiveService; + private final StudyExportService studyExportService; public StudyController(StudyService studyService, NetworkService networkStoreService, @@ -75,7 +73,7 @@ public StudyController(StudyService studyService, RemoteServicesInspector remoteServicesInspector, RootNetworkService rootNetworkService, RebuildNodeService rebuildNodeService, - StudyExportArchiveService studyExportArchiveService) { + StudyExportService studyExportService) { this.studyService = studyService; this.networkModificationTreeService = networkModificationTreeService; this.networkStoreService = networkStoreService; @@ -85,7 +83,7 @@ public StudyController(StudyService studyService, this.remoteServicesInspector = remoteServicesInspector; this.rootNetworkService = rootNetworkService; this.rebuildNodeService = rebuildNodeService; - this.studyExportArchiveService = studyExportArchiveService; + this.studyExportService = studyExportService; } @InitBinder @@ -1605,15 +1603,19 @@ public ResponseEntity> getAllComputationsStatus(@Pa return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(studyService.getAllComputationsStatus(studyUuid, rootNetworkUuid, nodeUuid)); } - @GetMapping(value = "/studies/{studyUuid}/export", produces = "application/gzip") + @GetMapping(value = "/studies/{studyUuid}/export/{studyName}", produces = "application/gzip") @Operation(summary = "Export a study as a gzip archive") @ApiResponse(responseCode = "200", description = "The study archive as gzip") @ApiResponse(responseCode = "404", description = "Study or root network not found") - public ResponseEntity exportStudyArchive(@PathVariable("studyUuid") UUID studyUuid, - @RequestHeader(HEADER_USER_ID) String userId) { + public ResponseEntity exportStudy(@PathVariable("studyUuid") UUID studyUuid, + @PathVariable("studyName") String studyName, + @RequestHeader(HEADER_USER_ID) String userId) { + ContentDisposition contentDisposition = ContentDisposition.attachment() + .filename(studyName + ".zip", StandardCharsets.UTF_8) + .build(); HttpHeaders headers = new HttpHeaders(); - headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + studyUuid + ".gz"); - headers.add(HttpHeaders.CONTENT_TYPE, "application/gzip"); - return ResponseEntity.ok().headers(headers).body(studyExportArchiveService.exportStudyArchive(studyUuid, userId)); + headers.setContentDisposition(contentDisposition); + headers.setContentType(MediaType.parseMediaType("application/zip")); + return ResponseEntity.ok().headers(headers).body(studyExportService.exportStudy(studyUuid, userId)); } } diff --git a/src/main/java/org/gridsuite/study/server/dto/studyexport/RootNetworkExportInfos.java b/src/main/java/org/gridsuite/study/server/dto/studyexport/RootNetworkExportInfos.java index d8c1b5b5af..2b6289fa0c 100644 --- a/src/main/java/org/gridsuite/study/server/dto/studyexport/RootNetworkExportInfos.java +++ b/src/main/java/org/gridsuite/study/server/dto/studyexport/RootNetworkExportInfos.java @@ -6,6 +6,8 @@ */ package org.gridsuite.study.server.dto.studyexport; +import org.gridsuite.study.server.dto.CaseInfos; + import java.util.Map; /** @@ -14,8 +16,7 @@ public record RootNetworkExportInfos( String name, String tag, - String caseFormat, - CaseExportInfos caseInfos, + CaseInfos caseInfos, Map importParameters ) { } diff --git a/src/main/java/org/gridsuite/study/server/service/StudyExportArchiveService.java b/src/main/java/org/gridsuite/study/server/service/StudyExportService.java similarity index 88% rename from src/main/java/org/gridsuite/study/server/service/StudyExportArchiveService.java rename to src/main/java/org/gridsuite/study/server/service/StudyExportService.java index 1fab7ecb0b..8cf7fb97ae 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyExportArchiveService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyExportService.java @@ -7,19 +7,16 @@ package org.gridsuite.study.server.service; import com.fasterxml.jackson.databind.ObjectMapper; -import org.gridsuite.study.server.dto.RootNetworkInfos; import org.gridsuite.study.server.dto.networkexport.PermissionType; import org.gridsuite.study.server.dto.studyexport.RootNetworkExportInfos; import org.gridsuite.study.server.dto.studyexport.TreeExportInfos; import org.gridsuite.study.server.error.StudyException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.context.annotation.Lazy; import org.springframework.core.io.InputStreamResource; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import java.io.*; import java.nio.file.Files; @@ -43,33 +40,28 @@ * @author Ghazwa Rehili */ @Service -public class StudyExportArchiveService { - private static final Logger LOGGER = LoggerFactory.getLogger(StudyExportArchiveService.class); +public class StudyExportService { + private static final Logger LOGGER = LoggerFactory.getLogger(StudyExportService.class); private final StudyService studyService; - private final RootNetworkService rootNetworkService; private final CaseService caseService; private final DirectoryService directoryService; private final ObjectMapper objectMapper; - private final StudyExportArchiveService self; - public StudyExportArchiveService(@Lazy StudyExportArchiveService self, StudyService studyService, RootNetworkService rootNetworkService, - CaseService caseService, DirectoryService directoryService, ObjectMapper objectMapper) { - this.self = self; + public StudyExportService(StudyService studyService, CaseService caseService, DirectoryService directoryService, ObjectMapper objectMapper) { this.studyService = studyService; - this.rootNetworkService = rootNetworkService; this.caseService = caseService; this.directoryService = directoryService; this.objectMapper = objectMapper; } /** - * Export a study as a gzip archive + * Export a study as a zip * @param studyUuid the study UUID * @param userId the requesting user, checked for read access to the study * @return InputStreamResource containing the zip archive */ - public InputStreamResource exportStudyArchive(UUID studyUuid, String userId) { + public InputStreamResource exportStudy(UUID studyUuid, String userId) { directoryService.checkPermission(List.of(studyUuid), null, userId, PermissionType.READ, false); Path tempDir = createTempWorkDir(studyUuid); Path zipFile = null; @@ -79,8 +71,8 @@ public InputStreamResource exportStudyArchive(UUID studyUuid, String userId) { objectMapper.writerWithDefaultPrettyPrinter().writeValue(studyJsonPath.toFile(), treeExportInfos); Path casesDir = Files.createDirectories(tempDir.resolve("cases")); for (RootNetworkExportInfos rootNetworkInfos : treeExportInfos.rootNetworks()) { - UUID caseUuid = rootNetworkInfos.caseInfos().uuid(); - String caseName = rootNetworkInfos.caseInfos().name(); + UUID caseUuid = rootNetworkInfos.caseInfos().getCaseUuid(); + String caseName = rootNetworkInfos.caseInfos().getCaseName(); exportCaseFile(caseUuid, caseName, casesDir); } zipFile = createTempExportFile(studyUuid); @@ -139,6 +131,7 @@ private void exportCaseFile(UUID caseUuid, String caseName, Path casesDir) throw Path caseDir = casesDir.resolve(caseUuid.toString()); Files.createDirectories(caseDir); String contentEncoding = response.getHeaders().getFirst(HttpHeaders.CONTENT_ENCODING); + // plain file cases are gzip by the case-server and need to be decompressed if ("gzip".equalsIgnoreCase(contentEncoding)) { body = decompressGzip(body); } diff --git a/src/main/java/org/gridsuite/study/server/service/StudyService.java b/src/main/java/org/gridsuite/study/server/service/StudyService.java index bf62186db2..20ad548d40 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyService.java @@ -28,7 +28,6 @@ import org.gridsuite.study.server.dto.networkexport.NodeExportInfos; import org.gridsuite.study.server.dto.networkexport.PermissionType; import org.gridsuite.study.server.dto.sequence.NodeSequenceType; -import org.gridsuite.study.server.dto.studyexport.CaseExportInfos; import org.gridsuite.study.server.dto.studyexport.NodeTreeExportInfos; import org.gridsuite.study.server.dto.studyexport.RootNetworkExportInfos; import org.gridsuite.study.server.dto.studyexport.TreeExportInfos; @@ -3038,8 +3037,8 @@ private RootNetworkExportInfos toRootNetworkExportInfos(RootNetworkInfos rootNet return new RootNetworkExportInfos( rootNetworkInfos.getName(), rootNetworkInfos.getTag(), - rootNetworkInfos.getCaseInfos().getCaseFormat(), - new CaseExportInfos(rootNetworkInfos.getCaseInfos().getCaseUuid(), rootNetworkInfos.getCaseInfos().getCaseName()), + new CaseInfos(rootNetworkInfos.getCaseInfos().getCaseUuid(), rootNetworkInfos.getCaseInfos().getOriginalCaseUuid(), + rootNetworkInfos.getCaseInfos().getCaseName(), rootNetworkInfos.getCaseInfos().getCaseFormat()), rootNetworkInfos.getImportParameters() ); } From e04ee14aebffce993d63e750e6261fcf56aadcc2 Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Fri, 7 Aug 2026 17:31:50 +0200 Subject: [PATCH 06/32] remove BuildStatus --- .../server/dto/studyexport/NodeTreeExportInfos.java | 6 +----- .../org/gridsuite/study/server/service/StudyService.java | 9 ++------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/gridsuite/study/server/dto/studyexport/NodeTreeExportInfos.java b/src/main/java/org/gridsuite/study/server/dto/studyexport/NodeTreeExportInfos.java index c32b032b3b..4cdcc737f5 100644 --- a/src/main/java/org/gridsuite/study/server/dto/studyexport/NodeTreeExportInfos.java +++ b/src/main/java/org/gridsuite/study/server/dto/studyexport/NodeTreeExportInfos.java @@ -6,9 +6,6 @@ */ package org.gridsuite.study.server.dto.studyexport; -import org.gridsuite.study.server.networkmodificationtree.dto.BuildStatus; -import org.gridsuite.study.server.networkmodificationtree.entities.NetworkModificationNodeType; - import java.util.List; import java.util.UUID; @@ -19,8 +16,7 @@ public record NodeTreeExportInfos( String name, String type, UUID modificationGroupUuid, - BuildStatus buildStatus, - NetworkModificationNodeType nodeType, + String nodeType, List children ) { } diff --git a/src/main/java/org/gridsuite/study/server/service/StudyService.java b/src/main/java/org/gridsuite/study/server/service/StudyService.java index 20ad548d40..0e93cc044e 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyService.java @@ -3046,20 +3046,15 @@ private RootNetworkExportInfos toRootNetworkExportInfos(RootNetworkInfos rootNet private NodeTreeExportInfos toNodeTreeExportInfos(AbstractNode node) { List children = CollectionUtils.emptyIfNull(node.getChildren()).stream().map(this::toNodeTreeExportInfos).toList(); UUID modificationGroupUuid = null; - BuildStatus buildStatus = BuildStatus.NOT_BUILT; - NetworkModificationNodeType nodeType = NetworkModificationNodeType.CONSTRUCTION; + String nodeType = null; if (node instanceof NetworkModificationNode modificationNode) { modificationGroupUuid = modificationNode.getModificationGroupUuid(); - nodeType = modificationNode.getNodeType(); - if (modificationNode.getNodeBuildStatus() != null) { - buildStatus = modificationNode.getNodeBuildStatus().getGlobalBuildStatus(); - } + nodeType = modificationNode.getNodeType().name(); } return new NodeTreeExportInfos( node.getName(), node.getType().name(), modificationGroupUuid, - buildStatus, nodeType, children ); From 34e39f639a008b5dc98562e9c78c2e703af4f05c Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Fri, 7 Aug 2026 17:36:50 +0200 Subject: [PATCH 07/32] update TU --- ...rtArchiveTest.java => TreeExportTest.java} | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) rename src/test/java/org/gridsuite/study/server/studycontroller/{TreeExportArchiveTest.java => TreeExportTest.java} (92%) diff --git a/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportArchiveTest.java b/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportTest.java similarity index 92% rename from src/test/java/org/gridsuite/study/server/studycontroller/TreeExportArchiveTest.java rename to src/test/java/org/gridsuite/study/server/studycontroller/TreeExportTest.java index b599b9acbd..fcdb67c041 100644 --- a/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportArchiveTest.java +++ b/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportTest.java @@ -42,13 +42,13 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -public class TreeExportArchiveTest extends StudyTestBase { +public class TreeExportTest extends StudyTestBase { @Autowired private ObjectMapper objectMapper; @Test - void testExportStudyArchive() throws Exception { + void testExportStudy() throws Exception { // Create a study UUID studyUuid = createStudyWithStubs("testUser", CASE_UUID); ReflectionTestUtils.setField(caseService, "caseServerBaseUri", wireMockServer.baseUrl()); @@ -59,11 +59,10 @@ void testExportStudyArchive() throws Exception { .willReturn(WireMock.aResponse().withStatus(200) .withHeader("Content-Type", "application/octet-stream") .withBody("dummy case content".getBytes()))); - // Export as archive - MvcResult result = mockMvc.perform(get("/v1/studies/{studyUuid}/export", studyUuid).header(HEADER_USER_ID, "testUser")) + // Export as zip + MvcResult result = mockMvc.perform(get("/v1/studies/{studyUuid}/export/{studyName}", studyUuid, "studyName").header(HEADER_USER_ID, "testUser")) .andExpect(status().isOk()) - .andExpect(header().string("Content-Disposition", "attachment; filename=" + studyUuid + ".gz")) - .andExpect(header().string("Content-Type", "application/gzip")) + .andExpect(header().string("Content-Type", "application/zip")) .andReturn(); // Verify the response contains data byte[] archiveContent = result.getResponse().getContentAsByteArray(); @@ -95,18 +94,18 @@ void testExportStudyArchive() throws Exception { } @Test - void testExportStudyArchiveFailNoPermission() throws Exception { + void testExportStudyFailNoPermission() throws Exception { UUID studyUuid = createStudyWithStubs("testUser", CASE_UUID); wireMockStubs.directoryServer.stubCheckPermission(List.of(studyUuid), null, "testUser", PermissionType.READ, false, HttpStatus.FORBIDDEN.value()); - mockMvc.perform(get("/v1/studies/{studyUuid}/export", studyUuid).header(HEADER_USER_ID, "testUser")) + mockMvc.perform(get("/v1/studies/{studyUuid}/export/{studyName}", studyUuid, "studyName").header(HEADER_USER_ID, "testUser")) .andExpect(status().isForbidden()) .andExpect(header().string("Content-Disposition", nullValue())); wireMockStubs.directoryServer.verifyCheckPermission(List.of(studyUuid), null, PermissionType.READ, false); } @Test - void testExportStudyArchiveFailToDeleteTempZipFile() throws Exception { + void testExportStudyFailToDeleteTempZipFile() throws Exception { // Create a study UUID studyUuid = createStudyWithStubs("testUser", CASE_UUID); ReflectionTestUtils.setField(caseService, "caseServerBaseUri", wireMockServer.baseUrl()); @@ -132,7 +131,7 @@ void testExportStudyArchiveFailToDeleteTempZipFile() throws Exception { mockedFiles.when(() -> Files.deleteIfExists(argThat(isStudyZipFile))) .thenThrow(new IOException("Simulated failure deleting temp zip file")); - mockMvc.perform(get("/v1/studies/{studyUuid}/export", studyUuid).header(HEADER_USER_ID, "testUser")) + mockMvc.perform(get("/v1/studies/{studyUuid}/export/{studyName}", studyUuid, "studyName").header(HEADER_USER_ID, "testUser")) .andExpect(status().isInternalServerError()) .andExpect(header().string("Content-Disposition", nullValue())); assertNotNull(capturedZipFile.get(), "the mocked zip file path was never matched"); From 7b241aa79c40214112a2da146311b72d791593ad Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Mon, 10 Aug 2026 11:20:35 +0200 Subject: [PATCH 08/32] add index --- .../study/server/controller/StudyController.java | 9 +++------ .../dto/studyexport/RootNetworkExportInfos.java | 1 + .../gridsuite/study/server/service/StudyService.java | 11 ++++++++--- .../study/server/studycontroller/TreeExportTest.java | 1 + 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/gridsuite/study/server/controller/StudyController.java b/src/main/java/org/gridsuite/study/server/controller/StudyController.java index b4816eec79..c7402969d3 100644 --- a/src/main/java/org/gridsuite/study/server/controller/StudyController.java +++ b/src/main/java/org/gridsuite/study/server/controller/StudyController.java @@ -39,7 +39,6 @@ import org.springframework.web.bind.annotation.*; import java.beans.PropertyEditorSupport; -import java.nio.charset.StandardCharsets; import java.util.*; import static org.gridsuite.study.server.StudyConstants.*; @@ -1603,16 +1602,14 @@ public ResponseEntity> getAllComputationsStatus(@Pa return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(studyService.getAllComputationsStatus(studyUuid, rootNetworkUuid, nodeUuid)); } - @GetMapping(value = "/studies/{studyUuid}/export/{studyName}", produces = "application/gzip") + @GetMapping(value = "/studies/{studyUuid}/export/{studyName}", produces = "application/zip") @Operation(summary = "Export a study as a gzip archive") - @ApiResponse(responseCode = "200", description = "The study archive as gzip") + @ApiResponse(responseCode = "200", description = "The study archive as zip") @ApiResponse(responseCode = "404", description = "Study or root network not found") public ResponseEntity exportStudy(@PathVariable("studyUuid") UUID studyUuid, @PathVariable("studyName") String studyName, @RequestHeader(HEADER_USER_ID) String userId) { - ContentDisposition contentDisposition = ContentDisposition.attachment() - .filename(studyName + ".zip", StandardCharsets.UTF_8) - .build(); + ContentDisposition contentDisposition = ContentDisposition.attachment().filename(studyName + ".zip").build(); HttpHeaders headers = new HttpHeaders(); headers.setContentDisposition(contentDisposition); headers.setContentType(MediaType.parseMediaType("application/zip")); diff --git a/src/main/java/org/gridsuite/study/server/dto/studyexport/RootNetworkExportInfos.java b/src/main/java/org/gridsuite/study/server/dto/studyexport/RootNetworkExportInfos.java index 2b6289fa0c..cfd1ed6c65 100644 --- a/src/main/java/org/gridsuite/study/server/dto/studyexport/RootNetworkExportInfos.java +++ b/src/main/java/org/gridsuite/study/server/dto/studyexport/RootNetworkExportInfos.java @@ -16,6 +16,7 @@ public record RootNetworkExportInfos( String name, String tag, + Integer index, CaseInfos caseInfos, Map importParameters ) { diff --git a/src/main/java/org/gridsuite/study/server/service/StudyService.java b/src/main/java/org/gridsuite/study/server/service/StudyService.java index 0e93cc044e..e195dad76d 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyService.java @@ -3022,21 +3022,26 @@ public Boolean getOperationQuotaStatus() { @Transactional(readOnly = true) public TreeExportInfos exportStudy(UUID studyUuid) { - assertIsStudyExist(studyUuid); + StudyEntity studyEntity = studyRepository.findById(studyUuid).orElseThrow(() -> new StudyException(NOT_FOUND, "Study not found")); List rootNetworkInfosList = rootNetworkService.getRootNetworkInfosWithLinksInfos(studyUuid); if (rootNetworkInfosList.isEmpty()) { throw new StudyException(NOT_FOUND, "No root network found for study " + studyUuid); } - List rootNetworks = rootNetworkInfosList.stream().map(this::toRootNetworkExportInfos).toList(); + // studyEntity.getRootNetworks() is ordered by the "index" column (@OrderColumn) in the root_network table + List orderedRootNetworkIds = studyEntity.getRootNetworks().stream().map(RootNetworkEntity::getId).toList(); + List rootNetworks = rootNetworkInfosList.stream() + .map(rootNetworkInfos -> toRootNetworkExportInfos(rootNetworkInfos, orderedRootNetworkIds.indexOf(rootNetworkInfos.getId()))) + .toList(); AbstractNode rootNode = networkModificationTreeService.getStudyTree(studyUuid, null); NodeTreeExportInfos nodeTree = rootNode != null ? toNodeTreeExportInfos(rootNode) : null; return new TreeExportInfos(studyUuid, rootNetworks, nodeTree); } - private RootNetworkExportInfos toRootNetworkExportInfos(RootNetworkInfos rootNetworkInfos) { + private RootNetworkExportInfos toRootNetworkExportInfos(RootNetworkInfos rootNetworkInfos, int index) { return new RootNetworkExportInfos( rootNetworkInfos.getName(), rootNetworkInfos.getTag(), + index, new CaseInfos(rootNetworkInfos.getCaseInfos().getCaseUuid(), rootNetworkInfos.getCaseInfos().getOriginalCaseUuid(), rootNetworkInfos.getCaseInfos().getCaseName(), rootNetworkInfos.getCaseInfos().getCaseFormat()), rootNetworkInfos.getImportParameters() diff --git a/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportTest.java b/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportTest.java index fcdb67c041..971fcf608b 100644 --- a/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportTest.java +++ b/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportTest.java @@ -84,6 +84,7 @@ void testExportStudy() throws Exception { assertNotNull(exportInfos.rootNetworks()); assertEquals(1, exportInfos.rootNetworks().size()); assertNotNull(exportInfos.rootNetworks().getFirst().importParameters()); + assertEquals(0, exportInfos.rootNetworks().getFirst().index()); assertNotNull(exportInfos.nodeTree()); assertEquals("ROOT", exportInfos.nodeTree().type()); assertNotNull(exportInfos.nodeTree().children()); From 2ca0b42b0c861b90d6aaff457cce3a465c85c054 Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Mon, 10 Aug 2026 14:33:25 +0200 Subject: [PATCH 09/32] remove CaseExportInfos --- .../server/controller/StudyController.java | 2 +- .../dto/studyexport/CaseExportInfos.java | 19 ------------------- 2 files changed, 1 insertion(+), 20 deletions(-) delete mode 100644 src/main/java/org/gridsuite/study/server/dto/studyexport/CaseExportInfos.java diff --git a/src/main/java/org/gridsuite/study/server/controller/StudyController.java b/src/main/java/org/gridsuite/study/server/controller/StudyController.java index c7402969d3..55b66ad5a8 100644 --- a/src/main/java/org/gridsuite/study/server/controller/StudyController.java +++ b/src/main/java/org/gridsuite/study/server/controller/StudyController.java @@ -1603,7 +1603,7 @@ public ResponseEntity> getAllComputationsStatus(@Pa } @GetMapping(value = "/studies/{studyUuid}/export/{studyName}", produces = "application/zip") - @Operation(summary = "Export a study as a gzip archive") + @Operation(summary = "Export a study as a zip archive") @ApiResponse(responseCode = "200", description = "The study archive as zip") @ApiResponse(responseCode = "404", description = "Study or root network not found") public ResponseEntity exportStudy(@PathVariable("studyUuid") UUID studyUuid, diff --git a/src/main/java/org/gridsuite/study/server/dto/studyexport/CaseExportInfos.java b/src/main/java/org/gridsuite/study/server/dto/studyexport/CaseExportInfos.java deleted file mode 100644 index 64d820f57e..0000000000 --- a/src/main/java/org/gridsuite/study/server/dto/studyexport/CaseExportInfos.java +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Copyright (c) 2026, RTE (http://www.rte-france.com) - * 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/. - */ -package org.gridsuite.study.server.dto.studyexport; - -import java.util.UUID; - -/** - * @author Ghazwa Rehili - */ -public record CaseExportInfos( - UUID uuid, - String name -) { -} - From 836cbfd635443967b1224591850d37ba16570cd8 Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Tue, 11 Aug 2026 14:14:21 +0200 Subject: [PATCH 10/32] fix sonar issues --- .../gridsuite/study/server/service/StudyExportService.java | 6 +++--- .../org/gridsuite/study/server/service/StudyService.java | 7 ++++--- .../study/server/studycontroller/TreeExportTest.java | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/gridsuite/study/server/service/StudyExportService.java b/src/main/java/org/gridsuite/study/server/service/StudyExportService.java index 8cf7fb97ae..2e844385d1 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyExportService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyExportService.java @@ -83,7 +83,7 @@ public InputStreamResource exportStudy(UUID studyUuid, String userId) { InputStream stream = Files.newInputStream(zipFile, StandardOpenOption.DELETE_ON_CLOSE); zipFile = null; return new InputStreamResource(stream); - } catch (IOException e) { + } catch (IOException _) { throw new StudyException(EXPORT_STUDY_ERROR, "Failed to export study: " + studyUuid); } finally { try { @@ -106,7 +106,7 @@ private Path createTempWorkDir(UUID studyUuid) { FileAttribute> attr = PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------")); return Files.createTempDirectory("study-export-" + studyUuid, attr); - } catch (IOException e) { + } catch (IOException _) { throw new StudyException(EXPORT_STUDY_ERROR, "Failed to create temp directory for study: " + studyUuid); } } @@ -116,7 +116,7 @@ private Path createTempExportFile(UUID studyUuid) { FileAttribute> attr = PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")); return Files.createTempFile("study-export-" + studyUuid, ".zip", attr); - } catch (IOException e) { + } catch (IOException _) { throw new StudyException(EXPORT_STUDY_ERROR, "Failed to create temp file for study: " + studyUuid); } } diff --git a/src/main/java/org/gridsuite/study/server/service/StudyService.java b/src/main/java/org/gridsuite/study/server/service/StudyService.java index e195dad76d..9a2ff249f6 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyService.java @@ -103,6 +103,7 @@ public class StudyService { private static final Logger LOGGER = LoggerFactory.getLogger(StudyService.class); + public static final String STUDY_NOT_FOUND = "Study not found"; private final DynamicSecurityAnalysisService dynamicSecurityAnalysisService; NotificationService notificationService; @@ -2042,7 +2043,7 @@ public void reindexRootNetwork(UUID studyUuid, UUID rootNetworkUuid) { } private StudyEntity getStudy(UUID studyUuid) { - return studyRepository.findById(studyUuid).orElseThrow(() -> new StudyException(NOT_FOUND, "Study not found")); + return studyRepository.findById(studyUuid).orElseThrow(() -> new StudyException(NOT_FOUND, STUDY_NOT_FOUND)); } @Transactional @@ -2565,7 +2566,7 @@ public boolean shouldApplyModifications(UUID studyUuid) { public UUID getFirstNetworkUuid(UUID studyUuid) { return studyRepository.findWithRootNetworksById(studyUuid) .map(study -> study.getFirstRootNetwork().getNetworkUuid()) - .orElseThrow(() -> new StudyException(NOT_FOUND, "Study not found")); + .orElseThrow(() -> new StudyException(NOT_FOUND, STUDY_NOT_FOUND)); } // --- Dynamic Mapping service methods BEGIN --- // @@ -3022,7 +3023,7 @@ public Boolean getOperationQuotaStatus() { @Transactional(readOnly = true) public TreeExportInfos exportStudy(UUID studyUuid) { - StudyEntity studyEntity = studyRepository.findById(studyUuid).orElseThrow(() -> new StudyException(NOT_FOUND, "Study not found")); + StudyEntity studyEntity = studyRepository.findById(studyUuid).orElseThrow(() -> new StudyException(NOT_FOUND, STUDY_NOT_FOUND)); List rootNetworkInfosList = rootNetworkService.getRootNetworkInfosWithLinksInfos(studyUuid); if (rootNetworkInfosList.isEmpty()) { throw new StudyException(NOT_FOUND, "No root network found for study " + studyUuid); diff --git a/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportTest.java b/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportTest.java index 971fcf608b..e5575a2bcf 100644 --- a/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportTest.java +++ b/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportTest.java @@ -42,7 +42,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -public class TreeExportTest extends StudyTestBase { +class TreeExportTest extends StudyTestBase { @Autowired private ObjectMapper objectMapper; From 83bb25b32b96a3e9141d46156d2d6354b1596420 Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Tue, 11 Aug 2026 15:14:12 +0200 Subject: [PATCH 11/32] code review Etienne L --- .../server/service/StudyExportService.java | 128 +++++++++++------- .../study/server/service/StudyService.java | 2 +- 2 files changed, 77 insertions(+), 53 deletions(-) diff --git a/src/main/java/org/gridsuite/study/server/service/StudyExportService.java b/src/main/java/org/gridsuite/study/server/service/StudyExportService.java index 2e844385d1..53d734621d 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyExportService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyExportService.java @@ -42,6 +42,8 @@ @Service public class StudyExportService { private static final Logger LOGGER = LoggerFactory.getLogger(StudyExportService.class); + public static final String TREE_JSON = "tree.json"; + public static final String CASES = "cases"; private final StudyService studyService; private final CaseService caseService; @@ -66,20 +68,7 @@ public InputStreamResource exportStudy(UUID studyUuid, String userId) { Path tempDir = createTempWorkDir(studyUuid); Path zipFile = null; try { - TreeExportInfos treeExportInfos = studyService.exportStudy(studyUuid); - Path studyJsonPath = tempDir.resolve("tree.json"); - objectMapper.writerWithDefaultPrettyPrinter().writeValue(studyJsonPath.toFile(), treeExportInfos); - Path casesDir = Files.createDirectories(tempDir.resolve("cases")); - for (RootNetworkExportInfos rootNetworkInfos : treeExportInfos.rootNetworks()) { - UUID caseUuid = rootNetworkInfos.caseInfos().getCaseUuid(); - String caseName = rootNetworkInfos.caseInfos().getCaseName(); - exportCaseFile(caseUuid, caseName, casesDir); - } - zipFile = createTempExportFile(studyUuid); - try (OutputStream fos = Files.newOutputStream(zipFile); - ZipOutputStream zipOut = new ZipOutputStream(fos)) { - writeZipEntries(tempDir, zipOut); - } + zipFile = compressStudyToZip(studyUuid, tempDir); InputStream stream = Files.newInputStream(zipFile, StandardOpenOption.DELETE_ON_CLOSE); zipFile = null; return new InputStreamResource(stream); @@ -101,26 +90,52 @@ public InputStreamResource exportStudy(UUID studyUuid, String userId) { } } - private Path createTempWorkDir(UUID studyUuid) { - try { - FileAttribute> attr = - PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------")); - return Files.createTempDirectory("study-export-" + studyUuid, attr); - } catch (IOException _) { - throw new StudyException(EXPORT_STUDY_ERROR, "Failed to create temp directory for study: " + studyUuid); + /** + * Build tree.json and the case files under tempDir, then compress them into a temp zip file + */ + private Path compressStudyToZip(UUID studyUuid, Path tempDir) throws IOException { + TreeExportInfos treeExportInfos = studyService.buildTreeExport(studyUuid); + Path studyJsonPath = tempDir.resolve(TREE_JSON); + objectMapper.writerWithDefaultPrettyPrinter().writeValue(studyJsonPath.toFile(), treeExportInfos); + Path casesDir = Files.createDirectories(tempDir.resolve(CASES)); + for (RootNetworkExportInfos rootNetworkInfos : treeExportInfos.rootNetworks()) { + UUID caseUuid = rootNetworkInfos.caseInfos().getCaseUuid(); + String caseName = rootNetworkInfos.caseInfos().getCaseName(); + exportCaseFile(caseUuid, caseName, casesDir); } + Path zipFile = createTempExportFile(studyUuid); + try (OutputStream fos = Files.newOutputStream(zipFile); + ZipOutputStream zipOut = new ZipOutputStream(fos)) { + writeZipEntries(tempDir, zipOut); + } + return zipFile; + } + + private Path createTempWorkDir(UUID studyUuid) { + FileAttribute> attr = + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------")); + return createTempPath(studyUuid, "temp directory", () -> Files.createTempDirectory("study-export-" + studyUuid, attr)); } private Path createTempExportFile(UUID studyUuid) { + FileAttribute> attr = + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")); + return createTempPath(studyUuid, "temp file", () -> Files.createTempFile("study-export-" + studyUuid, ".zip", attr)); + } + + private Path createTempPath(UUID studyUuid, String errorContext, IOSupplier creator) { try { - FileAttribute> attr = - PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")); - return Files.createTempFile("study-export-" + studyUuid, ".zip", attr); + return creator.get(); } catch (IOException _) { - throw new StudyException(EXPORT_STUDY_ERROR, "Failed to create temp file for study: " + studyUuid); + throw new StudyException(EXPORT_STUDY_ERROR, "Failed to create " + errorContext + " for study: " + studyUuid); } } + @FunctionalInterface + private interface IOSupplier { + T get() throws IOException; + } + /** * Export a case file from case-server */ @@ -152,23 +167,19 @@ private static byte[] decompressGzip(byte[] data) throws IOException { * Write directory contents to zip archive */ private void writeZipEntries(Path directory, ZipOutputStream zipOut) throws IOException { - try (Stream paths = Files.walk(directory)) { - paths.filter(Files::isRegularFile).forEach(file -> { - try { - Path relativePath = directory.relativize(file); - String entryName = relativePath.toString().replace('\\', '/'); - ZipEntry entry = new ZipEntry(entryName); - entry.setSize(Files.size(file)); - zipOut.putNextEntry(entry); - try (InputStream in = Files.newInputStream(file)) { - in.transferTo(zipOut); - } - zipOut.closeEntry(); - } catch (IOException e) { - throw new UncheckedIOException(e); + walkAndConsume(directory, null, file -> { + if (Files.isRegularFile(file)) { + Path relativePath = directory.relativize(file); + String entryName = relativePath.toString().replace('\\', '/'); + ZipEntry entry = new ZipEntry(entryName); + entry.setSize(Files.size(file)); + zipOut.putNextEntry(entry); + try (InputStream in = Files.newInputStream(file)) { + in.transferTo(zipOut); } - }); - } + zipOut.closeEntry(); + } + }); } /** @@ -176,17 +187,30 @@ private void writeZipEntries(Path directory, ZipOutputStream zipOut) throws IOEx */ private void deleteDirectory(Path directory) throws IOException { if (Files.exists(directory)) { - try (Stream paths = Files.walk(directory)) { - paths.sorted(Comparator.reverseOrder()).forEach(path -> { - try { - Files.delete(path); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - }); - } catch (UncheckedIOException e) { - throw e.getCause(); - } + walkAndConsume(directory, Comparator.reverseOrder(), Files::delete); + } + } + + /** + * Walk a directory tree and apply action to every path, translating any IOException thrown by action + * back into a checked IOException (Stream#forEach can't propagate checked exceptions on its own) + */ + private void walkAndConsume(Path directory, Comparator order, IOConsumer action) throws IOException { + try (Stream paths = Files.walk(directory)) { + (order == null ? paths : paths.sorted(order)).forEach(path -> { + try { + action.accept(path); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } catch (UncheckedIOException e) { + throw e.getCause(); } } + + @FunctionalInterface + private interface IOConsumer { + void accept(T t) throws IOException; + } } diff --git a/src/main/java/org/gridsuite/study/server/service/StudyService.java b/src/main/java/org/gridsuite/study/server/service/StudyService.java index 9a2ff249f6..480480314c 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyService.java @@ -3022,7 +3022,7 @@ public Boolean getOperationQuotaStatus() { } @Transactional(readOnly = true) - public TreeExportInfos exportStudy(UUID studyUuid) { + public TreeExportInfos buildTreeExport(UUID studyUuid) { StudyEntity studyEntity = studyRepository.findById(studyUuid).orElseThrow(() -> new StudyException(NOT_FOUND, STUDY_NOT_FOUND)); List rootNetworkInfosList = rootNetworkService.getRootNetworkInfosWithLinksInfos(studyUuid); if (rootNetworkInfosList.isEmpty()) { From 107d377adfe14fca27a07868074a3b4e697233f5 Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Tue, 11 Aug 2026 16:54:27 +0200 Subject: [PATCH 12/32] enhance TU --- .../server/studycontroller/TreeExportTest.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportTest.java b/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportTest.java index e5575a2bcf..b7e70195c4 100644 --- a/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportTest.java +++ b/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportTest.java @@ -24,6 +24,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.UUID; @@ -42,6 +43,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +/** + * @author Ghazwa Rehili + */ class TreeExportTest extends StudyTestBase { @Autowired @@ -69,12 +73,13 @@ void testExportStudy() throws Exception { assertNotNull(archiveContent); assertTrue(archiveContent.length > 0); TreeExportInfos exportInfos = null; + List zipEntryNames = new ArrayList<>(); try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(archiveContent))) { ZipEntry entry; while ((entry = zis.getNextEntry()) != null) { + zipEntryNames.add(entry.getName()); if ("tree.json".equals(entry.getName())) { exportInfos = objectMapper.readValue(zis.readAllBytes(), TreeExportInfos.class); - break; } } } @@ -89,6 +94,12 @@ void testExportStudy() throws Exception { assertEquals("ROOT", exportInfos.nodeTree().type()); assertNotNull(exportInfos.nodeTree().children()); assertEquals(1, exportInfos.nodeTree().children().size()); + // Verify the cases/ directory: one subfolder per root network case, named after its UUID, + // containing the case content file under its exported case name + UUID rootNetworkCaseUuid = exportInfos.rootNetworks().getFirst().caseInfos().getCaseUuid(); + String rootNetworkCaseName = exportInfos.rootNetworks().getFirst().caseInfos().getCaseName(); + String expectedCaseEntry = "cases/" + rootNetworkCaseUuid + "/" + rootNetworkCaseName; + assertEquals(List.of(expectedCaseEntry), zipEntryNames.stream().filter(name -> name.startsWith("cases/")).toList()); // Verify the case content download call WireMockUtilsCriteria.verifyGetRequest(wireMockServer, "/v1/cases/" + CASE_UUID, false, Map.of(), 1); wireMockStubs.directoryServer.verifyCheckPermission(List.of(studyUuid), null, PermissionType.READ, false); From ca94ad373ec3b797e3fb908fde6beb0f1d9466b2 Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Tue, 11 Aug 2026 13:51:53 +0200 Subject: [PATCH 13/32] import study --- .../server/controller/StudyController.java | 10 +++ .../StudyCreationRequestEntity.java | 11 +++- .../study/server/service/ConsumerService.java | 6 +- .../study/server/service/StudyService.java | 61 ++++++++++++++++++- .../changesets/changelog_20260811T120000Z.xml | 8 +++ .../db/changelog/db.changelog-master.yaml | 3 + 6 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 src/main/resources/db/changelog/changesets/changelog_20260811T120000Z.xml diff --git a/src/main/java/org/gridsuite/study/server/controller/StudyController.java b/src/main/java/org/gridsuite/study/server/controller/StudyController.java index 55b66ad5a8..99305f635a 100644 --- a/src/main/java/org/gridsuite/study/server/controller/StudyController.java +++ b/src/main/java/org/gridsuite/study/server/controller/StudyController.java @@ -23,6 +23,7 @@ import org.gridsuite.study.server.dto.networkexport.ExportNetworkStatus; import org.gridsuite.study.server.dto.networkexport.NodeExportInfos; import org.gridsuite.study.server.dto.sequence.NodeSequenceType; +import org.gridsuite.study.server.dto.studyexport.TreeExportInfos; import org.gridsuite.study.server.elasticsearch.EquipmentInfosService; import org.gridsuite.study.server.error.StudyException; import org.gridsuite.study.server.exception.PartialResultException; @@ -1615,4 +1616,13 @@ public ResponseEntity exportStudy(@PathVariable("studyUuid") UUID stud headers.setContentType(MediaType.parseMediaType("application/zip")); return ResponseEntity.ok().headers(headers).body(studyExportService.exportStudy(studyUuid, userId)); } + + @PostMapping(value = "/studies/import-with-case-import-action") + @Operation(summary = "Create a study and its root networks from a previously exported study archive") + @ApiResponse(responseCode = "200", description = "Study import initiated successfully") + public ResponseEntity importStudyWithCaseImportAction(@RequestBody TreeExportInfos treeExportInfos, + @RequestHeader(HEADER_USER_ID) String userId) { + studyService.importStudyWithCaseImportAction(treeExportInfos, userId); + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/org/gridsuite/study/server/repository/StudyCreationRequestEntity.java b/src/main/java/org/gridsuite/study/server/repository/StudyCreationRequestEntity.java index a1709cbf3b..dd7510f7a3 100644 --- a/src/main/java/org/gridsuite/study/server/repository/StudyCreationRequestEntity.java +++ b/src/main/java/org/gridsuite/study/server/repository/StudyCreationRequestEntity.java @@ -15,7 +15,6 @@ * @author Chamseddine Benhamed */ @NoArgsConstructor -@AllArgsConstructor @Getter @Setter @Entity @@ -28,4 +27,14 @@ public class StudyCreationRequestEntity implements BasicStudyEntity { @Column(name = "firstRootNetworkName") private String firstRootNetworkName; + + // JSON-serialized List still to create once this study creation succeeds + // (see StudyService#importStudyWithCaseImportAction / #createPendingImportedRootNetworks) + @Column(name = "pendingRootNetworksJson", columnDefinition = "CLOB") + private String pendingRootNetworksJson; + + public StudyCreationRequestEntity(UUID id, String firstRootNetworkName) { + this.id = id; + this.firstRootNetworkName = firstRootNetworkName; + } } diff --git a/src/main/java/org/gridsuite/study/server/service/ConsumerService.java b/src/main/java/org/gridsuite/study/server/service/ConsumerService.java index 3e8fdfd44f..6f1450fabb 100644 --- a/src/main/java/org/gridsuite/study/server/service/ConsumerService.java +++ b/src/main/java/org/gridsuite/study/server/service/ConsumerService.java @@ -241,8 +241,12 @@ private void handleConsumeCaseImportSucceeded(CaseImportReceiver receiver, UUID NetworkInfos networkInfos = new NetworkInfos(networkUuid, networkId); try { switch (caseImportAction) { - case STUDY_CREATION -> + case STUDY_CREATION -> { insertStudy(studyUuid, userId, networkInfos, caseInfos, importParameters, importReportUuid); + // the study now really exists: create any root network that was pending on this import + // (see StudyService#importStudyWithCaseImportAction) + studyService.createPendingImportedRootNetworks(studyUuid, userId); + } case ROOT_NETWORK_CREATION -> studyService.createRootNetwork(studyUuid, RootNetworkInfos.builder() .id(rootNetworkUuid) .caseInfos(caseInfos) diff --git a/src/main/java/org/gridsuite/study/server/service/StudyService.java b/src/main/java/org/gridsuite/study/server/service/StudyService.java index 480480314c..b58dc1d062 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyService.java @@ -7,6 +7,7 @@ package org.gridsuite.study.server.service; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.powsybl.iidm.network.ThreeSides; import com.powsybl.loadflow.LoadFlowParameters; @@ -37,7 +38,10 @@ import org.gridsuite.study.server.elasticsearch.StudyInfosService; import org.gridsuite.study.server.error.StudyException; import org.gridsuite.study.server.networkmodificationtree.dto.*; -import org.gridsuite.study.server.networkmodificationtree.entities.*; +import org.gridsuite.study.server.networkmodificationtree.entities.NetworkModificationNodeInfoEntity; +import org.gridsuite.study.server.networkmodificationtree.entities.NodeEntity; +import org.gridsuite.study.server.networkmodificationtree.entities.NodeType; +import org.gridsuite.study.server.networkmodificationtree.entities.RootNetworkNodeInfoEntity; import org.gridsuite.study.server.notification.NotificationService; import org.gridsuite.study.server.notification.dto.NetworkImpactsInfos; import org.gridsuite.study.server.repository.*; @@ -3065,4 +3069,59 @@ private NodeTreeExportInfos toNodeTreeExportInfos(AbstractNode node) { children ); } + + @Transactional + public void importStudyWithCaseImportAction(TreeExportInfos treeExportInfos, String userId) { + List orderedRootNetworks = treeExportInfos.rootNetworks().stream() + .sorted(Comparator.comparing(RootNetworkExportInfos::index)) + .toList(); + if (orderedRootNetworks.isEmpty()) { + throw new StudyException(NOT_FOUND, "No root network found in import archive"); + } + + RootNetworkExportInfos firstRootNetwork = orderedRootNetworks.getFirst(); + caseService.assertCaseExists(firstRootNetwork.caseInfos().getCaseUuid()); + createStudy(firstRootNetwork.caseInfos().getCaseUuid(), userId, treeExportInfos.studyUuid(), + firstRootNetwork.importParameters(), true, firstRootNetwork.caseInfos().getCaseFormat(), firstRootNetwork.name()); + + List pendingRootNetworks = orderedRootNetworks.stream().skip(1).toList(); + if (!pendingRootNetworks.isEmpty()) { + studyCreationRequestRepository.findById(treeExportInfos.studyUuid()) + .ifPresent(entity -> entity.setPendingRootNetworksJson(writeRootNetworksJson(pendingRootNetworks))); + } + } + + @Transactional + public void createPendingImportedRootNetworks(UUID studyUuid, String userId) { + studyCreationRequestRepository.findById(studyUuid) + .map(StudyCreationRequestEntity::getPendingRootNetworksJson) + .ifPresent(json -> readRootNetworksJson(json).forEach( + rootNetwork -> createRootNetworkRequest(studyUuid, toRootNetworkInfos(rootNetwork), userId))); + } + + private String writeRootNetworksJson(List rootNetworks) { + try { + return objectMapper.writeValueAsString(rootNetworks); + } catch (JsonProcessingException e) { + throw new UncheckedIOException(e); + } + } + + private List readRootNetworksJson(String json) { + try { + return objectMapper.readValue(json, new TypeReference<>() { }); + } catch (JsonProcessingException e) { + throw new UncheckedIOException(e); + } + } + + private RootNetworkInfos toRootNetworkInfos(RootNetworkExportInfos rootNetworkExportInfos) { + CaseInfos caseInfos = rootNetworkExportInfos.caseInfos(); + return RootNetworkInfos.builder() + .name(rootNetworkExportInfos.name()) + .tag(rootNetworkExportInfos.tag()) + .caseInfos(new CaseInfos(null, caseInfos.getCaseUuid(), caseInfos.getCaseName(), caseInfos.getCaseFormat())) + .importParameters(rootNetworkExportInfos.importParameters()) + .build(); + } } diff --git a/src/main/resources/db/changelog/changesets/changelog_20260811T120000Z.xml b/src/main/resources/db/changelog/changesets/changelog_20260811T120000Z.xml new file mode 100644 index 0000000000..bca79c45d0 --- /dev/null +++ b/src/main/resources/db/changelog/changesets/changelog_20260811T120000Z.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml index fbaffd43ab..82895b7ed7 100644 --- a/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -383,3 +383,6 @@ databaseChangeLog: - include: file: changesets/changelog_20260612T125031Z.xml relativeToChangelogFile: true + - include: + file: changesets/changelog_20260811T120000Z.xml + relativeToChangelogFile: true From a217703a05a3bbe2e7a75954540acc09599aedad Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Wed, 12 Aug 2026 14:51:17 +0200 Subject: [PATCH 14/32] simplify the studyentity creation --- .../StudyCreationRequestEntity.java | 5 - .../study/server/service/ConsumerService.java | 7 +- .../study/server/service/StudyService.java | 134 ++++++++++++++---- .../changesets/changelog_20260811T120000Z.xml | 8 -- .../db/changelog/db.changelog-master.yaml | 3 - 5 files changed, 109 insertions(+), 48 deletions(-) delete mode 100644 src/main/resources/db/changelog/changesets/changelog_20260811T120000Z.xml diff --git a/src/main/java/org/gridsuite/study/server/repository/StudyCreationRequestEntity.java b/src/main/java/org/gridsuite/study/server/repository/StudyCreationRequestEntity.java index dd7510f7a3..f6ebbd3002 100644 --- a/src/main/java/org/gridsuite/study/server/repository/StudyCreationRequestEntity.java +++ b/src/main/java/org/gridsuite/study/server/repository/StudyCreationRequestEntity.java @@ -28,11 +28,6 @@ public class StudyCreationRequestEntity implements BasicStudyEntity { @Column(name = "firstRootNetworkName") private String firstRootNetworkName; - // JSON-serialized List still to create once this study creation succeeds - // (see StudyService#importStudyWithCaseImportAction / #createPendingImportedRootNetworks) - @Column(name = "pendingRootNetworksJson", columnDefinition = "CLOB") - private String pendingRootNetworksJson; - public StudyCreationRequestEntity(UUID id, String firstRootNetworkName) { this.id = id; this.firstRootNetworkName = firstRootNetworkName; diff --git a/src/main/java/org/gridsuite/study/server/service/ConsumerService.java b/src/main/java/org/gridsuite/study/server/service/ConsumerService.java index 6f1450fabb..efe7ea9aa4 100644 --- a/src/main/java/org/gridsuite/study/server/service/ConsumerService.java +++ b/src/main/java/org/gridsuite/study/server/service/ConsumerService.java @@ -241,12 +241,7 @@ private void handleConsumeCaseImportSucceeded(CaseImportReceiver receiver, UUID NetworkInfos networkInfos = new NetworkInfos(networkUuid, networkId); try { switch (caseImportAction) { - case STUDY_CREATION -> { - insertStudy(studyUuid, userId, networkInfos, caseInfos, importParameters, importReportUuid); - // the study now really exists: create any root network that was pending on this import - // (see StudyService#importStudyWithCaseImportAction) - studyService.createPendingImportedRootNetworks(studyUuid, userId); - } + case STUDY_CREATION -> insertStudy(studyUuid, userId, networkInfos, caseInfos, importParameters, importReportUuid); case ROOT_NETWORK_CREATION -> studyService.createRootNetwork(studyUuid, RootNetworkInfos.builder() .id(rootNetworkUuid) .caseInfos(caseInfos) diff --git a/src/main/java/org/gridsuite/study/server/service/StudyService.java b/src/main/java/org/gridsuite/study/server/service/StudyService.java index b58dc1d062..b1c4d1189a 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyService.java @@ -7,7 +7,6 @@ package org.gridsuite.study.server.service; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.powsybl.iidm.network.ThreeSides; import com.powsybl.loadflow.LoadFlowParameters; @@ -39,6 +38,7 @@ import org.gridsuite.study.server.error.StudyException; import org.gridsuite.study.server.networkmodificationtree.dto.*; import org.gridsuite.study.server.networkmodificationtree.entities.NetworkModificationNodeInfoEntity; +import org.gridsuite.study.server.networkmodificationtree.entities.NetworkModificationNodeType; import org.gridsuite.study.server.networkmodificationtree.entities.NodeEntity; import org.gridsuite.study.server.networkmodificationtree.entities.NodeType; import org.gridsuite.study.server.networkmodificationtree.entities.RootNetworkNodeInfoEntity; @@ -648,12 +648,11 @@ public CreatedStudyBasicInfos insertStudy(UUID studyUuid, String userId, Network Objects.requireNonNull(importParameters); StudyEntity studyEntity = saveStudyThenCreateBasicTree(studyUuid, networkInfos, - caseInfos, computationParameterUUIDs, networkVisualizationParametersUuid, spreadsheetConfigCollectionUuid, workspacesConfigUuid, importParameters, importReportUuid); - + caseInfos, computationParameterUUIDs, networkVisualizationParametersUuid, spreadsheetConfigCollectionUuid, + workspacesConfigUuid, importParameters, importReportUuid); // Need to deal with the study creation (with a default root network ?) CreatedStudyBasicInfos createdStudyBasicInfos = toCreatedStudyBasicInfos(studyEntity); studyInfosService.add(createdStudyBasicInfos); - notificationService.emitStudyCreationFinished(studyUuid, userId); return createdStudyBasicInfos; @@ -3070,6 +3069,10 @@ private NodeTreeExportInfos toNodeTreeExportInfos(AbstractNode node) { ); } + // The node tree doesn't depend on any imported network (nodes only reference a modificationGroupUuid), so it + // is built synchronously right away. Each root network's case import, on the other hand, genuinely takes time, + // so it stays asynchronous: every root network (including the first) is attached later via the existing + // ROOT_NETWORK_CREATION flow, exactly like adding an extra root network to an already-existing study. @Transactional public void importStudyWithCaseImportAction(TreeExportInfos treeExportInfos, String userId) { List orderedRootNetworks = treeExportInfos.rootNetworks().stream() @@ -3078,43 +3081,122 @@ public void importStudyWithCaseImportAction(TreeExportInfos treeExportInfos, Str if (orderedRootNetworks.isEmpty()) { throw new StudyException(NOT_FOUND, "No root network found in import archive"); } + orderedRootNetworks.forEach(rootNetwork -> caseService.assertCaseExists(rootNetwork.caseInfos().getCaseUuid())); + + StudyEntity studyEntity = createStudyEntityWithTree(treeExportInfos.studyUuid(), userId, treeExportInfos.nodeTree()); + + orderedRootNetworks.forEach(rootNetwork -> + createRootNetworkRequest(studyEntity.getId(), toRootNetworkInfos(rootNetwork), userId)); + + notificationService.emitStudyCreationFinished(studyEntity.getId(), userId); + } - RootNetworkExportInfos firstRootNetwork = orderedRootNetworks.getFirst(); - caseService.assertCaseExists(firstRootNetwork.caseInfos().getCaseUuid()); - createStudy(firstRootNetwork.caseInfos().getCaseUuid(), userId, treeExportInfos.studyUuid(), - firstRootNetwork.importParameters(), true, firstRootNetwork.caseInfos().getCaseFormat(), firstRootNetwork.name()); + private StudyEntity createStudyEntityWithTree(UUID studyUuid, String userId, NodeTreeExportInfos nodeTree) { + UserProfileInfos userProfileInfos = getUserProfile(userId); + ComputationParameterUUIDs computationParameterUUIDs = computationParametersService.createDefaultComputationParameters(userId, userProfileInfos); + UUID networkVisualizationParametersUuid = createDefaultNetworkVisualizationParameters(userId, userProfileInfos); + UUID spreadsheetConfigCollectionUuid = createDefaultSpreadsheetConfigCollection(userId, userProfileInfos); + UUID workspacesConfigUuid = createWorkspacesConfig(userProfileInfos); + + StudyEntity studyEntity = studyRepository.save(StudyEntity.builder() + .id(studyUuid) + .loadFlowParametersUuid(computationParameterUUIDs.loadFlowParametersUuid()) + .shortCircuitParametersUuid(computationParameterUUIDs.shortCircuitParametersUuid()) + .voltageInitParametersUuid(computationParameterUUIDs.voltageInitParametersUuid()) + .securityAnalysisParametersUuid(computationParameterUUIDs.securityAnalysisParametersUuid()) + .sensitivityAnalysisParametersUuid(computationParameterUUIDs.sensitivityAnalysisParametersUuid()) + .voltageInitParameters(new StudyVoltageInitParametersEntity()) + .networkVisualizationParametersUuid(networkVisualizationParametersUuid) + .dynamicSimulationParametersUuid(computationParameterUUIDs.dynamicSimulationParametersUuid()) + .dynamicSecurityAnalysisParametersUuid(computationParameterUUIDs.dynamicSecurityAnalysisParametersUuid()) + .dynamicMarginCalculationParametersUuid(computationParameterUUIDs.dynamicMarginCalculationParametersUuid()) + .stateEstimationParametersUuid(computationParameterUUIDs.stateEstimationParametersUuid()) + .pccMinParametersUuid(computationParameterUUIDs.pccMinParametersUuid()) + .spreadsheetConfigCollectionUuid(spreadsheetConfigCollectionUuid) + .workspacesConfigUuid(workspacesConfigUuid) + .monoRoot(true) + .build()); - List pendingRootNetworks = orderedRootNetworks.stream().skip(1).toList(); - if (!pendingRootNetworks.isEmpty()) { - studyCreationRequestRepository.findById(treeExportInfos.studyUuid()) - .ifPresent(entity -> entity.setPendingRootNetworksJson(writeRootNetworksJson(pendingRootNetworks))); + UUID rootNodeUuid = networkModificationTreeService.createRoot(studyEntity).getIdNode(); + if (nodeTree != null) { + nodeTree.children().forEach(child -> createNodeRecursively(studyEntity, rootNodeUuid, child, userId)); } + + studyInfosService.add(toCreatedStudyBasicInfos(studyEntity)); + + return studyEntity; } - @Transactional - public void createPendingImportedRootNetworks(UUID studyUuid, String userId) { - studyCreationRequestRepository.findById(studyUuid) - .map(StudyCreationRequestEntity::getPendingRootNetworksJson) - .ifPresent(json -> readRootNetworksJson(json).forEach( - rootNetwork -> createRootNetworkRequest(studyUuid, toRootNetworkInfos(rootNetwork), userId))); + private UUID createDefaultNetworkVisualizationParameters(String userId, UserProfileInfos userProfileInfos) { + if (userProfileInfos != null && userProfileInfos.getNetworkVisualizationParameterId() != null) { + try { + return studyConfigService.duplicateNetworkVisualizationParameters(userProfileInfos.getNetworkVisualizationParameterId()); + } catch (Exception e) { + LOGGER.error(String.format("Could not duplicate network visualization parameters with id '%s' from user/profile '%s/%s'. Using default parameters", + userProfileInfos.getNetworkVisualizationParameterId(), userId, userProfileInfos.getName()), e); + } + } + try { + return studyConfigService.createDefaultNetworkVisualizationParameters(); + } catch (final Exception e) { + LOGGER.error("Error while creating network visualization default parameters", e); + return null; + } } - private String writeRootNetworksJson(List rootNetworks) { + private UUID createDefaultSpreadsheetConfigCollection(String userId, UserProfileInfos userProfileInfos) { + if (userProfileInfos != null && userProfileInfos.getSpreadsheetConfigCollectionId() != null) { + try { + return studyConfigService.duplicateSpreadsheetConfigCollection(userProfileInfos.getSpreadsheetConfigCollectionId()); + } catch (Exception e) { + LOGGER.error(String.format("Could not duplicate spreadsheet config collection with id '%s' from user/profile '%s/%s'. Using default spreadsheet config collection", + userProfileInfos.getSpreadsheetConfigCollectionId(), userId, userProfileInfos.getName()), e); + } + } try { - return objectMapper.writeValueAsString(rootNetworks); - } catch (JsonProcessingException e) { - throw new UncheckedIOException(e); + return studyConfigService.createDefaultSpreadsheetConfigCollection(); + } catch (final Exception e) { + LOGGER.error("Error while creating default spreadsheet config collection", e); + return null; } } - private List readRootNetworksJson(String json) { + private UUID createWorkspacesConfig(UserProfileInfos userProfileInfos) { try { - return objectMapper.readValue(json, new TypeReference<>() { }); - } catch (JsonProcessingException e) { - throw new UncheckedIOException(e); + List workspaceIds = new ArrayList<>(); + if (userProfileInfos != null && userProfileInfos.getWorkspaceId() != null) { + workspaceIds.add(userProfileInfos.getWorkspaceId()); + workspaceIds.add(null); + workspaceIds.add(null); + } + return studyConfigService.createWorkspacesConfigFromWorkspaces(workspaceIds); + } catch (final Exception e) { + LOGGER.error("Error while creating workspace collection", e); + return null; } } + private void createNodeRecursively(StudyEntity studyEntity, UUID parentNodeUuid, NodeTreeExportInfos exportNode, String userId) { + UUID newGroupUuid = null; + if (exportNode.modificationGroupUuid() != null) { + newGroupUuid = UUID.randomUUID(); + networkModificationService.duplicateModificationsGroup(exportNode.modificationGroupUuid(), newGroupUuid); + } + NetworkModificationNode newNode = networkModificationTreeService.createNode( + studyEntity, + parentNodeUuid, + NetworkModificationNode.builder() + .name(exportNode.name()) + .nodeType(NetworkModificationNodeType.valueOf(exportNode.nodeType())) + // buildStatus intentionally left at default (NOT_BUILT): + .modificationGroupUuid(newGroupUuid) + .build(), + InsertMode.CHILD, + userId + ); + exportNode.children().forEach(child -> createNodeRecursively(studyEntity, newNode.getId(), child, userId)); + } + private RootNetworkInfos toRootNetworkInfos(RootNetworkExportInfos rootNetworkExportInfos) { CaseInfos caseInfos = rootNetworkExportInfos.caseInfos(); return RootNetworkInfos.builder() diff --git a/src/main/resources/db/changelog/changesets/changelog_20260811T120000Z.xml b/src/main/resources/db/changelog/changesets/changelog_20260811T120000Z.xml deleted file mode 100644 index bca79c45d0..0000000000 --- a/src/main/resources/db/changelog/changesets/changelog_20260811T120000Z.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml index 82895b7ed7..fbaffd43ab 100644 --- a/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -383,6 +383,3 @@ databaseChangeLog: - include: file: changesets/changelog_20260612T125031Z.xml relativeToChangelogFile: true - - include: - file: changesets/changelog_20260811T120000Z.xml - relativeToChangelogFile: true From 4c79e998b52f27e207a643be47b065b1621cf7a1 Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Wed, 12 Aug 2026 17:07:23 +0200 Subject: [PATCH 15/32] add TU --- .../StudyCreationRequestEntity.java | 6 +- .../study/server/service/ConsumerService.java | 67 +---- .../study/server/service/StudyService.java | 113 ++++---- .../studycontroller/ImportStudyTest.java | 249 ++++++++++++++++++ 4 files changed, 316 insertions(+), 119 deletions(-) create mode 100644 src/test/java/org/gridsuite/study/server/studycontroller/ImportStudyTest.java diff --git a/src/main/java/org/gridsuite/study/server/repository/StudyCreationRequestEntity.java b/src/main/java/org/gridsuite/study/server/repository/StudyCreationRequestEntity.java index f6ebbd3002..a1709cbf3b 100644 --- a/src/main/java/org/gridsuite/study/server/repository/StudyCreationRequestEntity.java +++ b/src/main/java/org/gridsuite/study/server/repository/StudyCreationRequestEntity.java @@ -15,6 +15,7 @@ * @author Chamseddine Benhamed */ @NoArgsConstructor +@AllArgsConstructor @Getter @Setter @Entity @@ -27,9 +28,4 @@ public class StudyCreationRequestEntity implements BasicStudyEntity { @Column(name = "firstRootNetworkName") private String firstRootNetworkName; - - public StudyCreationRequestEntity(UUID id, String firstRootNetworkName) { - this.id = id; - this.firstRootNetworkName = firstRootNetworkName; - } } diff --git a/src/main/java/org/gridsuite/study/server/service/ConsumerService.java b/src/main/java/org/gridsuite/study/server/service/ConsumerService.java index efe7ea9aa4..bb4b763687 100644 --- a/src/main/java/org/gridsuite/study/server/service/ConsumerService.java +++ b/src/main/java/org/gridsuite/study/server/service/ConsumerService.java @@ -69,7 +69,6 @@ public class ConsumerService { private final CaseService caseService; private final LoadFlowRestService loadFlowRestService; private final NetworkModificationTreeService networkModificationTreeService; - private final StudyConfigService studyConfigService; private final RootNetworkNodeInfoService rootNetworkNodeInfoService; private final DirectoryService directoryService; private final ComputationParametersService computationParametersService; @@ -82,7 +81,6 @@ public ConsumerService(ObjectMapper objectMapper, CaseService caseService, LoadFlowRestService loadFlowRestService, NetworkModificationTreeService networkModificationTreeService, - StudyConfigService studyConfigService, RootNetworkNodeInfoService rootNetworkNodeInfoService, DirectoryService directoryService, ComputationParametersService computationParametersService, @@ -94,7 +92,6 @@ public ConsumerService(ObjectMapper objectMapper, this.caseService = caseService; this.loadFlowRestService = loadFlowRestService; this.networkModificationTreeService = networkModificationTreeService; - this.studyConfigService = studyConfigService; this.rootNetworkNodeInfoService = rootNetworkNodeInfoService; this.directoryService = directoryService; this.computationParametersService = computationParametersService; @@ -280,73 +277,15 @@ private void insertStudy(UUID studyUuid, String userId, NetworkInfos networkInfo UserProfileInfos userProfileInfos = studyService.getUserProfile(userId); ComputationParameterUUIDs computationParameterUUIDs = computationParametersService.createDefaultComputationParameters(userId, userProfileInfos); - UUID networkVisualizationParametersUuid = createDefaultNetworkVisualizationParameters(userId, userProfileInfos); - UUID spreadsheetConfigCollectionUuid = createDefaultSpreadsheetConfigCollection(userId, userProfileInfos); - UUID workspacesConfigUuid = createWorkspacesConfig(userProfileInfos); + UUID networkVisualizationParametersUuid = studyService.createDefaultNetworkVisualizationParameters(userId, userProfileInfos); + UUID spreadsheetConfigCollectionUuid = studyService.createDefaultSpreadsheetConfigCollection(userId, userProfileInfos); + UUID workspacesConfigUuid = studyService.createWorkspacesConfig(userProfileInfos); studyService.insertStudy(studyUuid, userId, networkInfos, caseInfos, computationParameterUUIDs, networkVisualizationParametersUuid, spreadsheetConfigCollectionUuid, workspacesConfigUuid, importParameters, importReportUuid); } - private UUID createDefaultNetworkVisualizationParameters(String userId, UserProfileInfos userProfileInfos) { - if (userProfileInfos != null && userProfileInfos.getNetworkVisualizationParameterId() != null) { - // try to access/duplicate the user profile network visualization parameters - try { - return studyConfigService.duplicateNetworkVisualizationParameters(userProfileInfos.getNetworkVisualizationParameterId()); - } catch (Exception e) { - // TODO try to report a log in Root subreporter ? - LOGGER.error(String.format("Could not duplicate network visualization parameters with id '%s' from user/profile '%s/%s'. Using default parameters", - userProfileInfos.getNetworkVisualizationParameterId(), userId, userProfileInfos.getName()), e); - } - } - // no profile, or no/bad network visualization parameters in profile => use default values - try { - return studyConfigService.createDefaultNetworkVisualizationParameters(); - } catch (final Exception e) { - LOGGER.error("Error while creating network visualization default parameters", e); - return null; - } - } - - private UUID createDefaultSpreadsheetConfigCollection(String userId, UserProfileInfos userProfileInfos) { - if (userProfileInfos != null && userProfileInfos.getSpreadsheetConfigCollectionId() != null) { - // try to access/duplicate the user profile spreadsheet config collection - try { - return studyConfigService.duplicateSpreadsheetConfigCollection(userProfileInfos.getSpreadsheetConfigCollectionId()); - } catch (Exception e) { - // TODO try to report a log in Root subreporter ? - LOGGER.error(String.format("Could not duplicate spreadsheet config collection with id '%s' from user/profile '%s/%s'. Using default spreadsheet config collection", - userProfileInfos.getSpreadsheetConfigCollectionId(), userId, userProfileInfos.getName()), e); - } - } - // no profile, or no/bad spreadsheet config collection in profile => use default values - try { - return studyConfigService.createDefaultSpreadsheetConfigCollection(); - } catch (final Exception e) { - LOGGER.error("Error while creating default spreadsheet config collection", e); - return null; - } - } - - @SuppressWarnings("checkstyle:LambdaBodyLength") - private UUID createWorkspacesConfig(UserProfileInfos userProfileInfos) { - try { - List workspaceIds = new ArrayList<>(); - if (userProfileInfos != null && userProfileInfos.getWorkspaceId() != null) { - // Create config with profile workspace as first, and two empty workspaces - workspaceIds.add(userProfileInfos.getWorkspaceId()); - workspaceIds.add(null); - workspaceIds.add(null); - } - // Empty list will create default config - return studyConfigService.createWorkspacesConfigFromWorkspaces(workspaceIds); - } catch (final Exception e) { - LOGGER.error("Error while creating workspace collection", e); - return null; - } - } - @Bean @SuppressWarnings("checkstyle:LambdaBodyLength") public Consumer> consumeCaseImportFailed() { diff --git a/src/main/java/org/gridsuite/study/server/service/StudyService.java b/src/main/java/org/gridsuite/study/server/service/StudyService.java index b1c4d1189a..6ce53bdad4 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyService.java @@ -648,8 +648,8 @@ public CreatedStudyBasicInfos insertStudy(UUID studyUuid, String userId, Network Objects.requireNonNull(importParameters); StudyEntity studyEntity = saveStudyThenCreateBasicTree(studyUuid, networkInfos, - caseInfos, computationParameterUUIDs, networkVisualizationParametersUuid, spreadsheetConfigCollectionUuid, - workspacesConfigUuid, importParameters, importReportUuid); + caseInfos, computationParameterUUIDs, networkVisualizationParametersUuid, spreadsheetConfigCollectionUuid, workspacesConfigUuid, importParameters, importReportUuid); + // Need to deal with the study creation (with a default root network ?) CreatedStudyBasicInfos createdStudyBasicInfos = toCreatedStudyBasicInfos(studyEntity); studyInfosService.add(createdStudyBasicInfos); @@ -3069,29 +3069,51 @@ private NodeTreeExportInfos toNodeTreeExportInfos(AbstractNode node) { ); } - // The node tree doesn't depend on any imported network (nodes only reference a modificationGroupUuid), so it - // is built synchronously right away. Each root network's case import, on the other hand, genuinely takes time, - // so it stays asynchronous: every root network (including the first) is attached later via the existing - // ROOT_NETWORK_CREATION flow, exactly like adding an extra root network to an already-existing study. - @Transactional + private NetworkModificationNodeType toNetworkModificationNodeType(String nodeType) { + if (nodeType == null) { + throw new StudyException(BAD_NODE_TYPE, "Missing node type in imported tree"); + } + try { + return NetworkModificationNodeType.valueOf(nodeType); + } catch (IllegalArgumentException e) { + throw new StudyException(BAD_NODE_TYPE, "Invalid node type in imported tree: '" + nodeType + "'"); + } + } + + private RootNetworkInfos toRootNetworkInfos(RootNetworkExportInfos rootNetworkExportInfos) { + CaseInfos caseInfos = rootNetworkExportInfos.caseInfos(); + return RootNetworkInfos.builder() + .name(rootNetworkExportInfos.name()) + .tag(rootNetworkExportInfos.tag()) + .caseInfos(new CaseInfos(null, caseInfos.getCaseUuid(), caseInfos.getCaseName(), caseInfos.getCaseFormat())) + .importParameters(rootNetworkExportInfos.importParameters()) + .build(); + } + public void importStudyWithCaseImportAction(TreeExportInfos treeExportInfos, String userId) { + if (treeExportInfos.rootNetworks().isEmpty()) { + throw new StudyException(NOT_FOUND, "No root network found in import archive"); + } List orderedRootNetworks = treeExportInfos.rootNetworks().stream() .sorted(Comparator.comparing(RootNetworkExportInfos::index)) .toList(); - if (orderedRootNetworks.isEmpty()) { - throw new StudyException(NOT_FOUND, "No root network found in import archive"); - } - orderedRootNetworks.forEach(rootNetwork -> caseService.assertCaseExists(rootNetwork.caseInfos().getCaseUuid())); - StudyEntity studyEntity = createStudyEntityWithTree(treeExportInfos.studyUuid(), userId, treeExportInfos.nodeTree()); + StudyEntity studyEntity = self.createStudyEntityWithTree(treeExportInfos.studyUuid(), userId, treeExportInfos.nodeTree()); - orderedRootNetworks.forEach(rootNetwork -> - createRootNetworkRequest(studyEntity.getId(), toRootNetworkInfos(rootNetwork), userId)); + orderedRootNetworks.forEach(rootNetwork -> { + try { + caseService.assertCaseExists(rootNetwork.caseInfos().getCaseUuid()); + self.createRootNetworkRequest(studyEntity.getId(), toRootNetworkInfos(rootNetwork), userId); + } catch (Exception e) { + LOGGER.error(String.format("Could not request root network '%s' for imported study '%s'", rootNetwork.name(), studyEntity.getId()), e); + } + }); notificationService.emitStudyCreationFinished(studyEntity.getId(), userId); } - private StudyEntity createStudyEntityWithTree(UUID studyUuid, String userId, NodeTreeExportInfos nodeTree) { + @Transactional + StudyEntity createStudyEntityWithTree(UUID studyUuid, String userId, NodeTreeExportInfos nodeTree) { UserProfileInfos userProfileInfos = getUserProfile(userId); ComputationParameterUUIDs computationParameterUUIDs = computationParametersService.createDefaultComputationParameters(userId, userProfileInfos); UUID networkVisualizationParametersUuid = createDefaultNetworkVisualizationParameters(userId, userProfileInfos); @@ -3118,7 +3140,7 @@ private StudyEntity createStudyEntityWithTree(UUID studyUuid, String userId, Nod .build()); UUID rootNodeUuid = networkModificationTreeService.createRoot(studyEntity).getIdNode(); - if (nodeTree != null) { + if (nodeTree != null && !nodeTree.children().isEmpty()) { nodeTree.children().forEach(child -> createNodeRecursively(studyEntity, rootNodeUuid, child, userId)); } @@ -3127,7 +3149,29 @@ private StudyEntity createStudyEntityWithTree(UUID studyUuid, String userId, Nod return studyEntity; } - private UUID createDefaultNetworkVisualizationParameters(String userId, UserProfileInfos userProfileInfos) { + private void createNodeRecursively(StudyEntity studyEntity, UUID parentNodeUuid, NodeTreeExportInfos exportNode, String userId) { + NetworkModificationNodeType nodeType = toNetworkModificationNodeType(exportNode.nodeType()); + UUID newGroupUuid = null; + if (exportNode.modificationGroupUuid() != null) { + newGroupUuid = UUID.randomUUID(); + networkModificationService.duplicateModificationsGroup(exportNode.modificationGroupUuid(), newGroupUuid); + } + NetworkModificationNode newNode = networkModificationTreeService.createNode( + studyEntity, + parentNodeUuid, + NetworkModificationNode.builder() + .name(exportNode.name()) + .nodeType(nodeType) + // buildStatus intentionally left by default (NOT_BUILT): + .modificationGroupUuid(newGroupUuid) + .build(), + InsertMode.CHILD, + userId + ); + CollectionUtils.emptyIfNull(exportNode.children()).forEach(child -> createNodeRecursively(studyEntity, newNode.getId(), child, userId)); + } + + UUID createDefaultNetworkVisualizationParameters(String userId, UserProfileInfos userProfileInfos) { if (userProfileInfos != null && userProfileInfos.getNetworkVisualizationParameterId() != null) { try { return studyConfigService.duplicateNetworkVisualizationParameters(userProfileInfos.getNetworkVisualizationParameterId()); @@ -3144,7 +3188,7 @@ private UUID createDefaultNetworkVisualizationParameters(String userId, UserProf } } - private UUID createDefaultSpreadsheetConfigCollection(String userId, UserProfileInfos userProfileInfos) { + UUID createDefaultSpreadsheetConfigCollection(String userId, UserProfileInfos userProfileInfos) { if (userProfileInfos != null && userProfileInfos.getSpreadsheetConfigCollectionId() != null) { try { return studyConfigService.duplicateSpreadsheetConfigCollection(userProfileInfos.getSpreadsheetConfigCollectionId()); @@ -3161,7 +3205,7 @@ private UUID createDefaultSpreadsheetConfigCollection(String userId, UserProfile } } - private UUID createWorkspacesConfig(UserProfileInfos userProfileInfos) { + UUID createWorkspacesConfig(UserProfileInfos userProfileInfos) { try { List workspaceIds = new ArrayList<>(); if (userProfileInfos != null && userProfileInfos.getWorkspaceId() != null) { @@ -3175,35 +3219,4 @@ private UUID createWorkspacesConfig(UserProfileInfos userProfileInfos) { return null; } } - - private void createNodeRecursively(StudyEntity studyEntity, UUID parentNodeUuid, NodeTreeExportInfos exportNode, String userId) { - UUID newGroupUuid = null; - if (exportNode.modificationGroupUuid() != null) { - newGroupUuid = UUID.randomUUID(); - networkModificationService.duplicateModificationsGroup(exportNode.modificationGroupUuid(), newGroupUuid); - } - NetworkModificationNode newNode = networkModificationTreeService.createNode( - studyEntity, - parentNodeUuid, - NetworkModificationNode.builder() - .name(exportNode.name()) - .nodeType(NetworkModificationNodeType.valueOf(exportNode.nodeType())) - // buildStatus intentionally left at default (NOT_BUILT): - .modificationGroupUuid(newGroupUuid) - .build(), - InsertMode.CHILD, - userId - ); - exportNode.children().forEach(child -> createNodeRecursively(studyEntity, newNode.getId(), child, userId)); - } - - private RootNetworkInfos toRootNetworkInfos(RootNetworkExportInfos rootNetworkExportInfos) { - CaseInfos caseInfos = rootNetworkExportInfos.caseInfos(); - return RootNetworkInfos.builder() - .name(rootNetworkExportInfos.name()) - .tag(rootNetworkExportInfos.tag()) - .caseInfos(new CaseInfos(null, caseInfos.getCaseUuid(), caseInfos.getCaseName(), caseInfos.getCaseFormat())) - .importParameters(rootNetworkExportInfos.importParameters()) - .build(); - } } diff --git a/src/test/java/org/gridsuite/study/server/studycontroller/ImportStudyTest.java b/src/test/java/org/gridsuite/study/server/studycontroller/ImportStudyTest.java new file mode 100644 index 0000000000..491d2a6a2e --- /dev/null +++ b/src/test/java/org/gridsuite/study/server/studycontroller/ImportStudyTest.java @@ -0,0 +1,249 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * 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/. + */ +package org.gridsuite.study.server.studycontroller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.powsybl.ws.commons.error.PowsyblWsProblemDetail; +import org.gridsuite.study.server.dto.CaseInfos; +import org.gridsuite.study.server.dto.studyexport.NodeTreeExportInfos; +import org.gridsuite.study.server.dto.studyexport.RootNetworkExportInfos; +import org.gridsuite.study.server.dto.studyexport.TreeExportInfos; +import org.gridsuite.study.server.networkmodificationtree.dto.AbstractNode; +import org.gridsuite.study.server.networkmodificationtree.dto.NetworkModificationNode; +import org.gridsuite.study.server.networkmodificationtree.dto.RootNode; +import org.gridsuite.study.server.notification.NotificationService; +import org.gridsuite.study.server.repository.rootnetwork.RootNetworkRequestRepository; +import org.gridsuite.study.server.utils.wiremock.WireMockUtilsCriteria; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.gridsuite.study.server.StudyConstants.HEADER_USER_ID; +import static org.gridsuite.study.server.error.StudyBusinessErrorCode.BAD_NODE_TYPE; +import static org.gridsuite.study.server.error.StudyBusinessErrorCode.NOT_FOUND; +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author Ghazwa Rehili + */ +class ImportStudyTest extends StudyTestBase { + + private static final String IMPORT_URL = "/v1/studies/import-with-case-import-action"; + private static final String USER_ID = "testUser"; + + @Autowired + private ObjectMapper objectMapper; + @Autowired + private RootNetworkRequestRepository rootNetworkRequestRepository; + + @Test + void testImportStudyWithCaseImportAction() throws Exception { + UUID studyUuid = UUID.randomUUID(); + UUID caseUuid1 = UUID.randomUUID(); + UUID caseUuid2 = UUID.randomUUID(); + UUID duplicatedCaseUuid1 = UUID.randomUUID(); + UUID duplicatedCaseUuid2 = UUID.randomUUID(); + UUID modificationGroupUuid1 = UUID.randomUUID(); + UUID modificationGroupUuid2 = UUID.randomUUID(); + + stubDefaultParametersCreation(); + UUID stubCaseExists1Id = wireMockStubs.caseServer.stubCaseExists(caseUuid1.toString(), true); + UUID stubCaseExists2Id = wireMockStubs.caseServer.stubCaseExists(caseUuid2.toString(), true); + wireMockStubs.caseServer.stubDuplicateCaseWithBody(caseUuid1.toString(), objectMapper.writeValueAsString(duplicatedCaseUuid1)); + wireMockStubs.caseServer.stubDuplicateCaseWithBody(caseUuid2.toString(), objectMapper.writeValueAsString(duplicatedCaseUuid2)); + stubImportNetworkOnly(); + UUID stubDuplicateModificationGroupId = wireMockStubs.stubDuplicateModificationGroup(objectMapper.writeValueAsString(Map.of())); + + NodeTreeExportInfos nodeTree = new NodeTreeExportInfos("Root", "ROOT", null, null, List.of( + new NodeTreeExportInfos("N1", "NETWORK_MODIFICATION", modificationGroupUuid1, "SECURITY", List.of( + new NodeTreeExportInfos("N2", "NETWORK_MODIFICATION", modificationGroupUuid2, "CONSTRUCTION", List.of()) + )) + )); + TreeExportInfos treeExportInfos = new TreeExportInfos(studyUuid, List.of( + rootNetworkExportInfos("rn1", "1", 0, caseUuid1), + rootNetworkExportInfos("rn2", "2", 1, caseUuid2) + ), nodeTree); + + mockMvc.perform(post(IMPORT_URL).header(HEADER_USER_ID, USER_ID) + .contentType("application/json") + .content(objectMapper.writeValueAsString(treeExportInfos))) + .andExpect(status().isOk()); + + checkRootNetworkRequestNotifications(2, studyUuid); + Message message = output.receive(TIMEOUT, studyUpdateDestination); + MessageHeaders headers = message.getHeaders(); + assertEquals(studyUuid, headers.get(NotificationService.HEADER_STUDY_UUID)); + assertEquals(NotificationService.UPDATE_TYPE_STUDY_CREATION_FINISHED, headers.get(NotificationService.HEADER_UPDATE_TYPE)); + + assertTrue(studyRepository.findById(studyUuid).isPresent()); + RootNode rootNode = networkModificationTreeService.getStudyTree(studyUuid, null); + assertNotNull(rootNode); + assertEquals(1, rootNode.getChildren().size()); + AbstractNode n1 = rootNode.getChildren().getFirst(); + assertEquals("N1", n1.getName()); + assertInstanceOf(NetworkModificationNode.class, n1); + assertEquals("SECURITY", ((NetworkModificationNode) n1).getNodeType().name()); + assertEquals(1, n1.getChildren().size()); + AbstractNode n2 = n1.getChildren().getFirst(); + assertEquals("N2", n2.getName()); + assertEquals("CONSTRUCTION", ((NetworkModificationNode) n2).getNodeType().name()); + assertNotEquals(modificationGroupUuid1, ((NetworkModificationNode) n1).getModificationGroupUuid()); + assertNotEquals(modificationGroupUuid2, ((NetworkModificationNode) n2).getModificationGroupUuid()); + wireMockStubs.verifyDuplicateModificationGroup(stubDuplicateModificationGroupId, 2); + + assertEquals(2, rootNetworkRequestRepository.countAllByStudyUuid(studyUuid)); + wireMockStubs.caseServer.verifyCaseExists(stubCaseExists1Id, caseUuid1.toString()); + wireMockStubs.caseServer.verifyCaseExists(stubCaseExists2Id, caseUuid2.toString()); + verifyDuplicateCaseRequest(caseUuid1); + verifyDuplicateCaseRequest(caseUuid2); + verifyImportNetworkRequest(duplicatedCaseUuid1); + verifyImportNetworkRequest(duplicatedCaseUuid2); + + verifyDefaultParametersCreation(); + } + + @Test + void testImportStudyWithCaseImportActionRootNetworkFailureIsResilient() throws Exception { + UUID studyUuid = UUID.randomUUID(); + UUID caseUuid1 = UUID.randomUUID(); + UUID caseUuid2 = UUID.randomUUID(); + UUID duplicatedCaseUuid1 = UUID.randomUUID(); + + stubDefaultParametersCreation(); + UUID stubCaseExists1Id = wireMockStubs.caseServer.stubCaseExists(caseUuid1.toString(), true); + UUID stubCaseExists2Id = wireMockStubs.caseServer.stubCaseExists(caseUuid2.toString(), true); + wireMockStubs.caseServer.stubDuplicateCaseWithBody(caseUuid1.toString(), objectMapper.writeValueAsString(duplicatedCaseUuid1)); + stubImportNetworkOnly(); + wireMockServer.stubFor(WireMock.post(WireMock.urlPathEqualTo("/v1/cases/" + caseUuid2 + "/duplicate")) + .withQueryParam("withExpiration", WireMock.matching(".*")) + .willReturn(WireMock.serverError())); + + NodeTreeExportInfos nodeTree = new NodeTreeExportInfos("Root", "ROOT", null, null, List.of()); + TreeExportInfos treeExportInfos = new TreeExportInfos(studyUuid, List.of( + rootNetworkExportInfos("rn1", "1", 0, caseUuid1), + rootNetworkExportInfos("rn2", "2", 1, caseUuid2) + ), nodeTree); + + mockMvc.perform(post(IMPORT_URL).header(HEADER_USER_ID, USER_ID) + .contentType("application/json") + .content(objectMapper.writeValueAsString(treeExportInfos))) + .andExpect(status().isOk()); + + checkRootNetworkRequestNotifications(1, studyUuid); + Message message = output.receive(TIMEOUT, studyUpdateDestination); + assertEquals(NotificationService.UPDATE_TYPE_STUDY_CREATION_FINISHED, message.getHeaders().get(NotificationService.HEADER_UPDATE_TYPE)); + + assertTrue(studyRepository.findById(studyUuid).isPresent()); + assertNotNull(networkModificationTreeService.getStudyTree(studyUuid, null)); + assertEquals(1, rootNetworkRequestRepository.countAllByStudyUuid(studyUuid)); + wireMockStubs.caseServer.verifyCaseExists(stubCaseExists1Id, caseUuid1.toString()); + wireMockStubs.caseServer.verifyCaseExists(stubCaseExists2Id, caseUuid2.toString()); + verifyDuplicateCaseRequest(caseUuid1); + verifyImportNetworkRequest(duplicatedCaseUuid1); + WireMockUtilsCriteria.verifyPostRequest(wireMockServer, "/v1/cases/" + caseUuid2 + "/duplicate", + Map.of("withExpiration", WireMock.matching(".*"))); + verifyDefaultParametersCreation(); + } + + @Test + void testImportStudyWithCaseImportActionInvalidNodeType() throws Exception { + UUID studyUuid = UUID.randomUUID(); + UUID caseUuid = UUID.randomUUID(); + + stubDefaultParametersCreation(); + + NodeTreeExportInfos nodeTree = new NodeTreeExportInfos("Root", "ROOT", null, null, List.of( + new NodeTreeExportInfos("N1", "NETWORK_MODIFICATION", UUID.randomUUID(), null, List.of()) + )); + TreeExportInfos treeExportInfos = new TreeExportInfos(studyUuid, List.of( + rootNetworkExportInfos("rn1", "1", 0, caseUuid) + ), nodeTree); + + MvcResult result = mockMvc.perform(post(IMPORT_URL).header(HEADER_USER_ID, USER_ID) + .contentType("application/json") + .content(objectMapper.writeValueAsString(treeExportInfos))) + .andExpect(status().isForbidden()) + .andReturn(); + PowsyblWsProblemDetail problemDetail = objectMapper.readValue(result.getResponse().getContentAsString(), PowsyblWsProblemDetail.class); + assertEquals(BAD_NODE_TYPE.value(), problemDetail.getBusinessErrorCode()); + + assertTrue(studyRepository.findById(studyUuid).isEmpty()); + assertEquals(0, rootNetworkRequestRepository.countAllByStudyUuid(studyUuid)); + wireMockServer.verify(0, WireMock.postRequestedFor(WireMock.urlPathEqualTo("/v1/cases/" + caseUuid + "/duplicate"))); + wireMockServer.verify(0, WireMock.getRequestedFor(WireMock.urlPathEqualTo("/v1/cases/" + caseUuid + "/exists"))); + verifyDefaultParametersCreation(); + } + + @Test + void testImportStudyWithCaseImportActionNoRootNetworks() throws Exception { + UUID studyUuid = UUID.randomUUID(); + TreeExportInfos treeExportInfos = new TreeExportInfos(studyUuid, List.of(), new NodeTreeExportInfos("Root", "ROOT", null, null, List.of())); + + MvcResult result = mockMvc.perform(post(IMPORT_URL).header(HEADER_USER_ID, USER_ID) + .contentType("application/json") + .content(objectMapper.writeValueAsString(treeExportInfos))) + .andExpect(status().isNotFound()) + .andReturn(); + PowsyblWsProblemDetail problemDetail = objectMapper.readValue(result.getResponse().getContentAsString(), PowsyblWsProblemDetail.class); + assertEquals(NOT_FOUND.value(), problemDetail.getBusinessErrorCode()); + + assertTrue(studyRepository.findById(studyUuid).isEmpty()); + } + + private RootNetworkExportInfos rootNetworkExportInfos(String name, String tag, int index, UUID caseUuid) { + return new RootNetworkExportInfos(name, tag, index, new CaseInfos(caseUuid, null, "caseName", "UCTE"), Map.of()); + } + + private void stubDefaultParametersCreation() throws Exception { + ReflectionTestUtils.setField(caseService, "caseServerBaseUri", wireMockServer.baseUrl()); + wireMockStubs.userAdminServer.stubGetUserProfile(USER_ID); + setupCreateParametersStubs(); + } + + private void verifyDefaultParametersCreation() { + wireMockStubs.userAdminServer.verifyGetUserProfile(USER_ID); + verifyCreateParameters(1, 9, 1, 1, 1); + } + + private void checkRootNetworkRequestNotifications(int successfulRootNetworkRequests, UUID studyUuid) { + for (int i = 0; i < successfulRootNetworkRequests; i++) { + Message rootNetworksUpdated = output.receive(TIMEOUT, studyUpdateDestination); + assertNotNull(rootNetworksUpdated); + assertEquals(studyUuid, rootNetworksUpdated.getHeaders().get(NotificationService.HEADER_STUDY_UUID)); + + Message elementUpdated = output.receive(TIMEOUT, elementUpdateDestination); + assertNotNull(elementUpdated); + assertEquals(studyUuid, elementUpdated.getHeaders().get(NotificationService.HEADER_ELEMENT_UUID)); + assertEquals(ImportStudyTest.USER_ID, elementUpdated.getHeaders().get(NotificationService.HEADER_MODIFIED_BY)); + } + } + + private void stubImportNetworkOnly() { + wireMockServer.stubFor(WireMock.post(WireMock.urlPathEqualTo("/v1/networks")) + .willReturn(WireMock.ok())); + } + + private void verifyDuplicateCaseRequest(UUID caseUuid) { + WireMockUtilsCriteria.verifyPostRequest(wireMockServer, "/v1/cases/" + caseUuid + "/duplicate", + Map.of("withExpiration", WireMock.matching(".*"))); + } + + private void verifyImportNetworkRequest(UUID caseUuid) { + WireMockUtilsCriteria.verifyPostRequest(wireMockServer, "/v1/networks", + Map.of("caseUuid", WireMock.equalTo(caseUuid.toString()), "receiver", WireMock.matching(".*"))); + } +} From ae80bc20959986a2839c40e3b521a42e0ddee6ef Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Thu, 13 Aug 2026 11:29:49 +0200 Subject: [PATCH 16/32] revert useless changes --- .../org/gridsuite/study/server/service/ConsumerService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/gridsuite/study/server/service/ConsumerService.java b/src/main/java/org/gridsuite/study/server/service/ConsumerService.java index bb4b763687..e2dd869537 100644 --- a/src/main/java/org/gridsuite/study/server/service/ConsumerService.java +++ b/src/main/java/org/gridsuite/study/server/service/ConsumerService.java @@ -238,7 +238,8 @@ private void handleConsumeCaseImportSucceeded(CaseImportReceiver receiver, UUID NetworkInfos networkInfos = new NetworkInfos(networkUuid, networkId); try { switch (caseImportAction) { - case STUDY_CREATION -> insertStudy(studyUuid, userId, networkInfos, caseInfos, importParameters, importReportUuid); + case STUDY_CREATION -> + insertStudy(studyUuid, userId, networkInfos, caseInfos, importParameters, importReportUuid); case ROOT_NETWORK_CREATION -> studyService.createRootNetwork(studyUuid, RootNetworkInfos.builder() .id(rootNetworkUuid) .caseInfos(caseInfos) From 53a1e224a2156ada09df6daa9f6d5ff2a1366685 Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Thu, 13 Aug 2026 11:30:33 +0200 Subject: [PATCH 17/32] revert useless changes --- .../org/gridsuite/study/server/service/ConsumerService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/gridsuite/study/server/service/ConsumerService.java b/src/main/java/org/gridsuite/study/server/service/ConsumerService.java index e2dd869537..a11710445f 100644 --- a/src/main/java/org/gridsuite/study/server/service/ConsumerService.java +++ b/src/main/java/org/gridsuite/study/server/service/ConsumerService.java @@ -239,7 +239,7 @@ private void handleConsumeCaseImportSucceeded(CaseImportReceiver receiver, UUID try { switch (caseImportAction) { case STUDY_CREATION -> - insertStudy(studyUuid, userId, networkInfos, caseInfos, importParameters, importReportUuid); + insertStudy(studyUuid, userId, networkInfos, caseInfos, importParameters, importReportUuid); case ROOT_NETWORK_CREATION -> studyService.createRootNetwork(studyUuid, RootNetworkInfos.builder() .id(rootNetworkUuid) .caseInfos(caseInfos) From cf2cda17623d36390124f227cc0ec586697ac98e Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Thu, 13 Aug 2026 11:31:02 +0200 Subject: [PATCH 18/32] revert useless changes --- .../org/gridsuite/study/server/service/ConsumerService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/gridsuite/study/server/service/ConsumerService.java b/src/main/java/org/gridsuite/study/server/service/ConsumerService.java index a11710445f..a55f7a4e99 100644 --- a/src/main/java/org/gridsuite/study/server/service/ConsumerService.java +++ b/src/main/java/org/gridsuite/study/server/service/ConsumerService.java @@ -239,7 +239,7 @@ private void handleConsumeCaseImportSucceeded(CaseImportReceiver receiver, UUID try { switch (caseImportAction) { case STUDY_CREATION -> - insertStudy(studyUuid, userId, networkInfos, caseInfos, importParameters, importReportUuid); + insertStudy(studyUuid, userId, networkInfos, caseInfos, importParameters, importReportUuid); case ROOT_NETWORK_CREATION -> studyService.createRootNetwork(studyUuid, RootNetworkInfos.builder() .id(rootNetworkUuid) .caseInfos(caseInfos) From 6223d03acf94056d0d1ecd03201147832fde34b7 Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Fri, 14 Aug 2026 16:33:02 +0200 Subject: [PATCH 19/32] add StudyImportService --- .../server/controller/StudyController.java | 12 +- .../dto/caseimport/CaseImportAction.java | 1 + .../study/server/repository/StudyEntity.java | 29 +++- .../server/repository/StudyRepository.java | 2 +- .../study/server/service/CaseService.java | 25 ++- .../study/server/service/ConsumerService.java | 21 ++- .../server/service/RootNetworkService.java | 4 + .../server/service/StudyExportService.java | 34 ++-- .../server/service/StudyImportService.java | 132 +++++++++++++++ .../study/server/service/StudyService.java | 61 ++----- .../changesets/changelog_20260813T120000Z.xml | 17 ++ .../db/changelog/db.changelog-master.yaml | 3 + .../studycontroller/ImportStudyTest.java | 151 ++++++++++++++++-- .../studycontroller/TreeExportTest.java | 38 +++++ 14 files changed, 436 insertions(+), 94 deletions(-) create mode 100644 src/main/java/org/gridsuite/study/server/service/StudyImportService.java create mode 100644 src/main/resources/db/changelog/changesets/changelog_20260813T120000Z.xml diff --git a/src/main/java/org/gridsuite/study/server/controller/StudyController.java b/src/main/java/org/gridsuite/study/server/controller/StudyController.java index 249ceac398..e3e8aa3dd5 100644 --- a/src/main/java/org/gridsuite/study/server/controller/StudyController.java +++ b/src/main/java/org/gridsuite/study/server/controller/StudyController.java @@ -18,6 +18,7 @@ import org.gridsuite.filter.utils.EquipmentType; import org.gridsuite.study.server.StudyApi; import org.gridsuite.study.server.dto.*; +import org.gridsuite.study.server.dto.caseimport.CaseImportAction; import org.gridsuite.study.server.dto.elasticsearch.EquipmentInfos; import org.gridsuite.study.server.dto.modification.*; import org.gridsuite.study.server.dto.networkexport.ExportNetworkStatus; @@ -63,6 +64,7 @@ public class StudyController { private final RootNetworkService rootNetworkService; private final RebuildNodeService rebuildNodeService; private final StudyExportService studyExportService; + private final StudyImportService studyImportService; public StudyController(StudyService studyService, NetworkService networkStoreService, @@ -73,7 +75,8 @@ public StudyController(StudyService studyService, RemoteServicesInspector remoteServicesInspector, RootNetworkService rootNetworkService, RebuildNodeService rebuildNodeService, - StudyExportService studyExportService) { + StudyExportService studyExportService, + StudyImportService studyImportService) { this.studyService = studyService; this.networkModificationTreeService = networkModificationTreeService; this.networkStoreService = networkStoreService; @@ -84,6 +87,7 @@ public StudyController(StudyService studyService, this.rootNetworkService = rootNetworkService; this.rebuildNodeService = rebuildNodeService; this.studyExportService = studyExportService; + this.studyImportService = studyImportService; } @InitBinder @@ -176,7 +180,7 @@ public ResponseEntity> getRootNetworks(@PathVariable public ResponseEntity createRootNetwork(@PathVariable("studyUuid") UUID studyUuid, @RequestBody RootNetworkInfos rootNetworkInfos, @RequestHeader(HEADER_USER_ID) String userId) { - return ResponseEntity.ok().body(studyService.createRootNetworkRequest(studyUuid, rootNetworkInfos, userId)); + return ResponseEntity.ok().body(studyService.createRootNetworkRequest(studyUuid, rootNetworkInfos, userId, CaseImportAction.ROOT_NETWORK_CREATION)); } @PutMapping(value = "/studies/{studyUuid}/root-networks/{rootNetworkUuid}") @@ -1617,12 +1621,12 @@ public ResponseEntity exportStudy(@PathVariable("studyUuid") UUID stud return ResponseEntity.ok().headers(headers).body(studyExportService.exportStudy(studyUuid, userId)); } - @PostMapping(value = "/studies/import-with-case-import-action") + @PostMapping(value = "/studies/import") @Operation(summary = "Create a study and its root networks from a previously exported study archive") @ApiResponse(responseCode = "200", description = "Study import initiated successfully") public ResponseEntity importStudyWithCaseImportAction(@RequestBody TreeExportInfos treeExportInfos, @RequestHeader(HEADER_USER_ID) String userId) { - studyService.importStudyWithCaseImportAction(treeExportInfos, userId); + studyImportService.importStudyWithCaseImportAction(treeExportInfos, userId); return ResponseEntity.ok().build(); } } diff --git a/src/main/java/org/gridsuite/study/server/dto/caseimport/CaseImportAction.java b/src/main/java/org/gridsuite/study/server/dto/caseimport/CaseImportAction.java index 73803bc2c3..550c66fe5c 100644 --- a/src/main/java/org/gridsuite/study/server/dto/caseimport/CaseImportAction.java +++ b/src/main/java/org/gridsuite/study/server/dto/caseimport/CaseImportAction.java @@ -11,6 +11,7 @@ public enum CaseImportAction { STUDY_CREATION("Study creation"), ROOT_NETWORK_CREATION("Root network creation"), + ROOT_NETWORK_CREATION_FOR_STUDY_IMPORT("Root network creation for study import"), NETWORK_RECREATION("Network recreation"), ROOT_NETWORK_MODIFICATION("Root network modification"); diff --git a/src/main/java/org/gridsuite/study/server/repository/StudyEntity.java b/src/main/java/org/gridsuite/study/server/repository/StudyEntity.java index af3dd02aa3..74d56b66eb 100644 --- a/src/main/java/org/gridsuite/study/server/repository/StudyEntity.java +++ b/src/main/java/org/gridsuite/study/server/repository/StudyEntity.java @@ -13,6 +13,7 @@ import org.gridsuite.study.server.repository.voltageinit.StudyVoltageInitParametersEntity; import java.util.*; +import java.util.stream.Collectors; /** * @author Abdelsalem Hedhili @@ -36,6 +37,18 @@ public class StudyEntity extends AbstractManuallyAssignedIdentifierEntity @Builder.Default private List rootNetworks = new ArrayList<>(); + /** + * Desired order of root network ids for an in-progress batch import (they can complete out of order). + * Used by {@link #addRootNetwork(RootNetworkEntity)} to place each one correctly; cleared once done. + */ + @ElementCollection + @CollectionTable(name = "StudyRootNetworkOrder", foreignKey = @ForeignKey( + name = "study_root_network_order_fk" + )) + @OrderColumn(name = "index") + @Column(name = "rootNetworkUuid") + private List rootNetworkOrder; + /** * @deprecated to remove when the data is migrated into the loadflow-server */ @@ -140,7 +153,21 @@ public RootNetworkEntity getFirstRootNetwork() { public void addRootNetwork(RootNetworkEntity rootNetworkEntity) { rootNetworkEntity.setStudy(this); rootNetworkEntity.setIndexationStatus(RootNetworkIndexationStatus.INDEXED); - rootNetworks.add(rootNetworkEntity); + rootNetworks.add(resolveInsertPosition(rootNetworkEntity.getId()), rootNetworkEntity); + } + + /** + * Position among the root networks already present, for the given target id: the count of ids that + * should come before it in {@link #rootNetworkOrder} and are already in {@link #rootNetworks}. Falls back + * to appending at the end when there is no pending import batch, or the id isn't part of one. + */ + private int resolveInsertPosition(UUID rootNetworkId) { + int targetPos = rootNetworkOrder == null ? -1 : rootNetworkOrder.indexOf(rootNetworkId); + if (targetPos < 0) { + return rootNetworks.size(); + } + Set alreadyPresent = rootNetworks.stream().map(RootNetworkEntity::getId).collect(Collectors.toSet()); + return (int) rootNetworkOrder.subList(0, targetPos).stream().filter(alreadyPresent::contains).count(); } public void deleteRootNetworks(Set uuids) { diff --git a/src/main/java/org/gridsuite/study/server/repository/StudyRepository.java b/src/main/java/org/gridsuite/study/server/repository/StudyRepository.java index b31b92a368..e22456c5c4 100644 --- a/src/main/java/org/gridsuite/study/server/repository/StudyRepository.java +++ b/src/main/java/org/gridsuite/study/server/repository/StudyRepository.java @@ -20,6 +20,6 @@ @Repository public interface StudyRepository extends JpaRepository { - @EntityGraph(attributePaths = {"rootNetworks"}, type = EntityGraph.EntityGraphType.LOAD) + @EntityGraph(attributePaths = {"rootNetworks", "rootNetworkOrder"}, type = EntityGraph.EntityGraphType.LOAD) Optional findWithRootNetworksById(UUID id); } diff --git a/src/main/java/org/gridsuite/study/server/service/CaseService.java b/src/main/java/org/gridsuite/study/server/service/CaseService.java index 03996030ea..68f4fe1ade 100644 --- a/src/main/java/org/gridsuite/study/server/service/CaseService.java +++ b/src/main/java/org/gridsuite/study/server/service/CaseService.java @@ -16,12 +16,16 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; +import org.springframework.http.client.ClientHttpResponse; import org.springframework.stereotype.Service; +import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; +import java.io.IOException; +import java.io.InputStream; import java.util.UUID; import static org.gridsuite.study.server.StudyConstants.CASE_API_VERSION; @@ -96,11 +100,26 @@ public UUID createCase(String caseKey, String contentType) { return restTemplate.exchange(caseServerBaseUri + path, HttpMethod.POST, null, UUID.class).getBody(); } - public ResponseEntity getCaseContent(UUID caseUuid) { + public void streamCaseContent(UUID caseUuid, CaseContentHandler handler) throws IOException { String path = UriComponentsBuilder.fromPath(DELIMITER + CASE_API_VERSION + "/cases/{caseUuid}") .buildAndExpand(caseUuid) .toUriString(); - return restTemplate.exchange(caseServerBaseUri + path, HttpMethod.GET, null, byte[].class); + try { + restTemplate.execute(caseServerBaseUri + path, HttpMethod.GET, null, (ClientHttpResponse response) -> { + handler.handle(response.getHeaders().getFirst(HttpHeaders.CONTENT_ENCODING), response.getBody()); + return null; + }); + } catch (ResourceAccessException e) { + if (e.getCause() instanceof IOException ioException) { + throw ioException; + } + throw e; + } + } + + @FunctionalInterface + public interface CaseContentHandler { + void handle(String contentEncoding, InputStream body) throws IOException; } } diff --git a/src/main/java/org/gridsuite/study/server/service/ConsumerService.java b/src/main/java/org/gridsuite/study/server/service/ConsumerService.java index a55f7a4e99..b2d51e35c8 100644 --- a/src/main/java/org/gridsuite/study/server/service/ConsumerService.java +++ b/src/main/java/org/gridsuite/study/server/service/ConsumerService.java @@ -66,6 +66,7 @@ public class ConsumerService { private final NotificationService notificationService; private final StudyService studyService; + private final StudyImportService studyImportService; private final CaseService caseService; private final LoadFlowRestService loadFlowRestService; private final NetworkModificationTreeService networkModificationTreeService; @@ -78,6 +79,7 @@ public class ConsumerService { public ConsumerService(ObjectMapper objectMapper, NotificationService notificationService, StudyService studyService, + StudyImportService studyImportService, CaseService caseService, LoadFlowRestService loadFlowRestService, NetworkModificationTreeService networkModificationTreeService, @@ -89,6 +91,7 @@ public ConsumerService(ObjectMapper objectMapper, this.objectMapper = objectMapper; this.notificationService = notificationService; this.studyService = studyService; + this.studyImportService = studyImportService; this.caseService = caseService; this.loadFlowRestService = loadFlowRestService; this.networkModificationTreeService = networkModificationTreeService; @@ -247,6 +250,16 @@ private void handleConsumeCaseImportSucceeded(CaseImportReceiver receiver, UUID .networkInfos(networkInfos) .importParameters(importParameters) .build()); + case ROOT_NETWORK_CREATION_FOR_STUDY_IMPORT -> { + studyService.createRootNetwork(studyUuid, RootNetworkInfos.builder() + .id(rootNetworkUuid) + .caseInfos(caseInfos) + .reportUuid(importReportUuid) + .networkInfos(networkInfos) + .importParameters(importParameters) + .build()); + studyImportService.checkFinishedStudyImport(studyUuid, userId); + } case NETWORK_RECREATION -> studyService.updateNetwork(studyUuid, rootNetworkUuid, networkInfos, userId); case ROOT_NETWORK_MODIFICATION -> studyService.modifyRootNetwork(studyUuid, RootNetworkInfos.builder() .id(rootNetworkUuid) @@ -303,12 +316,16 @@ public Consumer> consumeCaseImportFailed() { String userId = receiver.getUserId(); UUID rootNetworkUuid = receiver.getRootNetworkUuid(); - if (receiver.getCaseImportAction() == CaseImportAction.STUDY_CREATION) { + CaseImportAction caseImportAction = receiver.getCaseImportAction(); + if (caseImportAction == CaseImportAction.STUDY_CREATION) { studyService.deleteStudyIfNotCreationInProgress(studyUuid, userId); notificationService.emitStudyCreationError(studyUuid, userId, errorMessage); } else { - if (receiver.getCaseImportAction() == CaseImportAction.ROOT_NETWORK_CREATION) { + if (caseImportAction == CaseImportAction.ROOT_NETWORK_CREATION) { + studyService.deleteRootNetworkRequest(rootNetworkUuid); + } else if (caseImportAction == CaseImportAction.ROOT_NETWORK_CREATION_FOR_STUDY_IMPORT) { studyService.deleteRootNetworkRequest(rootNetworkUuid); + studyImportService.checkFinishedStudyImport(studyUuid, userId); } notificationService.emitRootNetworksUpdateFailed(studyUuid, errorMessage); } diff --git a/src/main/java/org/gridsuite/study/server/service/RootNetworkService.java b/src/main/java/org/gridsuite/study/server/service/RootNetworkService.java index f875dee49f..8e11289688 100644 --- a/src/main/java/org/gridsuite/study/server/service/RootNetworkService.java +++ b/src/main/java/org/gridsuite/study/server/service/RootNetworkService.java @@ -280,6 +280,10 @@ public List geRootNetworkRequests(UUID studyUuid) { return rootNetworkRequestRepository.findAllByStudyUuid(studyUuid); } + public int countRootNetworkRequests(UUID studyUuid) { + return rootNetworkRequestRepository.countAllByStudyUuid(studyUuid); + } + public void deleteRootNetworkRequest(RootNetworkRequestEntity rootNetworkRequestEntity) { rootNetworkRequestRepository.delete(rootNetworkRequestEntity); } diff --git a/src/main/java/org/gridsuite/study/server/service/StudyExportService.java b/src/main/java/org/gridsuite/study/server/service/StudyExportService.java index 39785ae8b6..c293fc5e5b 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyExportService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyExportService.java @@ -14,8 +14,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.io.InputStreamResource; -import org.springframework.http.HttpHeaders; -import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import java.io.*; @@ -138,30 +136,20 @@ private interface IOFunction { } /** - * Export a case file from the case-server + * Export a case file from the case-server, streaming it directly to disk + * so the whole case content is never buffered in the heap. */ private void exportCaseFile(UUID caseUuid, String caseName, Path casesDir) throws IOException { - ResponseEntity response = caseService.getCaseContent(caseUuid); - byte[] body = response.getBody(); - if (body != null) { - Path caseDir = casesDir.resolve(caseUuid.toString()); - Files.createDirectories(caseDir); - String contentEncoding = response.getHeaders().getFirst(HttpHeaders.CONTENT_ENCODING); - // plain file cases are gzip by the case-server and need to be decompressed - if ("gzip".equalsIgnoreCase(contentEncoding)) { - body = decompressGzip(body); + Path caseDir = casesDir.resolve(caseUuid.toString()); + Files.createDirectories(caseDir); + Path caseFile = caseDir.resolve(caseName); + caseService.streamCaseContent(caseUuid, (contentEncoding, body) -> { + // plain file cases are gzip'd by the case-server and need to be decompressed + try (InputStream in = "gzip".equalsIgnoreCase(contentEncoding) ? new GZIPInputStream(body) : body; + OutputStream out = Files.newOutputStream(caseFile)) { + in.transferTo(out); } - Path caseFile = caseDir.resolve(caseName); - Files.write(caseFile, body); - } - } - - private static byte[] decompressGzip(byte[] data) throws IOException { - try (GZIPInputStream gzipIn = new GZIPInputStream(new ByteArrayInputStream(data)); - ByteArrayOutputStream out = new ByteArrayOutputStream()) { - gzipIn.transferTo(out); - return out.toByteArray(); - } + }); } private void writeZipEntries(Path directory, ZipOutputStream zipOut) throws IOException { diff --git a/src/main/java/org/gridsuite/study/server/service/StudyImportService.java b/src/main/java/org/gridsuite/study/server/service/StudyImportService.java new file mode 100644 index 0000000000..d573125589 --- /dev/null +++ b/src/main/java/org/gridsuite/study/server/service/StudyImportService.java @@ -0,0 +1,132 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * 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/. + */ +package org.gridsuite.study.server.service; + +import org.apache.commons.collections4.CollectionUtils; +import org.gridsuite.study.server.dto.CaseInfos; +import org.gridsuite.study.server.dto.RootNetworkInfos; +import org.gridsuite.study.server.dto.caseimport.CaseImportAction; +import org.gridsuite.study.server.dto.studyexport.NodeTreeExportInfos; +import org.gridsuite.study.server.dto.studyexport.RootNetworkExportInfos; +import org.gridsuite.study.server.dto.studyexport.TreeExportInfos; +import org.gridsuite.study.server.error.StudyException; +import org.gridsuite.study.server.notification.NotificationService; +import org.gridsuite.study.server.repository.StudyEntity; +import org.gridsuite.study.server.repository.StudyRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.gridsuite.study.server.error.StudyBusinessErrorCode.NOT_FOUND; + +/** + * @author Ghazwa Rehili + */ +@Service +public class StudyImportService { + private static final Logger LOGGER = LoggerFactory.getLogger(StudyImportService.class); + + private final StudyService studyService; + private final StudyRepository studyRepository; + private final RootNetworkService rootNetworkService; + private final NetworkModificationService networkModificationService; + private final CaseService caseService; + private final NotificationService notificationService; + + public StudyImportService(StudyService studyService, StudyRepository studyRepository, RootNetworkService rootNetworkService, + NetworkModificationService networkModificationService, CaseService caseService, NotificationService notificationService) { + this.studyService = studyService; + this.studyRepository = studyRepository; + this.rootNetworkService = rootNetworkService; + this.networkModificationService = networkModificationService; + this.caseService = caseService; + this.notificationService = notificationService; + } + + public void importStudyWithCaseImportAction(TreeExportInfos treeExportInfos, String userId) { + if (treeExportInfos.rootNetworks().isEmpty()) { + throw new StudyException(NOT_FOUND, "No root network found in import archive"); + } + List orderedRootNetworks = treeExportInfos.rootNetworks().stream() + .sorted(Comparator.comparing(RootNetworkExportInfos::index)) + .map(this::toRootNetworkInfos) + .toList(); + + Map modificationGroupUuidMapping = duplicateModificationGroups(treeExportInfos.nodeTree()); + + StudyEntity studyEntity = studyService.createStudyEntityWithTree(treeExportInfos.studyUuid(), userId, treeExportInfos.nodeTree(), modificationGroupUuidMapping); + studyEntity.setRootNetworkOrder(orderedRootNetworks.stream().map(RootNetworkInfos::getId).toList()); + studyRepository.save(studyEntity); + + notificationService.emitStudyCreationStarted(studyEntity.getId(), userId); + orderedRootNetworks.forEach(rootNetworkInfos -> { + try { + caseService.assertCaseExists(rootNetworkInfos.getCaseInfos().getOriginalCaseUuid()); + studyService.createRootNetworkRequest(studyEntity.getId(), rootNetworkInfos, userId, CaseImportAction.ROOT_NETWORK_CREATION_FOR_STUDY_IMPORT); + } catch (Exception e) { + LOGGER.error(String.format("Could not request root network '%s' for imported study '%s'", rootNetworkInfos.getName(), studyEntity.getId()), e); + } + }); + } + + public void checkFinishedStudyImport(UUID studyUuid, String userId) { + if (rootNetworkService.countRootNetworkRequests(studyUuid) == 0) { + studyRepository.findById(studyUuid).ifPresent(studyEntity -> { + studyEntity.setRootNetworkOrder(null); + studyRepository.save(studyEntity); + }); + notificationService.emitStudyCreationFinished(studyUuid, userId); + } + } + + private RootNetworkInfos toRootNetworkInfos(RootNetworkExportInfos rootNetworkExportInfos) { + CaseInfos caseInfos = rootNetworkExportInfos.caseInfos(); + return RootNetworkInfos.builder() + .id(UUID.randomUUID()) + .name(rootNetworkExportInfos.name()) + .tag(rootNetworkExportInfos.tag()) + .caseInfos(new CaseInfos(null, caseInfos.getCaseUuid(), caseInfos.getCaseName(), caseInfos.getCaseFormat())) + .importParameters(rootNetworkExportInfos.importParameters()) + .build(); + } + + private Map duplicateModificationGroups(NodeTreeExportInfos nodeTree) { + Map modificationGroupUuidMapping = new HashMap<>(); + if (nodeTree == null) { + return modificationGroupUuidMapping; + } + try { + CollectionUtils.emptyIfNull(nodeTree.children()).forEach(child -> duplicateModificationGroupsRecursively(child, modificationGroupUuidMapping)); + } catch (Exception e) { + modificationGroupUuidMapping.values().forEach(newGroupUuid -> { + try { + networkModificationService.deleteModifications(newGroupUuid); + } catch (Exception cleanupException) { + LOGGER.error(String.format("Could not clean up orphaned modification group '%s' after import failure", newGroupUuid), cleanupException); + } + }); + throw e; + } + return modificationGroupUuidMapping; + } + + private void duplicateModificationGroupsRecursively(NodeTreeExportInfos exportNode, Map modificationGroupUuidMapping) { + studyService.toNetworkModificationNodeType(exportNode.nodeType()); + if (exportNode.modificationGroupUuid() != null) { + UUID newGroupUuid = UUID.randomUUID(); + networkModificationService.duplicateModificationsGroup(exportNode.modificationGroupUuid(), newGroupUuid); + modificationGroupUuidMapping.put(exportNode.modificationGroupUuid(), newGroupUuid); + } + CollectionUtils.emptyIfNull(exportNode.children()).forEach(child -> duplicateModificationGroupsRecursively(child, modificationGroupUuidMapping)); + } +} diff --git a/src/main/java/org/gridsuite/study/server/service/StudyService.java b/src/main/java/org/gridsuite/study/server/service/StudyService.java index d149344957..3c2b6bde14 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyService.java @@ -266,7 +266,7 @@ private static BasicStudyInfos toBasicStudyInfos(StudyCreationRequestEntity enti .build(); } - private CreatedStudyBasicInfos toCreatedStudyBasicInfos(StudyEntity entity) { + CreatedStudyBasicInfos toCreatedStudyBasicInfos(StudyEntity entity) { return CreatedStudyBasicInfos.builder() .id(entity.getId()) .build(); @@ -342,16 +342,17 @@ public void deleteRootNetworks(UUID studyUuid, List rootNetworksUuids, Str } @Transactional - public RootNetworkRequestInfos createRootNetworkRequest(UUID studyUuid, RootNetworkInfos rootNetworkInfos, String userId) { + public RootNetworkRequestInfos createRootNetworkRequest(UUID studyUuid, RootNetworkInfos rootNetworkInfos, String userId, CaseImportAction caseImportAction) { rootNetworkService.assertCanCreateRootNetwork(studyUuid, rootNetworkInfos.getName(), rootNetworkInfos.getTag()); StudyEntity studyEntity = getStudy(studyUuid); - - rootNetworkInfos.setId(UUID.randomUUID()); + if (rootNetworkInfos.getId() == null) { + rootNetworkInfos.setId(UUID.randomUUID()); + } RootNetworkRequestEntity rootNetworkCreationRequestEntity = rootNetworkService.insertCreationRequest(studyEntity.getId(), rootNetworkInfos, userId); try { UUID clonedCaseUuid = caseService.duplicateCase(rootNetworkInfos.getCaseInfos().getOriginalCaseUuid(), true); rootNetworkInfos.getCaseInfos().setCaseUuid(clonedCaseUuid); - persistNetwork(rootNetworkInfos, studyUuid, null, userId, rootNetworkInfos.getImportParameters(), CaseImportAction.ROOT_NETWORK_CREATION); + persistNetwork(rootNetworkInfos, studyUuid, null, userId, rootNetworkInfos.getImportParameters(), caseImportAction); } catch (Exception e) { rootNetworkService.deleteRootNetworkRequest(rootNetworkCreationRequestEntity); throw e; @@ -3068,51 +3069,19 @@ private NodeTreeExportInfos toNodeTreeExportInfos(AbstractNode node) { ); } - private NetworkModificationNodeType toNetworkModificationNodeType(String nodeType) { + NetworkModificationNodeType toNetworkModificationNodeType(String nodeType) { if (nodeType == null) { throw new StudyException(BAD_NODE_TYPE, "Missing node type in imported tree"); } try { return NetworkModificationNodeType.valueOf(nodeType); } catch (IllegalArgumentException e) { - throw new StudyException(BAD_NODE_TYPE, "Invalid node type in imported tree: '" + nodeType + "'"); + throw new StudyException(BAD_NODE_TYPE, e.getMessage()); } } - private RootNetworkInfos toRootNetworkInfos(RootNetworkExportInfos rootNetworkExportInfos) { - CaseInfos caseInfos = rootNetworkExportInfos.caseInfos(); - return RootNetworkInfos.builder() - .name(rootNetworkExportInfos.name()) - .tag(rootNetworkExportInfos.tag()) - .caseInfos(new CaseInfos(null, caseInfos.getCaseUuid(), caseInfos.getCaseName(), caseInfos.getCaseFormat())) - .importParameters(rootNetworkExportInfos.importParameters()) - .build(); - } - - public void importStudyWithCaseImportAction(TreeExportInfos treeExportInfos, String userId) { - if (treeExportInfos.rootNetworks().isEmpty()) { - throw new StudyException(NOT_FOUND, "No root network found in import archive"); - } - List orderedRootNetworks = treeExportInfos.rootNetworks().stream() - .sorted(Comparator.comparing(RootNetworkExportInfos::index)) - .toList(); - - StudyEntity studyEntity = self.createStudyEntityWithTree(treeExportInfos.studyUuid(), userId, treeExportInfos.nodeTree()); - - orderedRootNetworks.forEach(rootNetwork -> { - try { - caseService.assertCaseExists(rootNetwork.caseInfos().getCaseUuid()); - self.createRootNetworkRequest(studyEntity.getId(), toRootNetworkInfos(rootNetwork), userId); - } catch (Exception e) { - LOGGER.error(String.format("Could not request root network '%s' for imported study '%s'", rootNetwork.name(), studyEntity.getId()), e); - } - }); - - notificationService.emitStudyCreationFinished(studyEntity.getId(), userId); - } - @Transactional - StudyEntity createStudyEntityWithTree(UUID studyUuid, String userId, NodeTreeExportInfos nodeTree) { + public StudyEntity createStudyEntityWithTree(UUID studyUuid, String userId, NodeTreeExportInfos nodeTree, Map modificationGroupUuidMapping) { UserProfileInfos userProfileInfos = getUserProfile(userId); ComputationParameterUUIDs computationParameterUUIDs = computationParametersService.createDefaultComputationParameters(userId, userProfileInfos); UUID networkVisualizationParametersUuid = createDefaultNetworkVisualizationParameters(userId, userProfileInfos); @@ -3140,7 +3109,7 @@ StudyEntity createStudyEntityWithTree(UUID studyUuid, String userId, NodeTreeExp UUID rootNodeUuid = networkModificationTreeService.createRoot(studyEntity).getIdNode(); if (nodeTree != null && !nodeTree.children().isEmpty()) { - nodeTree.children().forEach(child -> createNodeRecursively(studyEntity, rootNodeUuid, child, userId)); + nodeTree.children().forEach(child -> createNodeRecursively(studyEntity, rootNodeUuid, child, userId, modificationGroupUuidMapping)); } studyInfosService.add(toCreatedStudyBasicInfos(studyEntity)); @@ -3148,13 +3117,9 @@ StudyEntity createStudyEntityWithTree(UUID studyUuid, String userId, NodeTreeExp return studyEntity; } - private void createNodeRecursively(StudyEntity studyEntity, UUID parentNodeUuid, NodeTreeExportInfos exportNode, String userId) { + private void createNodeRecursively(StudyEntity studyEntity, UUID parentNodeUuid, NodeTreeExportInfos exportNode, String userId, Map modificationGroupUuidMapping) { NetworkModificationNodeType nodeType = toNetworkModificationNodeType(exportNode.nodeType()); - UUID newGroupUuid = null; - if (exportNode.modificationGroupUuid() != null) { - newGroupUuid = UUID.randomUUID(); - networkModificationService.duplicateModificationsGroup(exportNode.modificationGroupUuid(), newGroupUuid); - } + UUID newGroupUuid = exportNode.modificationGroupUuid() != null ? modificationGroupUuidMapping.get(exportNode.modificationGroupUuid()) : null; NetworkModificationNode newNode = networkModificationTreeService.createNode( studyEntity, parentNodeUuid, @@ -3167,7 +3132,7 @@ private void createNodeRecursively(StudyEntity studyEntity, UUID parentNodeUuid, InsertMode.CHILD, userId ); - CollectionUtils.emptyIfNull(exportNode.children()).forEach(child -> createNodeRecursively(studyEntity, newNode.getId(), child, userId)); + CollectionUtils.emptyIfNull(exportNode.children()).forEach(child -> createNodeRecursively(studyEntity, newNode.getId(), child, userId, modificationGroupUuidMapping)); } UUID createDefaultNetworkVisualizationParameters(String userId, UserProfileInfos userProfileInfos) { diff --git a/src/main/resources/db/changelog/changesets/changelog_20260813T120000Z.xml b/src/main/resources/db/changelog/changesets/changelog_20260813T120000Z.xml new file mode 100644 index 0000000000..40193a26e1 --- /dev/null +++ b/src/main/resources/db/changelog/changesets/changelog_20260813T120000Z.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml index 5267f42f5c..969750e72b 100644 --- a/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -386,3 +386,6 @@ databaseChangeLog: - include: file: changesets/changelog_20260731T101841Z.xml relativeToChangelogFile: true + - include: + file: changesets/changelog_20260813T120000Z.xml + relativeToChangelogFile: true diff --git a/src/test/java/org/gridsuite/study/server/studycontroller/ImportStudyTest.java b/src/test/java/org/gridsuite/study/server/studycontroller/ImportStudyTest.java index 491d2a6a2e..6afc51be97 100644 --- a/src/test/java/org/gridsuite/study/server/studycontroller/ImportStudyTest.java +++ b/src/test/java/org/gridsuite/study/server/studycontroller/ImportStudyTest.java @@ -10,6 +10,8 @@ import com.github.tomakehurst.wiremock.client.WireMock; import com.powsybl.ws.commons.error.PowsyblWsProblemDetail; import org.gridsuite.study.server.dto.CaseInfos; +import org.gridsuite.study.server.dto.caseimport.CaseImportAction; +import org.gridsuite.study.server.dto.caseimport.CaseImportReceiver; import org.gridsuite.study.server.dto.studyexport.NodeTreeExportInfos; import org.gridsuite.study.server.dto.studyexport.RootNetworkExportInfos; import org.gridsuite.study.server.dto.studyexport.TreeExportInfos; @@ -17,19 +19,27 @@ import org.gridsuite.study.server.networkmodificationtree.dto.NetworkModificationNode; import org.gridsuite.study.server.networkmodificationtree.dto.RootNode; import org.gridsuite.study.server.notification.NotificationService; +import org.gridsuite.study.server.repository.StudyEntity; +import org.gridsuite.study.server.repository.rootnetwork.RootNetworkEntity; +import org.gridsuite.study.server.repository.rootnetwork.RootNetworkRequestEntity; import org.gridsuite.study.server.repository.rootnetwork.RootNetworkRequestRepository; +import org.gridsuite.study.server.service.ConsumerService; import org.gridsuite.study.server.utils.wiremock.WireMockUtilsCriteria; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.messaging.Message; -import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.support.GenericMessage; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MvcResult; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.function.Consumer; +import static org.gridsuite.study.server.StudyConstants.HEADER_IMPORT_PARAMETERS; +import static org.gridsuite.study.server.StudyConstants.HEADER_RECEIVER; import static org.gridsuite.study.server.StudyConstants.HEADER_USER_ID; import static org.gridsuite.study.server.error.StudyBusinessErrorCode.BAD_NODE_TYPE; import static org.gridsuite.study.server.error.StudyBusinessErrorCode.NOT_FOUND; @@ -42,13 +52,15 @@ */ class ImportStudyTest extends StudyTestBase { - private static final String IMPORT_URL = "/v1/studies/import-with-case-import-action"; + private static final String IMPORT_URL = "/v1/studies/import"; private static final String USER_ID = "testUser"; @Autowired private ObjectMapper objectMapper; @Autowired private RootNetworkRequestRepository rootNetworkRequestRepository; + @Autowired + private ConsumerService consumerService; @Test void testImportStudyWithCaseImportAction() throws Exception { @@ -84,10 +96,7 @@ void testImportStudyWithCaseImportAction() throws Exception { .andExpect(status().isOk()); checkRootNetworkRequestNotifications(2, studyUuid); - Message message = output.receive(TIMEOUT, studyUpdateDestination); - MessageHeaders headers = message.getHeaders(); - assertEquals(studyUuid, headers.get(NotificationService.HEADER_STUDY_UUID)); - assertEquals(NotificationService.UPDATE_TYPE_STUDY_CREATION_FINISHED, headers.get(NotificationService.HEADER_UPDATE_TYPE)); + assertNull(output.receive(TIMEOUT, studyUpdateDestination)); assertTrue(studyRepository.findById(studyUuid).isPresent()); RootNode rootNode = networkModificationTreeService.getStudyTree(studyUuid, null); @@ -116,6 +125,88 @@ void testImportStudyWithCaseImportAction() throws Exception { verifyDefaultParametersCreation(); } + @Test + void testImportStudyWithCaseImportActionAppliesExportedOrderRegardlessOfCompletionOrder() throws Exception { + UUID studyUuid = UUID.randomUUID(); + UUID caseUuid1 = UUID.randomUUID(); + UUID caseUuid2 = UUID.randomUUID(); + UUID duplicatedCaseUuid1 = UUID.randomUUID(); + UUID duplicatedCaseUuid2 = UUID.randomUUID(); + + stubDefaultParametersCreation(); + UUID stubCaseExists1Id = wireMockStubs.caseServer.stubCaseExists(caseUuid1.toString(), true); + UUID stubCaseExists2Id = wireMockStubs.caseServer.stubCaseExists(caseUuid2.toString(), true); + wireMockStubs.caseServer.stubDuplicateCaseWithBody(caseUuid1.toString(), objectMapper.writeValueAsString(duplicatedCaseUuid1)); + wireMockStubs.caseServer.stubDuplicateCaseWithBody(caseUuid2.toString(), objectMapper.writeValueAsString(duplicatedCaseUuid2)); + stubImportNetworkOnly(); + UUID stubDisableExpiration1Id = wireMockStubs.caseServer.stubDisableCaseExpiration(duplicatedCaseUuid1.toString()); + UUID stubDisableExpiration2Id = wireMockStubs.caseServer.stubDisableCaseExpiration(duplicatedCaseUuid2.toString()); + + NodeTreeExportInfos nodeTree = new NodeTreeExportInfos("Root", "ROOT", null, null, List.of()); + TreeExportInfos treeExportInfos = new TreeExportInfos(studyUuid, List.of( + rootNetworkExportInfos("rn1", "1", 0, caseUuid1), + rootNetworkExportInfos("rn2", "2", 1, caseUuid2) + ), nodeTree); + + mockMvc.perform(post(IMPORT_URL).header(HEADER_USER_ID, USER_ID) + .contentType("application/json") + .content(objectMapper.writeValueAsString(treeExportInfos))) + .andExpect(status().isOk()); + + checkRootNetworkRequestNotifications(2, studyUuid); + assertNull(output.receive(TIMEOUT, studyUpdateDestination)); + + List requests = rootNetworkRequestRepository.findAllByStudyUuid(studyUuid); + assertEquals(2, requests.size()); + RootNetworkRequestEntity request1 = requests.stream().filter(r -> "rn1".equals(r.getName())).findFirst().orElseThrow(); + RootNetworkRequestEntity request2 = requests.stream().filter(r -> "rn2".equals(r.getName())).findFirst().orElseThrow(); + List rootNetworkOrder = studyRepository.findWithRootNetworksById(studyUuid).orElseThrow().getRootNetworkOrder(); + assertEquals(List.of(request1.getId(), request2.getId()), rootNetworkOrder); + + completeRootNetworkCreation(studyUuid, request2, duplicatedCaseUuid2, caseUuid2); + Message afterFirstCompletion = output.receive(TIMEOUT, studyUpdateDestination); + assertNotNull(afterFirstCompletion); + assertNotEquals(NotificationService.UPDATE_TYPE_STUDY_CREATION_FINISHED, afterFirstCompletion.getHeaders().get(NotificationService.HEADER_UPDATE_TYPE)); + assertNull(output.receive(TIMEOUT, studyUpdateDestination)); + + completeRootNetworkCreation(studyUuid, request1, duplicatedCaseUuid1, caseUuid1); + Message afterLastCompletion = output.receive(TIMEOUT, studyUpdateDestination); + assertNotNull(afterLastCompletion); + assertNotEquals(NotificationService.UPDATE_TYPE_STUDY_CREATION_FINISHED, afterLastCompletion.getHeaders().get(NotificationService.HEADER_UPDATE_TYPE)); + Message finished = output.receive(TIMEOUT, studyUpdateDestination); + assertNotNull(finished); + assertEquals(studyUuid, finished.getHeaders().get(NotificationService.HEADER_STUDY_UUID)); + assertEquals(NotificationService.UPDATE_TYPE_STUDY_CREATION_FINISHED, finished.getHeaders().get(NotificationService.HEADER_UPDATE_TYPE)); + assertNull(output.receive(TIMEOUT, studyUpdateDestination)); + + StudyEntity studyEntity = studyRepository.findWithRootNetworksById(studyUuid).orElseThrow(); + assertEquals(List.of("rn1", "rn2"), studyEntity.getRootNetworks().stream().map(RootNetworkEntity::getName).toList()); + + wireMockStubs.caseServer.verifyCaseExists(stubCaseExists1Id, caseUuid1.toString()); + wireMockStubs.caseServer.verifyCaseExists(stubCaseExists2Id, caseUuid2.toString()); + verifyDuplicateCaseRequest(caseUuid1); + verifyDuplicateCaseRequest(caseUuid2); + verifyImportNetworkRequest(duplicatedCaseUuid1); + verifyImportNetworkRequest(duplicatedCaseUuid2); + wireMockStubs.caseServer.verifyDisableCaseExpiration(stubDisableExpiration1Id, duplicatedCaseUuid1.toString()); + wireMockStubs.caseServer.verifyDisableCaseExpiration(stubDisableExpiration2Id, duplicatedCaseUuid2.toString()); + verifyDefaultParametersCreation(); + } + + private void completeRootNetworkCreation(UUID studyUuid, RootNetworkRequestEntity request, UUID duplicatedCaseUuid, UUID originalCaseUuid) throws Exception { + Consumer> messageConsumer = consumerService.consumeCaseImportSucceeded(); + CaseImportReceiver caseImportReceiver = new CaseImportReceiver(studyUuid, request.getId(), duplicatedCaseUuid, originalCaseUuid, + UUID.randomUUID(), USER_ID, 0L, CaseImportAction.ROOT_NETWORK_CREATION_FOR_STUDY_IMPORT); + Map headers = new HashMap<>(); + headers.put("networkUuid", UUID.randomUUID().toString()); + headers.put("networkId", "networkId"); + headers.put("caseFormat", "UCTE"); + headers.put("caseName", "caseName"); + headers.put(HEADER_RECEIVER, objectMapper.writeValueAsString(caseImportReceiver)); + headers.put(HEADER_IMPORT_PARAMETERS, Map.of()); + messageConsumer.accept(new GenericMessage<>("", headers)); + } + @Test void testImportStudyWithCaseImportActionRootNetworkFailureIsResilient() throws Exception { UUID studyUuid = UUID.randomUUID(); @@ -144,8 +235,7 @@ void testImportStudyWithCaseImportActionRootNetworkFailureIsResilient() throws E .andExpect(status().isOk()); checkRootNetworkRequestNotifications(1, studyUuid); - Message message = output.receive(TIMEOUT, studyUpdateDestination); - assertEquals(NotificationService.UPDATE_TYPE_STUDY_CREATION_FINISHED, message.getHeaders().get(NotificationService.HEADER_UPDATE_TYPE)); + assertNull(output.receive(TIMEOUT, studyUpdateDestination)); assertTrue(studyRepository.findById(studyUuid).isPresent()); assertNotNull(networkModificationTreeService.getStudyTree(studyUuid, null)); @@ -160,14 +250,46 @@ void testImportStudyWithCaseImportActionRootNetworkFailureIsResilient() throws E } @Test - void testImportStudyWithCaseImportActionInvalidNodeType() throws Exception { + void testImportStudyWithCaseImportActionCleansUpOrphanedModificationGroupOnFailure() throws Exception { UUID studyUuid = UUID.randomUUID(); UUID caseUuid = UUID.randomUUID(); + UUID modificationGroupUuid1 = UUID.randomUUID(); + UUID modificationGroupUuid2 = UUID.randomUUID(); - stubDefaultParametersCreation(); + UUID stubDuplicateModificationGroupId = wireMockStubs.stubDuplicateModificationGroup(objectMapper.writeValueAsString(Map.of())); + UUID stubDeleteGroupId = wireMockStubs.stubNetworkModificationDeleteGroup(); NodeTreeExportInfos nodeTree = new NodeTreeExportInfos("Root", "ROOT", null, null, List.of( - new NodeTreeExportInfos("N1", "NETWORK_MODIFICATION", UUID.randomUUID(), null, List.of()) + new NodeTreeExportInfos("N1", "NETWORK_MODIFICATION", modificationGroupUuid1, "SECURITY", List.of( + new NodeTreeExportInfos("N2", "NETWORK_MODIFICATION", modificationGroupUuid2, null, List.of()) + )) + )); + TreeExportInfos treeExportInfos = new TreeExportInfos(studyUuid, List.of( + rootNetworkExportInfos("rn1", "1", 0, caseUuid) + ), nodeTree); + + MvcResult result = mockMvc.perform(post(IMPORT_URL).header(HEADER_USER_ID, USER_ID) + .contentType("application/json") + .content(objectMapper.writeValueAsString(treeExportInfos))) + .andExpect(status().isForbidden()) + .andReturn(); + PowsyblWsProblemDetail problemDetail = objectMapper.readValue(result.getResponse().getContentAsString(), PowsyblWsProblemDetail.class); + assertEquals(BAD_NODE_TYPE.value(), problemDetail.getBusinessErrorCode()); + wireMockStubs.verifyDuplicateModificationGroup(stubDuplicateModificationGroupId, 1); + wireMockStubs.verifyNetworkModificationDeleteGroup(stubDeleteGroupId, false); + + assertTrue(studyRepository.findById(studyUuid).isEmpty()); + assertEquals(0, rootNetworkRequestRepository.countAllByStudyUuid(studyUuid)); + } + + @Test + void testImportStudyWithCaseImportActionInvalidNodeType() throws Exception { + UUID studyUuid = UUID.randomUUID(); + UUID caseUuid = UUID.randomUUID(); + UUID modificationGroupUuid = UUID.randomUUID(); + + NodeTreeExportInfos nodeTree = new NodeTreeExportInfos("Root", "ROOT", null, null, List.of( + new NodeTreeExportInfos("N1", "NETWORK_MODIFICATION", modificationGroupUuid, null, List.of()) )); TreeExportInfos treeExportInfos = new TreeExportInfos(studyUuid, List.of( rootNetworkExportInfos("rn1", "1", 0, caseUuid) @@ -185,7 +307,8 @@ void testImportStudyWithCaseImportActionInvalidNodeType() throws Exception { assertEquals(0, rootNetworkRequestRepository.countAllByStudyUuid(studyUuid)); wireMockServer.verify(0, WireMock.postRequestedFor(WireMock.urlPathEqualTo("/v1/cases/" + caseUuid + "/duplicate"))); wireMockServer.verify(0, WireMock.getRequestedFor(WireMock.urlPathEqualTo("/v1/cases/" + caseUuid + "/exists"))); - verifyDefaultParametersCreation(); + wireMockServer.verify(0, WireMock.postRequestedFor(WireMock.urlPathMatching("/v1/groups/.*/duplicate"))); + wireMockServer.verify(0, WireMock.getRequestedFor(WireMock.urlPathEqualTo("/v1/users/" + USER_ID + "/profile"))); } @Test @@ -220,6 +343,10 @@ private void verifyDefaultParametersCreation() { } private void checkRootNetworkRequestNotifications(int successfulRootNetworkRequests, UUID studyUuid) { + Message studyCreationStarted = output.receive(TIMEOUT, studyUpdateDestination); + assertNotNull(studyCreationStarted); + assertEquals(studyUuid, studyCreationStarted.getHeaders().get(NotificationService.HEADER_STUDY_UUID)); + assertEquals(NotificationService.UPDATE_TYPE_STUDY_CREATION_STARTED, studyCreationStarted.getHeaders().get(NotificationService.HEADER_UPDATE_TYPE)); for (int i = 0; i < successfulRootNetworkRequests; i++) { Message rootNetworksUpdated = output.receive(TIMEOUT, studyUpdateDestination); assertNotNull(rootNetworksUpdated); diff --git a/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportTest.java b/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportTest.java index b7e70195c4..1a1b2191ef 100644 --- a/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportTest.java +++ b/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportTest.java @@ -20,7 +20,9 @@ import org.springframework.test.web.servlet.MvcResult; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; @@ -29,6 +31,7 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.atomic.AtomicReference; +import java.util.zip.GZIPOutputStream; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @@ -105,6 +108,41 @@ void testExportStudy() throws Exception { wireMockStubs.directoryServer.verifyCheckPermission(List.of(studyUuid), null, PermissionType.READ, false); } + @Test + void testExportStudyDecompressesGzipCaseContent() throws Exception { + UUID studyUuid = createStudyWithStubs("testUser", CASE_UUID); + ReflectionTestUtils.setField(caseService, "caseServerBaseUri", wireMockServer.baseUrl()); + wireMockStubs.directoryServer.stubCheckPermission(List.of(studyUuid), null, "testUser", PermissionType.READ, false, HttpStatus.OK.value()); + String caseContent = "dummy case content"; + ByteArrayOutputStream gzipped = new ByteArrayOutputStream(); + try (GZIPOutputStream gzipOut = new GZIPOutputStream(gzipped)) { + gzipOut.write(caseContent.getBytes(StandardCharsets.UTF_8)); + } + wireMockServer.stubFor(WireMock.get(WireMock.urlPathEqualTo("/v1/cases/" + CASE_UUID)) + .willReturn(WireMock.aResponse().withStatus(200) + .withHeader("Content-Type", "application/octet-stream") + .withHeader("Content-Encoding", "gzip") + .withBody(gzipped.toByteArray()))); + + MvcResult result = mockMvc.perform(get("/v1/studies/{studyUuid}/export/{studyName}", studyUuid, "studyName").header(HEADER_USER_ID, "testUser")) + .andExpect(status().isOk()) + .andReturn(); + + byte[] archiveContent = result.getResponse().getContentAsByteArray(); + String extractedCaseContent = null; + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(archiveContent))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (entry.getName().startsWith("cases/")) { + extractedCaseContent = new String(zis.readAllBytes(), StandardCharsets.UTF_8); + } + } + } + assertEquals(caseContent, extractedCaseContent); + WireMockUtilsCriteria.verifyGetRequest(wireMockServer, "/v1/cases/" + CASE_UUID, false, Map.of(), 1); + wireMockStubs.directoryServer.verifyCheckPermission(List.of(studyUuid), null, PermissionType.READ, false); + } + @Test void testExportStudyFailNoPermission() throws Exception { UUID studyUuid = createStudyWithStubs("testUser", CASE_UUID); From 67b9307140455149775a36b364d868eaedc5a4d7 Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Fri, 14 Aug 2026 17:50:10 +0200 Subject: [PATCH 20/32] code review rabbit --- .../server/controller/StudyController.java | 1 + .../RootNetworkRequestRepository.java | 3 +++ .../study/server/service/ConsumerService.java | 23 +++++++++++-------- .../server/service/RootNetworkService.java | 4 ++-- .../server/service/StudyExportService.java | 8 +++---- .../server/service/StudyImportService.java | 12 +++++++--- 6 files changed, 33 insertions(+), 18 deletions(-) diff --git a/src/main/java/org/gridsuite/study/server/controller/StudyController.java b/src/main/java/org/gridsuite/study/server/controller/StudyController.java index e3e8aa3dd5..b951e50c21 100644 --- a/src/main/java/org/gridsuite/study/server/controller/StudyController.java +++ b/src/main/java/org/gridsuite/study/server/controller/StudyController.java @@ -180,6 +180,7 @@ public ResponseEntity> getRootNetworks(@PathVariable public ResponseEntity createRootNetwork(@PathVariable("studyUuid") UUID studyUuid, @RequestBody RootNetworkInfos rootNetworkInfos, @RequestHeader(HEADER_USER_ID) String userId) { + rootNetworkInfos.setId(null); return ResponseEntity.ok().body(studyService.createRootNetworkRequest(studyUuid, rootNetworkInfos, userId, CaseImportAction.ROOT_NETWORK_CREATION)); } diff --git a/src/main/java/org/gridsuite/study/server/repository/rootnetwork/RootNetworkRequestRepository.java b/src/main/java/org/gridsuite/study/server/repository/rootnetwork/RootNetworkRequestRepository.java index c4cb8e810f..aa5be60a86 100644 --- a/src/main/java/org/gridsuite/study/server/repository/rootnetwork/RootNetworkRequestRepository.java +++ b/src/main/java/org/gridsuite/study/server/repository/rootnetwork/RootNetworkRequestRepository.java @@ -6,6 +6,7 @@ */ package org.gridsuite.study.server.repository.rootnetwork; +import org.gridsuite.study.server.dto.RootNetworkAction; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; @@ -17,6 +18,8 @@ public interface RootNetworkRequestRepository extends JpaRepository findByNameAndStudyUuid(String name, UUID studyUuid); Optional findByTagAndStudyUuid(String tag, UUID studyUuid); diff --git a/src/main/java/org/gridsuite/study/server/service/ConsumerService.java b/src/main/java/org/gridsuite/study/server/service/ConsumerService.java index b2d51e35c8..6a3bf0a640 100644 --- a/src/main/java/org/gridsuite/study/server/service/ConsumerService.java +++ b/src/main/java/org/gridsuite/study/server/service/ConsumerService.java @@ -251,14 +251,18 @@ private void handleConsumeCaseImportSucceeded(CaseImportReceiver receiver, UUID .importParameters(importParameters) .build()); case ROOT_NETWORK_CREATION_FOR_STUDY_IMPORT -> { - studyService.createRootNetwork(studyUuid, RootNetworkInfos.builder() - .id(rootNetworkUuid) - .caseInfos(caseInfos) - .reportUuid(importReportUuid) - .networkInfos(networkInfos) - .importParameters(importParameters) - .build()); - studyImportService.checkFinishedStudyImport(studyUuid, userId); + try { + studyService.createRootNetwork(studyUuid, RootNetworkInfos.builder() + .id(rootNetworkUuid) + .caseInfos(caseInfos) + .reportUuid(importReportUuid) + .networkInfos(networkInfos) + .importParameters(importParameters) + .build()); + } finally { + studyService.deleteRootNetworkRequest(rootNetworkUuid); + studyImportService.checkFinishedStudyImport(studyUuid, userId); + } } case NETWORK_RECREATION -> studyService.updateNetwork(studyUuid, rootNetworkUuid, networkInfos, userId); case ROOT_NETWORK_MODIFICATION -> studyService.modifyRootNetwork(studyUuid, RootNetworkInfos.builder() @@ -326,8 +330,9 @@ public Consumer> consumeCaseImportFailed() { } else if (caseImportAction == CaseImportAction.ROOT_NETWORK_CREATION_FOR_STUDY_IMPORT) { studyService.deleteRootNetworkRequest(rootNetworkUuid); studyImportService.checkFinishedStudyImport(studyUuid, userId); + } else { + notificationService.emitRootNetworksUpdateFailed(studyUuid, errorMessage); } - notificationService.emitRootNetworksUpdateFailed(studyUuid, errorMessage); } } catch (Exception e) { LOGGER.error(e.toString(), e); diff --git a/src/main/java/org/gridsuite/study/server/service/RootNetworkService.java b/src/main/java/org/gridsuite/study/server/service/RootNetworkService.java index 8e11289688..2bfac8e631 100644 --- a/src/main/java/org/gridsuite/study/server/service/RootNetworkService.java +++ b/src/main/java/org/gridsuite/study/server/service/RootNetworkService.java @@ -280,8 +280,8 @@ public List geRootNetworkRequests(UUID studyUuid) { return rootNetworkRequestRepository.findAllByStudyUuid(studyUuid); } - public int countRootNetworkRequests(UUID studyUuid) { - return rootNetworkRequestRepository.countAllByStudyUuid(studyUuid); + public int countRootNetworkCreationRequests(UUID studyUuid) { + return rootNetworkRequestRepository.countAllByStudyUuidAndActionRequest(studyUuid, RootNetworkAction.ROOT_NETWORK_CREATION); } public void deleteRootNetworkRequest(RootNetworkRequestEntity rootNetworkRequestEntity) { diff --git a/src/main/java/org/gridsuite/study/server/service/StudyExportService.java b/src/main/java/org/gridsuite/study/server/service/StudyExportService.java index c293fc5e5b..13011be7d0 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyExportService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyExportService.java @@ -70,8 +70,8 @@ public InputStreamResource exportStudy(UUID studyUuid, String userId) { InputStream stream = Files.newInputStream(zipFile, StandardOpenOption.DELETE_ON_CLOSE); zipFile = null; return new InputStreamResource(stream); - } catch (IOException _) { - throw new StudyException(EXPORT_STUDY_ERROR, "Failed to export study: " + studyUuid); + } catch (IOException e) { + throw new StudyException(EXPORT_STUDY_ERROR, e.getMessage()); } finally { try { deleteDirectory(tempDir); @@ -125,8 +125,8 @@ private Path createTempPath(UUID studyUuid, String errorContext, String permissi PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString(permissions)); try { return creator.apply(attr); - } catch (IOException _) { - throw new StudyException(EXPORT_STUDY_ERROR, "Failed to create " + errorContext + " for study: " + studyUuid); + } catch (IOException e) { + throw new StudyException(EXPORT_STUDY_ERROR, e.getMessage()); } } diff --git a/src/main/java/org/gridsuite/study/server/service/StudyImportService.java b/src/main/java/org/gridsuite/study/server/service/StudyImportService.java index d573125589..b1b60a2acf 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyImportService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyImportService.java @@ -69,18 +69,24 @@ public void importStudyWithCaseImportAction(TreeExportInfos treeExportInfos, Str studyRepository.save(studyEntity); notificationService.emitStudyCreationStarted(studyEntity.getId(), userId); - orderedRootNetworks.forEach(rootNetworkInfos -> { + int successfulRequests = 0; + for (RootNetworkInfos rootNetworkInfos : orderedRootNetworks) { try { caseService.assertCaseExists(rootNetworkInfos.getCaseInfos().getOriginalCaseUuid()); studyService.createRootNetworkRequest(studyEntity.getId(), rootNetworkInfos, userId, CaseImportAction.ROOT_NETWORK_CREATION_FOR_STUDY_IMPORT); + successfulRequests++; } catch (Exception e) { LOGGER.error(String.format("Could not request root network '%s' for imported study '%s'", rootNetworkInfos.getName(), studyEntity.getId()), e); } - }); + } + if (successfulRequests == 0) { + studyService.deleteStudyIfNotCreationInProgress(studyEntity.getId(), userId); + notificationService.emitStudyCreationError(studyEntity.getId(), userId, "Could not request any root network for imported study"); + } } public void checkFinishedStudyImport(UUID studyUuid, String userId) { - if (rootNetworkService.countRootNetworkRequests(studyUuid) == 0) { + if (rootNetworkService.countRootNetworkCreationRequests(studyUuid) == 0) { studyRepository.findById(studyUuid).ifPresent(studyEntity -> { studyEntity.setRootNetworkOrder(null); studyRepository.save(studyEntity); From f5046933dd63faa415b8becea2a1458fd7cb1743 Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Mon, 17 Aug 2026 13:41:31 +0200 Subject: [PATCH 21/32] enhance comments --- .../study/server/repository/StudyEntity.java | 7 ++----- .../study/server/service/ConsumerService.java | 16 +++++++--------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/gridsuite/study/server/repository/StudyEntity.java b/src/main/java/org/gridsuite/study/server/repository/StudyEntity.java index 74d56b66eb..3d7ebdec5f 100644 --- a/src/main/java/org/gridsuite/study/server/repository/StudyEntity.java +++ b/src/main/java/org/gridsuite/study/server/repository/StudyEntity.java @@ -38,8 +38,7 @@ public class StudyEntity extends AbstractManuallyAssignedIdentifierEntity private List rootNetworks = new ArrayList<>(); /** - * Desired order of root network ids for an in-progress batch import (they can complete out of order). - * Used by {@link #addRootNetwork(RootNetworkEntity)} to place each one correctly; cleared once done. + * Root network order to restore during an in-progress study import; null otherwise. */ @ElementCollection @CollectionTable(name = "StudyRootNetworkOrder", foreignKey = @ForeignKey( @@ -157,9 +156,7 @@ public void addRootNetwork(RootNetworkEntity rootNetworkEntity) { } /** - * Position among the root networks already present, for the given target id: the count of ids that - * should come before it in {@link #rootNetworkOrder} and are already in {@link #rootNetworks}. Falls back - * to appending at the end when there is no pending import batch, or the id isn't part of one. + * Insert index for rootNetworkId based on prior ordered networks, append outside pending import */ private int resolveInsertPosition(UUID rootNetworkId) { int targetPos = rootNetworkOrder == null ? -1 : rootNetworkOrder.indexOf(rootNetworkId); diff --git a/src/main/java/org/gridsuite/study/server/service/ConsumerService.java b/src/main/java/org/gridsuite/study/server/service/ConsumerService.java index 6a3bf0a640..d60bf8fa00 100644 --- a/src/main/java/org/gridsuite/study/server/service/ConsumerService.java +++ b/src/main/java/org/gridsuite/study/server/service/ConsumerService.java @@ -324,15 +324,13 @@ public Consumer> consumeCaseImportFailed() { if (caseImportAction == CaseImportAction.STUDY_CREATION) { studyService.deleteStudyIfNotCreationInProgress(studyUuid, userId); notificationService.emitStudyCreationError(studyUuid, userId, errorMessage); - } else { - if (caseImportAction == CaseImportAction.ROOT_NETWORK_CREATION) { - studyService.deleteRootNetworkRequest(rootNetworkUuid); - } else if (caseImportAction == CaseImportAction.ROOT_NETWORK_CREATION_FOR_STUDY_IMPORT) { - studyService.deleteRootNetworkRequest(rootNetworkUuid); - studyImportService.checkFinishedStudyImport(studyUuid, userId); - } else { - notificationService.emitRootNetworksUpdateFailed(studyUuid, errorMessage); - } + } else if (caseImportAction == CaseImportAction.ROOT_NETWORK_CREATION) { + studyService.deleteRootNetworkRequest(rootNetworkUuid); + notificationService.emitRootNetworksUpdateFailed(studyUuid, errorMessage); + } else if (caseImportAction == CaseImportAction.ROOT_NETWORK_CREATION_FOR_STUDY_IMPORT) { + studyService.deleteRootNetworkRequest(rootNetworkUuid); + studyImportService.checkFinishedStudyImport(studyUuid, userId); + notificationService.emitRootNetworksUpdateFailed(studyUuid, errorMessage); } } catch (Exception e) { LOGGER.error(e.toString(), e); From 77429357f8eaf2f7532f302c2d7c8076668d6db2 Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Mon, 17 Aug 2026 13:53:36 +0200 Subject: [PATCH 22/32] revert --- .../study/server/service/ConsumerService.java | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/gridsuite/study/server/service/ConsumerService.java b/src/main/java/org/gridsuite/study/server/service/ConsumerService.java index d60bf8fa00..61fcd685da 100644 --- a/src/main/java/org/gridsuite/study/server/service/ConsumerService.java +++ b/src/main/java/org/gridsuite/study/server/service/ConsumerService.java @@ -324,12 +324,13 @@ public Consumer> consumeCaseImportFailed() { if (caseImportAction == CaseImportAction.STUDY_CREATION) { studyService.deleteStudyIfNotCreationInProgress(studyUuid, userId); notificationService.emitStudyCreationError(studyUuid, userId, errorMessage); - } else if (caseImportAction == CaseImportAction.ROOT_NETWORK_CREATION) { - studyService.deleteRootNetworkRequest(rootNetworkUuid); - notificationService.emitRootNetworksUpdateFailed(studyUuid, errorMessage); - } else if (caseImportAction == CaseImportAction.ROOT_NETWORK_CREATION_FOR_STUDY_IMPORT) { - studyService.deleteRootNetworkRequest(rootNetworkUuid); - studyImportService.checkFinishedStudyImport(studyUuid, userId); + } else { + if (receiver.getCaseImportAction() == CaseImportAction.ROOT_NETWORK_CREATION) { + studyService.deleteRootNetworkRequest(rootNetworkUuid); + } else if (caseImportAction == CaseImportAction.ROOT_NETWORK_CREATION_FOR_STUDY_IMPORT) { + studyService.deleteRootNetworkRequest(rootNetworkUuid); + studyImportService.checkFinishedStudyImport(studyUuid, userId); + } notificationService.emitRootNetworksUpdateFailed(studyUuid, errorMessage); } } catch (Exception e) { From f613fb4f2ef34321c966ad0478e8e789c259b273 Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Mon, 17 Aug 2026 14:25:37 +0200 Subject: [PATCH 23/32] renaming fix --- .../study/server/controller/StudyController.java | 6 +++--- .../study/server/service/StudyImportService.java | 2 +- .../server/studycontroller/ImportStudyTest.java | 12 ++++++------ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/gridsuite/study/server/controller/StudyController.java b/src/main/java/org/gridsuite/study/server/controller/StudyController.java index b951e50c21..6cfc0a041c 100644 --- a/src/main/java/org/gridsuite/study/server/controller/StudyController.java +++ b/src/main/java/org/gridsuite/study/server/controller/StudyController.java @@ -1625,9 +1625,9 @@ public ResponseEntity exportStudy(@PathVariable("studyUuid") UUID stud @PostMapping(value = "/studies/import") @Operation(summary = "Create a study and its root networks from a previously exported study archive") @ApiResponse(responseCode = "200", description = "Study import initiated successfully") - public ResponseEntity importStudyWithCaseImportAction(@RequestBody TreeExportInfos treeExportInfos, - @RequestHeader(HEADER_USER_ID) String userId) { - studyImportService.importStudyWithCaseImportAction(treeExportInfos, userId); + public ResponseEntity importStudy(@RequestBody TreeExportInfos treeExportInfos, + @RequestHeader(HEADER_USER_ID) String userId) { + studyImportService.importStudy(treeExportInfos, userId); return ResponseEntity.ok().build(); } } diff --git a/src/main/java/org/gridsuite/study/server/service/StudyImportService.java b/src/main/java/org/gridsuite/study/server/service/StudyImportService.java index b1b60a2acf..9c82b3710f 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyImportService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyImportService.java @@ -53,7 +53,7 @@ public StudyImportService(StudyService studyService, StudyRepository studyReposi this.notificationService = notificationService; } - public void importStudyWithCaseImportAction(TreeExportInfos treeExportInfos, String userId) { + public void importStudy(TreeExportInfos treeExportInfos, String userId) { if (treeExportInfos.rootNetworks().isEmpty()) { throw new StudyException(NOT_FOUND, "No root network found in import archive"); } diff --git a/src/test/java/org/gridsuite/study/server/studycontroller/ImportStudyTest.java b/src/test/java/org/gridsuite/study/server/studycontroller/ImportStudyTest.java index 6afc51be97..3a16ab8459 100644 --- a/src/test/java/org/gridsuite/study/server/studycontroller/ImportStudyTest.java +++ b/src/test/java/org/gridsuite/study/server/studycontroller/ImportStudyTest.java @@ -63,7 +63,7 @@ class ImportStudyTest extends StudyTestBase { private ConsumerService consumerService; @Test - void testImportStudyWithCaseImportAction() throws Exception { + void testImportStudy() throws Exception { UUID studyUuid = UUID.randomUUID(); UUID caseUuid1 = UUID.randomUUID(); UUID caseUuid2 = UUID.randomUUID(); @@ -126,7 +126,7 @@ void testImportStudyWithCaseImportAction() throws Exception { } @Test - void testImportStudyWithCaseImportActionAppliesExportedOrderRegardlessOfCompletionOrder() throws Exception { + void testImportStudyWithExportedOrder() throws Exception { UUID studyUuid = UUID.randomUUID(); UUID caseUuid1 = UUID.randomUUID(); UUID caseUuid2 = UUID.randomUUID(); @@ -208,7 +208,7 @@ private void completeRootNetworkCreation(UUID studyUuid, RootNetworkRequestEntit } @Test - void testImportStudyWithCaseImportActionRootNetworkFailureIsResilient() throws Exception { + void testImportStudyWithRootNetworkFailure() throws Exception { UUID studyUuid = UUID.randomUUID(); UUID caseUuid1 = UUID.randomUUID(); UUID caseUuid2 = UUID.randomUUID(); @@ -250,7 +250,7 @@ void testImportStudyWithCaseImportActionRootNetworkFailureIsResilient() throws E } @Test - void testImportStudyWithCaseImportActionCleansUpOrphanedModificationGroupOnFailure() throws Exception { + void testImportStudyWithModificationGroupOnFailure() throws Exception { UUID studyUuid = UUID.randomUUID(); UUID caseUuid = UUID.randomUUID(); UUID modificationGroupUuid1 = UUID.randomUUID(); @@ -283,7 +283,7 @@ void testImportStudyWithCaseImportActionCleansUpOrphanedModificationGroupOnFailu } @Test - void testImportStudyWithCaseImportActionInvalidNodeType() throws Exception { + void testImportStudyWithInvalidNodeType() throws Exception { UUID studyUuid = UUID.randomUUID(); UUID caseUuid = UUID.randomUUID(); UUID modificationGroupUuid = UUID.randomUUID(); @@ -312,7 +312,7 @@ void testImportStudyWithCaseImportActionInvalidNodeType() throws Exception { } @Test - void testImportStudyWithCaseImportActionNoRootNetworks() throws Exception { + void testImportStudyWithNoRootNetworks() throws Exception { UUID studyUuid = UUID.randomUUID(); TreeExportInfos treeExportInfos = new TreeExportInfos(studyUuid, List.of(), new NodeTreeExportInfos("Root", "ROOT", null, null, List.of())); From 9dfe44cf500cd746f62a7cefc92751b18a2156db Mon Sep 17 00:00:00 2001 From: Etienne Homer Date: Mon, 31 Aug 2026 16:00:36 +0200 Subject: [PATCH 24/32] Fix merge Signed-off-by: Etienne Homer --- .../java/org/gridsuite/study/server/service/StudyService.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/org/gridsuite/study/server/service/StudyService.java b/src/main/java/org/gridsuite/study/server/service/StudyService.java index c86ea5409d..293bbc4f8d 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyService.java @@ -36,6 +36,7 @@ import org.gridsuite.study.server.error.StudyException; import org.gridsuite.study.server.networkmodificationtree.dto.*; import org.gridsuite.study.server.networkmodificationtree.entities.NetworkModificationNodeInfoEntity; +import org.gridsuite.study.server.networkmodificationtree.entities.NetworkModificationNodeType; import org.gridsuite.study.server.networkmodificationtree.entities.NodeEntity; import org.gridsuite.study.server.networkmodificationtree.entities.NodeType; import org.gridsuite.study.server.notification.NotificationService; From d3c6cb80d3412b97830edaff83a753a87e7483ef Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Fri, 4 Sep 2026 10:30:36 +0200 Subject: [PATCH 25/32] code review remarks --- .../dto/caseimport/CaseImportAction.java | 1 - .../study/server/repository/StudyEntity.java | 26 +-- .../server/repository/StudyRepository.java | 2 +- .../RootNetworkRequestRepository.java | 3 - .../study/server/service/ConsumerService.java | 21 -- .../server/service/RootNetworkService.java | 4 - .../server/service/StudyImportService.java | 69 ++---- .../changesets/changelog_20260813T120000Z.xml | 17 -- .../db/changelog/db.changelog-master.yaml | 3 - .../studycontroller/ImportStudyTest.java | 197 ++---------------- 10 files changed, 46 insertions(+), 297 deletions(-) delete mode 100644 src/main/resources/db/changelog/changesets/changelog_20260813T120000Z.xml diff --git a/src/main/java/org/gridsuite/study/server/dto/caseimport/CaseImportAction.java b/src/main/java/org/gridsuite/study/server/dto/caseimport/CaseImportAction.java index 550c66fe5c..73803bc2c3 100644 --- a/src/main/java/org/gridsuite/study/server/dto/caseimport/CaseImportAction.java +++ b/src/main/java/org/gridsuite/study/server/dto/caseimport/CaseImportAction.java @@ -11,7 +11,6 @@ public enum CaseImportAction { STUDY_CREATION("Study creation"), ROOT_NETWORK_CREATION("Root network creation"), - ROOT_NETWORK_CREATION_FOR_STUDY_IMPORT("Root network creation for study import"), NETWORK_RECREATION("Network recreation"), ROOT_NETWORK_MODIFICATION("Root network modification"); diff --git a/src/main/java/org/gridsuite/study/server/repository/StudyEntity.java b/src/main/java/org/gridsuite/study/server/repository/StudyEntity.java index 925b0b7449..0aae3bb0dd 100644 --- a/src/main/java/org/gridsuite/study/server/repository/StudyEntity.java +++ b/src/main/java/org/gridsuite/study/server/repository/StudyEntity.java @@ -13,7 +13,6 @@ import org.gridsuite.study.server.repository.voltageinit.StudyVoltageInitParametersEntity; import java.util.*; -import java.util.stream.Collectors; /** * @author Abdelsalem Hedhili @@ -37,17 +36,6 @@ public class StudyEntity extends AbstractManuallyAssignedIdentifierEntity @Builder.Default private List rootNetworks = new ArrayList<>(); - /** - * Root network order to restore during an in-progress study import; null otherwise. - */ - @ElementCollection - @CollectionTable(name = "StudyRootNetworkOrder", foreignKey = @ForeignKey( - name = "study_root_network_order_fk" - )) - @OrderColumn(name = "index") - @Column(name = "rootNetworkUuid") - private List rootNetworkOrder; - /** * @deprecated to remove when the data is migrated into the loadflow-server */ @@ -155,19 +143,7 @@ public RootNetworkEntity getFirstRootNetwork() { public void addRootNetwork(RootNetworkEntity rootNetworkEntity) { rootNetworkEntity.setStudy(this); rootNetworkEntity.setIndexationStatus(RootNetworkIndexationStatus.INDEXED); - rootNetworks.add(resolveInsertPosition(rootNetworkEntity.getId()), rootNetworkEntity); - } - - /** - * Insert index for rootNetworkId based on prior ordered networks, append outside pending import - */ - private int resolveInsertPosition(UUID rootNetworkId) { - int targetPos = rootNetworkOrder == null ? -1 : rootNetworkOrder.indexOf(rootNetworkId); - if (targetPos < 0) { - return rootNetworks.size(); - } - Set alreadyPresent = rootNetworks.stream().map(RootNetworkEntity::getId).collect(Collectors.toSet()); - return (int) rootNetworkOrder.subList(0, targetPos).stream().filter(alreadyPresent::contains).count(); + rootNetworks.add(rootNetworkEntity); } public void deleteRootNetworks(Set uuids) { diff --git a/src/main/java/org/gridsuite/study/server/repository/StudyRepository.java b/src/main/java/org/gridsuite/study/server/repository/StudyRepository.java index e22456c5c4..b31b92a368 100644 --- a/src/main/java/org/gridsuite/study/server/repository/StudyRepository.java +++ b/src/main/java/org/gridsuite/study/server/repository/StudyRepository.java @@ -20,6 +20,6 @@ @Repository public interface StudyRepository extends JpaRepository { - @EntityGraph(attributePaths = {"rootNetworks", "rootNetworkOrder"}, type = EntityGraph.EntityGraphType.LOAD) + @EntityGraph(attributePaths = {"rootNetworks"}, type = EntityGraph.EntityGraphType.LOAD) Optional findWithRootNetworksById(UUID id); } diff --git a/src/main/java/org/gridsuite/study/server/repository/rootnetwork/RootNetworkRequestRepository.java b/src/main/java/org/gridsuite/study/server/repository/rootnetwork/RootNetworkRequestRepository.java index aa5be60a86..c4cb8e810f 100644 --- a/src/main/java/org/gridsuite/study/server/repository/rootnetwork/RootNetworkRequestRepository.java +++ b/src/main/java/org/gridsuite/study/server/repository/rootnetwork/RootNetworkRequestRepository.java @@ -6,7 +6,6 @@ */ package org.gridsuite.study.server.repository.rootnetwork; -import org.gridsuite.study.server.dto.RootNetworkAction; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; @@ -18,8 +17,6 @@ public interface RootNetworkRequestRepository extends JpaRepository findByNameAndStudyUuid(String name, UUID studyUuid); Optional findByTagAndStudyUuid(String tag, UUID studyUuid); diff --git a/src/main/java/org/gridsuite/study/server/service/ConsumerService.java b/src/main/java/org/gridsuite/study/server/service/ConsumerService.java index a002ce94d4..f3df64ec69 100644 --- a/src/main/java/org/gridsuite/study/server/service/ConsumerService.java +++ b/src/main/java/org/gridsuite/study/server/service/ConsumerService.java @@ -67,7 +67,6 @@ public class ConsumerService { private final NotificationService notificationService; private final StudyService studyService; - private final StudyImportService studyImportService; private final CaseService caseService; private final LoadFlowRestService loadFlowRestService; private final NetworkModificationTreeService networkModificationTreeService; @@ -83,7 +82,6 @@ public class ConsumerService { public ConsumerService(ObjectMapper objectMapper, NotificationService notificationService, StudyService studyService, - StudyImportService studyImportService, CaseService caseService, LoadFlowRestService loadFlowRestService, NetworkModificationTreeService networkModificationTreeService, @@ -98,7 +96,6 @@ public ConsumerService(ObjectMapper objectMapper, this.objectMapper = objectMapper; this.notificationService = notificationService; this.studyService = studyService; - this.studyImportService = studyImportService; this.caseService = caseService; this.loadFlowRestService = loadFlowRestService; this.networkModificationTreeService = networkModificationTreeService; @@ -261,20 +258,6 @@ private void handleConsumeCaseImportSucceeded(CaseImportReceiver receiver, UUID .networkInfos(networkInfos) .importParameters(importParameters) .build()); - case ROOT_NETWORK_CREATION_FOR_STUDY_IMPORT -> { - try { - studyService.createRootNetwork(studyUuid, RootNetworkInfos.builder() - .id(rootNetworkUuid) - .caseInfos(caseInfos) - .reportUuid(importReportUuid) - .networkInfos(networkInfos) - .importParameters(importParameters) - .build()); - } finally { - studyService.deleteRootNetworkRequest(rootNetworkUuid); - studyImportService.checkFinishedStudyImport(studyUuid, userId); - } - } case NETWORK_RECREATION -> studyService.updateNetwork(studyUuid, rootNetworkUuid, networkInfos, userId); case ROOT_NETWORK_MODIFICATION -> studyService.modifyRootNetwork(studyUuid, RootNetworkInfos.builder() .id(rootNetworkUuid) @@ -354,10 +337,6 @@ private void handleRootNetworkImportFailed(CaseImportReceiver receiver, String e if (receiver.getCaseImportAction() == CaseImportAction.NETWORK_RECREATION) { rootNetworkService.updateNetworkLoadStatus(receiver.getRootNetworkUuid(), RootNetworkLoadStatus.UNLOADED); } - if (receiver.getCaseImportAction() == CaseImportAction.ROOT_NETWORK_CREATION_FOR_STUDY_IMPORT) { - studyService.deleteRootNetworkRequest(receiver.getRootNetworkUuid()); - studyImportService.checkFinishedStudyImport(receiver.getStudyUuid(), receiver.getUserId()); - } notificationService.emitRootNetworksUpdateFailed(receiver.getStudyUuid(), errorMessage); } diff --git a/src/main/java/org/gridsuite/study/server/service/RootNetworkService.java b/src/main/java/org/gridsuite/study/server/service/RootNetworkService.java index 917111a067..b72d863b7f 100644 --- a/src/main/java/org/gridsuite/study/server/service/RootNetworkService.java +++ b/src/main/java/org/gridsuite/study/server/service/RootNetworkService.java @@ -284,10 +284,6 @@ public List geRootNetworkRequests(UUID studyUuid) { return rootNetworkRequestRepository.findAllByStudyUuid(studyUuid); } - public int countRootNetworkCreationRequests(UUID studyUuid) { - return rootNetworkRequestRepository.countAllByStudyUuidAndActionRequest(studyUuid, RootNetworkAction.ROOT_NETWORK_CREATION); - } - public void deleteRootNetworkRequest(RootNetworkRequestEntity rootNetworkRequestEntity) { rootNetworkRequestRepository.delete(rootNetworkRequestEntity); } diff --git a/src/main/java/org/gridsuite/study/server/service/StudyImportService.java b/src/main/java/org/gridsuite/study/server/service/StudyImportService.java index 9c82b3710f..5a24ff0c30 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyImportService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyImportService.java @@ -8,18 +8,20 @@ import org.apache.commons.collections4.CollectionUtils; import org.gridsuite.study.server.dto.CaseInfos; +import org.gridsuite.study.server.dto.NetworkInfos; import org.gridsuite.study.server.dto.RootNetworkInfos; -import org.gridsuite.study.server.dto.caseimport.CaseImportAction; +import org.gridsuite.study.server.dto.RootNetworkLoadStatus; import org.gridsuite.study.server.dto.studyexport.NodeTreeExportInfos; import org.gridsuite.study.server.dto.studyexport.RootNetworkExportInfos; import org.gridsuite.study.server.dto.studyexport.TreeExportInfos; import org.gridsuite.study.server.error.StudyException; -import org.gridsuite.study.server.notification.NotificationService; import org.gridsuite.study.server.repository.StudyEntity; import org.gridsuite.study.server.repository.StudyRepository; +import org.gridsuite.study.server.repository.rootnetwork.RootNetworkEntity; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.Comparator; import java.util.HashMap; @@ -41,71 +43,42 @@ public class StudyImportService { private final RootNetworkService rootNetworkService; private final NetworkModificationService networkModificationService; private final CaseService caseService; - private final NotificationService notificationService; public StudyImportService(StudyService studyService, StudyRepository studyRepository, RootNetworkService rootNetworkService, - NetworkModificationService networkModificationService, CaseService caseService, NotificationService notificationService) { + NetworkModificationService networkModificationService, CaseService caseService) { this.studyService = studyService; this.studyRepository = studyRepository; this.rootNetworkService = rootNetworkService; this.networkModificationService = networkModificationService; this.caseService = caseService; - this.notificationService = notificationService; } + @Transactional public void importStudy(TreeExportInfos treeExportInfos, String userId) { if (treeExportInfos.rootNetworks().isEmpty()) { throw new StudyException(NOT_FOUND, "No root network found in import archive"); } - List orderedRootNetworks = treeExportInfos.rootNetworks().stream() - .sorted(Comparator.comparing(RootNetworkExportInfos::index)) - .map(this::toRootNetworkInfos) - .toList(); - Map modificationGroupUuidMapping = duplicateModificationGroups(treeExportInfos.nodeTree()); - StudyEntity studyEntity = studyService.createStudyEntityWithTree(treeExportInfos.studyUuid(), userId, treeExportInfos.nodeTree(), modificationGroupUuidMapping); - studyEntity.setRootNetworkOrder(orderedRootNetworks.stream().map(RootNetworkInfos::getId).toList()); studyRepository.save(studyEntity); - - notificationService.emitStudyCreationStarted(studyEntity.getId(), userId); - int successfulRequests = 0; - for (RootNetworkInfos rootNetworkInfos : orderedRootNetworks) { - try { - caseService.assertCaseExists(rootNetworkInfos.getCaseInfos().getOriginalCaseUuid()); - studyService.createRootNetworkRequest(studyEntity.getId(), rootNetworkInfos, userId, CaseImportAction.ROOT_NETWORK_CREATION_FOR_STUDY_IMPORT); - successfulRequests++; - } catch (Exception e) { - LOGGER.error(String.format("Could not request root network '%s' for imported study '%s'", rootNetworkInfos.getName(), studyEntity.getId()), e); - } - } - if (successfulRequests == 0) { - studyService.deleteStudyIfNotCreationInProgress(studyEntity.getId(), userId); - notificationService.emitStudyCreationError(studyEntity.getId(), userId, "Could not request any root network for imported study"); - } - } - - public void checkFinishedStudyImport(UUID studyUuid, String userId) { - if (rootNetworkService.countRootNetworkCreationRequests(studyUuid) == 0) { - studyRepository.findById(studyUuid).ifPresent(studyEntity -> { - studyEntity.setRootNetworkOrder(null); - studyRepository.save(studyEntity); - }); - notificationService.emitStudyCreationFinished(studyUuid, userId); + List orderedRootNetworks = treeExportInfos.rootNetworks().stream() + .sorted(Comparator.comparing(RootNetworkExportInfos::index)) + .toList(); + for (RootNetworkExportInfos rootNetworkInfos : orderedRootNetworks) { + UUID newCaseUuid = caseService.duplicateCase(rootNetworkInfos.caseInfos().getCaseUuid(), true); + RootNetworkEntity rootNetworkEntity = rootNetworkService.createRootNetwork(studyEntity, RootNetworkInfos.builder() + .id(UUID.randomUUID()) + .name(rootNetworkInfos.name()) + .tag(rootNetworkInfos.tag()) + .caseInfos(new CaseInfos(newCaseUuid, rootNetworkInfos.caseInfos().getOriginalCaseUuid(), + rootNetworkInfos.caseInfos().getCaseName(), rootNetworkInfos.caseInfos().getCaseFormat())) + .importParameters(rootNetworkInfos.importParameters()) + .networkInfos(new NetworkInfos(UUID.randomUUID(), "")) + .build()); + rootNetworkService.updateNetworkLoadStatus(rootNetworkEntity.getId(), RootNetworkLoadStatus.UNLOADED); } } - private RootNetworkInfos toRootNetworkInfos(RootNetworkExportInfos rootNetworkExportInfos) { - CaseInfos caseInfos = rootNetworkExportInfos.caseInfos(); - return RootNetworkInfos.builder() - .id(UUID.randomUUID()) - .name(rootNetworkExportInfos.name()) - .tag(rootNetworkExportInfos.tag()) - .caseInfos(new CaseInfos(null, caseInfos.getCaseUuid(), caseInfos.getCaseName(), caseInfos.getCaseFormat())) - .importParameters(rootNetworkExportInfos.importParameters()) - .build(); - } - private Map duplicateModificationGroups(NodeTreeExportInfos nodeTree) { Map modificationGroupUuidMapping = new HashMap<>(); if (nodeTree == null) { diff --git a/src/main/resources/db/changelog/changesets/changelog_20260813T120000Z.xml b/src/main/resources/db/changelog/changesets/changelog_20260813T120000Z.xml deleted file mode 100644 index 40193a26e1..0000000000 --- a/src/main/resources/db/changelog/changesets/changelog_20260813T120000Z.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml index 15cb6f75bb..a83f9401b2 100644 --- a/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -395,6 +395,3 @@ databaseChangeLog: - include: file: changesets/changelog_20260818T152042Z.xml relativeToChangelogFile: true - - include: - file: changesets/changelog_20260813T120000Z.xml - relativeToChangelogFile: true diff --git a/src/test/java/org/gridsuite/study/server/studycontroller/ImportStudyTest.java b/src/test/java/org/gridsuite/study/server/studycontroller/ImportStudyTest.java index 3a16ab8459..8d2f29cc18 100644 --- a/src/test/java/org/gridsuite/study/server/studycontroller/ImportStudyTest.java +++ b/src/test/java/org/gridsuite/study/server/studycontroller/ImportStudyTest.java @@ -10,36 +10,25 @@ import com.github.tomakehurst.wiremock.client.WireMock; import com.powsybl.ws.commons.error.PowsyblWsProblemDetail; import org.gridsuite.study.server.dto.CaseInfos; -import org.gridsuite.study.server.dto.caseimport.CaseImportAction; -import org.gridsuite.study.server.dto.caseimport.CaseImportReceiver; +import org.gridsuite.study.server.dto.RootNetworkLoadStatus; import org.gridsuite.study.server.dto.studyexport.NodeTreeExportInfos; import org.gridsuite.study.server.dto.studyexport.RootNetworkExportInfos; import org.gridsuite.study.server.dto.studyexport.TreeExportInfos; import org.gridsuite.study.server.networkmodificationtree.dto.AbstractNode; import org.gridsuite.study.server.networkmodificationtree.dto.NetworkModificationNode; import org.gridsuite.study.server.networkmodificationtree.dto.RootNode; -import org.gridsuite.study.server.notification.NotificationService; -import org.gridsuite.study.server.repository.StudyEntity; import org.gridsuite.study.server.repository.rootnetwork.RootNetworkEntity; -import org.gridsuite.study.server.repository.rootnetwork.RootNetworkRequestEntity; import org.gridsuite.study.server.repository.rootnetwork.RootNetworkRequestRepository; -import org.gridsuite.study.server.service.ConsumerService; import org.gridsuite.study.server.utils.wiremock.WireMockUtilsCriteria; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.messaging.Message; -import org.springframework.messaging.support.GenericMessage; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MvcResult; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; -import java.util.function.Consumer; -import static org.gridsuite.study.server.StudyConstants.HEADER_IMPORT_PARAMETERS; -import static org.gridsuite.study.server.StudyConstants.HEADER_RECEIVER; import static org.gridsuite.study.server.StudyConstants.HEADER_USER_ID; import static org.gridsuite.study.server.error.StudyBusinessErrorCode.BAD_NODE_TYPE; import static org.gridsuite.study.server.error.StudyBusinessErrorCode.NOT_FOUND; @@ -59,8 +48,6 @@ class ImportStudyTest extends StudyTestBase { private ObjectMapper objectMapper; @Autowired private RootNetworkRequestRepository rootNetworkRequestRepository; - @Autowired - private ConsumerService consumerService; @Test void testImportStudy() throws Exception { @@ -73,11 +60,8 @@ void testImportStudy() throws Exception { UUID modificationGroupUuid2 = UUID.randomUUID(); stubDefaultParametersCreation(); - UUID stubCaseExists1Id = wireMockStubs.caseServer.stubCaseExists(caseUuid1.toString(), true); - UUID stubCaseExists2Id = wireMockStubs.caseServer.stubCaseExists(caseUuid2.toString(), true); wireMockStubs.caseServer.stubDuplicateCaseWithBody(caseUuid1.toString(), objectMapper.writeValueAsString(duplicatedCaseUuid1)); wireMockStubs.caseServer.stubDuplicateCaseWithBody(caseUuid2.toString(), objectMapper.writeValueAsString(duplicatedCaseUuid2)); - stubImportNetworkOnly(); UUID stubDuplicateModificationGroupId = wireMockStubs.stubDuplicateModificationGroup(objectMapper.writeValueAsString(Map.of())); NodeTreeExportInfos nodeTree = new NodeTreeExportInfos("Root", "ROOT", null, null, List.of( @@ -95,8 +79,9 @@ void testImportStudy() throws Exception { .content(objectMapper.writeValueAsString(treeExportInfos))) .andExpect(status().isOk()); - checkRootNetworkRequestNotifications(2, studyUuid); + // Import is fully synchronous: no study-update / element-update notifications are sent assertNull(output.receive(TIMEOUT, studyUpdateDestination)); + assertNull(output.receive(TIMEOUT, elementUpdateDestination)); assertTrue(studyRepository.findById(studyUuid).isPresent()); RootNode rootNode = networkModificationTreeService.getStudyTree(studyUuid, null); @@ -114,138 +99,29 @@ void testImportStudy() throws Exception { assertNotEquals(modificationGroupUuid2, ((NetworkModificationNode) n2).getModificationGroupUuid()); wireMockStubs.verifyDuplicateModificationGroup(stubDuplicateModificationGroupId, 2); - assertEquals(2, rootNetworkRequestRepository.countAllByStudyUuid(studyUuid)); - wireMockStubs.caseServer.verifyCaseExists(stubCaseExists1Id, caseUuid1.toString()); - wireMockStubs.caseServer.verifyCaseExists(stubCaseExists2Id, caseUuid2.toString()); - verifyDuplicateCaseRequest(caseUuid1); - verifyDuplicateCaseRequest(caseUuid2); - verifyImportNetworkRequest(duplicatedCaseUuid1); - verifyImportNetworkRequest(duplicatedCaseUuid2); - - verifyDefaultParametersCreation(); - } - - @Test - void testImportStudyWithExportedOrder() throws Exception { - UUID studyUuid = UUID.randomUUID(); - UUID caseUuid1 = UUID.randomUUID(); - UUID caseUuid2 = UUID.randomUUID(); - UUID duplicatedCaseUuid1 = UUID.randomUUID(); - UUID duplicatedCaseUuid2 = UUID.randomUUID(); - - stubDefaultParametersCreation(); - UUID stubCaseExists1Id = wireMockStubs.caseServer.stubCaseExists(caseUuid1.toString(), true); - UUID stubCaseExists2Id = wireMockStubs.caseServer.stubCaseExists(caseUuid2.toString(), true); - wireMockStubs.caseServer.stubDuplicateCaseWithBody(caseUuid1.toString(), objectMapper.writeValueAsString(duplicatedCaseUuid1)); - wireMockStubs.caseServer.stubDuplicateCaseWithBody(caseUuid2.toString(), objectMapper.writeValueAsString(duplicatedCaseUuid2)); - stubImportNetworkOnly(); - UUID stubDisableExpiration1Id = wireMockStubs.caseServer.stubDisableCaseExpiration(duplicatedCaseUuid1.toString()); - UUID stubDisableExpiration2Id = wireMockStubs.caseServer.stubDisableCaseExpiration(duplicatedCaseUuid2.toString()); - - NodeTreeExportInfos nodeTree = new NodeTreeExportInfos("Root", "ROOT", null, null, List.of()); - TreeExportInfos treeExportInfos = new TreeExportInfos(studyUuid, List.of( - rootNetworkExportInfos("rn1", "1", 0, caseUuid1), - rootNetworkExportInfos("rn2", "2", 1, caseUuid2) - ), nodeTree); - - mockMvc.perform(post(IMPORT_URL).header(HEADER_USER_ID, USER_ID) - .contentType("application/json") - .content(objectMapper.writeValueAsString(treeExportInfos))) - .andExpect(status().isOk()); - - checkRootNetworkRequestNotifications(2, studyUuid); - assertNull(output.receive(TIMEOUT, studyUpdateDestination)); - - List requests = rootNetworkRequestRepository.findAllByStudyUuid(studyUuid); - assertEquals(2, requests.size()); - RootNetworkRequestEntity request1 = requests.stream().filter(r -> "rn1".equals(r.getName())).findFirst().orElseThrow(); - RootNetworkRequestEntity request2 = requests.stream().filter(r -> "rn2".equals(r.getName())).findFirst().orElseThrow(); - List rootNetworkOrder = studyRepository.findWithRootNetworksById(studyUuid).orElseThrow().getRootNetworkOrder(); - assertEquals(List.of(request1.getId(), request2.getId()), rootNetworkOrder); - - completeRootNetworkCreation(studyUuid, request2, duplicatedCaseUuid2, caseUuid2); - Message afterFirstCompletion = output.receive(TIMEOUT, studyUpdateDestination); - assertNotNull(afterFirstCompletion); - assertNotEquals(NotificationService.UPDATE_TYPE_STUDY_CREATION_FINISHED, afterFirstCompletion.getHeaders().get(NotificationService.HEADER_UPDATE_TYPE)); - assertNull(output.receive(TIMEOUT, studyUpdateDestination)); - - completeRootNetworkCreation(studyUuid, request1, duplicatedCaseUuid1, caseUuid1); - Message afterLastCompletion = output.receive(TIMEOUT, studyUpdateDestination); - assertNotNull(afterLastCompletion); - assertNotEquals(NotificationService.UPDATE_TYPE_STUDY_CREATION_FINISHED, afterLastCompletion.getHeaders().get(NotificationService.HEADER_UPDATE_TYPE)); - Message finished = output.receive(TIMEOUT, studyUpdateDestination); - assertNotNull(finished); - assertEquals(studyUuid, finished.getHeaders().get(NotificationService.HEADER_STUDY_UUID)); - assertEquals(NotificationService.UPDATE_TYPE_STUDY_CREATION_FINISHED, finished.getHeaders().get(NotificationService.HEADER_UPDATE_TYPE)); - assertNull(output.receive(TIMEOUT, studyUpdateDestination)); - - StudyEntity studyEntity = studyRepository.findWithRootNetworksById(studyUuid).orElseThrow(); - assertEquals(List.of("rn1", "rn2"), studyEntity.getRootNetworks().stream().map(RootNetworkEntity::getName).toList()); + // Root networks are created directly synchronously + assertEquals(0, rootNetworkRequestRepository.countAllByStudyUuid(studyUuid)); + List rootNetworks = rootNetworkRepository.findAllByStudyId(studyUuid); + assertEquals(2, rootNetworks.size()); + + RootNetworkEntity rn1 = rootNetworkRepository.findByNameAndStudyId("rn1", studyUuid).orElseThrow(); + assertEquals("1", rn1.getTag()); + assertEquals(duplicatedCaseUuid1, rn1.getCaseUuid()); + assertNull(rn1.getOriginalCaseUuid()); + assertEquals(RootNetworkLoadStatus.UNLOADED, rn1.getLoadStatus()); + // Network is not actually imported during a study import: networkInfos is only a placeholder, + // the real network will be loaded later on demand to recreate network + assertNotNull(rn1.getNetworkUuid()); + assertEquals("", rn1.getNetworkId()); + + RootNetworkEntity rn2 = rootNetworkRepository.findByNameAndStudyId("rn2", studyUuid).orElseThrow(); + assertEquals("2", rn2.getTag()); + assertEquals(duplicatedCaseUuid2, rn2.getCaseUuid()); + assertNull(rn2.getOriginalCaseUuid()); + assertEquals(RootNetworkLoadStatus.UNLOADED, rn2.getLoadStatus()); - wireMockStubs.caseServer.verifyCaseExists(stubCaseExists1Id, caseUuid1.toString()); - wireMockStubs.caseServer.verifyCaseExists(stubCaseExists2Id, caseUuid2.toString()); verifyDuplicateCaseRequest(caseUuid1); verifyDuplicateCaseRequest(caseUuid2); - verifyImportNetworkRequest(duplicatedCaseUuid1); - verifyImportNetworkRequest(duplicatedCaseUuid2); - wireMockStubs.caseServer.verifyDisableCaseExpiration(stubDisableExpiration1Id, duplicatedCaseUuid1.toString()); - wireMockStubs.caseServer.verifyDisableCaseExpiration(stubDisableExpiration2Id, duplicatedCaseUuid2.toString()); - verifyDefaultParametersCreation(); - } - - private void completeRootNetworkCreation(UUID studyUuid, RootNetworkRequestEntity request, UUID duplicatedCaseUuid, UUID originalCaseUuid) throws Exception { - Consumer> messageConsumer = consumerService.consumeCaseImportSucceeded(); - CaseImportReceiver caseImportReceiver = new CaseImportReceiver(studyUuid, request.getId(), duplicatedCaseUuid, originalCaseUuid, - UUID.randomUUID(), USER_ID, 0L, CaseImportAction.ROOT_NETWORK_CREATION_FOR_STUDY_IMPORT); - Map headers = new HashMap<>(); - headers.put("networkUuid", UUID.randomUUID().toString()); - headers.put("networkId", "networkId"); - headers.put("caseFormat", "UCTE"); - headers.put("caseName", "caseName"); - headers.put(HEADER_RECEIVER, objectMapper.writeValueAsString(caseImportReceiver)); - headers.put(HEADER_IMPORT_PARAMETERS, Map.of()); - messageConsumer.accept(new GenericMessage<>("", headers)); - } - - @Test - void testImportStudyWithRootNetworkFailure() throws Exception { - UUID studyUuid = UUID.randomUUID(); - UUID caseUuid1 = UUID.randomUUID(); - UUID caseUuid2 = UUID.randomUUID(); - UUID duplicatedCaseUuid1 = UUID.randomUUID(); - - stubDefaultParametersCreation(); - UUID stubCaseExists1Id = wireMockStubs.caseServer.stubCaseExists(caseUuid1.toString(), true); - UUID stubCaseExists2Id = wireMockStubs.caseServer.stubCaseExists(caseUuid2.toString(), true); - wireMockStubs.caseServer.stubDuplicateCaseWithBody(caseUuid1.toString(), objectMapper.writeValueAsString(duplicatedCaseUuid1)); - stubImportNetworkOnly(); - wireMockServer.stubFor(WireMock.post(WireMock.urlPathEqualTo("/v1/cases/" + caseUuid2 + "/duplicate")) - .withQueryParam("withExpiration", WireMock.matching(".*")) - .willReturn(WireMock.serverError())); - - NodeTreeExportInfos nodeTree = new NodeTreeExportInfos("Root", "ROOT", null, null, List.of()); - TreeExportInfos treeExportInfos = new TreeExportInfos(studyUuid, List.of( - rootNetworkExportInfos("rn1", "1", 0, caseUuid1), - rootNetworkExportInfos("rn2", "2", 1, caseUuid2) - ), nodeTree); - - mockMvc.perform(post(IMPORT_URL).header(HEADER_USER_ID, USER_ID) - .contentType("application/json") - .content(objectMapper.writeValueAsString(treeExportInfos))) - .andExpect(status().isOk()); - - checkRootNetworkRequestNotifications(1, studyUuid); - assertNull(output.receive(TIMEOUT, studyUpdateDestination)); - - assertTrue(studyRepository.findById(studyUuid).isPresent()); - assertNotNull(networkModificationTreeService.getStudyTree(studyUuid, null)); - assertEquals(1, rootNetworkRequestRepository.countAllByStudyUuid(studyUuid)); - wireMockStubs.caseServer.verifyCaseExists(stubCaseExists1Id, caseUuid1.toString()); - wireMockStubs.caseServer.verifyCaseExists(stubCaseExists2Id, caseUuid2.toString()); - verifyDuplicateCaseRequest(caseUuid1); - verifyImportNetworkRequest(duplicatedCaseUuid1); - WireMockUtilsCriteria.verifyPostRequest(wireMockServer, "/v1/cases/" + caseUuid2 + "/duplicate", - Map.of("withExpiration", WireMock.matching(".*"))); verifyDefaultParametersCreation(); } @@ -342,35 +218,8 @@ private void verifyDefaultParametersCreation() { verifyCreateParameters(1, 9, 1, 1, 1); } - private void checkRootNetworkRequestNotifications(int successfulRootNetworkRequests, UUID studyUuid) { - Message studyCreationStarted = output.receive(TIMEOUT, studyUpdateDestination); - assertNotNull(studyCreationStarted); - assertEquals(studyUuid, studyCreationStarted.getHeaders().get(NotificationService.HEADER_STUDY_UUID)); - assertEquals(NotificationService.UPDATE_TYPE_STUDY_CREATION_STARTED, studyCreationStarted.getHeaders().get(NotificationService.HEADER_UPDATE_TYPE)); - for (int i = 0; i < successfulRootNetworkRequests; i++) { - Message rootNetworksUpdated = output.receive(TIMEOUT, studyUpdateDestination); - assertNotNull(rootNetworksUpdated); - assertEquals(studyUuid, rootNetworksUpdated.getHeaders().get(NotificationService.HEADER_STUDY_UUID)); - - Message elementUpdated = output.receive(TIMEOUT, elementUpdateDestination); - assertNotNull(elementUpdated); - assertEquals(studyUuid, elementUpdated.getHeaders().get(NotificationService.HEADER_ELEMENT_UUID)); - assertEquals(ImportStudyTest.USER_ID, elementUpdated.getHeaders().get(NotificationService.HEADER_MODIFIED_BY)); - } - } - - private void stubImportNetworkOnly() { - wireMockServer.stubFor(WireMock.post(WireMock.urlPathEqualTo("/v1/networks")) - .willReturn(WireMock.ok())); - } - private void verifyDuplicateCaseRequest(UUID caseUuid) { WireMockUtilsCriteria.verifyPostRequest(wireMockServer, "/v1/cases/" + caseUuid + "/duplicate", Map.of("withExpiration", WireMock.matching(".*"))); } - - private void verifyImportNetworkRequest(UUID caseUuid) { - WireMockUtilsCriteria.verifyPostRequest(wireMockServer, "/v1/networks", - Map.of("caseUuid", WireMock.equalTo(caseUuid.toString()), "receiver", WireMock.matching(".*"))); - } } From df48eb034903728966edde69d2afaa6df4e9e664 Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Fri, 4 Sep 2026 10:47:03 +0200 Subject: [PATCH 26/32] revert unused changes --- .../study/server/controller/StudyController.java | 4 +--- .../study/server/service/ConsumerService.java | 3 +-- .../gridsuite/study/server/service/StudyService.java | 10 +++++----- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/gridsuite/study/server/controller/StudyController.java b/src/main/java/org/gridsuite/study/server/controller/StudyController.java index 5770a6f00e..596e54e91f 100644 --- a/src/main/java/org/gridsuite/study/server/controller/StudyController.java +++ b/src/main/java/org/gridsuite/study/server/controller/StudyController.java @@ -18,7 +18,6 @@ import org.gridsuite.filter.utils.EquipmentType; import org.gridsuite.study.server.StudyApi; import org.gridsuite.study.server.dto.*; -import org.gridsuite.study.server.dto.caseimport.CaseImportAction; import org.gridsuite.study.server.dto.elasticsearch.EquipmentInfos; import org.gridsuite.study.server.dto.modification.*; import org.gridsuite.study.server.dto.networkexport.ExportNetworkStatus; @@ -192,8 +191,7 @@ public ResponseEntity> getRootNetworks(@PathVariable public ResponseEntity createRootNetwork(@PathVariable("studyUuid") UUID studyUuid, @RequestBody RootNetworkInfos rootNetworkInfos, @RequestHeader(HEADER_USER_ID) String userId) { - rootNetworkInfos.setId(null); - return ResponseEntity.ok().body(studyService.createRootNetworkRequest(studyUuid, rootNetworkInfos, userId, CaseImportAction.ROOT_NETWORK_CREATION)); + return ResponseEntity.ok().body(studyService.createRootNetworkRequest(studyUuid, rootNetworkInfos, userId)); } @PutMapping(value = "/studies/{studyUuid}/root-networks/{rootNetworkUuid}") diff --git a/src/main/java/org/gridsuite/study/server/service/ConsumerService.java b/src/main/java/org/gridsuite/study/server/service/ConsumerService.java index f3df64ec69..4c7f936509 100644 --- a/src/main/java/org/gridsuite/study/server/service/ConsumerService.java +++ b/src/main/java/org/gridsuite/study/server/service/ConsumerService.java @@ -312,8 +312,7 @@ public Consumer> consumeCaseImportFailed() { UUID studyUuid = receiver.getStudyUuid(); String userId = receiver.getUserId(); - CaseImportAction caseImportAction = receiver.getCaseImportAction(); - if (caseImportAction == CaseImportAction.STUDY_CREATION) { + if (receiver.getCaseImportAction() == CaseImportAction.STUDY_CREATION) { studyService.deleteStudyIfNotCreationInProgress(studyUuid, userId); notificationService.emitStudyCreationError(studyUuid, userId, errorMessage); } else { diff --git a/src/main/java/org/gridsuite/study/server/service/StudyService.java b/src/main/java/org/gridsuite/study/server/service/StudyService.java index 2929883c56..89f7da0379 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyService.java @@ -321,17 +321,17 @@ public void deleteRootNetworks(UUID studyUuid, List rootNetworksUuids, Str } @Transactional - public RootNetworkRequestInfos createRootNetworkRequest(UUID studyUuid, RootNetworkInfos rootNetworkInfos, String userId, CaseImportAction caseImportAction) { + public RootNetworkRequestInfos createRootNetworkRequest(UUID studyUuid, RootNetworkInfos rootNetworkInfos, String userId) { rootNetworkService.assertCanCreateRootNetwork(studyUuid, rootNetworkInfos.getName(), rootNetworkInfos.getTag()); StudyEntity studyEntity = getStudy(studyUuid); - if (rootNetworkInfos.getId() == null) { - rootNetworkInfos.setId(UUID.randomUUID()); - } + + rootNetworkInfos.setId(UUID.randomUUID()); + RootNetworkRequestEntity rootNetworkCreationRequestEntity = rootNetworkService.insertCreationRequest(studyEntity.getId(), rootNetworkInfos, userId); try { UUID clonedCaseUuid = caseService.duplicateCase(rootNetworkInfos.getCaseInfos().getOriginalCaseUuid(), true); rootNetworkInfos.getCaseInfos().setCaseUuid(clonedCaseUuid); - persistNetwork(rootNetworkInfos, studyUuid, null, userId, rootNetworkInfos.getImportParameters(), caseImportAction, UUID.randomUUID()); + persistNetwork(rootNetworkInfos, studyUuid, null, userId, rootNetworkInfos.getImportParameters(), CaseImportAction.ROOT_NETWORK_CREATION, UUID.randomUUID()); } catch (Exception e) { rootNetworkService.deleteRootNetworkRequest(rootNetworkCreationRequestEntity); throw e; From 15797bee70c37a5beeff5c3531e4b630116cc1a0 Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Fri, 4 Sep 2026 11:00:41 +0200 Subject: [PATCH 27/32] code review remarks --- .../study/server/service/CaseService.java | 25 ++---------- .../server/service/StudyExportService.java | 40 ++++++++++++------- 2 files changed, 29 insertions(+), 36 deletions(-) diff --git a/src/main/java/org/gridsuite/study/server/service/CaseService.java b/src/main/java/org/gridsuite/study/server/service/CaseService.java index 68f4fe1ade..03996030ea 100644 --- a/src/main/java/org/gridsuite/study/server/service/CaseService.java +++ b/src/main/java/org/gridsuite/study/server/service/CaseService.java @@ -16,16 +16,12 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; -import org.springframework.http.client.ClientHttpResponse; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; -import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; -import java.io.IOException; -import java.io.InputStream; import java.util.UUID; import static org.gridsuite.study.server.StudyConstants.CASE_API_VERSION; @@ -100,26 +96,11 @@ public UUID createCase(String caseKey, String contentType) { return restTemplate.exchange(caseServerBaseUri + path, HttpMethod.POST, null, UUID.class).getBody(); } - public void streamCaseContent(UUID caseUuid, CaseContentHandler handler) throws IOException { + public ResponseEntity getCaseContent(UUID caseUuid) { String path = UriComponentsBuilder.fromPath(DELIMITER + CASE_API_VERSION + "/cases/{caseUuid}") .buildAndExpand(caseUuid) .toUriString(); - try { - restTemplate.execute(caseServerBaseUri + path, HttpMethod.GET, null, (ClientHttpResponse response) -> { - handler.handle(response.getHeaders().getFirst(HttpHeaders.CONTENT_ENCODING), response.getBody()); - return null; - }); - } catch (ResourceAccessException e) { - if (e.getCause() instanceof IOException ioException) { - throw ioException; - } - throw e; - } - } - - @FunctionalInterface - public interface CaseContentHandler { - void handle(String contentEncoding, InputStream body) throws IOException; + return restTemplate.exchange(caseServerBaseUri + path, HttpMethod.GET, null, byte[].class); } } diff --git a/src/main/java/org/gridsuite/study/server/service/StudyExportService.java b/src/main/java/org/gridsuite/study/server/service/StudyExportService.java index 13011be7d0..c19531363a 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyExportService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyExportService.java @@ -14,6 +14,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.io.InputStreamResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import java.io.*; @@ -71,7 +73,7 @@ public InputStreamResource exportStudy(UUID studyUuid, String userId) { zipFile = null; return new InputStreamResource(stream); } catch (IOException e) { - throw new StudyException(EXPORT_STUDY_ERROR, e.getMessage()); + throw new StudyException(EXPORT_STUDY_ERROR, "Failed to export study: " + studyUuid); } finally { try { deleteDirectory(tempDir); @@ -125,8 +127,8 @@ private Path createTempPath(UUID studyUuid, String errorContext, String permissi PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString(permissions)); try { return creator.apply(attr); - } catch (IOException e) { - throw new StudyException(EXPORT_STUDY_ERROR, e.getMessage()); + } catch (IOException _) { + throw new StudyException(EXPORT_STUDY_ERROR, "Failed to create " + errorContext + " for study: " + studyUuid); } } @@ -136,20 +138,30 @@ private interface IOFunction { } /** - * Export a case file from the case-server, streaming it directly to disk - * so the whole case content is never buffered in the heap. + * Export a case file from the case-server */ private void exportCaseFile(UUID caseUuid, String caseName, Path casesDir) throws IOException { - Path caseDir = casesDir.resolve(caseUuid.toString()); - Files.createDirectories(caseDir); - Path caseFile = caseDir.resolve(caseName); - caseService.streamCaseContent(caseUuid, (contentEncoding, body) -> { - // plain file cases are gzip'd by the case-server and need to be decompressed - try (InputStream in = "gzip".equalsIgnoreCase(contentEncoding) ? new GZIPInputStream(body) : body; - OutputStream out = Files.newOutputStream(caseFile)) { - in.transferTo(out); + ResponseEntity response = caseService.getCaseContent(caseUuid); + byte[] body = response.getBody(); + if (body != null) { + Path caseDir = casesDir.resolve(caseUuid.toString()); + Files.createDirectories(caseDir); + String contentEncoding = response.getHeaders().getFirst(HttpHeaders.CONTENT_ENCODING); + // plain file cases are gzip by the case-server and need to be decompressed + if ("gzip".equalsIgnoreCase(contentEncoding)) { + body = decompressGzip(body); } - }); + Path caseFile = caseDir.resolve(caseName); + Files.write(caseFile, body); + } + } + + private static byte[] decompressGzip(byte[] data) throws IOException { + try (GZIPInputStream gzipIn = new GZIPInputStream(new ByteArrayInputStream(data)); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + gzipIn.transferTo(out); + return out.toByteArray(); + } } private void writeZipEntries(Path directory, ZipOutputStream zipOut) throws IOException { From d8e6c85ea4d115b6cddc7ec7d310915598abbece Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Fri, 4 Sep 2026 11:02:50 +0200 Subject: [PATCH 28/32] clean code --- .../org/gridsuite/study/server/service/StudyExportService.java | 2 +- .../java/org/gridsuite/study/server/service/StudyService.java | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/org/gridsuite/study/server/service/StudyExportService.java b/src/main/java/org/gridsuite/study/server/service/StudyExportService.java index c19531363a..39785ae8b6 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyExportService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyExportService.java @@ -72,7 +72,7 @@ public InputStreamResource exportStudy(UUID studyUuid, String userId) { InputStream stream = Files.newInputStream(zipFile, StandardOpenOption.DELETE_ON_CLOSE); zipFile = null; return new InputStreamResource(stream); - } catch (IOException e) { + } catch (IOException _) { throw new StudyException(EXPORT_STUDY_ERROR, "Failed to export study: " + studyUuid); } finally { try { diff --git a/src/main/java/org/gridsuite/study/server/service/StudyService.java b/src/main/java/org/gridsuite/study/server/service/StudyService.java index 9bdbc70a4a..b265368014 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyService.java @@ -326,7 +326,6 @@ public RootNetworkRequestInfos createRootNetworkRequest(UUID studyUuid, RootNetw StudyEntity studyEntity = getStudy(studyUuid); rootNetworkInfos.setId(UUID.randomUUID()); - RootNetworkRequestEntity rootNetworkCreationRequestEntity = rootNetworkService.insertCreationRequest(studyEntity.getId(), rootNetworkInfos, userId); try { UUID clonedCaseUuid = caseService.duplicateCase(rootNetworkInfos.getCaseInfos().getOriginalCaseUuid(), true); From 4d8597753b7bbe9252d76bee407294829f660fb6 Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Fri, 4 Sep 2026 16:19:39 +0200 Subject: [PATCH 29/32] add emitStudyCreationFinished --- .../gridsuite/study/server/service/StudyImportService.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/gridsuite/study/server/service/StudyImportService.java b/src/main/java/org/gridsuite/study/server/service/StudyImportService.java index 5a24ff0c30..4497554bf5 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyImportService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyImportService.java @@ -15,6 +15,7 @@ import org.gridsuite.study.server.dto.studyexport.RootNetworkExportInfos; import org.gridsuite.study.server.dto.studyexport.TreeExportInfos; import org.gridsuite.study.server.error.StudyException; +import org.gridsuite.study.server.notification.NotificationService; import org.gridsuite.study.server.repository.StudyEntity; import org.gridsuite.study.server.repository.StudyRepository; import org.gridsuite.study.server.repository.rootnetwork.RootNetworkEntity; @@ -43,14 +44,16 @@ public class StudyImportService { private final RootNetworkService rootNetworkService; private final NetworkModificationService networkModificationService; private final CaseService caseService; + private final NotificationService notificationService; public StudyImportService(StudyService studyService, StudyRepository studyRepository, RootNetworkService rootNetworkService, - NetworkModificationService networkModificationService, CaseService caseService) { + NetworkModificationService networkModificationService, CaseService caseService, NotificationService notificationService) { this.studyService = studyService; this.studyRepository = studyRepository; this.rootNetworkService = rootNetworkService; this.networkModificationService = networkModificationService; this.caseService = caseService; + this.notificationService = notificationService; } @Transactional @@ -77,6 +80,7 @@ public void importStudy(TreeExportInfos treeExportInfos, String userId) { .build()); rootNetworkService.updateNetworkLoadStatus(rootNetworkEntity.getId(), RootNetworkLoadStatus.UNLOADED); } + notificationService.emitStudyCreationFinished(studyEntity.getId(), userId); } private Map duplicateModificationGroups(NodeTreeExportInfos nodeTree) { From 61f2dd2e5adbc981f20b46e7b87ba5c40886084d Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Fri, 4 Sep 2026 16:50:08 +0200 Subject: [PATCH 30/32] fix test --- .../study/server/studycontroller/ImportStudyTest.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/test/java/org/gridsuite/study/server/studycontroller/ImportStudyTest.java b/src/test/java/org/gridsuite/study/server/studycontroller/ImportStudyTest.java index 8d2f29cc18..8cd081e5d0 100644 --- a/src/test/java/org/gridsuite/study/server/studycontroller/ImportStudyTest.java +++ b/src/test/java/org/gridsuite/study/server/studycontroller/ImportStudyTest.java @@ -17,11 +17,14 @@ import org.gridsuite.study.server.networkmodificationtree.dto.AbstractNode; import org.gridsuite.study.server.networkmodificationtree.dto.NetworkModificationNode; import org.gridsuite.study.server.networkmodificationtree.dto.RootNode; +import org.gridsuite.study.server.notification.NotificationService; import org.gridsuite.study.server.repository.rootnetwork.RootNetworkEntity; import org.gridsuite.study.server.repository.rootnetwork.RootNetworkRequestRepository; +import org.gridsuite.study.server.utils.TestUtils; import org.gridsuite.study.server.utils.wiremock.WireMockUtilsCriteria; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.messaging.Message; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MvcResult; @@ -79,7 +82,12 @@ void testImportStudy() throws Exception { .content(objectMapper.writeValueAsString(treeExportInfos))) .andExpect(status().isOk()); - // Import is fully synchronous: no study-update / element-update notifications are sent + // Import is fully synchronous, the only notification sent is the study creation finished one + Message message = TestUtils.receiveStudyUpdate(output, studyUpdateDestination); + assertNotNull(message); + assertEquals(studyUuid, message.getHeaders().get(NotificationService.HEADER_STUDY_UUID)); + assertEquals(USER_ID, message.getHeaders().get(HEADER_USER_ID)); + assertEquals(NotificationService.UPDATE_TYPE_STUDY_CREATION_FINISHED, message.getHeaders().get(NotificationService.HEADER_UPDATE_TYPE)); assertNull(output.receive(TIMEOUT, studyUpdateDestination)); assertNull(output.receive(TIMEOUT, elementUpdateDestination)); From 14d9d7b8743dbad98f0e18d97044e0574103dac1 Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Mon, 7 Sep 2026 11:15:01 +0200 Subject: [PATCH 31/32] clean code --- .../server/service/StudyImportService.java | 45 ++++++++++++++----- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/gridsuite/study/server/service/StudyImportService.java b/src/main/java/org/gridsuite/study/server/service/StudyImportService.java index 4497554bf5..2527b606db 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyImportService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyImportService.java @@ -24,6 +24,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; import java.util.List; @@ -67,18 +68,38 @@ public void importStudy(TreeExportInfos treeExportInfos, String userId) { List orderedRootNetworks = treeExportInfos.rootNetworks().stream() .sorted(Comparator.comparing(RootNetworkExportInfos::index)) .toList(); - for (RootNetworkExportInfos rootNetworkInfos : orderedRootNetworks) { - UUID newCaseUuid = caseService.duplicateCase(rootNetworkInfos.caseInfos().getCaseUuid(), true); - RootNetworkEntity rootNetworkEntity = rootNetworkService.createRootNetwork(studyEntity, RootNetworkInfos.builder() - .id(UUID.randomUUID()) - .name(rootNetworkInfos.name()) - .tag(rootNetworkInfos.tag()) - .caseInfos(new CaseInfos(newCaseUuid, rootNetworkInfos.caseInfos().getOriginalCaseUuid(), - rootNetworkInfos.caseInfos().getCaseName(), rootNetworkInfos.caseInfos().getCaseFormat())) - .importParameters(rootNetworkInfos.importParameters()) - .networkInfos(new NetworkInfos(UUID.randomUUID(), "")) - .build()); - rootNetworkService.updateNetworkLoadStatus(rootNetworkEntity.getId(), RootNetworkLoadStatus.UNLOADED); + List duplicatedCaseUuids = new ArrayList<>(); + try { + for (RootNetworkExportInfos rootNetworkInfos : orderedRootNetworks) { + UUID newCaseUuid = caseService.duplicateCase(rootNetworkInfos.caseInfos().getCaseUuid(), true); + duplicatedCaseUuids.add(newCaseUuid); + RootNetworkEntity rootNetworkEntity = rootNetworkService.createRootNetwork(studyEntity, RootNetworkInfos.builder() + .id(UUID.randomUUID()) + .name(rootNetworkInfos.name()) + .tag(rootNetworkInfos.tag()) + .caseInfos(new CaseInfos(newCaseUuid, rootNetworkInfos.caseInfos().getOriginalCaseUuid(), + rootNetworkInfos.caseInfos().getCaseName(), rootNetworkInfos.caseInfos().getCaseFormat())) + .importParameters(rootNetworkInfos.importParameters()) + .networkInfos(new NetworkInfos(UUID.randomUUID(), "")) + .build()); + rootNetworkService.updateNetworkLoadStatus(rootNetworkEntity.getId(), RootNetworkLoadStatus.UNLOADED); + } + } catch (Exception e) { + modificationGroupUuidMapping.values().forEach(newGroupUuid -> { + try { + networkModificationService.deleteModifications(newGroupUuid); + } catch (Exception exception) { + LOGGER.error(String.format("Could not clean up orphaned modification group '%s' after import failure", newGroupUuid), exception); + } + }); + duplicatedCaseUuids.forEach(caseUuid -> { + try { + caseService.deleteCase(caseUuid); + } catch (Exception exception) { + LOGGER.error(String.format("Could not clean up orphaned case '%s' after import failure", caseUuid), exception); + } + }); + throw e; } notificationService.emitStudyCreationFinished(studyEntity.getId(), userId); } From a4caf4d2b5e8b454a8bf54c7fdcac41e10028e8b Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Mon, 7 Sep 2026 13:05:37 +0200 Subject: [PATCH 32/32] fix merge --- .../study/server/studycontroller/ImportStudyTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/org/gridsuite/study/server/studycontroller/ImportStudyTest.java b/src/test/java/org/gridsuite/study/server/studycontroller/ImportStudyTest.java index 8cd081e5d0..aa053894cd 100644 --- a/src/test/java/org/gridsuite/study/server/studycontroller/ImportStudyTest.java +++ b/src/test/java/org/gridsuite/study/server/studycontroller/ImportStudyTest.java @@ -65,7 +65,7 @@ void testImportStudy() throws Exception { stubDefaultParametersCreation(); wireMockStubs.caseServer.stubDuplicateCaseWithBody(caseUuid1.toString(), objectMapper.writeValueAsString(duplicatedCaseUuid1)); wireMockStubs.caseServer.stubDuplicateCaseWithBody(caseUuid2.toString(), objectMapper.writeValueAsString(duplicatedCaseUuid2)); - UUID stubDuplicateModificationGroupId = wireMockStubs.stubDuplicateModificationGroup(objectMapper.writeValueAsString(Map.of())); + UUID stubDuplicateModificationGroupId = wireMockStubs.stubDuplicateModificationGroup(); NodeTreeExportInfos nodeTree = new NodeTreeExportInfos("Root", "ROOT", null, null, List.of( new NodeTreeExportInfos("N1", "NETWORK_MODIFICATION", modificationGroupUuid1, "SECURITY", List.of( @@ -140,7 +140,7 @@ void testImportStudyWithModificationGroupOnFailure() throws Exception { UUID modificationGroupUuid1 = UUID.randomUUID(); UUID modificationGroupUuid2 = UUID.randomUUID(); - UUID stubDuplicateModificationGroupId = wireMockStubs.stubDuplicateModificationGroup(objectMapper.writeValueAsString(Map.of())); + UUID stubDuplicateModificationGroupId = wireMockStubs.stubDuplicateModificationGroup(); UUID stubDeleteGroupId = wireMockStubs.stubNetworkModificationDeleteGroup(); NodeTreeExportInfos nodeTree = new NodeTreeExportInfos("Root", "ROOT", null, null, List.of(