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 6cfc0a041c..2e3012573e 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; @@ -39,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.*; @@ -954,8 +956,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)); } @@ -1622,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/NetworkModificationExportInfos.java b/src/main/java/org/gridsuite/study/server/dto/studyexport/NetworkModificationExportInfos.java new file mode 100644 index 0000000000..53588cba7e --- /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/dto/studyexport/NetworkModificationImportInfos.java b/src/main/java/org/gridsuite/study/server/dto/studyexport/NetworkModificationImportInfos.java new file mode 100644 index 0000000000..a6e8a025f9 --- /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 c54913e2db..2e7effe8b4 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,8 @@ 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.studyexport.NetworkModificationImportInfos; import org.gridsuite.study.server.dto.workflow.AbstractWorkflowInfos; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.ParameterizedTypeReference; @@ -148,14 +150,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) { @@ -468,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 13011be7d0..60b7043cd8 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(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,48 @@ 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(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 -> filtersById.put(filter.getId(), filter))); + } + Path filtersJsonPath = tempDir.resolve(NETWORK_MODIFICATION_FILTERS_JSON); + objectMapper.writerWithDefaultPrettyPrinter().writeValue(filtersJsonPath.toFile(), filtersById); + } + + 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/StudyImportService.java b/src/main/java/org/gridsuite/study/server/service/StudyImportService.java index 9c82b3710f..f186865701 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/main/java/org/gridsuite/study/server/service/StudyService.java b/src/main/java/org/gridsuite/study/server/service/StudyService.java index 39d442ccc9..1b1472656e 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 9a091e510b..06b40940df 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/ImportStudyTest.java b/src/test/java/org/gridsuite/study/server/studycontroller/ImportStudyTest.java index 3a16ab8459..aae72b47f1 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/studycontroller/TreeExportTest.java b/src/test/java/org/gridsuite/study/server/studycontroller/TreeExportTest.java index 1a1b2191ef..27f0a20968 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); + } } 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 1ef7c6f6fb..dc4de0364f 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())