From 285872bf9f58c2420a2a93dcad9af9587e525fde Mon Sep 17 00:00:00 2001
From: srosse <none@none>
Date: Mon, 11 Sep 2017 16:30:46 +0200
Subject: [PATCH] OO-3006: implement REST API to import, delete question in the
 question pool, add and remove authors to questions

---
 .../model/xml/AssessmentItemMetadata.java     |   4 +-
 .../model/xml/ManifestMetadataBuilder.java    |  40 +++
 .../org/olat/modules/qpool/QPoolService.java  |   2 +-
 .../modules/qpool/manager/CollectionDAO.java  |   6 +-
 .../olat/modules/qpool/manager/PoolDAO.java   |   4 +-
 .../qpool/manager/QuestionItemDAO.java        |   6 +-
 .../manager/QuestionPoolServiceImpl.java      |   2 +-
 .../modules/qpool/restapi/QuestionItemVO.java |  85 +++++
 .../qpool/restapi/QuestionItemVOes.java       |  64 ++++
 .../qpool/restapi/QuestionPoolWebService.java | 298 ++++++++++++++++++
 .../olat/restapi/_spring/restApiContext.xml   |   1 +
 .../restapi/security/RestSecurityHelper.java  |   9 +
 .../org/olat/restapi/QuestionPoolTest.java    | 218 +++++++++++++
 .../restapi/multiple_choice_per_answer.zip    | Bin 0 -> 3079 bytes
 14 files changed, 728 insertions(+), 11 deletions(-)
 create mode 100644 src/main/java/org/olat/modules/qpool/restapi/QuestionItemVO.java
 create mode 100644 src/main/java/org/olat/modules/qpool/restapi/QuestionItemVOes.java
 create mode 100644 src/main/java/org/olat/modules/qpool/restapi/QuestionPoolWebService.java
 create mode 100644 src/test/java/org/olat/restapi/QuestionPoolTest.java
 create mode 100644 src/test/java/org/olat/restapi/multiple_choice_per_answer.zip

