From f5553f3c9c055fd0ecd47d5941a19f5b052f5f61 Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Fri, 7 Aug 2026 13:01:37 +0200 Subject: [PATCH 01/25] 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 4e4ab1d40..42c06225d 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 000000000..64d820f57 --- /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 000000000..c32b032b3 --- /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 000000000..d8c1b5b5a --- /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 000000000..4aee1a1f2 --- /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 609f41105..16cee3967 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 1779459a4..03996030e 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 000000000..734563e0f --- /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 e0882f854..bf62186db 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/25] 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 000000000..b4d71dacd --- /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/25] 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 b4d71dacd..b599b9acb 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/25] 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 734563e0f..1fab7ecb0 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/25] 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 42c06225d..b4816eec7 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 d8c1b5b5a..2b6289fa0 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 1fab7ecb0..8cf7fb97a 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 bf62186db..20ad548d4 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/25] 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 c32b032b3..4cdcc737f 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 20ad548d4..0e93cc044 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/25] 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 b599b9acb..fcdb67c04 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/25] 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 b4816eec7..c7402969d 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 2b6289fa0..cfd1ed6c6 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 0e93cc044..e195dad76 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 fcdb67c04..971fcf608 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/25] 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 c7402969d..55b66ad5a 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 64d820f57..000000000 --- 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/25] 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 8cf7fb97a..2e844385d 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 e195dad76..9a2ff249f 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 971fcf608..e5575a2bc 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/25] 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 2e844385d..53d734621 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 9a2ff249f..480480314 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/25] 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 e5575a2bc..b7e70195c 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/25] 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 55b66ad5a..99305f635 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 a1709cbf3..dd7510f7a 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 3e8fdfd44..6f1450fab 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 480480314..b58dc1d06 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 000000000..bca79c45d --- /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 fbaffd43a..82895b7ed 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/25] 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 dd7510f7a..f6ebbd300 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 6f1450fab..efe7ea9aa 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 b58dc1d06..b1c4d1189 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 bca79c45d..000000000 --- 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 82895b7ed..fbaffd43a 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/25] 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 f6ebbd300..a1709cbf3 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 efe7ea9aa..bb4b76368 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 b1c4d1189..6ce53bdad 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 000000000..491d2a6a2 --- /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/25] 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 bb4b76368..e2dd86953 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/25] 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 e2dd86953..a11710445 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/25] 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 a11710445..a55f7a4e9 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/25] 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 249ceac39..e3e8aa3dd 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 73803bc2c..550c66fe5 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 af3dd02aa..74d56b66e 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 b31b92a36..e22456c5c 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 03996030e..68f4fe1ad 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 a55f7a4e9..b2d51e35c 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 f875dee49..8e1128968 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 39785ae8b..c293fc5e5 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 000000000..d57312558 --- /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 d14934495..3c2b6bde1 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 000000000..40193a26e --- /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 5267f42f5..969750e72 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 491d2a6a2..6afc51be9 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 b7e70195c..1a1b2191e 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/25] 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 e3e8aa3dd..b951e50c2 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 c4cb8e810..aa5be60a8 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 b2d51e35c..6a3bf0a64 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 8e1128968..2bfac8e63 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 c293fc5e5..13011be7d 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 d57312558..b1b60a2ac 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/25] 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 74d56b66e..3d7ebdec5 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 6a3bf0a64..d60bf8fa0 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/25] 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 d60bf8fa0..61fcd685d 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/25] 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 b951e50c2..6cfc0a041 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 b1b60a2ac..9c82b3710 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 6afc51be9..3a16ab845 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 2b02356398cda186024e281a23d61a612ba429fa Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Mon, 17 Aug 2026 22:30:42 +0200 Subject: [PATCH 24/25] export network modification group plus filters plus loadflow parameters --- .../server/controller/StudyController.java | 5 +- .../NetworkModificationExportInfos.java | 27 +++++++ .../service/NetworkModificationService.java | 6 +- .../server/service/StudyExportService.java | 73 +++++++++++++++++-- .../study/server/service/StudyService.java | 24 +++++- .../server/NetworkModificationTreeTest.java | 3 + .../studycontroller/TreeExportTest.java | 18 +++++ 7 files changed, 146 insertions(+), 10 deletions(-) create mode 100644 src/main/java/org/gridsuite/study/server/dto/studyexport/NetworkModificationExportInfos.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 6cfc0a041..c29ff19c9 100644 --- a/src/main/java/org/gridsuite/study/server/controller/StudyController.java +++ b/src/main/java/org/gridsuite/study/server/controller/StudyController.java @@ -24,6 +24,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.NetworkModificationExportInfos; import org.gridsuite.study.server.dto.studyexport.TreeExportInfos; import org.gridsuite.study.server.elasticsearch.EquipmentInfosService; import org.gridsuite.study.server.error.StudyException; @@ -954,8 +955,8 @@ public ResponseEntity getNetworkModifications(@Parameter(description = " @Operation(summary = "Get network modifications to export for a given node") @ApiResponses(value = {@ApiResponse(responseCode = "200", description = "The network modifications to export was returned"), @ApiResponse(responseCode = "404", description = "The study/node is not found")}) - public ResponseEntity getExportedNetworkModifications(@Parameter(description = "Study UUID") @PathVariable("studyUuid") UUID studyUuid, - @Parameter(description = "Node UUID") @PathVariable("nodeUuid") UUID nodeUuid) { + public ResponseEntity getExportedNetworkModifications(@Parameter(description = "Study UUID") @PathVariable("studyUuid") UUID studyUuid, + @Parameter(description = "Node UUID") @PathVariable("nodeUuid") UUID nodeUuid) { studyService.assertIsStudyAndNodeExist(studyUuid, nodeUuid); return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(studyService.getExportedNetworkModifications(studyUuid, nodeUuid)); } diff --git a/src/main/java/org/gridsuite/study/server/dto/studyexport/NetworkModificationExportInfos.java b/src/main/java/org/gridsuite/study/server/dto/studyexport/NetworkModificationExportInfos.java new file mode 100644 index 000000000..53588cba7 --- /dev/null +++ b/src/main/java/org/gridsuite/study/server/dto/studyexport/NetworkModificationExportInfos.java @@ -0,0 +1,27 @@ +/** + * 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 com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import org.gridsuite.filter.AbstractFilter; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * @author Ghazwa Rehili + */ +public record NetworkModificationExportInfos( + @JsonProperty("modifications") + List exportedModifications, + @JsonProperty("filters") + Map> exportedFilters, + @JsonProperty("loadFlowParameters") + Map exportedLoadFlowParameters +) { } diff --git a/src/main/java/org/gridsuite/study/server/service/NetworkModificationService.java b/src/main/java/org/gridsuite/study/server/service/NetworkModificationService.java index c54913e2d..32430ebc0 100644 --- a/src/main/java/org/gridsuite/study/server/service/NetworkModificationService.java +++ b/src/main/java/org/gridsuite/study/server/service/NetworkModificationService.java @@ -14,6 +14,7 @@ import org.gridsuite.study.server.dto.BuildInfos; import org.gridsuite.study.server.dto.NodeReceiver; import org.gridsuite.study.server.dto.modification.*; +import org.gridsuite.study.server.dto.studyexport.NetworkModificationExportInfos; import org.gridsuite.study.server.dto.workflow.AbstractWorkflowInfos; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.ParameterizedTypeReference; @@ -148,14 +149,15 @@ public String getModifications(UUID groupUUid, boolean stashedModifications, boo return restTemplate.exchange(getNetworkModificationServerURI(false) + path, HttpMethod.GET, null, String.class).getBody(); } - public String getModificationsToExport(UUID groupUUid) { + public NetworkModificationExportInfos getModificationsToExport(UUID groupUUid) { Objects.requireNonNull(groupUUid); var path = UriComponentsBuilder.fromPath(GROUP_PATH + DELIMITER + NETWORK_MODIFICATIONS_PATH + DELIMITER + "export") .queryParam(QUERY_PARAM_ERROR_ON_GROUP_NOT_FOUND, false) .buildAndExpand(groupUUid) .toUriString(); - return restTemplate.exchange(getNetworkModificationServerURI(false) + path, HttpMethod.GET, null, String.class).getBody(); + return restTemplate.exchange(getNetworkModificationServerURI(false) + path, + HttpMethod.GET, null, NetworkModificationExportInfos.class).getBody(); } public Integer getModificationsCount(UUID groupUUid, boolean stashedModifications) { 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 13011be7d..341015e26 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyExportService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyExportService.java @@ -6,11 +6,18 @@ */ package org.gridsuite.study.server.service; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.commons.collections4.CollectionUtils; +import org.gridsuite.filter.AbstractFilter; +import org.gridsuite.study.server.dto.LoadFlowParametersInfos; import org.gridsuite.study.server.dto.networkexport.PermissionType; +import org.gridsuite.study.server.dto.studyexport.NetworkModificationExportInfos; 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.service.loadflow.LoadFlowService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.io.InputStreamResource; @@ -23,10 +30,7 @@ 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.*; import java.util.stream.Stream; import java.util.zip.GZIPInputStream; import java.util.zip.ZipEntry; @@ -41,17 +45,23 @@ public class StudyExportService { private static final Logger LOGGER = LoggerFactory.getLogger(StudyExportService.class); public static final String TREE_JSON_FILE_NAME = "tree.json"; + public static final String NETWORK_MODIFICATIONS_JSON = "network-modification.json"; + public static final String NETWORK_MODIFICATION_FILTERS_JSON = "network-modification-filters.json"; + public static final String NETWORK_MODIFICATION_LOAD_FLOW_PARAMETERS_JSON = "network-modification-load-flow-parameters.json"; public static final String CASES_FOLDER = "cases"; private final StudyService studyService; private final CaseService caseService; private final DirectoryService directoryService; + private final LoadFlowService loadFlowService; private final ObjectMapper objectMapper; - public StudyExportService(StudyService studyService, CaseService caseService, DirectoryService directoryService, ObjectMapper objectMapper) { + public StudyExportService(StudyService studyService, CaseService caseService, DirectoryService directoryService, + LoadFlowService loadFlowService, ObjectMapper objectMapper) { this.studyService = studyService; this.caseService = caseService; this.directoryService = directoryService; + this.loadFlowService = loadFlowService; this.objectMapper = objectMapper; } @@ -95,6 +105,10 @@ private Path compressStudyToZip(UUID studyUuid, Path tempDir) throws IOException TreeExportInfos treeExportInfos = studyService.buildTreeExport(studyUuid); Path studyJsonPath = tempDir.resolve(TREE_JSON_FILE_NAME); objectMapper.writerWithDefaultPrettyPrinter().writeValue(studyJsonPath.toFile(), treeExportInfos); + Map modificationsByGroup = studyService.buildNetworkModificationsExport(treeExportInfos.nodeTree()); + writeNetworkModificationsExport(modificationsByGroup, tempDir); + writeNetworkModificationFiltersExport(studyUuid, modificationsByGroup, tempDir); + writeLoadFlowParametersExport(modificationsByGroup, tempDir); Path casesDir = Files.createDirectories(tempDir.resolve(CASES_FOLDER)); for (RootNetworkExportInfos rootNetworkInfos : treeExportInfos.rootNetworks()) { UUID caseUuid = rootNetworkInfos.caseInfos().getCaseUuid(); @@ -109,6 +123,55 @@ private Path compressStudyToZip(UUID studyUuid, Path tempDir) throws IOException return zipFile; } + private void writeNetworkModificationsExport(Map modificationsByGroup, Path tempDir) throws IOException { + Map>> root = new LinkedHashMap<>(); + for (Map.Entry entry : modificationsByGroup.entrySet()) { + root.put(entry.getKey().toString(), Map.of("modifications", CollectionUtils.emptyIfNull(entry.getValue().exportedModifications()))); + } + Path networkModificationsJsonPath = tempDir.resolve(NETWORK_MODIFICATIONS_JSON); + objectMapper.writerWithDefaultPrettyPrinter().writeValue(networkModificationsJsonPath.toFile(), root); + } + + private void writeNetworkModificationFiltersExport(UUID studyUuid, Map modificationsByGroup, Path tempDir) throws IOException { + Set filterIds = new LinkedHashSet<>(); + for (NetworkModificationExportInfos groupExport : modificationsByGroup.values()) { + Map> filtersByModification = groupExport.exportedFilters(); + if (filtersByModification == null) { + continue; + } + filtersByModification.values().forEach(filtersForModification -> + CollectionUtils.emptyIfNull(filtersForModification).forEach(filter -> filterIds.add(filter.getId()))); + } + ObjectNode filtersRoot = objectMapper.createObjectNode(); + for (UUID filterId : filterIds) { + String exportedFilter = studyService.exportFilterFromFirstRootNetwork(studyUuid, filterId); + if (exportedFilter != null) { + filtersRoot.set(filterId.toString(), objectMapper.readTree(exportedFilter)); + } + } + Path filtersJsonPath = tempDir.resolve(NETWORK_MODIFICATION_FILTERS_JSON); + objectMapper.writerWithDefaultPrettyPrinter().writeValue(filtersJsonPath.toFile(), filtersRoot); + } + + private void writeLoadFlowParametersExport(Map modificationsByGroup, Path tempDir) throws IOException { + Set loadFlowParametersIds = new LinkedHashSet<>(); + for (NetworkModificationExportInfos groupExport : modificationsByGroup.values()) { + Map loadFlowParametersByModification = groupExport.exportedLoadFlowParameters(); + if (loadFlowParametersByModification != null) { + loadFlowParametersIds.addAll(loadFlowParametersByModification.values()); + } + } + ObjectNode loadFlowParametersRoot = objectMapper.createObjectNode(); + for (UUID loadFlowParametersId : loadFlowParametersIds) { + LoadFlowParametersInfos loadFlowParametersInfos = loadFlowService.getLoadFlowParameters(loadFlowParametersId); + if (loadFlowParametersInfos != null) { + loadFlowParametersRoot.set(loadFlowParametersId.toString(), objectMapper.valueToTree(loadFlowParametersInfos)); + } + } + Path loadFlowParametersJsonPath = tempDir.resolve(NETWORK_MODIFICATION_LOAD_FLOW_PARAMETERS_JSON); + objectMapper.writerWithDefaultPrettyPrinter().writeValue(loadFlowParametersJsonPath.toFile(), loadFlowParametersRoot); + } + private Path createTempWorkDir(UUID studyUuid) { return createTempPath(studyUuid, "temp directory", "rwx------", attr -> Files.createTempDirectory("study-export-" + studyUuid, attr)); 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 39d442ccc..1b1472656 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyService.java @@ -28,6 +28,7 @@ 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.NetworkModificationExportInfos; import org.gridsuite.study.server.dto.studyexport.NodeTreeExportInfos; import org.gridsuite.study.server.dto.studyexport.RootNetworkExportInfos; import org.gridsuite.study.server.dto.studyexport.TreeExportInfos; @@ -543,7 +544,7 @@ public List searchModifications(@NonNull UUID r } @Transactional - public String getExportedNetworkModifications(UUID studyUuid, UUID nodeUuid) { + public NetworkModificationExportInfos getExportedNetworkModifications(UUID studyUuid, UUID nodeUuid) { if (!networkModificationTreeService.getStudyUuidForNodeId(nodeUuid).equals(studyUuid)) { throw new StudyException(NOT_ALLOWED); } @@ -3042,6 +3043,27 @@ public TreeExportInfos buildTreeExport(UUID studyUuid) { return new TreeExportInfos(studyUuid, rootNetworks, nodeTree); } + @Transactional(readOnly = true) + public Map buildNetworkModificationsExport(NodeTreeExportInfos nodeTree) { + Set modificationGroupUuids = new LinkedHashSet<>(); + collectModificationGroupUuids(nodeTree, modificationGroupUuids); + Map modificationsByGroup = new LinkedHashMap<>(); + for (UUID modificationGroupUuid : modificationGroupUuids) { + modificationsByGroup.put(modificationGroupUuid, networkModificationService.getModificationsToExport(modificationGroupUuid)); + } + return modificationsByGroup; + } + + private void collectModificationGroupUuids(NodeTreeExportInfos node, Set modificationGroupUuids) { + if (node == null) { + return; + } + if (node.modificationGroupUuid() != null) { + modificationGroupUuids.add(node.modificationGroupUuid()); + } + CollectionUtils.emptyIfNull(node.children()).forEach(child -> collectModificationGroupUuids(child, modificationGroupUuids)); + } + private RootNetworkExportInfos toRootNetworkExportInfos(RootNetworkInfos rootNetworkInfos, int index) { return new RootNetworkExportInfos( rootNetworkInfos.getName(), diff --git a/src/test/java/org/gridsuite/study/server/NetworkModificationTreeTest.java b/src/test/java/org/gridsuite/study/server/NetworkModificationTreeTest.java index 9a091e510..06b40940d 100644 --- a/src/test/java/org/gridsuite/study/server/NetworkModificationTreeTest.java +++ b/src/test/java/org/gridsuite/study/server/NetworkModificationTreeTest.java @@ -312,6 +312,9 @@ public MockResponse dispatch(RecordedRequest request) { return new MockResponse(HttpStatus.OK.value(), Headers.of(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE), objectMapper.writeValueAsString(2)); } else if (path.matches("/v1/groups/" + MODIFICATION_GROUP_UUID_3 + "/network-modifications-count.*") && request.getMethod().equals("GET")) { return new MockResponse(HttpStatus.OK.value(), Headers.of(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE), objectMapper.writeValueAsString(0)); + } else if (path.matches("/v1/groups/" + MODIFICATION_GROUP_UUID + "/network-modifications/export.*") && request.getMethod().equals("GET")) { + return new MockResponse(HttpStatus.OK.value(), Headers.of(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE), + "{\"modifications\":[],\"filters\":{},\"loadFlowParameters\":{}}"); } else if (path.matches("/v1/groups/" + MODIFICATION_GROUP_UUID + "/.*") && request.getMethod().equals("GET")) { return new MockResponse(HttpStatus.OK.value(), Headers.of(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE), objectMapper.writeValueAsString(List.of())); } else if (path.matches("/v1/groups/" + MODIFICATION_GROUP_UUID_2 + "/.*") && request.getMethod().equals("GET")) { 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 1a1b2191e..27f0a2096 100644 --- a/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportTest.java +++ b/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportTest.java @@ -66,6 +66,8 @@ void testExportStudy() throws Exception { .willReturn(WireMock.aResponse().withStatus(200) .withHeader("Content-Type", "application/octet-stream") .withBody("dummy case content".getBytes()))); + // Stub the network modifications export + stubNetworkModificationsExport(); // Export as zip MvcResult result = mockMvc.perform(get("/v1/studies/{studyUuid}/export/{studyName}", studyUuid, "studyName").header(HEADER_USER_ID, "testUser")) .andExpect(status().isOk()) @@ -105,6 +107,7 @@ void testExportStudy() throws Exception { 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); + verifyNetworkModificationsExport(); wireMockStubs.directoryServer.verifyCheckPermission(List.of(studyUuid), null, PermissionType.READ, false); } @@ -123,6 +126,7 @@ void testExportStudyDecompressesGzipCaseContent() throws Exception { .withHeader("Content-Type", "application/octet-stream") .withHeader("Content-Encoding", "gzip") .withBody(gzipped.toByteArray()))); + stubNetworkModificationsExport(); MvcResult result = mockMvc.perform(get("/v1/studies/{studyUuid}/export/{studyName}", studyUuid, "studyName").header(HEADER_USER_ID, "testUser")) .andExpect(status().isOk()) @@ -140,6 +144,7 @@ void testExportStudyDecompressesGzipCaseContent() throws Exception { } assertEquals(caseContent, extractedCaseContent); WireMockUtilsCriteria.verifyGetRequest(wireMockServer, "/v1/cases/" + CASE_UUID, false, Map.of(), 1); + verifyNetworkModificationsExport(); wireMockStubs.directoryServer.verifyCheckPermission(List.of(studyUuid), null, PermissionType.READ, false); } @@ -163,6 +168,7 @@ void testExportStudyFailToDeleteTempZipFile() throws Exception { 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()))); + stubNetworkModificationsExport(); // 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<>(); @@ -193,6 +199,18 @@ void testExportStudyFailToDeleteTempZipFile() throws Exception { } } WireMockUtilsCriteria.verifyGetRequest(wireMockServer, "/v1/cases/" + CASE_UUID, false, Map.of(), 1); + verifyNetworkModificationsExport(); wireMockStubs.directoryServer.verifyCheckPermission(List.of(studyUuid), null, PermissionType.READ, false); } + + private void stubNetworkModificationsExport() { + wireMockServer.stubFor(WireMock.get(WireMock.urlPathMatching("/v1/groups/[0-9a-f-]+/network-modifications/export")) + .withQueryParam("errorOnGroupNotFound", WireMock.equalTo("false")) + .willReturn(WireMock.okJson("{\"modifications\":[],\"filters\":{},\"loadFlowParameters\":{}}"))); + } + + private void verifyNetworkModificationsExport() { + WireMockUtilsCriteria.verifyGetRequest(wireMockServer, "/v1/groups/[0-9a-f-]+/network-modifications/export", true, + Map.of("errorOnGroupNotFound", WireMock.equalTo("false")), 1); + } } From ba64d3ebc715d90fa88ba9c5003058f3a6b773e0 Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Tue, 18 Aug 2026 11:16:35 +0200 Subject: [PATCH 25/25] draft version --- .../server/controller/StudyController.java | 8 +- .../NetworkModificationImportInfos.java | 27 +++++ .../service/NetworkModificationService.java | 19 ++++ .../server/service/StudyExportService.java | 17 +-- .../server/service/StudyImportService.java | 106 ++++++++++++++++-- .../studycontroller/ImportStudyTest.java | 78 +++++++++---- .../server/utils/wiremock/WireMockStubs.java | 10 ++ 7 files changed, 217 insertions(+), 48 deletions(-) create mode 100644 src/main/java/org/gridsuite/study/server/dto/studyexport/NetworkModificationImportInfos.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 c29ff19c9..2e3012573 100644 --- a/src/main/java/org/gridsuite/study/server/controller/StudyController.java +++ b/src/main/java/org/gridsuite/study/server/controller/StudyController.java @@ -40,6 +40,7 @@ import org.springframework.util.CollectionUtils; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import java.beans.PropertyEditorSupport; import java.util.*; @@ -1623,12 +1624,13 @@ public ResponseEntity exportStudy(@PathVariable("studyUuid") UUID stud return ResponseEntity.ok().headers(headers).body(studyExportService.exportStudy(studyUuid, userId)); } - @PostMapping(value = "/studies/import") + @PostMapping(value = "/studies/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @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 importStudy(@RequestBody TreeExportInfos treeExportInfos, + public ResponseEntity importStudy(@RequestPart("treeExportInfos") TreeExportInfos treeExportInfos, + @RequestPart("modificationsArchive") MultipartFile modificationsArchive, @RequestHeader(HEADER_USER_ID) String userId) { - studyImportService.importStudy(treeExportInfos, userId); + studyImportService.importStudy(treeExportInfos, modificationsArchive, userId); return ResponseEntity.ok().build(); } } diff --git a/src/main/java/org/gridsuite/study/server/dto/studyexport/NetworkModificationImportInfos.java b/src/main/java/org/gridsuite/study/server/dto/studyexport/NetworkModificationImportInfos.java new file mode 100644 index 000000000..a6e8a025f --- /dev/null +++ b/src/main/java/org/gridsuite/study/server/dto/studyexport/NetworkModificationImportInfos.java @@ -0,0 +1,27 @@ +/** + * 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 com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import org.gridsuite.filter.AbstractFilter; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * @author Ghazwa Rehili + */ +public record NetworkModificationImportInfos( + @JsonProperty("modifications") + List modifications, + @JsonProperty("filtersByOldId") + Map filtersByOldId, + @JsonProperty("loadFlowParametersIdMapping") + Map loadFlowParametersIdMapping +) { } diff --git a/src/main/java/org/gridsuite/study/server/service/NetworkModificationService.java b/src/main/java/org/gridsuite/study/server/service/NetworkModificationService.java index 32430ebc0..2e7effe8b 100644 --- a/src/main/java/org/gridsuite/study/server/service/NetworkModificationService.java +++ b/src/main/java/org/gridsuite/study/server/service/NetworkModificationService.java @@ -15,6 +15,7 @@ import org.gridsuite.study.server.dto.NodeReceiver; import org.gridsuite.study.server.dto.modification.*; import org.gridsuite.study.server.dto.studyexport.NetworkModificationExportInfos; +import org.gridsuite.study.server.dto.studyexport.NetworkModificationImportInfos; import org.gridsuite.study.server.dto.workflow.AbstractWorkflowInfos; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.ParameterizedTypeReference; @@ -470,6 +471,24 @@ public Map duplicateModificationsGroup(UUID sourceGroupUuid, UUID gr ).getBody(); } + public Map importNetworkModifications(UUID groupUuid, NetworkModificationImportInfos networkModificationImportInfos) { + Objects.requireNonNull(groupUuid); + var path = UriComponentsBuilder.fromPath("groups/{groupUuid}/network-modifications/import") + .buildAndExpand(groupUuid) + .toUriString(); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity httpEntity = new HttpEntity<>(networkModificationImportInfos, headers); + + return restTemplate.exchange( + getNetworkModificationServerURI(false) + path, + HttpMethod.POST, + httpEntity, + new ParameterizedTypeReference>() { } + ).getBody(); + } + public NetworkModificationsResult duplicateModificationsFromGroup(UUID groupUuid, UUID originGroupUuid, Pair, List> modificationContextInfos) { return handleModifications(groupUuid, originGroupUuid, StudyConstants.ModificationsActionType.COPY, modificationContextInfos); } 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 341015e26..60b7043cd 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyExportService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyExportService.java @@ -107,7 +107,7 @@ private Path compressStudyToZip(UUID studyUuid, Path tempDir) throws IOException objectMapper.writerWithDefaultPrettyPrinter().writeValue(studyJsonPath.toFile(), treeExportInfos); Map modificationsByGroup = studyService.buildNetworkModificationsExport(treeExportInfos.nodeTree()); writeNetworkModificationsExport(modificationsByGroup, tempDir); - writeNetworkModificationFiltersExport(studyUuid, modificationsByGroup, tempDir); + writeNetworkModificationFiltersExport(modificationsByGroup, tempDir); writeLoadFlowParametersExport(modificationsByGroup, tempDir); Path casesDir = Files.createDirectories(tempDir.resolve(CASES_FOLDER)); for (RootNetworkExportInfos rootNetworkInfos : treeExportInfos.rootNetworks()) { @@ -132,25 +132,18 @@ private void writeNetworkModificationsExport(Map modificationsByGroup, Path tempDir) throws IOException { - Set filterIds = new LinkedHashSet<>(); + private void writeNetworkModificationFiltersExport(Map modificationsByGroup, Path tempDir) throws IOException { + Map filtersById = new LinkedHashMap<>(); for (NetworkModificationExportInfos groupExport : modificationsByGroup.values()) { Map> filtersByModification = groupExport.exportedFilters(); if (filtersByModification == null) { continue; } filtersByModification.values().forEach(filtersForModification -> - CollectionUtils.emptyIfNull(filtersForModification).forEach(filter -> filterIds.add(filter.getId()))); - } - ObjectNode filtersRoot = objectMapper.createObjectNode(); - for (UUID filterId : filterIds) { - String exportedFilter = studyService.exportFilterFromFirstRootNetwork(studyUuid, filterId); - if (exportedFilter != null) { - filtersRoot.set(filterId.toString(), objectMapper.readTree(exportedFilter)); - } + CollectionUtils.emptyIfNull(filtersForModification).forEach(filter -> filtersById.put(filter.getId(), filter))); } Path filtersJsonPath = tempDir.resolve(NETWORK_MODIFICATION_FILTERS_JSON); - objectMapper.writerWithDefaultPrettyPrinter().writeValue(filtersJsonPath.toFile(), filtersRoot); + objectMapper.writerWithDefaultPrettyPrinter().writeValue(filtersJsonPath.toFile(), filtersById); } private void writeLoadFlowParametersExport(Map modificationsByGroup, Path tempDir) 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 index 9c82b3710..f18686570 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyImportService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyImportService.java @@ -6,10 +6,16 @@ */ package org.gridsuite.study.server.service; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.commons.collections4.CollectionUtils; +import org.gridsuite.filter.AbstractFilter; 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.NetworkModificationImportInfos; 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,17 +23,28 @@ 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.service.loadflow.LoadFlowRestService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; +import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; import static org.gridsuite.study.server.error.StudyBusinessErrorCode.NOT_FOUND; +import static org.gridsuite.study.server.service.StudyExportService.NETWORK_MODIFICATIONS_JSON; +import static org.gridsuite.study.server.service.StudyExportService.NETWORK_MODIFICATION_FILTERS_JSON; +import static org.gridsuite.study.server.service.StudyExportService.NETWORK_MODIFICATION_LOAD_FLOW_PARAMETERS_JSON; /** * @author Ghazwa Rehili @@ -42,18 +59,23 @@ public class StudyImportService { private final NetworkModificationService networkModificationService; private final CaseService caseService; private final NotificationService notificationService; + private final LoadFlowRestService loadFlowRestService; + private final ObjectMapper objectMapper; public StudyImportService(StudyService studyService, StudyRepository studyRepository, RootNetworkService rootNetworkService, - NetworkModificationService networkModificationService, CaseService caseService, NotificationService notificationService) { + NetworkModificationService networkModificationService, CaseService caseService, NotificationService notificationService, + LoadFlowRestService loadFlowRestService, ObjectMapper objectMapper) { this.studyService = studyService; this.studyRepository = studyRepository; this.rootNetworkService = rootNetworkService; this.networkModificationService = networkModificationService; this.caseService = caseService; this.notificationService = notificationService; + this.loadFlowRestService = loadFlowRestService; + this.objectMapper = objectMapper; } - public void importStudy(TreeExportInfos treeExportInfos, String userId) { + public void importStudy(TreeExportInfos treeExportInfos, MultipartFile modificationsArchive, String userId) { if (treeExportInfos.rootNetworks().isEmpty()) { throw new StudyException(NOT_FOUND, "No root network found in import archive"); } @@ -62,7 +84,8 @@ public void importStudy(TreeExportInfos treeExportInfos, String userId) { .map(this::toRootNetworkInfos) .toList(); - Map modificationGroupUuidMapping = duplicateModificationGroups(treeExportInfos.nodeTree()); + NetworkModificationsArchiveContent archiveContent = readModificationsArchive(modificationsArchive); + Map modificationGroupUuidMapping = importModificationGroups(treeExportInfos.nodeTree(), archiveContent); StudyEntity studyEntity = studyService.createStudyEntityWithTree(treeExportInfos.studyUuid(), userId, treeExportInfos.nodeTree(), modificationGroupUuidMapping); studyEntity.setRootNetworkOrder(orderedRootNetworks.stream().map(RootNetworkInfos::getId).toList()); @@ -106,13 +129,56 @@ private RootNetworkInfos toRootNetworkInfos(RootNetworkExportInfos rootNetworkEx .build(); } - private Map duplicateModificationGroups(NodeTreeExportInfos nodeTree) { + private record NetworkModificationsArchiveContent( + Map> modificationsByGroup, + Map filtersByOldId, + Map loadFlowParametersByOldId) { + } + + private record ModificationsGroupExport(@JsonProperty("modifications") List modifications) { + } + + private NetworkModificationsArchiveContent readModificationsArchive(MultipartFile modificationsArchive) { + Map entriesByName = new HashMap<>(); + try (ZipInputStream zipIn = new ZipInputStream(modificationsArchive.getInputStream())) { + ZipEntry entry; + while ((entry = zipIn.getNextEntry()) != null) { + entriesByName.put(entry.getName(), zipIn.readAllBytes()); + } + } catch (IOException e) { + throw new StudyException(NOT_FOUND, "Could not read modifications archive: " + e.getMessage()); + } + try { + Map modificationsRoot = entriesByName.containsKey(NETWORK_MODIFICATIONS_JSON) + ? objectMapper.readValue(entriesByName.get(NETWORK_MODIFICATIONS_JSON), new TypeReference>() { }) + : Map.of(); + Map> modificationsByGroup = modificationsRoot.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().modifications() == null ? List.of() : entry.getValue().modifications())); + + Map filtersByOldId = entriesByName.containsKey(NETWORK_MODIFICATION_FILTERS_JSON) + ? objectMapper.readValue(entriesByName.get(NETWORK_MODIFICATION_FILTERS_JSON), new TypeReference>() { }) + : Map.of(); + + Map loadFlowParametersByOldId = entriesByName.containsKey(NETWORK_MODIFICATION_LOAD_FLOW_PARAMETERS_JSON) + ? objectMapper.readValue(entriesByName.get(NETWORK_MODIFICATION_LOAD_FLOW_PARAMETERS_JSON), new TypeReference>() { }) + : Map.of(); + + return new NetworkModificationsArchiveContent(modificationsByGroup, filtersByOldId, loadFlowParametersByOldId); + } catch (IOException e) { + throw new StudyException(NOT_FOUND, "Invalid modifications archive content: " + e.getMessage()); + } + } + + private Map importModificationGroups(NodeTreeExportInfos nodeTree, NetworkModificationsArchiveContent archiveContent) { Map modificationGroupUuidMapping = new HashMap<>(); if (nodeTree == null) { return modificationGroupUuidMapping; } + List createdLoadFlowParametersIds = new ArrayList<>(); try { - CollectionUtils.emptyIfNull(nodeTree.children()).forEach(child -> duplicateModificationGroupsRecursively(child, modificationGroupUuidMapping)); + Map loadFlowParametersIdMapping = recreateLoadFlowParameters(archiveContent.loadFlowParametersByOldId(), createdLoadFlowParametersIds); + CollectionUtils.emptyIfNull(nodeTree.children()).forEach(child -> + importModificationGroupsRecursively(child, archiveContent, loadFlowParametersIdMapping, modificationGroupUuidMapping)); } catch (Exception e) { modificationGroupUuidMapping.values().forEach(newGroupUuid -> { try { @@ -121,18 +187,40 @@ private Map duplicateModificationGroups(NodeTreeExportInfos nodeTree LOGGER.error(String.format("Could not clean up orphaned modification group '%s' after import failure", newGroupUuid), cleanupException); } }); + createdLoadFlowParametersIds.forEach(loadFlowParametersId -> { + try { + loadFlowRestService.deleteParameters(loadFlowParametersId); + } catch (Exception cleanupException) { + LOGGER.error(String.format("Could not clean up orphaned load flow parameters '%s' after import failure", loadFlowParametersId), cleanupException); + } + }); throw e; } return modificationGroupUuidMapping; } - private void duplicateModificationGroupsRecursively(NodeTreeExportInfos exportNode, Map modificationGroupUuidMapping) { + private Map recreateLoadFlowParameters(Map loadFlowParametersByOldId, List createdLoadFlowParametersIds) { + Map loadFlowParametersIdMapping = new LinkedHashMap<>(); + for (Map.Entry entry : loadFlowParametersByOldId.entrySet()) { + UUID newLoadFlowParametersId = loadFlowRestService.createLoadFlowParameters(entry.getValue().toString()); + createdLoadFlowParametersIds.add(newLoadFlowParametersId); + loadFlowParametersIdMapping.put(entry.getKey(), newLoadFlowParametersId); + } + return loadFlowParametersIdMapping; + } + + private void importModificationGroupsRecursively(NodeTreeExportInfos exportNode, NetworkModificationsArchiveContent archiveContent, + Map loadFlowParametersIdMapping, Map modificationGroupUuidMapping) { studyService.toNetworkModificationNodeType(exportNode.nodeType()); if (exportNode.modificationGroupUuid() != null) { + UUID oldGroupUuid = exportNode.modificationGroupUuid(); UUID newGroupUuid = UUID.randomUUID(); - networkModificationService.duplicateModificationsGroup(exportNode.modificationGroupUuid(), newGroupUuid); - modificationGroupUuidMapping.put(exportNode.modificationGroupUuid(), newGroupUuid); + List modifications = archiveContent.modificationsByGroup().getOrDefault(oldGroupUuid, List.of()); + NetworkModificationImportInfos importInfos = new NetworkModificationImportInfos(modifications, archiveContent.filtersByOldId(), loadFlowParametersIdMapping); + networkModificationService.importNetworkModifications(newGroupUuid, importInfos); + modificationGroupUuidMapping.put(oldGroupUuid, newGroupUuid); } - CollectionUtils.emptyIfNull(exportNode.children()).forEach(child -> duplicateModificationGroupsRecursively(child, modificationGroupUuidMapping)); + CollectionUtils.emptyIfNull(exportNode.children()).forEach(child -> + importModificationGroupsRecursively(child, archiveContent, loadFlowParametersIdMapping, modificationGroupUuidMapping)); } } 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 3a16ab845..aae72b47f 100644 --- a/src/test/java/org/gridsuite/study/server/studycontroller/ImportStudyTest.java +++ b/src/test/java/org/gridsuite/study/server/studycontroller/ImportStudyTest.java @@ -27,16 +27,23 @@ 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.MediaType; import org.springframework.messaging.Message; import org.springframework.messaging.support.GenericMessage; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MvcResult; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.function.Consumer; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; import static org.gridsuite.study.server.StudyConstants.HEADER_IMPORT_PARAMETERS; import static org.gridsuite.study.server.StudyConstants.HEADER_RECEIVER; @@ -44,7 +51,7 @@ 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.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** @@ -78,7 +85,7 @@ void testImportStudy() throws Exception { 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())); + UUID stubImportNetworkModificationsId = wireMockStubs.stubImportNetworkModifications(objectMapper.writeValueAsString(Map.of())); NodeTreeExportInfos nodeTree = new NodeTreeExportInfos("Root", "ROOT", null, null, List.of( new NodeTreeExportInfos("N1", "NETWORK_MODIFICATION", modificationGroupUuid1, "SECURITY", List.of( @@ -90,9 +97,10 @@ void testImportStudy() throws Exception { rootNetworkExportInfos("rn2", "2", 1, caseUuid2) ), nodeTree); - mockMvc.perform(post(IMPORT_URL).header(HEADER_USER_ID, USER_ID) - .contentType("application/json") - .content(objectMapper.writeValueAsString(treeExportInfos))) + mockMvc.perform(multipart(IMPORT_URL) + .file(treeExportInfosPart(treeExportInfos)) + .file(emptyModificationsArchivePart()) + .header(HEADER_USER_ID, USER_ID)) .andExpect(status().isOk()); checkRootNetworkRequestNotifications(2, studyUuid); @@ -112,7 +120,7 @@ void testImportStudy() throws Exception { assertEquals("CONSTRUCTION", ((NetworkModificationNode) n2).getNodeType().name()); assertNotEquals(modificationGroupUuid1, ((NetworkModificationNode) n1).getModificationGroupUuid()); assertNotEquals(modificationGroupUuid2, ((NetworkModificationNode) n2).getModificationGroupUuid()); - wireMockStubs.verifyDuplicateModificationGroup(stubDuplicateModificationGroupId, 2); + wireMockStubs.verifyImportNetworkModifications(stubImportNetworkModificationsId, 2); assertEquals(2, rootNetworkRequestRepository.countAllByStudyUuid(studyUuid)); wireMockStubs.caseServer.verifyCaseExists(stubCaseExists1Id, caseUuid1.toString()); @@ -148,9 +156,10 @@ void testImportStudyWithExportedOrder() throws Exception { rootNetworkExportInfos("rn2", "2", 1, caseUuid2) ), nodeTree); - mockMvc.perform(post(IMPORT_URL).header(HEADER_USER_ID, USER_ID) - .contentType("application/json") - .content(objectMapper.writeValueAsString(treeExportInfos))) + mockMvc.perform(multipart(IMPORT_URL) + .file(treeExportInfosPart(treeExportInfos)) + .file(emptyModificationsArchivePart()) + .header(HEADER_USER_ID, USER_ID)) .andExpect(status().isOk()); checkRootNetworkRequestNotifications(2, studyUuid); @@ -229,9 +238,10 @@ void testImportStudyWithRootNetworkFailure() throws Exception { rootNetworkExportInfos("rn2", "2", 1, caseUuid2) ), nodeTree); - mockMvc.perform(post(IMPORT_URL).header(HEADER_USER_ID, USER_ID) - .contentType("application/json") - .content(objectMapper.writeValueAsString(treeExportInfos))) + mockMvc.perform(multipart(IMPORT_URL) + .file(treeExportInfosPart(treeExportInfos)) + .file(emptyModificationsArchivePart()) + .header(HEADER_USER_ID, USER_ID)) .andExpect(status().isOk()); checkRootNetworkRequestNotifications(1, studyUuid); @@ -256,7 +266,7 @@ void testImportStudyWithModificationGroupOnFailure() throws Exception { UUID modificationGroupUuid1 = UUID.randomUUID(); UUID modificationGroupUuid2 = UUID.randomUUID(); - UUID stubDuplicateModificationGroupId = wireMockStubs.stubDuplicateModificationGroup(objectMapper.writeValueAsString(Map.of())); + UUID stubImportNetworkModificationsId = wireMockStubs.stubImportNetworkModifications(objectMapper.writeValueAsString(Map.of())); UUID stubDeleteGroupId = wireMockStubs.stubNetworkModificationDeleteGroup(); NodeTreeExportInfos nodeTree = new NodeTreeExportInfos("Root", "ROOT", null, null, List.of( @@ -268,14 +278,15 @@ void testImportStudyWithModificationGroupOnFailure() throws Exception { 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))) + MvcResult result = mockMvc.perform(multipart(IMPORT_URL) + .file(treeExportInfosPart(treeExportInfos)) + .file(emptyModificationsArchivePart()) + .header(HEADER_USER_ID, USER_ID)) .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.verifyImportNetworkModifications(stubImportNetworkModificationsId, 1); wireMockStubs.verifyNetworkModificationDeleteGroup(stubDeleteGroupId, false); assertTrue(studyRepository.findById(studyUuid).isEmpty()); @@ -295,9 +306,10 @@ void testImportStudyWithInvalidNodeType() throws Exception { 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))) + MvcResult result = mockMvc.perform(multipart(IMPORT_URL) + .file(treeExportInfosPart(treeExportInfos)) + .file(emptyModificationsArchivePart()) + .header(HEADER_USER_ID, USER_ID)) .andExpect(status().isForbidden()) .andReturn(); PowsyblWsProblemDetail problemDetail = objectMapper.readValue(result.getResponse().getContentAsString(), PowsyblWsProblemDetail.class); @@ -307,7 +319,7 @@ void testImportStudyWithInvalidNodeType() 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"))); - wireMockServer.verify(0, WireMock.postRequestedFor(WireMock.urlPathMatching("/v1/groups/.*/duplicate"))); + wireMockServer.verify(0, WireMock.postRequestedFor(WireMock.urlPathMatching("/v1/groups/.*/network-modifications/import"))); wireMockServer.verify(0, WireMock.getRequestedFor(WireMock.urlPathEqualTo("/v1/users/" + USER_ID + "/profile"))); } @@ -316,9 +328,10 @@ void testImportStudyWithNoRootNetworks() 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))) + MvcResult result = mockMvc.perform(multipart(IMPORT_URL) + .file(treeExportInfosPart(treeExportInfos)) + .file(emptyModificationsArchivePart()) + .header(HEADER_USER_ID, USER_ID)) .andExpect(status().isNotFound()) .andReturn(); PowsyblWsProblemDetail problemDetail = objectMapper.readValue(result.getResponse().getContentAsString(), PowsyblWsProblemDetail.class); @@ -331,6 +344,23 @@ private RootNetworkExportInfos rootNetworkExportInfos(String name, String tag, i return new RootNetworkExportInfos(name, tag, index, new CaseInfos(caseUuid, null, "caseName", "UCTE"), Map.of()); } + private MockMultipartFile treeExportInfosPart(TreeExportInfos treeExportInfos) throws Exception { + return new MockMultipartFile("treeExportInfos", "treeExportInfos", MediaType.APPLICATION_JSON_VALUE, + objectMapper.writeValueAsBytes(treeExportInfos)); + } + + private MockMultipartFile emptyModificationsArchivePart() throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zipOut = new ZipOutputStream(baos)) { + for (String fileName : List.of("network-modification.json", "network-modification-filters.json", "network-modification-load-flow-parameters.json")) { + zipOut.putNextEntry(new ZipEntry(fileName)); + zipOut.write("{}".getBytes(StandardCharsets.UTF_8)); + zipOut.closeEntry(); + } + } + return new MockMultipartFile("modificationsArchive", "modifications.zip", "application/zip", baos.toByteArray()); + } + private void stubDefaultParametersCreation() throws Exception { ReflectionTestUtils.setField(caseService, "caseServerBaseUri", wireMockServer.baseUrl()); wireMockStubs.userAdminServer.stubGetUserProfile(USER_ID); diff --git a/src/test/java/org/gridsuite/study/server/utils/wiremock/WireMockStubs.java b/src/test/java/org/gridsuite/study/server/utils/wiremock/WireMockStubs.java index 1ef7c6f6f..dc4de0364 100644 --- a/src/test/java/org/gridsuite/study/server/utils/wiremock/WireMockStubs.java +++ b/src/test/java/org/gridsuite/study/server/utils/wiremock/WireMockStubs.java @@ -231,6 +231,16 @@ public UUID stubDuplicateModificationGroup(String responseBody) { ).getId(); } + public UUID stubImportNetworkModifications(String responseBody) { + return wireMock.stubFor(WireMock.post(WireMock.urlPathMatching(URI_NETWORK_MODIFICATION_GROUPS + "/.*/network-modifications/import")) + .willReturn(WireMock.ok().withBody(responseBody).withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)) + ).getId(); + } + + public void verifyImportNetworkModifications(UUID stubId, int nbRequests) { + verifyPostRequest(wireMock, stubId, URI_NETWORK_MODIFICATION_GROUPS + "/.*/network-modifications/import", true, Map.of(), nbRequests); + } + public UUID stubNetworkModificationDeleteGroup() { return wireMock.stubFor(WireMock.delete(WireMock.urlPathMatching(URI_NETWORK_MODIFICATION_GROUPS + DELIMITER + ".*")) .willReturn(WireMock.ok())