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..2e3012573 100644 --- a/src/main/java/org/gridsuite/study/server/controller/StudyController.java +++ b/src/main/java/org/gridsuite/study/server/controller/StudyController.java @@ -18,11 +18,14 @@ 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; 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; import org.gridsuite.study.server.exception.PartialResultException; @@ -37,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.*; @@ -62,6 +66,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, @@ -72,7 +77,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; @@ -83,6 +89,7 @@ public StudyController(StudyService studyService, this.rootNetworkService = rootNetworkService; this.rebuildNodeService = rebuildNodeService; this.studyExportService = studyExportService; + this.studyImportService = studyImportService; } @InitBinder @@ -175,7 +182,8 @@ 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)); + rootNetworkInfos.setId(null); + return ResponseEntity.ok().body(studyService.createRootNetworkRequest(studyUuid, rootNetworkInfos, userId, CaseImportAction.ROOT_NETWORK_CREATION)); } @PutMapping(value = "/studies/{studyUuid}/root-networks/{rootNetworkUuid}") @@ -948,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)); } @@ -1615,4 +1623,14 @@ 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", 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(@RequestPart("treeExportInfos") TreeExportInfos treeExportInfos, + @RequestPart("modificationsArchive") MultipartFile modificationsArchive, + @RequestHeader(HEADER_USER_ID) String userId) { + studyImportService.importStudy(treeExportInfos, modificationsArchive, 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/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/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/repository/StudyEntity.java b/src/main/java/org/gridsuite/study/server/repository/StudyEntity.java index af3dd02aa..3d7ebdec5 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,17 @@ public class StudyEntity extends AbstractManuallyAssignedIdentifierEntity @Builder.Default private List rootNetworks = new ArrayList<>(); + /** + * Root network order to restore during an in-progress study import; null otherwise. + */ + @ElementCollection + @CollectionTable(name = "StudyRootNetworkOrder", foreignKey = @ForeignKey( + name = "study_root_network_order_fk" + )) + @OrderColumn(name = "index") + @Column(name = "rootNetworkUuid") + private List rootNetworkOrder; + /** * @deprecated to remove when the data is migrated into the loadflow-server */ @@ -140,7 +152,19 @@ public RootNetworkEntity getFirstRootNetwork() { public void addRootNetwork(RootNetworkEntity rootNetworkEntity) { rootNetworkEntity.setStudy(this); rootNetworkEntity.setIndexationStatus(RootNetworkIndexationStatus.INDEXED); - rootNetworks.add(rootNetworkEntity); + rootNetworks.add(resolveInsertPosition(rootNetworkEntity.getId()), rootNetworkEntity); + } + + /** + * Insert index for rootNetworkId based on prior ordered networks, append outside pending import + */ + private int resolveInsertPosition(UUID rootNetworkId) { + int targetPos = rootNetworkOrder == null ? -1 : rootNetworkOrder.indexOf(rootNetworkId); + if (targetPos < 0) { + return rootNetworks.size(); + } + Set alreadyPresent = rootNetworks.stream().map(RootNetworkEntity::getId).collect(Collectors.toSet()); + return (int) rootNetworkOrder.subList(0, targetPos).stream().filter(alreadyPresent::contains).count(); } 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/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/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 3e8fdfd44..61fcd685d 100644 --- a/src/main/java/org/gridsuite/study/server/service/ConsumerService.java +++ b/src/main/java/org/gridsuite/study/server/service/ConsumerService.java @@ -66,10 +66,10 @@ 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; - private final StudyConfigService studyConfigService; private final RootNetworkNodeInfoService rootNetworkNodeInfoService; private final DirectoryService directoryService; private final ComputationParametersService computationParametersService; @@ -79,10 +79,10 @@ public class ConsumerService { public ConsumerService(ObjectMapper objectMapper, NotificationService notificationService, StudyService studyService, + StudyImportService studyImportService, CaseService caseService, LoadFlowRestService loadFlowRestService, NetworkModificationTreeService networkModificationTreeService, - StudyConfigService studyConfigService, RootNetworkNodeInfoService rootNetworkNodeInfoService, DirectoryService directoryService, ComputationParametersService computationParametersService, @@ -91,10 +91,10 @@ 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; - this.studyConfigService = studyConfigService; this.rootNetworkNodeInfoService = rootNetworkNodeInfoService; this.directoryService = directoryService; this.computationParametersService = computationParametersService; @@ -250,6 +250,20 @@ private void handleConsumeCaseImportSucceeded(CaseImportReceiver receiver, UUID .networkInfos(networkInfos) .importParameters(importParameters) .build()); + case ROOT_NETWORK_CREATION_FOR_STUDY_IMPORT -> { + try { + studyService.createRootNetwork(studyUuid, RootNetworkInfos.builder() + .id(rootNetworkUuid) + .caseInfos(caseInfos) + .reportUuid(importReportUuid) + .networkInfos(networkInfos) + .importParameters(importParameters) + .build()); + } finally { + studyService.deleteRootNetworkRequest(rootNetworkUuid); + studyImportService.checkFinishedStudyImport(studyUuid, userId); + } + } case NETWORK_RECREATION -> studyService.updateNetwork(studyUuid, rootNetworkUuid, networkInfos, userId); case ROOT_NETWORK_MODIFICATION -> studyService.modifyRootNetwork(studyUuid, RootNetworkInfos.builder() .id(rootNetworkUuid) @@ -281,73 +295,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() { @@ -364,12 +320,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) { 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/NetworkModificationService.java b/src/main/java/org/gridsuite/study/server/service/NetworkModificationService.java index c54913e2d..2e7effe8b 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/RootNetworkService.java b/src/main/java/org/gridsuite/study/server/service/RootNetworkService.java index f875dee49..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,6 +280,10 @@ public List geRootNetworkRequests(UUID studyUuid) { return rootNetworkRequestRepository.findAllByStudyUuid(studyUuid); } + public int countRootNetworkCreationRequests(UUID studyUuid) { + return rootNetworkRequestRepository.countAllByStudyUuidAndActionRequest(studyUuid, RootNetworkAction.ROOT_NETWORK_CREATION); + } + public void deleteRootNetworkRequest(RootNetworkRequestEntity rootNetworkRequestEntity) { rootNetworkRequestRepository.delete(rootNetworkRequestEntity); } diff --git a/src/main/java/org/gridsuite/study/server/service/StudyExportService.java b/src/main/java/org/gridsuite/study/server/service/StudyExportService.java index 39785ae8b..60b7043cd 100644 --- a/src/main/java/org/gridsuite/study/server/service/StudyExportService.java +++ b/src/main/java/org/gridsuite/study/server/service/StudyExportService.java @@ -6,16 +6,21 @@ */ 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; -import org.springframework.http.HttpHeaders; -import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import java.io.*; @@ -25,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; @@ -43,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; } @@ -72,8 +80,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); @@ -97,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(); @@ -111,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)); @@ -127,8 +181,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()); } } @@ -138,30 +192,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..f18686570 --- /dev/null +++ b/src/main/java/org/gridsuite/study/server/service/StudyImportService.java @@ -0,0 +1,226 @@ +/** + * 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.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; +import org.gridsuite.study.server.error.StudyException; +import org.gridsuite.study.server.notification.NotificationService; +import org.gridsuite.study.server.repository.StudyEntity; +import org.gridsuite.study.server.repository.StudyRepository; +import org.gridsuite.study.server.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 + */ +@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; + private final LoadFlowRestService loadFlowRestService; + private final ObjectMapper objectMapper; + + public StudyImportService(StudyService studyService, StudyRepository studyRepository, RootNetworkService rootNetworkService, + 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, MultipartFile modificationsArchive, 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(); + + 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()); + studyRepository.save(studyEntity); + + notificationService.emitStudyCreationStarted(studyEntity.getId(), userId); + int successfulRequests = 0; + for (RootNetworkInfos rootNetworkInfos : orderedRootNetworks) { + try { + caseService.assertCaseExists(rootNetworkInfos.getCaseInfos().getOriginalCaseUuid()); + studyService.createRootNetworkRequest(studyEntity.getId(), rootNetworkInfos, userId, CaseImportAction.ROOT_NETWORK_CREATION_FOR_STUDY_IMPORT); + successfulRequests++; + } catch (Exception e) { + LOGGER.error(String.format("Could not request root network '%s' for imported study '%s'", rootNetworkInfos.getName(), studyEntity.getId()), e); + } + } + if (successfulRequests == 0) { + studyService.deleteStudyIfNotCreationInProgress(studyEntity.getId(), userId); + notificationService.emitStudyCreationError(studyEntity.getId(), userId, "Could not request any root network for imported study"); + } + } + + public void checkFinishedStudyImport(UUID studyUuid, String userId) { + if (rootNetworkService.countRootNetworkCreationRequests(studyUuid) == 0) { + studyRepository.findById(studyUuid).ifPresent(studyEntity -> { + studyEntity.setRootNetworkOrder(null); + studyRepository.save(studyEntity); + }); + notificationService.emitStudyCreationFinished(studyUuid, userId); + } + } + + 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 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 { + 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 { + networkModificationService.deleteModifications(newGroupUuid); + } catch (Exception cleanupException) { + 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 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(); + 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 -> + 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 8a3c31bd2..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; @@ -342,16 +343,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, UUID.randomUUID()); + persistNetwork(rootNetworkInfos, studyUuid, null, userId, rootNetworkInfos.getImportParameters(), caseImportAction, UUID.randomUUID()); } catch (Exception e) { rootNetworkService.deleteRootNetworkRequest(rootNetworkCreationRequestEntity); throw e; @@ -542,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); } @@ -3041,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(), @@ -3068,4 +3091,119 @@ private NodeTreeExportInfos toNodeTreeExportInfos(AbstractNode node) { children ); } + + 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, e.getMessage()); + } + } + + @Transactional + 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); + 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()); + + UUID rootNodeUuid = networkModificationTreeService.createRoot(studyEntity).getIdNode(); + if (nodeTree != null && !nodeTree.children().isEmpty()) { + nodeTree.children().forEach(child -> createNodeRecursively(studyEntity, rootNodeUuid, child, userId, modificationGroupUuidMapping)); + } + + studyInfosService.add(toCreatedStudyBasicInfos(studyEntity)); + + return studyEntity; + } + + private void createNodeRecursively(StudyEntity studyEntity, UUID parentNodeUuid, NodeTreeExportInfos exportNode, String userId, Map modificationGroupUuidMapping) { + NetworkModificationNodeType nodeType = toNetworkModificationNodeType(exportNode.nodeType()); + UUID newGroupUuid = exportNode.modificationGroupUuid() != null ? modificationGroupUuidMapping.get(exportNode.modificationGroupUuid()) : null; + 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, modificationGroupUuidMapping)); + } + + 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; + } + } + + 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 studyConfigService.createDefaultSpreadsheetConfigCollection(); + } catch (final Exception e) { + LOGGER.error("Error while creating default spreadsheet config collection", e); + return null; + } + } + + UUID createWorkspacesConfig(UserProfileInfos userProfileInfos) { + try { + 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; + } + } } 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/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/ImportStudyTest.java b/src/test/java/org/gridsuite/study/server/studycontroller/ImportStudyTest.java new file mode 100644 index 000000000..aae72b47f --- /dev/null +++ b/src/test/java/org/gridsuite/study/server/studycontroller/ImportStudyTest.java @@ -0,0 +1,406 @@ +/** + * 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.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; +import org.gridsuite.study.server.networkmodificationtree.dto.AbstractNode; +import org.gridsuite.study.server.networkmodificationtree.dto.NetworkModificationNode; +import org.gridsuite.study.server.networkmodificationtree.dto.RootNode; +import org.gridsuite.study.server.notification.NotificationService; +import org.gridsuite.study.server.repository.StudyEntity; +import org.gridsuite.study.server.repository.rootnetwork.RootNetworkEntity; +import org.gridsuite.study.server.repository.rootnetwork.RootNetworkRequestEntity; +import org.gridsuite.study.server.repository.rootnetwork.RootNetworkRequestRepository; +import org.gridsuite.study.server.service.ConsumerService; +import org.gridsuite.study.server.utils.wiremock.WireMockUtilsCriteria; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.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; +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.multipart; +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"; + private static final String USER_ID = "testUser"; + + @Autowired + private ObjectMapper objectMapper; + @Autowired + private RootNetworkRequestRepository rootNetworkRequestRepository; + @Autowired + private ConsumerService consumerService; + + @Test + void testImportStudy() 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 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( + 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(multipart(IMPORT_URL) + .file(treeExportInfosPart(treeExportInfos)) + .file(emptyModificationsArchivePart()) + .header(HEADER_USER_ID, USER_ID)) + .andExpect(status().isOk()); + + checkRootNetworkRequestNotifications(2, studyUuid); + assertNull(output.receive(TIMEOUT, studyUpdateDestination)); + + 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.verifyImportNetworkModifications(stubImportNetworkModificationsId, 2); + + assertEquals(2, rootNetworkRequestRepository.countAllByStudyUuid(studyUuid)); + wireMockStubs.caseServer.verifyCaseExists(stubCaseExists1Id, caseUuid1.toString()); + wireMockStubs.caseServer.verifyCaseExists(stubCaseExists2Id, caseUuid2.toString()); + verifyDuplicateCaseRequest(caseUuid1); + verifyDuplicateCaseRequest(caseUuid2); + verifyImportNetworkRequest(duplicatedCaseUuid1); + verifyImportNetworkRequest(duplicatedCaseUuid2); + + verifyDefaultParametersCreation(); + } + + @Test + void testImportStudyWithExportedOrder() throws Exception { + UUID studyUuid = UUID.randomUUID(); + UUID caseUuid1 = UUID.randomUUID(); + UUID caseUuid2 = UUID.randomUUID(); + UUID duplicatedCaseUuid1 = UUID.randomUUID(); + UUID duplicatedCaseUuid2 = UUID.randomUUID(); + + stubDefaultParametersCreation(); + UUID stubCaseExists1Id = wireMockStubs.caseServer.stubCaseExists(caseUuid1.toString(), true); + UUID stubCaseExists2Id = wireMockStubs.caseServer.stubCaseExists(caseUuid2.toString(), true); + wireMockStubs.caseServer.stubDuplicateCaseWithBody(caseUuid1.toString(), objectMapper.writeValueAsString(duplicatedCaseUuid1)); + wireMockStubs.caseServer.stubDuplicateCaseWithBody(caseUuid2.toString(), objectMapper.writeValueAsString(duplicatedCaseUuid2)); + stubImportNetworkOnly(); + UUID stubDisableExpiration1Id = wireMockStubs.caseServer.stubDisableCaseExpiration(duplicatedCaseUuid1.toString()); + UUID stubDisableExpiration2Id = wireMockStubs.caseServer.stubDisableCaseExpiration(duplicatedCaseUuid2.toString()); + + NodeTreeExportInfos nodeTree = new NodeTreeExportInfos("Root", "ROOT", null, null, List.of()); + TreeExportInfos treeExportInfos = new TreeExportInfos(studyUuid, List.of( + rootNetworkExportInfos("rn1", "1", 0, caseUuid1), + rootNetworkExportInfos("rn2", "2", 1, caseUuid2) + ), nodeTree); + + mockMvc.perform(multipart(IMPORT_URL) + .file(treeExportInfosPart(treeExportInfos)) + .file(emptyModificationsArchivePart()) + .header(HEADER_USER_ID, USER_ID)) + .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 testImportStudyWithRootNetworkFailure() throws Exception { + UUID studyUuid = UUID.randomUUID(); + UUID caseUuid1 = UUID.randomUUID(); + UUID caseUuid2 = UUID.randomUUID(); + UUID duplicatedCaseUuid1 = UUID.randomUUID(); + + stubDefaultParametersCreation(); + UUID stubCaseExists1Id = wireMockStubs.caseServer.stubCaseExists(caseUuid1.toString(), true); + UUID stubCaseExists2Id = wireMockStubs.caseServer.stubCaseExists(caseUuid2.toString(), true); + wireMockStubs.caseServer.stubDuplicateCaseWithBody(caseUuid1.toString(), objectMapper.writeValueAsString(duplicatedCaseUuid1)); + stubImportNetworkOnly(); + wireMockServer.stubFor(WireMock.post(WireMock.urlPathEqualTo("/v1/cases/" + caseUuid2 + "/duplicate")) + .withQueryParam("withExpiration", WireMock.matching(".*")) + .willReturn(WireMock.serverError())); + + NodeTreeExportInfos nodeTree = new NodeTreeExportInfos("Root", "ROOT", null, null, List.of()); + TreeExportInfos treeExportInfos = new TreeExportInfos(studyUuid, List.of( + rootNetworkExportInfos("rn1", "1", 0, caseUuid1), + rootNetworkExportInfos("rn2", "2", 1, caseUuid2) + ), nodeTree); + + mockMvc.perform(multipart(IMPORT_URL) + .file(treeExportInfosPart(treeExportInfos)) + .file(emptyModificationsArchivePart()) + .header(HEADER_USER_ID, USER_ID)) + .andExpect(status().isOk()); + + checkRootNetworkRequestNotifications(1, studyUuid); + assertNull(output.receive(TIMEOUT, studyUpdateDestination)); + + assertTrue(studyRepository.findById(studyUuid).isPresent()); + assertNotNull(networkModificationTreeService.getStudyTree(studyUuid, null)); + assertEquals(1, rootNetworkRequestRepository.countAllByStudyUuid(studyUuid)); + wireMockStubs.caseServer.verifyCaseExists(stubCaseExists1Id, caseUuid1.toString()); + wireMockStubs.caseServer.verifyCaseExists(stubCaseExists2Id, caseUuid2.toString()); + verifyDuplicateCaseRequest(caseUuid1); + verifyImportNetworkRequest(duplicatedCaseUuid1); + WireMockUtilsCriteria.verifyPostRequest(wireMockServer, "/v1/cases/" + caseUuid2 + "/duplicate", + Map.of("withExpiration", WireMock.matching(".*"))); + verifyDefaultParametersCreation(); + } + + @Test + void testImportStudyWithModificationGroupOnFailure() throws Exception { + UUID studyUuid = UUID.randomUUID(); + UUID caseUuid = UUID.randomUUID(); + UUID modificationGroupUuid1 = UUID.randomUUID(); + UUID modificationGroupUuid2 = UUID.randomUUID(); + + UUID stubImportNetworkModificationsId = wireMockStubs.stubImportNetworkModifications(objectMapper.writeValueAsString(Map.of())); + UUID stubDeleteGroupId = wireMockStubs.stubNetworkModificationDeleteGroup(); + + 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, null, List.of()) + )) + )); + TreeExportInfos treeExportInfos = new TreeExportInfos(studyUuid, List.of( + rootNetworkExportInfos("rn1", "1", 0, caseUuid) + ), nodeTree); + + 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.verifyImportNetworkModifications(stubImportNetworkModificationsId, 1); + wireMockStubs.verifyNetworkModificationDeleteGroup(stubDeleteGroupId, false); + + assertTrue(studyRepository.findById(studyUuid).isEmpty()); + assertEquals(0, rootNetworkRequestRepository.countAllByStudyUuid(studyUuid)); + } + + @Test + void testImportStudyWithInvalidNodeType() 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) + ), nodeTree); + + 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()); + + 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"))); + wireMockServer.verify(0, WireMock.postRequestedFor(WireMock.urlPathMatching("/v1/groups/.*/network-modifications/import"))); + wireMockServer.verify(0, WireMock.getRequestedFor(WireMock.urlPathEqualTo("/v1/users/" + USER_ID + "/profile"))); + } + + @Test + 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(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); + 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 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); + setupCreateParametersStubs(); + } + + private void verifyDefaultParametersCreation() { + wireMockStubs.userAdminServer.verifyGetUserProfile(USER_ID); + verifyCreateParameters(1, 9, 1, 1, 1); + } + + private void checkRootNetworkRequestNotifications(int successfulRootNetworkRequests, UUID studyUuid) { + Message studyCreationStarted = output.receive(TIMEOUT, studyUpdateDestination); + assertNotNull(studyCreationStarted); + assertEquals(studyUuid, studyCreationStarted.getHeaders().get(NotificationService.HEADER_STUDY_UUID)); + assertEquals(NotificationService.UPDATE_TYPE_STUDY_CREATION_STARTED, studyCreationStarted.getHeaders().get(NotificationService.HEADER_UPDATE_TYPE)); + for (int i = 0; i < successfulRootNetworkRequests; i++) { + Message rootNetworksUpdated = output.receive(TIMEOUT, studyUpdateDestination); + assertNotNull(rootNetworksUpdated); + assertEquals(studyUuid, rootNetworksUpdated.getHeaders().get(NotificationService.HEADER_STUDY_UUID)); + + Message elementUpdated = output.receive(TIMEOUT, elementUpdateDestination); + assertNotNull(elementUpdated); + assertEquals(studyUuid, elementUpdated.getHeaders().get(NotificationService.HEADER_ELEMENT_UUID)); + assertEquals(ImportStudyTest.USER_ID, elementUpdated.getHeaders().get(NotificationService.HEADER_MODIFIED_BY)); + } + } + + private void stubImportNetworkOnly() { + wireMockServer.stubFor(WireMock.post(WireMock.urlPathEqualTo("/v1/networks")) + .willReturn(WireMock.ok())); + } + + private void verifyDuplicateCaseRequest(UUID caseUuid) { + WireMockUtilsCriteria.verifyPostRequest(wireMockServer, "/v1/cases/" + caseUuid + "/duplicate", + Map.of("withExpiration", WireMock.matching(".*"))); + } + + private void verifyImportNetworkRequest(UUID caseUuid) { + WireMockUtilsCriteria.verifyPostRequest(wireMockServer, "/v1/networks", + Map.of("caseUuid", WireMock.equalTo(caseUuid.toString()), "receiver", WireMock.matching(".*"))); + } +} 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..27f0a2096 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; @@ -63,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()) @@ -102,6 +107,44 @@ 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); + } + + @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()))); + stubNetworkModificationsExport(); + + 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); + verifyNetworkModificationsExport(); wireMockStubs.directoryServer.verifyCheckPermission(List.of(studyUuid), null, PermissionType.READ, false); } @@ -125,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<>(); @@ -155,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 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())