diff --git a/src/main/java/org/olat/ims/qti21/model/xml/AssessmentItemMetadata.java b/src/main/java/org/olat/ims/qti21/model/xml/AssessmentItemMetadata.java
index 7f205a853c1..af88a7f3a71 100644
--- a/src/main/java/org/olat/ims/qti21/model/xml/AssessmentItemMetadata.java
+++ b/src/main/java/org/olat/ims/qti21/model/xml/AssessmentItemMetadata.java
@@ -251,9 +251,11 @@ public class AssessmentItemMetadata {
 		
 		EducationalType educational = metadata.getEducational(false);
 		if(educational != null) {
-			//
+			level = metadata.getEducationContext();
 		}
 		
+		taxonomyPath = metadata.getClassificationTaxonomy();
+	
 		//qti metadata
 		QTIMetadataType qtiMetadata = metadata.getQtiMetadata(true);
 		if(qtiMetadata != null) {
diff --git a/src/main/java/org/olat/ims/qti21/model/xml/ManifestMetadataBuilder.java b/src/main/java/org/olat/ims/qti21/model/xml/ManifestMetadataBuilder.java
index 2d58b846626..01e478957b4 100644
--- a/src/main/java/org/olat/ims/qti21/model/xml/ManifestMetadataBuilder.java
+++ b/src/main/java/org/olat/ims/qti21/model/xml/ManifestMetadataBuilder.java
@@ -26,6 +26,7 @@ import java.util.StringTokenizer;
 import javax.xml.bind.JAXBElement;
 import javax.xml.namespace.QName;
 
+import org.olat.core.util.StringHelper;
 import org.olat.imscp.xml.manifest.ManifestMetadataType;
 import org.olat.imscp.xml.manifest.MetadataType;
 import org.olat.imsmd.xml.manifest.ClassificationType;
@@ -204,6 +205,23 @@ public class ManifestMetadataBuilder {
 		}
 	}
 	
+	public String getEducationContext() {
+		EducationalType educational = getEducational(true);
+		StringBuilder sb = new StringBuilder();
+		if(educational != null) {
+			ContextType type = getFromAny(ContextType.class, educational.getContent());
+			if(type != null && type.getSource() != null && type.getSource().getLangstring() != null
+					&& type.getValue() != null && type.getValue().getLangstring() != null) {
+				String source = type.getSource().getLangstring().getValue();
+				String value = type.getValue().getLangstring().getValue();
+				if(StringHelper.containsNonWhitespace(source) && StringHelper.containsNonWhitespace(value)) {
+					sb.append(value);
+				}
+			}
+		}
+		return sb.length() == 0 ? null: sb.toString();
+	}
+	
 	public void setEducationalContext(String context, String lang) {
 		EducationalType educational = getEducational(true);
 		if(educational != null) {
@@ -250,6 +268,28 @@ public class ManifestMetadataBuilder {
 		}
 	}
 	
+	public String getClassificationTaxonomy() {
+		StringBuilder sb = new StringBuilder();
+		ClassificationType classification = getClassification("discipline", null, false);
+		if(classification != null) {
+			TaxonpathType taxonpath = getFromAny(TaxonpathType.class, classification.getContent());
+			if(taxonpath != null) {
+				List<TaxonType> taxons = taxonpath.getTaxon();
+				if(taxons != null) {
+					for(TaxonType taxon:taxons) {
+						if(taxon.getEntry() != null && taxon.getEntry().getLangstring().size() > 0) {
+							LangstringType value = taxon.getEntry().getLangstring().get(0);
+							if(value != null && value.getValue() != null) {
+								sb.append("/").append(value.getValue());
+							}
+						}
+					}
+				}
+			}
+		}
+		return sb.length() == 0 ? null : sb.toString();
+	}
+	
 	/**
 	 * Set a taxonomy path of purpose "discipline"
 	 * @param taxonomyPath
diff --git a/src/main/java/org/olat/modules/qpool/QPoolService.java b/src/main/java/org/olat/modules/qpool/QPoolService.java
index a7eb26affc3..24707c2d169 100644
--- a/src/main/java/org/olat/modules/qpool/QPoolService.java
+++ b/src/main/java/org/olat/modules/qpool/QPoolService.java
@@ -70,7 +70,7 @@ public interface QPoolService {
 	
 	public QuestionItem updateItem(QuestionItem item);
 	
-	public void deleteItems(List<QuestionItemShort> items);
+	public void deleteItems(List<? extends QuestionItemShort> items);
 	
 	public int countItems(SearchQuestionItemParams params);
 	
diff --git a/src/main/java/org/olat/modules/qpool/manager/CollectionDAO.java b/src/main/java/org/olat/modules/qpool/manager/CollectionDAO.java
index 7e1a300cf79..adea8da0a05 100644
--- a/src/main/java/org/olat/modules/qpool/manager/CollectionDAO.java
+++ b/src/main/java/org/olat/modules/qpool/manager/CollectionDAO.java
@@ -160,7 +160,7 @@ public class CollectionDAO {
 	public int removeItemFromCollection(List<QuestionItemShort> items, QuestionItemCollection collection) {
 		if(items == null || items.isEmpty()) return 0;//noting to do
 		
-		List<Long> keys = new ArrayList<Long>();
+		List<Long> keys = new ArrayList<>();
 		for(QuestionItemShort item:items) {
 			keys.add(item.getKey());
 		}
@@ -174,10 +174,10 @@ public class CollectionDAO {
 				.executeUpdate();
 	}
 	
-	public int deleteItemFromCollections(List<QuestionItemShort> items) {
+	public int deleteItemFromCollections(List<? extends QuestionItemShort> items) {
 		if(items == null || items.isEmpty()) return 0;//noting to do
 		
-		List<Long> keys = new ArrayList<Long>();
+		List<Long> keys = new ArrayList<>();
 		for(QuestionItemShort item:items) {
 			keys.add(item.getKey());
 		}
diff --git a/src/main/java/org/olat/modules/qpool/manager/PoolDAO.java b/src/main/java/org/olat/modules/qpool/manager/PoolDAO.java
index 4b6b890e4cf..e373e31b992 100644
--- a/src/main/java/org/olat/modules/qpool/manager/PoolDAO.java
+++ b/src/main/java/org/olat/modules/qpool/manager/PoolDAO.java
@@ -73,10 +73,10 @@ public class PoolDAO {
 		return pool;
 	}
 	
-	public int removeFromPools(List<QuestionItemShort> items) {
+	public int removeFromPools(List<? extends QuestionItemShort> items) {
 		if(items == null || items.isEmpty()) return 0;
 		
-		List<Long> keys = new ArrayList<Long>();
+		List<Long> keys = new ArrayList<>();
 		for(QuestionItemShort item:items) {
 			keys.add(item.getKey());
 		}
diff --git a/src/main/java/org/olat/modules/qpool/manager/QuestionItemDAO.java b/src/main/java/org/olat/modules/qpool/manager/QuestionItemDAO.java
index 6cb7c42604f..e94021a0bde 100644
--- a/src/main/java/org/olat/modules/qpool/manager/QuestionItemDAO.java
+++ b/src/main/java/org/olat/modules/qpool/manager/QuestionItemDAO.java
@@ -423,8 +423,8 @@ public class QuestionItemDAO {
 				.getResultList();
 	}
 	
-	public int removeFromShares(List<QuestionItemShort> items) {
-		List<Long> keys = new ArrayList<Long>();
+	public int removeFromShares(List<? extends QuestionItemShort> items) {
+		List<Long> keys = new ArrayList<>();
 		for(QuestionItemShort item:items) {
 			keys.add(item.getKey());
 		}
@@ -437,7 +437,7 @@ public class QuestionItemDAO {
 	}
 	
 	public int removeFromShare(List<QuestionItemShort> items, OLATResource resource) {
-		List<Long> keys = new ArrayList<Long>();
+		List<Long> keys = new ArrayList<>();
 		for(QuestionItemShort item:items) {
 			keys.add(item.getKey());
 		}
diff --git a/src/main/java/org/olat/modules/qpool/manager/QuestionPoolServiceImpl.java b/src/main/java/org/olat/modules/qpool/manager/QuestionPoolServiceImpl.java
index d451ec13074..bae904c8fa6 100644
--- a/src/main/java/org/olat/modules/qpool/manager/QuestionPoolServiceImpl.java
+++ b/src/main/java/org/olat/modules/qpool/manager/QuestionPoolServiceImpl.java
@@ -114,7 +114,7 @@ public class QuestionPoolServiceImpl implements QPoolService {
 	
 
 	@Override
-	public void deleteItems(List<QuestionItemShort> items) {
+	public void deleteItems(List<? extends QuestionItemShort> items) {
 		if(items == null || items.isEmpty()) {
 			return; //nothing to do
 		}
diff --git a/src/main/java/org/olat/modules/qpool/restapi/QuestionItemVO.java b/src/main/java/org/olat/modules/qpool/restapi/QuestionItemVO.java
new file mode 100644
index 00000000000..3827ead6026
--- /dev/null
+++ b/src/main/java/org/olat/modules/qpool/restapi/QuestionItemVO.java
@@ -0,0 +1,85 @@
+/**
+ * <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>
+ * frentix GmbH, http://www.frentix.com
+ * <p>
+ */
+package org.olat.modules.qpool.restapi;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlRootElement;
+
+import org.olat.modules.qpool.QuestionItem;
+
+/**
+ * 
+ * Initial date: 8 sept. 2017<br>
+ * @author srosse, stephane.rosse@frentix.com, http://www.frentix.com
+ *
+ */
+@XmlAccessorType(XmlAccessType.FIELD)
+@XmlRootElement(name = "questionItemVO")
+public class QuestionItemVO {
+	
+	private Long key;
+	private String identifier;
+	private String masterIdentifier;
+	private String title;
+	
+	public QuestionItemVO() {
+		//
+	}
+	
+	public QuestionItemVO(QuestionItem item) {
+		key = item.getKey();
+		identifier = item.getIdentifier();
+		masterIdentifier = item.getMasterIdentifier();
+		title = item.getTitle();
+	}
+
+	public Long getKey() {
+		return key;
+	}
+
+	public void setKey(Long key) {
+		this.key = key;
+	}
+
+	public String getIdentifier() {
+		return identifier;
+	}
+
+	public void setIdentifier(String identifier) {
+		this.identifier = identifier;
+	}
+
+	public String getMasterIdentifier() {
+		return masterIdentifier;
+	}
+
+	public void setMasterIdentifier(String masterIdentifier) {
+		this.masterIdentifier = masterIdentifier;
+	}
+
+	public String getTitle() {
+		return title;
+	}
+
+	public void setTitle(String title) {
+		this.title = title;
+	}
+}
diff --git a/src/main/java/org/olat/modules/qpool/restapi/QuestionItemVOes.java b/src/main/java/org/olat/modules/qpool/restapi/QuestionItemVOes.java
new file mode 100644
index 00000000000..6be10a576a7
--- /dev/null
+++ b/src/main/java/org/olat/modules/qpool/restapi/QuestionItemVOes.java
@@ -0,0 +1,64 @@
+/**
+ * <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>
+ * frentix GmbH, http://www.frentix.com
+ * <p>
+ */
+package org.olat.modules.qpool.restapi;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlAttribute;
+import javax.xml.bind.annotation.XmlElement;
+import javax.xml.bind.annotation.XmlElementWrapper;
+import javax.xml.bind.annotation.XmlRootElement;
+
+/**
+ * 
+ * Initial date: 8 sept. 2017<br>
+ * @author srosse, stephane.rosse@frentix.com, http://www.frentix.com
+ *
+ */
+@XmlAccessorType(XmlAccessType.FIELD)
+@XmlRootElement(name = "questionItemVOes")
+public class QuestionItemVOes {
+	
+	@XmlElementWrapper(name="questionItems")
+	@XmlElement(name="questionItem")
+	private QuestionItemVO[] questionItems;
+	@XmlAttribute(name="totalCount")
+	private int totalCount;
+	
+	public QuestionItemVOes() {
+		//
+	}
+
+	public QuestionItemVO[] getQuestionItems() {
+		return questionItems;
+	}
+
+	public void setQuestionItems(QuestionItemVO[] questionItems) {
+		this.questionItems = questionItems;
+	}
+
+	public int getTotalCount() {
+		return totalCount;
+	}
+
+	public void setTotalCount(int totalCount) {
+		this.totalCount = totalCount;
+	}
+}
diff --git a/src/main/java/org/olat/modules/qpool/restapi/QuestionPoolWebService.java b/src/main/java/org/olat/modules/qpool/restapi/QuestionPoolWebService.java
new file mode 100644
index 00000000000..60323d02866
--- /dev/null
+++ b/src/main/java/org/olat/modules/qpool/restapi/QuestionPoolWebService.java
@@ -0,0 +1,298 @@
+/**
+ * <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>
+ * frentix GmbH, http://www.frentix.com
+ * <p>
+ */
+package org.olat.modules.qpool.restapi;
+
+import static org.olat.restapi.security.RestSecurityHelper.isQuestionPoolManager;
+
+import java.io.File;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.ws.rs.Consumes;
+import javax.ws.rs.DELETE;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.PUT;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.Response.Status;
+
+import org.olat.basesecurity.BaseSecurity;
+import org.olat.core.CoreSpringFactory;
+import org.olat.core.id.Identity;
+import org.olat.core.logging.OLog;
+import org.olat.core.logging.Tracing;
+import org.olat.core.util.i18n.I18nManager;
+import org.olat.modules.qpool.QPoolService;
+import org.olat.modules.qpool.QuestionItem;
+import org.olat.modules.qpool.QuestionItemShort;
+import org.olat.restapi.security.RestSecurityHelper;
+import org.olat.restapi.support.MultipartReader;
+import org.olat.user.restapi.UserVO;
+import org.olat.user.restapi.UserVOFactory;
+
+/**
+ * 
+ * Initial date: 8 sept. 2017<br>
+ * @author srosse, stephane.rosse@frentix.com, http://www.frentix.com
+ *
+ */
+@Path("qpool/items")
+public class QuestionPoolWebService {
+	
+	private static final OLog log = Tracing.createLoggerFor(QuestionPoolWebService.class);
+	
+	@PUT
+	@Produces({MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON})
+	@Consumes({MediaType.MULTIPART_FORM_DATA})
+	public Response importQuestionItemsPut(@Context HttpServletRequest request) {
+		return importQuestionItems(request);
+	}
+	
+	@POST
+	@Produces({MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON})
+	@Consumes({MediaType.MULTIPART_FORM_DATA})
+	public Response importQuestionItemsPost(@Context HttpServletRequest request) {
+		return importQuestionItems(request);
+	}
+	
+	private Response importQuestionItems(HttpServletRequest request) {
+		if(!isQuestionPoolManager(request)) {
+			return Response.serverError().status(Status.UNAUTHORIZED).build();
+		}
+
+		MultipartReader partsReader = null;
+		try {		
+			Identity identity = RestSecurityHelper.getUserRequest(request).getIdentity();
+			partsReader = new MultipartReader(request);
+			File tmpFile = partsReader.getFile();
+			long length = tmpFile.length();
+			if(length > 0) {
+				String filename = partsReader.getValue("filename");
+				String language = partsReader.getValue("language");
+				QuestionItemVOes voes = importQuestionItem(identity, filename, tmpFile, language);
+				return Response.ok(voes).build();
+			}
+			return Response.serverError().status(Status.NO_CONTENT).build();
+		} catch (Exception e) {
+			log.error("Error while importing a file",e);
+		} finally {
+			MultipartReader.closeQuietly(partsReader);
+		}
+		return Response.serverError().status(Status.INTERNAL_SERVER_ERROR).build();
+	}
+	
+	private QuestionItemVOes importQuestionItem(Identity owner, String filename, File tmpFile, String language) {
+		Locale locale = CoreSpringFactory.getImpl(I18nManager.class).getLocaleOrDefault(language);
+		
+		List<QuestionItem> items = CoreSpringFactory.getImpl(QPoolService.class)
+			.importItems(owner, locale, filename, tmpFile);
+		QuestionItemVOes voes = new QuestionItemVOes();
+		QuestionItemVO[] voArray = new QuestionItemVO[items.size()];
+		for(int i=items.size(); i-->0; ) {
+			voArray[i] = new QuestionItemVO(items.get(i));
+		}
+		voes.setQuestionItems(voArray);
+		voes.setTotalCount(items.size());
+		return voes;
+	}
+	
+	/**
+	 * Delete a question item by id.
+	 * 
+	 * @response.representation.200.doc Nothing
+	 * @response.representation.401.doc The roles of the authenticated user are not sufficient
+	 * @response.representation.404.doc The question item not found
+	 * @param itemKey The question item identifier
+	 * @param request The HTTP request
+	 * @return Nothing
+	 */
+	@DELETE
+	@Path("{itemKey}")
+	public Response deleteQuestionItem(@PathParam("itemKey") Long itemKey, @Context HttpServletRequest request) {
+		if(!isQuestionPoolManager(request)) {
+			return Response.serverError().status(Status.UNAUTHORIZED).build();
+		}
+		QPoolService poolService = CoreSpringFactory.getImpl(QPoolService.class);
+		QuestionItem item = poolService.loadItemById(itemKey);
+		if(item == null) {
+			return Response.serverError().status(Status.NOT_FOUND).build();
+		}
+		List<QuestionItem> itemToDelete = Collections.singletonList(item);
+		poolService.deleteItems(itemToDelete);
+		
+		return Response.ok().build();
+	}
+	
+	/**
+	 * Get all authors of the question item.
+	 * 
+	 * @response.representation.200.qname {http://www.example.com}userVO
+	 * @response.representation.200.mediaType application/xml, application/json
+	 * @response.representation.200.doc The array of authors
+	 * @response.representation.401.doc The roles of the authenticated user are not sufficient
+	 * @response.representation.404.doc The question item not found
+	 * @param itemKey The question item identifier
+	 * @param httpRequest The HTTP request
+	 * @return It returns an array of <code>UserVO</code>
+	 */
+	@GET
+	@Path("{itemKey}/authors")
+	@Produces({MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON})
+	public Response getAuthors(@PathParam("itemKey") Long itemKey,
+			@Context HttpServletRequest request) {
+
+		if(!isQuestionPoolManager(request)) {
+			return Response.serverError().status(Status.UNAUTHORIZED).build();
+		}
+
+		QPoolService poolService = CoreSpringFactory.getImpl(QPoolService.class);
+		QuestionItem item = poolService.loadItemById(itemKey);
+		if(item == null) {
+			return Response.serverError().status(Status.NOT_FOUND).build();
+		}
+		List<Identity> authorList = poolService.getAuthors(item);
+		
+		int count = 0;
+		UserVO[] authors = new UserVO[authorList.size()];
+		for(Identity author:authorList) {
+			authors[count++] = UserVOFactory.get(author);
+		}
+		return Response.ok(authors).build();
+	}
+	
+	/**
+	 * Get this specific author of the quesiton item.
+	 * 
+	 * @response.representation.200.qname {http://www.example.com}userVO
+	 * @response.representation.200.mediaType application/xml, application/json
+	 * @response.representation.200.doc The author
+	 * @response.representation.401.doc The roles of the authenticated user are not sufficient
+	 * @response.representation.404.doc The question item not found or the user is not an author of the course
+	 * @param itemKey The question item identifier
+	 * @param identityKey The user identifier
+	 * @param httpRequest The HTTP request
+	 * @return It returns an <code>UserVO</code>
+	 */
+	@GET
+	@Path("{itemKey}/authors/{identityKey}")
+	@Produces({MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON})
+	public Response getAuthor(@PathParam("itemKey") Long itemKey, @PathParam("identityKey") Long identityKey,
+			@Context HttpServletRequest request) {
+		if(!isQuestionPoolManager(request)) {
+			return Response.serverError().status(Status.UNAUTHORIZED).build();
+		}
+
+		QPoolService poolService = CoreSpringFactory.getImpl(QPoolService.class);
+		QuestionItem item = poolService.loadItemById(itemKey);
+		if(item == null) {
+			return Response.serverError().status(Status.NOT_FOUND).build();
+		}
+		List<Identity> authorList = poolService.getAuthors(item);
+		for(Identity author:authorList) {
+			if(author.getKey().equals(identityKey)) {
+				UserVO authorVo = UserVOFactory.get(author);
+				return Response.ok(authorVo).build();
+			}
+		}
+		return Response.serverError().status(Status.NOT_FOUND).build();
+	}
+	
+	/**
+	 * Add an author to the question item.
+	 * 
+	 * @response.representation.200.doc The user is an author of the question item
+	 * @response.representation.401.doc The roles of the authenticated user are not sufficient
+	 * @response.representation.404.doc The question item or the user not found
+	 * @param itemKey The question item identifier
+	 * @param identityKey The user identifier
+	 * @param httpRequest The HTTP request
+	 * @return It returns 200  if the user is added as author of the question item
+	 */
+	@PUT
+	@Path("{itemKey}/authors/{identityKey}")
+	public Response addAuthor(@PathParam("itemKey") Long itemKey, @PathParam("identityKey") Long identityKey,
+			@Context HttpServletRequest httpRequest) {
+		if(!isQuestionPoolManager(httpRequest)) {
+			return Response.serverError().status(Status.UNAUTHORIZED).build();
+		}
+
+		QPoolService poolService = CoreSpringFactory.getImpl(QPoolService.class);
+		QuestionItem item = poolService.loadItemById(itemKey);
+		if(item == null) {
+			return Response.serverError().status(Status.NOT_FOUND).build();
+		}
+		
+		BaseSecurity securityManager = CoreSpringFactory.getImpl(BaseSecurity.class);
+		Identity author = securityManager.loadIdentityByKey(identityKey, false);
+		if(author == null) {
+			return Response.serverError().status(Status.NOT_FOUND).build();
+		}
+		
+		List<Identity> authors = Collections.singletonList(author);
+		List<QuestionItemShort> items = Collections.singletonList(item);
+		poolService.addAuthors(authors, items);
+		return Response.ok().build();
+	}
+	
+	/**
+	 * Remove an author to the question item.
+	 * 
+	 * @response.representation.200.doc The user was successfully removed as author of the question item
+	 * @response.representation.401.doc The roles of the authenticated user are not sufficient
+	 * @response.representation.404.doc The question item or the user not found
+	 * @param itemKey The question item identifier
+	 * @param identityKey The user identifier
+	 * @param httpRequest The HTTP request
+	 * @return It returns 200  if the user is removed as author of the question item
+	 */
+	@DELETE
+	@Path("{itemKey}/authors/{identityKey}")
+	@Produces({MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON})
+	public Response removeAuthor(@PathParam("itemKey") Long itemKey, @PathParam("identityKey") Long identityKey,
+			@Context HttpServletRequest httpRequest) {
+		if(!isQuestionPoolManager(httpRequest)) {
+			return Response.serverError().status(Status.UNAUTHORIZED).build();
+		}
+
+		QPoolService poolService = CoreSpringFactory.getImpl(QPoolService.class);
+		QuestionItem item = poolService.loadItemById(itemKey);
+		if(item == null) {
+			return Response.serverError().status(Status.NOT_FOUND).build();
+		}
+		
+		BaseSecurity securityManager = CoreSpringFactory.getImpl(BaseSecurity.class);
+		Identity author = securityManager.loadIdentityByKey(identityKey, false);
+		if(author == null) {
+			return Response.serverError().status(Status.NOT_FOUND).build();
+		}
+		
+		List<Identity> authors = Collections.singletonList(author);
+		List<QuestionItemShort> items = Collections.singletonList(item);
+		poolService.removeAuthors(authors, items);
+		return Response.ok().build();
+	}	
+}
diff --git a/src/main/java/org/olat/restapi/_spring/restApiContext.xml b/src/main/java/org/olat/restapi/_spring/restApiContext.xml
index 74745c58a9b..abd6ba571dd 100644
--- a/src/main/java/org/olat/restapi/_spring/restApiContext.xml
+++ b/src/main/java/org/olat/restapi/_spring/restApiContext.xml
@@ -35,6 +35,7 @@
 				<value>org.olat.course.nodes.bc.BCWebService</value>
 				<value>org.olat.course.assessment.restapi.EfficiencyStatementWebService</value>
 				<value>org.olat.course.certificate.restapi.CertificationWebService</value>
+				<value>org.olat.modules.qpool.restapi.QuestionPoolWebService</value>
 				<value>org.olat.modules.wiki.restapi.WikisWebService</value>
 				<value>org.olat.modules.fo.restapi.ForumImportWebService</value>
 				<value>org.olat.modules.fo.restapi.ForumCourseNodeWebService</value>
diff --git a/src/main/java/org/olat/restapi/security/RestSecurityHelper.java b/src/main/java/org/olat/restapi/security/RestSecurityHelper.java
index e347e34aaba..6de4d7ae762 100644
--- a/src/main/java/org/olat/restapi/security/RestSecurityHelper.java
+++ b/src/main/java/org/olat/restapi/security/RestSecurityHelper.java
@@ -145,6 +145,15 @@ public class RestSecurityHelper {
 		}
 	}
 	
+	public static boolean isQuestionPoolManager(HttpServletRequest request) {
+		try {
+			Roles roles = getRoles(request);
+			return (roles.isPoolAdmin() || roles.isOLATAdmin());
+		} catch (Exception e) {
+			return false;
+		}
+	}
+	
 	public static boolean isAdmin(HttpServletRequest request) {
 		try {
 			Roles roles = getRoles(request);
diff --git a/src/test/java/org/olat/restapi/QuestionPoolTest.java b/src/test/java/org/olat/restapi/QuestionPoolTest.java
new file mode 100644
index 00000000000..db251f851ff
--- /dev/null
+++ b/src/test/java/org/olat/restapi/QuestionPoolTest.java
@@ -0,0 +1,218 @@
+/**
+ * <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>
+ * frentix GmbH, http://www.frentix.com
+ * <p>
+ */
+package org.olat.restapi;
+
+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.Collections;
+import java.util.List;
+import java.util.Locale;
+
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.UriBuilder;
+
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpResponse;
+import org.apache.http.client.methods.HttpDelete;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPut;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.mime.HttpMultipartMode;
+import org.apache.http.entity.mime.MultipartEntityBuilder;
+import org.apache.http.util.EntityUtils;
+import org.codehaus.jackson.map.ObjectMapper;
+import org.codehaus.jackson.type.TypeReference;
+import org.junit.Assert;
+import org.junit.Test;
+import org.olat.core.commons.persistence.DB;
+import org.olat.core.id.Identity;
+import org.olat.ims.qti.QTIConstants;
+import org.olat.modules.qpool.QPoolService;
+import org.olat.modules.qpool.QuestionItem;
+import org.olat.modules.qpool.QuestionType;
+import org.olat.modules.qpool.manager.QItemTypeDAO;
+import org.olat.modules.qpool.manager.QuestionItemDAO;
+import org.olat.modules.qpool.model.QItemType;
+import org.olat.modules.qpool.restapi.QuestionItemVO;
+import org.olat.modules.qpool.restapi.QuestionItemVOes;
+import org.olat.test.JunitTestHelper;
+import org.olat.test.OlatJerseyTestCase;
+import org.olat.user.restapi.UserVO;
+import org.springframework.beans.factory.annotation.Autowired;
+
+/**
+ * 
+ * Initial date: 8 sept. 2017<br>
+ * @author srosse, stephane.rosse@frentix.com, http://www.frentix.com
+ *
+ */
+public class QuestionPoolTest extends OlatJerseyTestCase {
+	
+	@Autowired
+	private DB dbInstance;
+	@Autowired
+	private QPoolService qpoolService;
+	@Autowired
+	private QItemTypeDAO qItemTypeDao;
+	@Autowired
+	private QuestionItemDAO questionDao;
+	@Autowired
+	private QuestionItemDAO questionItemDao;
+
+	@Test
+	public void importQuestion()
+	throws IOException, URISyntaxException {
+		URL itemUrl = QuestionPoolTest.class.getResource("multiple_choice_per_answer.zip");
+		assertNotNull(itemUrl);
+		File itemFile = new File(itemUrl.toURI());
+
+		RestConnection conn = new RestConnection();
+		assertTrue(conn.login("administrator", "openolat"));
+		
+		URI request = UriBuilder.fromUri(getContextURI()).path("qpool/items").build();
+		HttpPut method = conn.createPut(request, MediaType.APPLICATION_JSON, true);
+		HttpEntity entity = MultipartEntityBuilder.create()
+				.setMode(HttpMultipartMode.BROWSER_COMPATIBLE)
+				.addBinaryBody("file", itemFile, ContentType.APPLICATION_OCTET_STREAM, itemFile.getName())
+				.addTextBody("filename", "multiple_choice_per_answer.zip")
+				.build();
+		method.setEntity(entity);
+		
+		HttpResponse response = conn.execute(method);
+		Assert.assertEquals(200, response.getStatusLine().getStatusCode());
+
+		QuestionItemVOes voes = conn.parse(response, QuestionItemVOes.class);
+		Assert.assertNotNull(voes);
+		QuestionItemVO[] voArray = voes.getQuestionItems();
+		Assert.assertNotNull(voArray);
+		Assert.assertEquals(1, voArray.length);
+		QuestionItemVO vo = voArray[0];
+		Assert.assertNotNull(vo);
+	}
+
+	@Test
+	public void getAuthors() throws IOException, URISyntaxException {
+		QItemType mcType = qItemTypeDao.loadByType(QuestionType.MC.name());
+		Identity author = JunitTestHelper.createAndPersistIdentityAsRndUser("item-author-1");
+		QuestionItem item = questionDao.createAndPersist(author, "NGC 55", QTIConstants.QTI_12_FORMAT, Locale.ENGLISH.getLanguage(), null, null, null, mcType);
+		dbInstance.commitAndCloseSession();
+		
+		RestConnection conn = new RestConnection();
+		Assert.assertTrue(conn.login("administrator", "openolat"));
+		
+		URI request = UriBuilder.fromUri(getContextURI()).path("/qpool/items/" + item.getKey() + "/authors/").build();
+		HttpGet method = conn.createGet(request, MediaType.APPLICATION_JSON, true);
+		HttpResponse response = conn.execute(method);
+		Assert.assertEquals(200, response.getStatusLine().getStatusCode());
+		List<UserVO> users = parseUserArray(response.getEntity().getContent());
+		//check
+		Assert.assertNotNull(users);
+		Assert.assertEquals(1, users.size());
+		Assert.assertTrue(author.getKey().equals(users.get(0).getKey()));
+	}
+	
+	@Test
+	public void getAuthor() throws IOException, URISyntaxException {
+		QItemType mcType = qItemTypeDao.loadByType(QuestionType.MC.name());
+		Identity author = JunitTestHelper.createAndPersistIdentityAsRndUser("item-author-2");
+		QuestionItem item = questionDao.createAndPersist(author, "NGC 55", QTIConstants.QTI_12_FORMAT, Locale.ENGLISH.getLanguage(), null, null, null, mcType);
+		dbInstance.commitAndCloseSession();
+		
+		RestConnection conn = new RestConnection();
+		Assert.assertTrue(conn.login("administrator", "openolat"));
+		
+		URI request = UriBuilder.fromUri(getContextURI()).path("/qpool/items/" + item.getKey() + "/authors/" + author.getKey()).build();
+		HttpGet method = conn.createGet(request, MediaType.APPLICATION_JSON, true);
+		HttpResponse response = conn.execute(method);
+		Assert.assertEquals(200, response.getStatusLine().getStatusCode());
+		UserVO user = conn.parse(response.getEntity().getContent(), UserVO.class);
+		//check
+		Assert.assertNotNull(user);
+		Assert.assertTrue(author.getKey().equals(user.getKey()));
+	}
+	
+	@Test
+	public void addAuthor() throws IOException, URISyntaxException {
+		QItemType mcType = qItemTypeDao.loadByType(QuestionType.MC.name());
+		Identity author = JunitTestHelper.createAndPersistIdentityAsRndUser("item-author-1");
+		QuestionItem item = questionDao.createAndPersist(author, "NGC 55", QTIConstants.QTI_12_FORMAT, Locale.ENGLISH.getLanguage(), null, null, null, mcType);
+		Identity coAuthor = JunitTestHelper.createAndPersistIdentityAsRndUser("item-author-1");
+
+		RestConnection conn = new RestConnection();
+		Assert.assertTrue(conn.login("administrator", "openolat"));
+		
+		URI request = UriBuilder.fromUri(getContextURI()).path("/qpool/items/" + item.getKey() + "/authors/" + coAuthor.getKey()).build();
+		HttpPut method = conn.createPut(request, MediaType.APPLICATION_JSON, true);
+		HttpResponse response = conn.execute(method);
+		Assert.assertEquals(200, response.getStatusLine().getStatusCode());
+		EntityUtils.consume(response.getEntity());
+
+		//check
+		List<Identity> authors = qpoolService.getAuthors(item);
+		Assert.assertNotNull(authors);
+		Assert.assertEquals(2, authors.size());
+		Assert.assertTrue(authors.contains(author));
+		Assert.assertTrue(authors.contains(coAuthor));
+	}
+	
+	@Test
+	public void removeAuthor() throws IOException, URISyntaxException {
+		QItemType mcType = qItemTypeDao.loadByType(QuestionType.MC.name());
+		Identity author = JunitTestHelper.createAndPersistIdentityAsRndUser("item-author-1");
+		QuestionItem item = questionDao.createAndPersist(author, "NGC 55", QTIConstants.QTI_12_FORMAT, Locale.ENGLISH.getLanguage(), null, null, null, mcType);
+		Identity coAuthor = JunitTestHelper.createAndPersistIdentityAsRndUser("item-author-1");
+		List<Identity> authors = Collections.singletonList(coAuthor);
+		questionItemDao.addAuthors(authors, item);
+		dbInstance.commit();
+
+		RestConnection conn = new RestConnection();
+		Assert.assertTrue(conn.login("administrator", "openolat"));
+		
+		URI request = UriBuilder.fromUri(getContextURI()).path("/qpool/items/" + item.getKey() + "/authors/" + coAuthor.getKey()).build();
+		HttpDelete method = conn.createDelete(request, MediaType.APPLICATION_JSON);
+		HttpResponse response = conn.execute(method);
+		Assert.assertEquals(200, response.getStatusLine().getStatusCode());
+		EntityUtils.consume(response.getEntity());
+
+		//check
+		List<Identity> itemsAuthors = qpoolService.getAuthors(item);
+		Assert.assertNotNull(itemsAuthors);
+		Assert.assertEquals(1, itemsAuthors.size());
+		Assert.assertTrue(itemsAuthors.contains(author));
+		Assert.assertFalse(itemsAuthors.contains(coAuthor));
+	}
+
+	protected List<UserVO> parseUserArray(InputStream body) {
+		try {
+			ObjectMapper mapper = new ObjectMapper(jsonFactory); 
+			return mapper.readValue(body, new TypeReference<List<UserVO>>(){/* */});
+		} catch (Exception e) {
+			e.printStackTrace();
+			return null;
+		}
+	}
+}
diff --git a/src/test/java/org/olat/restapi/multiple_choice_per_answer.zip b/src/test/java/org/olat/restapi/multiple_choice_per_answer.zip
new file mode 100644
index 0000000000000000000000000000000000000000..db49796f9efeb569f151a9a05cf932505e473a30
GIT binary patch
literal 3079
zcmbW3c{rO{8^&Xc&`?GMwHLA0UZGPvDWc@E?}AWU><lecTdY-8Yf6co3{{GWZK}4~
zDNQ<Pt+lkORMpaICuXjhG4stg-}0X8d9U}M^PJ~A=e_UWK_ThrApj6{<mXEu0Vkf*
z05*UZHW=&T=jGuZ93mTz^|iDC19Hb2Y%{5`O9KHETFv;C%Ckkivle>UHIKsU#n;G5
zTY9&&P5U<a-*!8l+=MpYa5&uac`UZ9lZh1-;4WHH(h%_YM%}M*%1)Q?1B7GdJM|e_
zZLuX+g$L25KdR}@nBhSPGfA{-bZmp*Qk#TGta$N5_kEqq8|CB!Zo-Ji(tG1`W@_Tq
z(y0v7QaI!ImByx`PhkmycMbaL8WpV?0upx#2cdYH5ykO%_RAhV?YW#%s^}rUc;jTh
z0W+)2b8bY|U6nX~=(T&dzO;9KE<rv?>egRpXXrMI{64IU%P{#R>1rvQj8)yhQ;5%}
zN%nye0h4v&eNG<S;Rjid8aK=))8EPLq~a4^hjP_KrbU|+QJ(MC3dVmCJC%gu*94L+
z`R<+#>i|BZ-<y9y=&v3|)uyqQm(pfAa6&8+r+Q>YwLI?yNWfGm854{bxg%WI*yhA_
zFM@eU{c+kLVWsnBn!!6GQ<vlxl*}zHwPjBkz?v77^5|GOE`rxG(mw+<>1D#vbije`
z4xg=$8n*;0O~%4n$+pfnZp4<YD)1MqTu8WXVSPI(;c0@`nO9dDuN6KAZ|n~YT2-$^
zFhd=U{J_J6`Xm&u#Muzz&J6Ze$?^;Qz!d033M1Fo><>R?E?REeWf>7%h~M#-)^c#%
z52=8;3c7a}PgIfAKb7*<kw~t^$gXvk-i#+_MJ~v?PmrsPrX`Wn3Wh3~=hs~AxJ2#S
z#yA5kywXQ@rav)+#LR|Uu-Q53t$R3d&hKcPAqXt@Vw<wozsuDA?1Af_8Jc|fqQa3h
z80~MD{mzw3(0A+lY(48R$y*D9n6(*{X3Z|XmCZ9ZlB{%wa0jiK8)#nMCvRKzoS63l
zr5RXm%;+!i45_IqGB`UFJQKJyE<(4Mu*aH)=wXs)DO5w&->E-{+?<S{)vapQAl!Sf
zlWE?sOSmaM`>^5b+KVbBJ(Q9@zH2gH8Bzb`66f3z9SX?^0;jl>D6{}THWL7VqH2Ws
z7iARwMI%g9jW|1B*48yOw~;$m2cD{o<2Y8w@wC5})|r#jWS#$m%G}VLZdcH*=&j4f
zR$Hg7l2A8jGq~x**?4KTEro#Zd4A$aqD>bT)@Fm+^a}`mM3_IO=UlylW-}cfdxn&e
zcw+eMlwqwz;0SWmbYK{nSoJMSj|v!E0y>{io~)l7A(kIe4JHWtwL;0bJ{SVGk^_Y$
z90GyRK5|8lAz~mo8Ak3(_Da^zC$GTLS8xafuoYo~L$tzx>9AIl(LPu!v?3koQ3nLV
z5FVpo>b@C4>MZc-dTPrUX?|_ruYCy5`Lo4{Qu}~)gJaxX-4#_l6jhboRNd4RlojF1
z7-cv{3FGboKmVKWlnpl69>;NK-`f1-x1<80`VQ!-pwDiJmdd;+tGDQbIE;K=3yr?h
z1-*xsWlQ`1hv$wOo@TbAa@vhE!<eT`n?rJg<;EO61p`?NF0rLPxM6TD^>Lnadmx!o
zlwcqSc6(s+5NaYl;DN*9bGDDZY%*!;#&_1|or1WHM^8!>&8E1<=tn|fG|a)e{sr9c
z2Lxk}!kf%}<^Uq!u$6)vIt5%94enC{9Uu9@*TS8~m2Mj<ytSEXE{wu<>4Bo6s`PhI
z^~&@tn&RNN9Gwb6o}AE(wGMrGY=@D0ijEUU7Mk5bmwB7nzR@Bxo&#d2WG;E3gZFxE
z?g5bJis3Bf9MQdH1J4%}*0rNIC&r!Z@Np{MyOOzi**{7s>J!hFaxy86Em30VNV0NE
z_3G6;lY0nF8OLr35gCLq2QuQKdwS?OExt}!bDl#h1-R_UlIRd1wg2f*F_;pqzrY7%
zZ>H@J<8SdfOL=VvY9U~!pO=)qoxgF25N@et;MbFUjqfR+Vx=7uDZAX7gWcn+oGD{K
zH#;t(T2_IOJpiMlH@;^xuPiPWc5V!Ty@Lnw!G)~GwI$X*B+X=}^V#dK@ir|SvKyI0
z$CPOy1K&s4dwmFPbbrn3Z-#-S`c@CfwbT#)WGd6YE5oHG%OF)P7_L5M_e{q=C$2No
zI3}yF)8;8Y@X72#f#ooNQ<$nWr_YLlPoilB&t3nl>XB;SsQ|U;+rtbdC5V){Gz2BL
zn>aJHFu7mzI7s#;JSg&hSn8N`oW3*Hquy*ikp?yBK!s448fw9=pa->JZaEGA=%yy#
z^J#r|YqMQPL)u^oB5!Iv**`IHaZ+A;G5&kQpeVDaoBel!i?7S`YADn#?A=nQ{j^F@
zBt<&FFt$<^aZJP7Z3E-RIXbNZXAdBP{j`elI|1Zu4Zl2#&k-hGKjg1B#I!rULM(?h
zOopnZif*UEvtKM3wDI|x*t>Q*h8}TkQ;NE6WHOC?Gw##Q?FObSiF9o3@CoL_Q<ix1
ztw^PjX-guFWwML4S6CQ=NY`kfKBsr~UvYn<Rzk!`?URr_BW52m#4;7z`Q*Qq!$@*8
zTUotKTW5W#${ttPJg=mYpSYm44r#zC;$qqGWLaGW7Ypn@Zn1Y&s6xN?)idkK2+nJf
z0)?@a<H}McL5FqLg&Y&oo6Dx08_6zJboPPu-cAWRM^=N2LHav4Z>}-jXm0C0jBGKL
za!}0`k$@S|bB-?CLicT>$RWnw9%<_3ZtF1;oI}%75QTTz)%B(tdC4_op7<Kh7sEv6
zT!?8WLG*p&{tmIR5-0sGrTV~v6@|Set{JS_TT=tH_N!TaBt2{q5hf!B2Z(Cz_xj9u
zP?}{ZAEw_r>EbXyTzm9|=%6|)`1MM64$DiqQ5*_8RH?FngSK}v^R#IvynFubXu!_c
zs^3QfiOAzjT-co;5kq%g&SC|LZS{g;^a*bsXYAEtw%hbX{BOU%TbJF^5$MLW>70`t
zREz!b&0Wt6+&JNYn}LK5$X-m)o7|?BQ*WGUUt?pSdu6bY26m~hk57LA9$VgW*XpI)
z^Z-B(*FRaF+@G!g|8c*6;K5lT;P!oaHU>Ujo!DaIjC39?-?)l+c41k3K@l<629<HA
z#@YsNla*(knV&m|s`5vpU3{_L`IxMNJWp5tg1j7?EHA9zqNkrH22<>_jPWe<ah>-p
zd9&>6TjJyL+7rFvjYhj-R)&xF$OsB8iu{~NwfQM(ppZ1QoOFKx?<7G_1HixVllszn
z8*}{mFU$k|ezW{F(Z2)kABm2%Md<)1@eBGLzkjXeB#zFUsHH5%{$z^3LGfS9`U7Mq
z&s+3F&bKjc)W6`rJ6I6lH>{nk_!yHXJ1ci0ds&PX^+xjhokQ07e_x0Jczgg1)KN*L
Jv(Yb&{u$1Q{dxcZ

literal 0
HcmV?d00001

-- 
GitLab