Skip to content
Snippets Groups Projects
Commit 5b9e1610 authored by Sonia Zorba's avatar Sonia Zorba
Browse files

Changed backend for handling copy and move of multiple selected nodes

parent 0341cfd6
No related branches found
No related tags found
No related merge requests found
......@@ -9,16 +9,20 @@ import it.inaf.ia2.aa.data.User;
import it.inaf.ia2.vospace.ui.client.VOSpaceClient;
import it.inaf.ia2.vospace.ui.data.Job;
import it.inaf.ia2.vospace.ui.data.ListNodeData;
import it.inaf.ia2.vospace.ui.data.MoveOrCopyRequest;
import it.inaf.ia2.vospace.ui.exception.VOSpaceException;
import it.inaf.ia2.vospace.ui.service.MainNodesHtmlGenerator;
import it.inaf.ia2.vospace.ui.service.MoveNodeModalHtmlGenerator;
import it.inaf.ia2.vospace.ui.service.MoveOrCopyNodeModalHtmlGenerator;
import it.inaf.oats.vospace.datamodel.NodeUtils;
import static it.inaf.oats.vospace.datamodel.NodeUtils.urlEncodePath;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.servlet.http.HttpServletRequest;
import net.ivoa.xml.uws.v1.ExecutionPhase;
import net.ivoa.xml.uws.v1.JobSummary;
......@@ -49,8 +53,8 @@ public class NodesController extends BaseController {
@Value("${vospace-authority}")
private String authority;
@Value("${maxPollingAttempts:10}")
private int maxPollingAttempts;
@Value("${pollingTimeout:15}")
private int pollingTimeout;
@Autowired
private VOSpaceClient client;
......@@ -76,10 +80,10 @@ public class NodesController extends BaseController {
return ResponseEntity.ok(listNodeData);
}
@GetMapping(value = "/nodesForMove", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<ListNodeData> listNodesForMoveModal(@RequestParam("path") String path, @RequestParam("nodeToMove") String nodeToMove, User principal) throws Exception {
@GetMapping(value = "/nodesForMoveOrCopy", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<ListNodeData> listNodesForMoveOrCopyModal(@RequestParam("path") String path, @RequestParam("target") List<String> targetNodes, User principal) throws Exception {
LOG.debug("listNodes called for path {}", path);
LOG.debug("nodesForMoveOrCopy called for path {}", path);
ListNodeData listNodeData = new ListNodeData();
......@@ -87,7 +91,7 @@ public class NodesController extends BaseController {
listNodeData.setWritable(NodeUtils.checkIfWritable(node, principal.getName(), principal.getGroups()));
MoveNodeModalHtmlGenerator htmlGenerator = new MoveNodeModalHtmlGenerator(node, nodeToMove, principal, authority);
MoveOrCopyNodeModalHtmlGenerator htmlGenerator = new MoveOrCopyNodeModalHtmlGenerator(node, targetNodes, principal, authority);
listNodeData.setHtml(htmlGenerator.generateNodes());
return ResponseEntity.ok(listNodeData);
......@@ -203,40 +207,83 @@ public class NodesController extends BaseController {
return commonParent;
}
@PostMapping(value = "/move")
public ResponseEntity<Job> moveNode(@RequestBody Map<String, Object> params) {
String target = urlEncodePath(getRequiredParam(params, "target"));
String direction = urlEncodePath(getRequiredParam(params, "direction"));
@PostMapping(value = "/moveOrCopy")
public ResponseEntity<List<Job>> moveOrCopyNodes(@RequestBody MoveOrCopyRequest request) throws Exception {
CompletableFuture<JobSummary>[] futureJobs = request.getTargets().stream().map(t -> {
String target = urlEncodePath(t);
String direction = urlEncodePath(request.getDirection());
Transfer transfer = new Transfer();
transfer.setTarget("vos://" + authority + target);
transfer.setDirection("vos://" + authority + direction);
transfer.setKeepBytes(request.isKeepBytes());
return CompletableFuture.supplyAsync(() -> client.startTransferJob(transfer), Runnable::run);
}).collect(Collectors.toList()).toArray(CompletableFuture[]::new);
CompletableFuture.allOf(futureJobs).join();
List<JobSummary> jobs = Stream.of(futureJobs).map(j -> j.join()).collect(Collectors.toList());
AtomicReference<Boolean> cancelled = new AtomicReference<>(false);
CompletableFuture timeout = CompletableFuture.runAsync(() -> {
try {
Thread.sleep(pollingTimeout * 1000);
} catch (InterruptedException ex) {
}
cancelled.set(true);
});
JobSummary job = client.startTransferJob(transfer);
try {
CompletableFuture.anyOf(jobsPolling(jobs, cancelled), timeout).join();
} catch (CompletionException ex) {
if (ex.getCause() != null && ex.getCause() instanceof VOSpaceException) {
throw (VOSpaceException) ex.getCause();
}
throw ex;
}
// Try to perform polling until completion. If it takes too much time
// sends the execution phase to the UI and let it handles the polling.
ExecutionPhase phase;
int i = 0;
do {
phase = client.getJobPhase(job.getJobId());
if (phase == ExecutionPhase.COMPLETED) {
break;
} else if (phase == ExecutionPhase.ERROR) {
String errorDetail = client.getErrorDetail(job.getJobId());
throw new VOSpaceException("MoveNode operation failed: " + errorDetail);
Job.JobType type = request.isKeepBytes() ? Job.JobType.COPY : Job.JobType.MOVE;
return ResponseEntity.ok(jobs.stream().map(j -> new Job(j, type))
.collect(Collectors.toList()));
}
private CompletableFuture<?> jobsPolling(List<JobSummary> jobs, AtomicReference<Boolean> cancelled) {
return CompletableFuture.runAsync(() -> {
List<JobSummary> uncompletedJobs;
do {
uncompletedJobs = jobs.stream()
.filter(j -> ExecutionPhase.COMPLETED != j.getPhase())
.collect(Collectors.toList());
if (!uncompletedJobs.isEmpty()) {
try {
Thread.sleep(1000);
} catch (InterruptedException ex) {
break;
}
updatePhases(uncompletedJobs);
}
} while (!uncompletedJobs.isEmpty() && !cancelled.get());
}, Runnable::run);
}
i++;
} while (i < maxPollingAttempts);
private void updatePhases(List<JobSummary> uncompletedJobs) {
CompletableFuture[] phaseFutures = uncompletedJobs.stream()
.map(job -> CompletableFuture.runAsync(() -> {
ExecutionPhase phase = client.getJobPhase(job.getJobId());
if (phase == ExecutionPhase.ERROR) {
String errorDetail = client.getErrorDetail(job.getJobId());
throw new VOSpaceException("Operation failed: " + errorDetail);
}
job.setPhase(phase);
}, Runnable::run)).collect(Collectors.toList()).toArray(CompletableFuture[]::new);
return ResponseEntity.ok(new Job(job, Job.JobType.MOVE));
CompletableFuture.allOf(phaseFutures).join();
}
protected String getPath(String prefix) {
......
......@@ -16,7 +16,8 @@ public class Job {
public static enum JobType {
ASYNC_RECALL,
ARCHIVE,
MOVE
MOVE,
COPY
}
private String id;
......
/*
* This file is part of vospace-ui
* Copyright (C) 2021 Istituto Nazionale di Astrofisica
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package it.inaf.ia2.vospace.ui.data;
import java.util.List;
public class MoveOrCopyRequest {
private List<String> targets;
private String direction;
private boolean keepBytes;
public List<String> getTargets() {
return targets;
}
public void setTargets(List<String> targets) {
this.targets = targets;
}
public String getDirection() {
return direction;
}
public void setDirection(String direction) {
this.direction = direction;
}
public boolean isKeepBytes() {
return keepBytes;
}
public void setKeepBytes(boolean keepBytes) {
this.keepBytes = keepBytes;
}
}
......@@ -137,6 +137,12 @@ public class MainNodesHtmlGenerator extends NodesHtmlGenerator {
moveBtn.attr("class", "dropdown-item");
moveBtn.attr("onclick", "moveNode(" + nodePathJs + ")");
Element copyBtn = dropdown.appendElement("button");
copyBtn.text("Copy");
copyBtn.attr("type", "button");
copyBtn.attr("class", "dropdown-item");
copyBtn.attr("onclick", "copyNode(" + nodePathJs + ")");
Element deleteBtn = dropdown.appendElement("button");
deleteBtn.text("Delete");
deleteBtn.attr("type", "button");
......
......@@ -6,23 +6,24 @@
package it.inaf.ia2.vospace.ui.service;
import it.inaf.ia2.aa.data.User;
import java.util.List;
import net.ivoa.xml.vospace.v2.Node;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
public class MoveNodeModalHtmlGenerator extends NodesHtmlGenerator {
public class MoveOrCopyNodeModalHtmlGenerator extends NodesHtmlGenerator {
private final String nodeNodeMovePath;
private final List<String> targetNodes;
public MoveNodeModalHtmlGenerator(Node node, String nodeNodeMovePath, User user, String authority) {
public MoveOrCopyNodeModalHtmlGenerator(Node node, List<String> targetNodes, User user, String authority) {
super(node, user, authority);
this.nodeNodeMovePath = nodeNodeMovePath;
this.targetNodes = targetNodes;
}
@Override
protected Element createContainerElement(Document html) {
Element container = html.body().appendElement("div");
container.attr("id", "move-nodes");
container.attr("id", "move-or-copy-nodes");
container.attr("class", "list-group");
return container;
}
......@@ -31,7 +32,7 @@ public class MoveNodeModalHtmlGenerator extends NodesHtmlGenerator {
protected void addChild(Node child, Element containerElement) {
NodeInfo nodeInfo = new NodeInfo(child, user, authority);
if (!nodeInfo.isFolder() || nodeInfo.getPath().equals(nodeNodeMovePath)) {
if (!nodeInfo.isFolder() || targetNodes.contains(nodeInfo.getPath())) {
return;
}
......@@ -45,7 +46,7 @@ public class MoveNodeModalHtmlGenerator extends NodesHtmlGenerator {
private void addLink(NodeInfo nodeInfo, Element cell) {
Element link = cell.appendElement("a");
link.attr("href", "#");
link.attr("onclick", "openNodeInMoveModal(event, " + makeJsArg(nodeInfo.getPath()) + ")");
link.attr("onclick", "openNodeInMoveOrCopyModal(event, " + makeJsArg(nodeInfo.getPath()) + ")");
link.text(nodeInfo.getName());
}
}
......@@ -8,17 +8,16 @@ package it.inaf.ia2.vospace.ui.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import it.inaf.ia2.aa.data.User;
import it.inaf.ia2.vospace.ui.client.VOSpaceClient;
import it.inaf.ia2.vospace.ui.data.Job;
import it.inaf.ia2.vospace.ui.data.MoveOrCopyRequest;
import it.inaf.ia2.vospace.ui.exception.VOSpaceException;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import javax.servlet.http.HttpSession;
import net.ivoa.xml.uws.v1.ExecutionPhase;
import net.ivoa.xml.uws.v1.JobSummary;
import net.ivoa.xml.vospace.v2.ContainerNode;
import net.ivoa.xml.vospace.v2.DataNode;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.hamcrest.CoreMatchers.is;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
......@@ -41,12 +40,13 @@ import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.springframework.web.util.NestedServletException;
@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(properties = {"vospace-authority=example.com!vospace", "maxPollingAttempts=2"})
@TestPropertySource(properties = {"vospace-authority=example.com!vospace", "pollingTimeout=2"})
public class NodesControllerTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
......@@ -127,7 +127,7 @@ public class NodesControllerTest {
}
@Test
public void testListNodesForMoveModal() throws Exception {
public void testListNodesForMoveOrCopyModal() throws Exception {
ContainerNode parent = new ContainerNode();
parent.setUri("vos://example.com!vospace/a/b/c");
......@@ -144,15 +144,23 @@ public class NodesControllerTest {
child3.setUri("vos://example.com!vospace/a/b/c/c3");
parent.getNodes().add(child3);
ContainerNode child4 = new ContainerNode();
child4.setUri("vos://example.com!vospace/a/b/c/c3");
parent.getNodes().add(child4);
when(client.getNode(any())).thenReturn(parent);
String response = mockMvc.perform(get("/nodesForMove?path=/a/b/c&nodeToMove=/a/b/c/c3"))
String response = mockMvc.perform(get("/nodesForMoveOrCopy")
.param("path", "/a/b/c")
.param("target", "/a/b/c/c3")
.param("target", "/a/b/c/c4"))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertTrue(response.contains("c1")); // folder
assertFalse(response.contains("c2")); // data node
assertFalse(response.contains("c3")); // nodeToMove
assertFalse(response.contains("c4")); // nodeToMove
verify(client, times(1)).getNode(eq("/a/b/c"));
}
......@@ -164,13 +172,9 @@ public class NodesControllerTest {
.thenReturn(ExecutionPhase.EXECUTING)
.thenReturn(ExecutionPhase.COMPLETED);
String response = testMoveNode()
testMoveNode()
.andExpect(status().isOk())
.andReturn().getResponse()
.getContentAsString();
Job job = MAPPER.readValue(response, Job.class);
assertEquals(ExecutionPhase.COMPLETED, job.getPhase());
.andExpect(jsonPath("$[0].phase", is("COMPLETED")));
}
@Test
......@@ -179,13 +183,9 @@ public class NodesControllerTest {
when(client.getJobPhase("job_id"))
.thenReturn(ExecutionPhase.EXECUTING);
String response = testMoveNode()
testMoveNode()
.andExpect(status().isOk())
.andReturn().getResponse()
.getContentAsString();
Job job = MAPPER.readValue(response, Job.class);
assertEquals(ExecutionPhase.EXECUTING, job.getPhase());
.andExpect(jsonPath("$[0].phase", is("EXECUTING")));
}
@Test
......@@ -228,10 +228,12 @@ public class NodesControllerTest {
when(client.startTransferJob(any())).thenReturn(job);
Map<String, String> params = Map.of("target", "/path/to/target", "direction", "/path/to/direction");
MoveOrCopyRequest request = new MoveOrCopyRequest();
request.setTargets(List.of("/path/to/target"));
request.setDirection("/path/to/direction");
return mockMvc.perform(post("/move")
return mockMvc.perform(post("/moveOrCopy")
.contentType(MediaType.APPLICATION_JSON)
.content(MAPPER.writeValueAsString(params)));
.content(MAPPER.writeValueAsString(request)));
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment