From a927d79a1261ecc81cbae2a27de8b3833d6d5d56 Mon Sep 17 00:00:00 2001
From: srosse <none@none>
Date: Mon, 23 Jul 2012 10:21:32 +0200
Subject: [PATCH] OO-316: implement a method to deploy a course

---
 .../java/org/olat/course/CourseFactory.java   |   8 +-
 .../repository/RepositoryEntriesResource.java | 118 ++++++++-------
 .../repository/course/CoursesWebService.java  |  60 ++++++++
 .../olat/restapi/support/MultipartReader.java | 143 ++++++++++++++++++
 .../java/org/olat/restapi/CoursesTest.java    |  47 ++++++
 .../org/olat/restapi/Very_small_course.zip    | Bin 0 -> 7527 bytes
 6 files changed, 319 insertions(+), 57 deletions(-)
 create mode 100644 src/main/java/org/olat/restapi/support/MultipartReader.java
 create mode 100644 src/test/java/org/olat/restapi/Very_small_course.zip

diff --git a/src/main/java/org/olat/course/CourseFactory.java b/src/main/java/org/olat/course/CourseFactory.java
index 659cab77c5e..9bacdc3668a 100644
--- a/src/main/java/org/olat/course/CourseFactory.java
+++ b/src/main/java/org/olat/course/CourseFactory.java
@@ -587,6 +587,10 @@ public class CourseFactory extends BasicManager {
 	 * @param exportedCourseZIPFile
 	 */
 	public static RepositoryEntry deployCourseFromZIP(File exportedCourseZIPFile, int access) {
+		return deployCourseFromZIP(exportedCourseZIPFile, "administrator", null, access);
+	}
+	
+	public static RepositoryEntry deployCourseFromZIP(File exportedCourseZIPFile, String initialAuthor, String softKey, int access) {
 		// create the course instance
 		OLATResource newCourseResource = olatResourceManager.createOLATResourceInstance(CourseModule.class);
 		ICourse course = CourseFactory.importCourseFromZip(newCourseResource, exportedCourseZIPFile);
@@ -600,7 +604,9 @@ public class CourseFactory extends BasicManager {
 		// create the repository entry
 		RepositoryEntry re = repositoryManager.createRepositoryEntryInstance("administrator");
 		RepositoryEntryImportExport importExport = new RepositoryEntryImportExport(courseExportData);
-		String softKey = importExport.getSoftkey();
+		if(!StringHelper.containsNonWhitespace(softKey)) {
+			softKey = importExport.getSoftkey();
+		}
 		RepositoryEntry existingEntry = repositoryManager.lookupRepositoryEntryBySoftkey(softKey, false);
 		if (existingEntry != null) {
 			Tracing.logInfo("RepositoryEntry with softkey " + softKey + " already exists. Course will not be deployed.", CourseFactory.class);
diff --git a/src/main/java/org/olat/restapi/repository/RepositoryEntriesResource.java b/src/main/java/org/olat/restapi/repository/RepositoryEntriesResource.java
index bb01debd2e5..d401ae52c2d 100644
--- a/src/main/java/org/olat/restapi/repository/RepositoryEntriesResource.java
+++ b/src/main/java/org/olat/restapi/repository/RepositoryEntriesResource.java
@@ -58,6 +58,7 @@ import org.olat.basesecurity.BaseSecurityManager;
 import org.olat.basesecurity.Constants;
 import org.olat.basesecurity.SecurityGroup;
 import org.olat.core.id.Identity;
+import org.olat.core.id.OLATResourceable;
 import org.olat.core.id.Roles;
 import org.olat.core.logging.OLog;
 import org.olat.core.logging.Tracing;
@@ -316,66 +317,71 @@ public class RepositoryEntriesResource {
 		try {
 			FileResourceManager frm = FileResourceManager.getInstance();
 			FileResource newResource = frm.addFileResource(fResource, fResource.getName());
+			return importResource(identity, newResource, resourcename, displayname, softkey);
+		} catch(Exception e) {
+			log.error("Fail to import a resource", e);
+			throw new WebApplicationException(e);
+		}
+	}
+		
+	public static RepositoryEntry importResource(Identity identity, OLATResourceable newResource, String resourcename, String displayname,
+			String softkey) {
 
-			RepositoryEntry addedEntry = RepositoryManager.getInstance().createRepositoryEntryInstance(identity.getName());
-			addedEntry.setCanDownload(false);
-			addedEntry.setCanLaunch(true);
-			if(StringHelper.containsNonWhitespace(resourcename)) {
-				addedEntry.setResourcename(resourcename);
-			}
-			if(StringHelper.containsNonWhitespace(displayname)) {
-				addedEntry.setDisplayname(displayname);
-			}
-			if(StringHelper.containsNonWhitespace(softkey)) {
-				addedEntry.setSoftkey(softkey);
-			}
-			// Do set access for owner at the end, because unfinished course should be
-			// invisible
-			// addedEntry.setAccess(RepositoryEntry.ACC_OWNERS);
-			addedEntry.setAccess(0);// Access for nobody
+		RepositoryEntry addedEntry = RepositoryManager.getInstance().createRepositoryEntryInstance(identity.getName());
+		addedEntry.setCanDownload(false);
+		addedEntry.setCanLaunch(true);
+		if(StringHelper.containsNonWhitespace(resourcename)) {
+			addedEntry.setResourcename(resourcename);
+		}
+		if(StringHelper.containsNonWhitespace(displayname)) {
+			addedEntry.setDisplayname(displayname);
+		}
+		if(StringHelper.containsNonWhitespace(softkey)) {
+			addedEntry.setSoftkey(softkey);
+		}
+		// Do set access for owner at the end, because unfinished course should be
+		// invisible
+		// addedEntry.setAccess(RepositoryEntry.ACC_OWNERS);
+		addedEntry.setAccess(0);// Access for nobody
 
-			// Set the resource on the repository entry and save the entry.
-			RepositoryManager rm = RepositoryManager.getInstance();
-			OLATResource ores = OLATResourceManager.getInstance().findOrPersistResourceable(newResource);
-			addedEntry.setOlatResource(ores);
+		// Set the resource on the repository entry and save the entry.
+		RepositoryManager rm = RepositoryManager.getInstance();
+		OLATResource ores = OLATResourceManager.getInstance().findOrPersistResourceable(newResource);
+		addedEntry.setOlatResource(ores);
 
-			BaseSecurity securityManager = BaseSecurityManager.getInstance();
-			// create security group
-			SecurityGroup newGroup = securityManager.createAndPersistSecurityGroup();
-			// member of this group may modify member's membership
-			securityManager.createAndPersistPolicy(newGroup, Constants.PERMISSION_ACCESS, newGroup);
-			// members of this group are always authors also
-			securityManager.createAndPersistPolicy(newGroup, Constants.PERMISSION_HASROLE, Constants.ORESOURCE_AUTHOR);
+		BaseSecurity securityManager = BaseSecurityManager.getInstance();
+		// create security group
+		SecurityGroup newGroup = securityManager.createAndPersistSecurityGroup();
+		// member of this group may modify member's membership
+		securityManager.createAndPersistPolicy(newGroup, Constants.PERMISSION_ACCESS, newGroup);
+		// members of this group are always authors also
+		securityManager.createAndPersistPolicy(newGroup, Constants.PERMISSION_HASROLE, Constants.ORESOURCE_AUTHOR);
 
-			securityManager.addIdentityToSecurityGroup(identity, newGroup);
-			addedEntry.setOwnerGroup(newGroup);
-			
-			//fxdiff VCRP-1,2: access control of resources
-			// security group for tutors / coaches
-			SecurityGroup tutorGroup = securityManager.createAndPersistSecurityGroup();
-			// member of this group may modify member's membership
-			securityManager.createAndPersistPolicy(tutorGroup, Constants.PERMISSION_ACCESS, addedEntry.getOlatResource());
-			// members of this group are always tutors also
-			securityManager.createAndPersistPolicy(tutorGroup, Constants.PERMISSION_HASROLE, Constants.ORESOURCE_TUTOR);
-			addedEntry.setTutorGroup(tutorGroup);
-			
-			// security group for participants
-			SecurityGroup participantGroup = securityManager.createAndPersistSecurityGroup();
-			// member of this group may modify member's membership
-			securityManager.createAndPersistPolicy(participantGroup, Constants.PERMISSION_ACCESS, addedEntry.getOlatResource());
-			// members of this group are always participants also
-			securityManager.createAndPersistPolicy(participantGroup, Constants.PERMISSION_HASROLE, Constants.ORESOURCE_PARTICIPANT);
-			addedEntry.setParticipantGroup(participantGroup);
-			
-			// Do set access for owner at the end, because unfinished course should be
-			// invisible
-			addedEntry.setAccess(RepositoryEntry.ACC_OWNERS);
-			rm.saveRepositoryEntry(addedEntry);
-			return addedEntry;
-		} catch (Exception e) {
-			log.error("Fail to import a resource", e);
-			throw new WebApplicationException(e);
-		}
+		securityManager.addIdentityToSecurityGroup(identity, newGroup);
+		addedEntry.setOwnerGroup(newGroup);
+		
+		//fxdiff VCRP-1,2: access control of resources
+		// security group for tutors / coaches
+		SecurityGroup tutorGroup = securityManager.createAndPersistSecurityGroup();
+		// member of this group may modify member's membership
+		securityManager.createAndPersistPolicy(tutorGroup, Constants.PERMISSION_ACCESS, addedEntry.getOlatResource());
+		// members of this group are always tutors also
+		securityManager.createAndPersistPolicy(tutorGroup, Constants.PERMISSION_HASROLE, Constants.ORESOURCE_TUTOR);
+		addedEntry.setTutorGroup(tutorGroup);
+		
+		// security group for participants
+		SecurityGroup participantGroup = securityManager.createAndPersistSecurityGroup();
+		// member of this group may modify member's membership
+		securityManager.createAndPersistPolicy(participantGroup, Constants.PERMISSION_ACCESS, addedEntry.getOlatResource());
+		// members of this group are always participants also
+		securityManager.createAndPersistPolicy(participantGroup, Constants.PERMISSION_HASROLE, Constants.ORESOURCE_PARTICIPANT);
+		addedEntry.setParticipantGroup(participantGroup);
+		
+		// Do set access for owner at the end, because unfinished course should be
+		// invisible
+		addedEntry.setAccess(RepositoryEntry.ACC_OWNERS);
+		rm.saveRepositoryEntry(addedEntry);
+		return addedEntry;
 	}
 	
 	private File getTmpFile(String suffix) {
diff --git a/src/main/java/org/olat/restapi/repository/course/CoursesWebService.java b/src/main/java/org/olat/restapi/repository/course/CoursesWebService.java
index 013568f8b4d..d1c5d10d63d 100644
--- a/src/main/java/org/olat/restapi/repository/course/CoursesWebService.java
+++ b/src/main/java/org/olat/restapi/repository/course/CoursesWebService.java
@@ -24,12 +24,16 @@ import static org.olat.restapi.security.RestSecurityHelper.getRoles;
 import static org.olat.restapi.security.RestSecurityHelper.getUserRequest;
 import static org.olat.restapi.security.RestSecurityHelper.isAuthor;
 
+import java.io.File;
 import java.util.ArrayList;
 import java.util.List;
 
 import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.Consumes;
 import javax.ws.rs.DefaultValue;
+import javax.ws.rs.FormParam;
 import javax.ws.rs.GET;
+import javax.ws.rs.POST;
 import javax.ws.rs.PUT;
 import javax.ws.rs.Path;
 import javax.ws.rs.Produces;
@@ -52,8 +56,11 @@ import org.olat.core.id.OLATResourceable;
 import org.olat.core.id.Roles;
 import org.olat.core.logging.OLog;
 import org.olat.core.logging.Tracing;
+import org.olat.core.util.CodeHelper;
+import org.olat.core.util.FileUtils;
 import org.olat.core.util.Formatter;
 import org.olat.core.util.StringHelper;
+import org.olat.core.util.WebappHelper;
 import org.olat.core.util.coordinate.LockResult;
 import org.olat.core.util.resource.OresHelper;
 import org.olat.course.CourseFactory;
@@ -70,7 +77,9 @@ import org.olat.repository.handlers.RepositoryHandler;
 import org.olat.repository.handlers.RepositoryHandlerFactory;
 import org.olat.resource.OLATResource;
 import org.olat.resource.OLATResourceManager;
+import org.olat.restapi.security.RestSecurityHelper;
 import org.olat.restapi.support.MediaTypeVariants;
+import org.olat.restapi.support.MultipartReader;
 import org.olat.restapi.support.ObjectFactory;
 import org.olat.restapi.support.vo.CourseConfigVO;
 import org.olat.restapi.support.vo.CourseVO;
@@ -203,6 +212,57 @@ public class CoursesWebService {
 		return Response.ok(vo).build();
 	}
 	
+	/**
+	 * 
+	 * 
+	 * 
+	 * @param request
+	 * @param access
+	 * @return
+	 */
+	@POST
+	@Produces({MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON})
+	@Consumes({MediaType.MULTIPART_FORM_DATA})
+	public Response importCourse(@Context HttpServletRequest request) {
+		if(!isAuthor(request)) {
+			return Response.serverError().status(Status.UNAUTHORIZED).build();
+		}
+		
+		Identity identity = RestSecurityHelper.getUserRequest(request).getIdentity();
+		
+		File tmpFile = null;
+		try {
+			MultipartReader partsReader = new MultipartReader(request);
+			tmpFile = partsReader.getFile();
+			long length = tmpFile.length();
+			if(length > 0) {
+				Long accessRaw = partsReader.getLongValue("access");
+				int access = accessRaw != null ? accessRaw.intValue() : RepositoryEntry.ACC_OWNERS;
+				String softKey = partsReader.getValue("softkey");
+				
+				ICourse course = importCourse(identity, tmpFile, softKey, access);
+				CourseVO vo = ObjectFactory.get(course);
+				return Response.ok(vo).build();
+			}
+			return Response.serverError().status(Status.NO_CONTENT).build();
+		} catch (Exception e) {
+			log.error("Error while importing a file",e);
+		} finally {
+			if(tmpFile != null && tmpFile.exists()) {
+				tmpFile.delete();
+			}
+		}
+
+		CourseVO vo = null;
+		return Response.ok(vo).build();
+	}
+	
+	public static ICourse importCourse(Identity identity, File fCourseImportZIP, String softKey, int access) {
+		RepositoryEntry re = CourseFactory.deployCourseFromZIP(fCourseImportZIP, identity.getName(), softKey, access);
+		ICourse course = CourseFactory.loadCourse(re.getOlatResource());
+		return course;
+	}
+	
 	public static ICourse copyCourse(Long copyFrom, UserRequest ureq, String name, String longTitle, CourseConfigVO courseConfigVO) {
 		String shortTitle = name;
 		//String learningObjectives = name + " (Example of creating a new course)";
diff --git a/src/main/java/org/olat/restapi/support/MultipartReader.java b/src/main/java/org/olat/restapi/support/MultipartReader.java
new file mode 100644
index 00000000000..ec2b1b99431
--- /dev/null
+++ b/src/main/java/org/olat/restapi/support/MultipartReader.java
@@ -0,0 +1,143 @@
+/**
+ * <a href="http://www.openolat.org">
+ * OpenOLAT - Online Learning and Training</a><br>
+ * <p>
+ * Licensed under the Apache License, Version 2.0 (the "License"); <br>
+ * you may not use this file except in compliance with the License.<br>
+ * You may obtain a copy of the License at the
+ * <a href="http://www.apache.org/licenses/LICENSE-2.0">Apache homepage</a>
+ * <p>
+ * Unless required by applicable law or agreed to in writing,<br>
+ * software distributed under the License is distributed on an "AS IS" BASIS, <br>
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. <br>
+ * See the License for the specific language governing permissions and <br>
+ * limitations under the License.
+ * <p>
+ * Initial code contributed and copyrighted by<br>
+ * 12.10.2011 by frentix GmbH, http://www.frentix.com
+ * <p>
+ */
+package org.olat.restapi.support;
+
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.apache.commons.fileupload.FileItemIterator;
+import org.apache.commons.fileupload.FileItemStream;
+import org.apache.commons.fileupload.servlet.ServletFileUpload;
+import org.apache.commons.fileupload.util.Streams;
+import org.olat.core.logging.OLog;
+import org.olat.core.logging.Tracing;
+
+/**
+ * @author srosse, stephane.rosse@frentix.com, http://www.frentix.com
+ */
+public class MultipartReader {
+
+	private static final OLog log = Tracing.createLoggerFor(MultipartReader.class);
+
+	private String filename;
+	private String contentType;
+	private File file;
+	private Map<String, String> fields = new HashMap<String, String>();
+
+	public MultipartReader(HttpServletRequest request) {
+		long uploadLimit = -1l;
+		apache(request, uploadLimit);
+	}
+
+	private final void apache(HttpServletRequest request, long uploadLimit) {
+		ServletFileUpload uploadParser = new ServletFileUpload();
+		uploadParser.setSizeMax((uploadLimit * 1024l) + 512000l);
+		// Parse the request
+		try {
+			FileItemIterator iter = uploadParser.getItemIterator(request);
+			while (iter.hasNext()) {
+				FileItemStream item = iter.next();
+				String itemName = item.getFieldName();
+				InputStream itemStream = item.openStream();
+				if (item.isFormField()) {
+					String value = Streams.asString(itemStream, "UTF-8");
+					fields.put(itemName, value);
+				} else {
+					// File item, store it to temp location
+					filename = item.getName();
+					contentType = item.getContentType();
+					file = new File(System.getProperty("java.io.tmpdir"), "upload-" + UUID.randomUUID().toString().replace("-", ""));
+					try {
+						save(itemStream, file);
+					} catch (Exception e) {
+						log.error("", e);
+					}
+				}
+			}
+		} catch (Exception e) {
+			log.error("", e);
+		}
+	}
+
+	public String getFilename() {
+		return filename;
+	}
+
+	public String getContentType() {
+		return contentType;
+	}
+	
+	public String getText() {
+		return fields.get("text");
+	}
+
+	public String getValue(String key) {
+		String value = fields.get(key);
+		return value;
+	}
+
+	public Long getLongValue(String key) {
+		String value = fields.get(key);
+		if (value == null) { return null; }
+		try {
+			return Long.parseLong(value);
+		} catch (NumberFormatException e) {
+			return null;
+		}
+	}
+
+	public File getFile() {
+		return file;
+	}
+
+	private void save(InputStream source, File targetFile)
+	throws IOException {
+		InputStream in = new BufferedInputStream(source);
+		OutputStream out = new FileOutputStream(targetFile);
+
+		byte[] buffer = new byte[4096];
+
+		int c;
+		while ((c = in.read(buffer, 0, buffer.length)) != -1) {
+			out.write(buffer, 0, c);
+		}
+
+		out.flush();
+		out.close();
+		in.close();
+	}
+
+	public void close() {
+		if (file != null) {
+			file.delete();
+		}
+		fields.clear();
+	}
+
+}
diff --git a/src/test/java/org/olat/restapi/CoursesTest.java b/src/test/java/org/olat/restapi/CoursesTest.java
index 61abfc7b649..f727a6b6250 100644
--- a/src/test/java/org/olat/restapi/CoursesTest.java
+++ b/src/test/java/org/olat/restapi/CoursesTest.java
@@ -31,18 +31,26 @@ import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertTrue;
 
+import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
 import java.net.URI;
 import java.net.URISyntaxException;
+import java.net.URL;
 import java.util.List;
+import java.util.UUID;
 
 import javax.ws.rs.core.MediaType;
 import javax.ws.rs.core.UriBuilder;
 
 import org.apache.http.HttpResponse;
 import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
 import org.apache.http.client.methods.HttpPut;
+import org.apache.http.entity.mime.HttpMultipartMode;
+import org.apache.http.entity.mime.MultipartEntity;
+import org.apache.http.entity.mime.content.FileBody;
+import org.apache.http.entity.mime.content.StringBody;
 import org.codehaus.jackson.map.ObjectMapper;
 import org.codehaus.jackson.type.TypeReference;
 import org.junit.After;
@@ -166,6 +174,45 @@ public class CoursesTest extends OlatJerseyTestCase {
 		assertNotNull(re.getOwnerGroup());
 	}
 	
+	@Test
+	public void testImportCourse() throws IOException, URISyntaxException {
+		URL cpUrl = CoursesTest.class.getResource("Very_small_course.zip");
+		assertNotNull(cpUrl);
+		File cp = new File(cpUrl.toURI());
+
+		assertTrue(conn.login("administrator", "openolat"));
+		
+		URI request = UriBuilder.fromUri(getContextURI()).path("repo/courses").build();
+		HttpPost method = conn.createPost(request, MediaType.APPLICATION_JSON, true);
+		MultipartEntity entity = new MultipartEntity(HttpMultipartMode.BROWSER_COMPATIBLE);
+		entity.addPart("file", new FileBody(cp));
+		entity.addPart("filename", new StringBody("Very_small_course.zip"));
+		entity.addPart("resourcename", new StringBody("Very small course"));
+		entity.addPart("displayname", new StringBody("Very small course"));
+		entity.addPart("access", new StringBody("3"));
+		String softKey = UUID.randomUUID().toString().replace("-", "").substring(0, 30);
+		entity.addPart("softkey", new StringBody(softKey));
+		method.setEntity(entity);
+		
+		HttpResponse response = conn.execute(method);
+		assertTrue(response.getStatusLine().getStatusCode() == 200 || response.getStatusLine().getStatusCode() == 201);
+		
+		InputStream body = response.getEntity().getContent();
+		
+		CourseVO vo = parse(body, CourseVO.class);
+		assertNotNull(vo);
+		assertNotNull(vo.getRepoEntryKey());
+		assertNotNull(vo.getKey());
+		
+		Long repoKey = vo.getRepoEntryKey();
+		RepositoryEntry re = RepositoryManager.getInstance().lookupRepositoryEntry(repoKey);
+		assertNotNull(re);
+		assertNotNull(re.getOwnerGroup());
+		assertNotNull(re.getOlatResource());
+		assertEquals("Very small course", re.getDisplayname());
+		assertEquals(softKey, re.getSoftkey());
+	}
+	
 	@Test
 	public void testGetCourseInfos() throws IOException, URISyntaxException {
 		boolean loggedIN = conn.login("administrator", "openolat");
diff --git a/src/test/java/org/olat/restapi/Very_small_course.zip b/src/test/java/org/olat/restapi/Very_small_course.zip
new file mode 100644
index 0000000000000000000000000000000000000000..8e6d123ddf009e18441dcb219c551e6841e86135
GIT binary patch
literal 7527
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
WX@-Zd{s@6K;b#fu)ZgF2(SHFt%3>%0

literal 0
HcmV?d00001

-- 
GitLab