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