From 1c5db9f273ec7bcd5188ce3db97129ff979f89e1 Mon Sep 17 00:00:00 2001 From: srosse <none@none> Date: Thu, 11 Sep 2014 08:17:25 +0200 Subject: [PATCH] OO-1190: import and export metadata of qti items in question pool --- .../java/org/olat/core/util/PathUtils.java | 105 ++++++ .../ims/qti/qpool/QTIExportProcessor.java | 121 ++----- .../ims/qti/qpool/QTIImportProcessor.java | 188 +++++++++-- .../org/olat/ims/qti/qpool/QTIMetadata.java | 306 ++++++++++++++++++ .../qti/qpool/QTIQPoolServiceProvider.java | 13 +- .../qpool/manager/TaxonomyLevelDAO.java | 24 ++ .../ims/qti/qpool/QTIExportProcessorTest.java | 8 +- .../ims/qti/qpool/QTIImportProcessorTest.java | 136 +++++++- .../org/olat/ims/qti/qpool/multiple_items.zip | Bin 0 -> 14107 bytes .../olat/ims/qti/qpool/qitem_metadatas.zip | Bin 0 -> 1758 bytes 10 files changed, 761 insertions(+), 140 deletions(-) create mode 100644 src/main/java/org/olat/core/util/PathUtils.java create mode 100644 src/main/java/org/olat/ims/qti/qpool/QTIMetadata.java create mode 100644 src/test/java/org/olat/ims/qti/qpool/multiple_items.zip create mode 100644 src/test/java/org/olat/ims/qti/qpool/qitem_metadatas.zip diff --git a/src/main/java/org/olat/core/util/PathUtils.java b/src/main/java/org/olat/core/util/PathUtils.java new file mode 100644 index 00000000000..c49169774e3 --- /dev/null +++ b/src/main/java/org/olat/core/util/PathUtils.java @@ -0,0 +1,105 @@ +/** + * <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.core.util; + +import java.io.File; +import java.io.IOException; +import java.nio.file.FileSystems; +import java.nio.file.FileVisitResult; +import java.nio.file.FileVisitor; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.nio.file.Paths; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.BasicFileAttributes; + +/** + * + * Initial date: 08.05.2014<br> + * @author srosse, stephane.rosse@frentix.com, http://www.frentix.com + * + */ +public class PathUtils { + + public static Path visit(File file, String filename, FileVisitor<Path> visitor) + throws IOException { + if(!StringHelper.containsNonWhitespace(filename)) { + filename = file.getName(); + } + + Path fPath = null; + if(file.isDirectory()) { + fPath = file.toPath(); + } else if(filename != null && filename.toLowerCase().endsWith(".zip")) { + fPath = FileSystems.newFileSystem(file.toPath(), null).getPath("/"); + } else { + fPath = file.toPath(); + } + if(fPath != null) { + Files.walkFileTree(fPath, visitor); + } + return fPath; + } + + public static class YesMatcher implements PathMatcher { + @Override + public boolean matches(Path path) { + return true; + } + } + + public static class CopyVisitor extends SimpleFileVisitor<Path> { + + private final Path source; + private final Path destDir; + private final PathMatcher filter; + + public CopyVisitor(Path source, Path destDir, PathMatcher filter) { + this.source = source; + this.destDir = destDir; + this.filter = filter; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) + throws IOException { + Path relativeFile = source.relativize(file); + final Path destFile = Paths.get(destDir.toString(), relativeFile.toString()); + if(filter.matches(file)) { + Files.copy(file, destFile, StandardCopyOption.REPLACE_EXISTING); + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) + throws IOException { + Path relativeDir = source.relativize(dir); + final Path dirToCreate = Paths.get(destDir.toString(), relativeDir.toString()); + if(Files.notExists(dirToCreate)){ + Files.createDirectory(dirToCreate); + } + return FileVisitResult.CONTINUE; + } + } + +} diff --git a/src/main/java/org/olat/ims/qti/qpool/QTIExportProcessor.java b/src/main/java/org/olat/ims/qti/qpool/QTIExportProcessor.java index edf45415ba5..68e013cf7bf 100644 --- a/src/main/java/org/olat/ims/qti/qpool/QTIExportProcessor.java +++ b/src/main/java/org/olat/ims/qti/qpool/QTIExportProcessor.java @@ -36,6 +36,7 @@ import org.dom4j.Attribute; import org.dom4j.CDATA; import org.dom4j.Document; import org.dom4j.DocumentFactory; +import org.dom4j.DocumentHelper; import org.dom4j.Element; import org.dom4j.Node; import org.dom4j.io.OutputFormat; @@ -83,10 +84,26 @@ public class QTIExportProcessor { String rootDir = "qitem_" + fullItem.getKey(); List<VFSItem> items = container.getItems(); for(VFSItem item:items) { + addMetadata(fullItem, rootDir, zout); ZipUtil.addToZip(item, rootDir, zout); } } + private void addMetadata(QuestionItemFull fullItem, String dir, ZipOutputStream zout) { + try { + Document document = DocumentHelper.createDocument(); + Element qtimetadata = document.addElement("qtimetadata"); + QTIMetadata enricher = new QTIMetadata(qtimetadata); + enricher.toXml(fullItem); + zout.putNextEntry(new ZipEntry(dir + "/" + "qitem_" + fullItem.getKey() + "_metadata.xml")); + OutputFormat format = OutputFormat.createPrettyPrint(); + XMLWriter writer = new XMLWriter(zout, format); + writer.write(document); + } catch (IOException e) { + log.error("", e); + } + } + /** * <li>List all items * <li>Rewrite path @@ -294,7 +311,7 @@ public class QTIExportProcessor { //metadata /* <qtimetadata> - <qtimetadatafield> + <qtimetadatafield> <fieldlabel>qmd_assessmenttype</fieldlabel> <fieldentry>Assessment</fieldentry> </qtimetadatafield> @@ -320,6 +337,14 @@ public class QTIExportProcessor { return section; } + private void addMetadataField(String label, String entry, Element qtimetadata) { + if(entry != null) { + Element qtimetadatafield = qtimetadata.addElement("qtimetadatafield"); + qtimetadatafield.addElement("fieldlabel").setText(label); + qtimetadatafield.addElement("fieldentry").setText(entry); + } + } + private Element readItemXml(VFSLeaf leaf) { Document doc = null; try { @@ -379,100 +404,10 @@ public class QTIExportProcessor { private void enrichWithMetadata(QuestionItemFull fullItem, Element item) { Element qtimetadata = (Element)item.selectSingleNode("./itemmetadata/qtimetadata"); - String path = fullItem.getTaxonomicPath(); + QTIMetadata enricher = new QTIMetadata(qtimetadata); + enricher.toXml(fullItem); } - private void addMetadataField(String label, String entry, Element qtimetadata) { - Element qtimetadatafield = qtimetadata.addElement("qtimetadatafield"); - qtimetadatafield.addElement("fieldlabel").setText(label); - qtimetadatafield.addElement("fieldentry").setText(entry); - } - - /* - * - * <itemmetadata> - <qtimetadata> - <qtimetadatafield> - <fieldlabel>qmd_levelofdifficulty</fieldlabel> - <fieldentry>basic</fieldentry> - </qtimetadatafield> - <qtimetadatafield> - <fieldlabel>qmd_topic</fieldlabel> - <fieldentry>qtiv1p2test</fieldentry> - </qtimetadatafield> - </qtimetadata> - </itemmetadata> - - <qtimetadata> - <vocabulary uri="imsqtiv1p2_metadata.txt" vocab_type="text/plain"/> - <qtimetadatafield> - <fieldlabel>qmd_weighting</fieldlabel> - <fieldentry>2</fieldentry> - </qtimetadatafield> - ... - </qtimetadata> - - - - http://qtimigration.googlecode.com/svn-history/r29/trunk/pyslet/unittests/data_imsqtiv1p2p1/input/ - - -<qtimetadatafield> - <fieldlabel>name</fieldlabel> - <fieldentry>Metadata New-Style</fieldentry> - </qtimetadatafield> - <qtimetadatafield> - <fieldlabel>marks</fieldlabel> - <fieldentry>50.0</fieldentry> - </qtimetadatafield> - <qtimetadatafield> - <fieldlabel>syllabusarea</fieldlabel> - <fieldentry>Migration</fieldentry> - </qtimetadatafield> - <qtimetadatafield> - <fieldlabel>author</fieldlabel> - <fieldentry>Steve Author</fieldentry> - </qtimetadatafield> - <qtimetadatafield> - <fieldlabel>creator</fieldlabel> - <fieldentry>Steve Creator</fieldentry> - </qtimetadatafield> - <qtimetadatafield> - <fieldlabel>owner</fieldlabel> - <fieldentry>Steve Owner</fieldentry> - </qtimetadatafield> - <qtimetadatafield> - <fieldlabel>item type</fieldlabel> - <fieldentry>MCQ</fieldentry> - </qtimetadatafield> - <qtimetadatafield> - <fieldlabel>status</fieldlabel> - <fieldentry>Experimental</fieldentry> - </qtimetadatafield> - <qtimetadatafield> - <fieldlabel>qmd_levelofdifficulty</fieldlabel> - <fieldentry>Professional Development</fieldentry> - </qtimetadatafield> - <qtimetadatafield> - <fieldlabel>qmd_toolvendor</fieldlabel> - <fieldentry>Steve Lay</fieldentry> - </qtimetadatafield> - <qtimetadatafield> - <fieldlabel>description</fieldlabel> - <fieldentry>General Description Extension</fieldentry> - </qtimetadatafield> - - - <itemmetadata> - <qmd_itemtype>MCQ</qmd_itemtype> - <qmd_levelofdifficulty>Professional Development</qmd_levelofdifficulty> - <qmd_maximumscore>50.0</qmd_maximumscore> - <qmd_status>Experimental</qmd_status> - <qmd_toolvendor>Steve Lay</qmd_toolvendor> - <qmd_topic>Migration</qmd_topic> - </itemmetadata> - */ - private static final class HTMLHandler extends DefaultHandler { private final List<String> materialPath = new ArrayList<String>(); diff --git a/src/main/java/org/olat/ims/qti/qpool/QTIImportProcessor.java b/src/main/java/org/olat/ims/qti/qpool/QTIImportProcessor.java index 57e9250aeae..ecdd8967f01 100644 --- a/src/main/java/org/olat/ims/qti/qpool/QTIImportProcessor.java +++ b/src/main/java/org/olat/ims/qti/qpool/QTIImportProcessor.java @@ -27,7 +27,14 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.StringReader; +import java.nio.file.FileSystems; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Locale; import java.util.zip.ZipEntry; @@ -40,13 +47,18 @@ import org.dom4j.Document; import org.dom4j.DocumentFactory; import org.dom4j.Element; import org.dom4j.io.OutputFormat; +import org.dom4j.io.SAXReader; import org.dom4j.io.XMLWriter; +import org.olat.core.commons.persistence.DB; import org.olat.core.id.Identity; import org.olat.core.logging.OLog; import org.olat.core.logging.Tracing; import org.olat.core.util.FileUtils; +import org.olat.core.util.PathUtils.CopyVisitor; +import org.olat.core.util.PathUtils.YesMatcher; import org.olat.core.util.StringHelper; import org.olat.core.util.ZipUtil; +import org.olat.core.util.vfs.LocalImpl; import org.olat.core.util.vfs.VFSContainer; import org.olat.core.util.vfs.VFSLeaf; import org.olat.core.util.xml.XMLParser; @@ -56,10 +68,11 @@ import org.olat.ims.qti.editor.beecom.parser.ItemParser; import org.olat.ims.resources.IMSEntityResolver; import org.olat.modules.qpool.QuestionItem; import org.olat.modules.qpool.QuestionType; -import org.olat.modules.qpool.manager.QPoolFileStorage; import org.olat.modules.qpool.manager.QEducationalContextDAO; import org.olat.modules.qpool.manager.QItemTypeDAO; +import org.olat.modules.qpool.manager.QPoolFileStorage; import org.olat.modules.qpool.manager.QuestionItemDAO; +import org.olat.modules.qpool.manager.TaxonomyLevelDAO; import org.olat.modules.qpool.model.QEducationalContext; import org.olat.modules.qpool.model.QItemType; import org.olat.modules.qpool.model.QuestionItemImpl; @@ -86,20 +99,25 @@ class QTIImportProcessor { private final String importedFilename; private final File importedFile; + private final DB dbInstance; private final QItemTypeDAO qItemTypeDao; private final QPoolFileStorage qpoolFileStorage; private final QuestionItemDAO questionItemDao; + private final TaxonomyLevelDAO taxonomyLevelDao; private final QEducationalContextDAO qEduContextDao; public QTIImportProcessor(Identity owner, Locale defaultLocale, QuestionItemDAO questionItemDao, - QItemTypeDAO qItemTypeDao, QEducationalContextDAO qEduContextDao, QPoolFileStorage qpoolFileStorage) { - this(owner, defaultLocale, null, null, questionItemDao, qItemTypeDao, qEduContextDao, qpoolFileStorage); + QItemTypeDAO qItemTypeDao, QEducationalContextDAO qEduContextDao, + TaxonomyLevelDAO taxonomyLevelDao, QPoolFileStorage qpoolFileStorage, DB dbInstance) { + this(owner, defaultLocale, null, null, questionItemDao, qItemTypeDao, qEduContextDao, + taxonomyLevelDao, qpoolFileStorage, dbInstance); } public QTIImportProcessor(Identity owner, Locale defaultLocale, String importedFilename, File importedFile, QuestionItemDAO questionItemDao, QItemTypeDAO qItemTypeDao, QEducationalContextDAO qEduContextDao, - QPoolFileStorage qpoolFileStorage) { + TaxonomyLevelDAO taxonomyLevelDao, QPoolFileStorage qpoolFileStorage, DB dbInstance) { this.owner = owner; + this.dbInstance = dbInstance; this.defaultLocale = defaultLocale; this.importedFilename = importedFilename; this.importedFile = importedFile; @@ -107,20 +125,18 @@ class QTIImportProcessor { this.questionItemDao = questionItemDao; this.qEduContextDao = qEduContextDao; this.qpoolFileStorage = qpoolFileStorage; + this.taxonomyLevelDao = taxonomyLevelDao; } public List<QuestionItem> process() { List<QuestionItem> qItems = new ArrayList<QuestionItem>(); try { - DocInfos docInfos = getDocInfos(); - if(docInfos != null && docInfos.doc != null) { - List<ItemInfos> itemInfos = getItemList(docInfos); - for(ItemInfos itemInfo:itemInfos) { - QuestionItemImpl qItem = processItem(docInfos, itemInfo); - if(qItem != null) { - processFiles(qItem, itemInfo); - qItems.add(qItem); - } + List<DocInfos> docInfoList = getDocInfos(); + if(docInfoList != null) { + for(DocInfos docInfos:docInfoList) { + List<QuestionItem> processdItems = process(docInfos); + qItems.addAll(processdItems); + dbInstance.commit(); } } } catch (IOException e) { @@ -128,6 +144,22 @@ class QTIImportProcessor { } return qItems; } + + private List<QuestionItem> process(DocInfos docInfos) { + List<QuestionItem> qItems = new ArrayList<>(); + if(docInfos.doc != null) { + List<ItemInfos> itemInfos = getItemList(docInfos); + for(ItemInfos itemInfo:itemInfos) { + QuestionItemImpl qItem = processItem(docInfos, itemInfo); + if(qItem != null) { + processFiles(qItem, itemInfo, docInfos); + qItem = questionItemDao.merge(qItem); + qItems.add(qItem); + } + } + } + return qItems; + } protected List<ItemInfos> getItemList(DocInfos doc) { Document document = doc.getDocument(); @@ -157,10 +189,11 @@ class QTIImportProcessor { if(itemInfos.isOriginalItem()) { originalFilename = docInfos.filename; } - return processItem(itemEl, comment, originalFilename, null, null); + return processItem(itemEl, comment, originalFilename, null, null, docInfos); } - protected QuestionItemImpl processItem(Element itemEl, String comment, String originalItemFilename, String editor, String editorVersion) { + protected QuestionItemImpl processItem(Element itemEl, String comment, String originalItemFilename, + String editor, String editorVersion, DocInfos docInfos) { //filename String filename; String ident = getAttributeValue(itemEl, "ident"); @@ -181,6 +214,7 @@ class QTIImportProcessor { if(!StringHelper.containsNonWhitespace(title)) { title = importedFilename; } + QuestionItemImpl poolItem = questionItemDao.create(title, QTIConstants.QTI_12_FORMAT, dir, filename); //description poolItem.setDescription(comment); @@ -200,6 +234,9 @@ class QTIImportProcessor { QItemType defType = qItemTypeDao.loadByType(QuestionType.UNKOWN.name()); poolItem.setType(defType); } + if(docInfos != null) { + processSidecarMetadata(poolItem, docInfos); + } questionItemDao.persist(owner, poolItem); return poolItem; } @@ -284,9 +321,9 @@ class QTIImportProcessor { * @param item * @param itemEl */ - protected void processFiles(QuestionItemImpl item, ItemInfos itemInfos) { + protected void processFiles(QuestionItemImpl item, ItemInfos itemInfos, DocInfos docInfos) { if(itemInfos.originalItem) { - processItemFiles(item); + processItemFiles(item, docInfos); } else { //an assessment package processAssessmentFiles(item, itemInfos); @@ -469,12 +506,22 @@ class QTIImportProcessor { * @param item * @param itemInfos */ - protected void processItemFiles(QuestionItemImpl item) { + protected void processItemFiles(QuestionItemImpl item, DocInfos docInfos) { //a package with an item String dir = item.getDirectory(); String rootFilename = item.getRootFilename(); VFSContainer container = qpoolFileStorage.getContainer(dir); - if(importedFilename.toLowerCase().endsWith(".zip")) { + + if(docInfos != null && docInfos.getRoot() != null) { + try { + Path destDir = ((LocalImpl)container).getBasefile().toPath(); + //unzip to container + Path path = docInfos.getRoot(); + Files.walkFileTree(path, new CopyVisitor(path, destDir, new YesMatcher())); + } catch (IOException e) { + log.error("", e); + } + } else if(importedFilename.toLowerCase().endsWith(".zip")) { ZipUtil.unzipStrict(importedFile, container); } else { VFSLeaf endFile = container.createChildLeaf(rootFilename); @@ -494,6 +541,23 @@ class QTIImportProcessor { } } + private boolean processSidecarMetadata(QuestionItemImpl item, DocInfos docInfos) { + try { + Path path = docInfos.root; + Path metadata = path.resolve(path.getFileName().toString() + "_metadata.xml"); + InputStream metadataIn = Files.newInputStream(metadata); + SAXReader reader = new SAXReader(); + Document document = reader.read(metadataIn); + Element rootElement = document.getRootElement(); + QTIMetadata enricher = new QTIMetadata(rootElement, qItemTypeDao, taxonomyLevelDao, qEduContextDao); + enricher.toQuestion(item); + return true; + } catch (Exception e) { + log.error("", e); + return false; + } + } + private boolean processItemQuestionType(QuestionItemImpl poolItem, String ident, Element itemEl) { boolean openolatFormat = false; @@ -545,12 +609,13 @@ class QTIImportProcessor { return el.getText(); } - protected DocInfos getDocInfos() throws IOException { - DocInfos doc; + protected List<DocInfos> getDocInfos() throws IOException { + List<DocInfos> doc; if(importedFilename.toLowerCase().endsWith(".zip")) { - doc = traverseZip(importedFile); + //doc = traverseZip(importedFile); + doc = traverseZip_nio(importedFile); } else { - doc = traverseFile(importedFile); + doc = Collections.singletonList(traverseFile(importedFile)); } return doc; } @@ -574,32 +639,81 @@ class QTIImportProcessor { } } - private DocInfos traverseZip(File file) throws IOException { + /* + private List<DocInfos> traverseZip(File file) throws IOException { InputStream in = new FileInputStream(file); ZipInputStream zis = new ZipInputStream(in); + List<DocInfos> docInfos = new ArrayList<>(); ZipEntry entry; try { while ((entry = zis.getNextEntry()) != null) { String name = entry.getName(); if(name != null && name.toLowerCase().endsWith(".xml")) { - Document doc = readXml(zis); + Document doc = readXml(new ShieldInputStream(zis)); if(doc != null) { DocInfos d = new DocInfos(); d.doc = doc; d.filename = name; - return d; + docInfos.add(d); } } } - return null; } catch(Exception e) { log.error("", e); - return null; } finally { IOUtils.closeQuietly(zis); IOUtils.closeQuietly(in); } + return docInfos; + } + */ + + private List<DocInfos> traverseZip_nio(File file) throws IOException { + List<DocInfos> docInfos = new ArrayList<>(); + + Path fPath = FileSystems.newFileSystem(file.toPath(), null).getPath("/"); + if(fPath != null) { + DocInfosVisitor visitor = new DocInfosVisitor(); + Files.walkFileTree(fPath, visitor); + + List<Path> xmlFiles = visitor.getXmlFiles(); + for(Path xmlFile:xmlFiles) { + InputStream in = Files.newInputStream(xmlFile); + + Document doc = readXml(in); + if(doc != null) { + DocInfos d = new DocInfos(); + d.setDocument(doc); + d.setRoot(xmlFile.getParent()); + d.setFilename(xmlFile.getFileName().toString()); + docInfos.add(d); + } + + } + } + + + return docInfos; + } + + public static class DocInfosVisitor extends SimpleFileVisitor<Path> { + + private final List<Path> xmlFiles = new ArrayList<>(); + + public List<Path> getXmlFiles() { + return xmlFiles; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) + throws IOException { + String name = file.getFileName().toString(); + if(name != null && name.toLowerCase().endsWith(".xml")) { + xmlFiles.add(file); + } + return FileVisitResult.CONTINUE; + } } private Document readXml(InputStream in) { @@ -643,6 +757,8 @@ class QTIImportProcessor { public static class DocInfos { private Document doc; private String filename; + private Path root; + private Path metadata; private String qtiComment; public String getFilename() { @@ -661,6 +777,22 @@ class QTIImportProcessor { this.doc = doc; } + public Path getMetadata() { + return metadata; + } + + public void setMetadata(Path metadata) { + this.metadata = metadata; + } + + public Path getRoot() { + return root; + } + + public void setRoot(Path root) { + this.root = root; + } + public String getQtiComment() { return qtiComment; } diff --git a/src/main/java/org/olat/ims/qti/qpool/QTIMetadata.java b/src/main/java/org/olat/ims/qti/qpool/QTIMetadata.java new file mode 100644 index 00000000000..75a886f79cc --- /dev/null +++ b/src/main/java/org/olat/ims/qti/qpool/QTIMetadata.java @@ -0,0 +1,306 @@ +/** + * <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.ims.qti.qpool; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +import org.dom4j.Element; +import org.olat.core.util.StringHelper; +import org.olat.modules.qpool.QuestionItemFull; +import org.olat.modules.qpool.QuestionStatus; +import org.olat.modules.qpool.TaxonomyLevel; +import org.olat.modules.qpool.manager.QEducationalContextDAO; +import org.olat.modules.qpool.manager.QItemTypeDAO; +import org.olat.modules.qpool.manager.TaxonomyLevelDAO; +import org.olat.modules.qpool.model.QEducationalContext; +import org.olat.modules.qpool.model.QItemType; +import org.olat.modules.qpool.model.QLicense; +import org.olat.modules.qpool.model.QuestionItemImpl; + +/** + * + * Initial date: 10.09.2014<br> + * @author srosse, stephane.rosse@frentix.com, http://www.frentix.com + * + */ +class QTIMetadata { + + private Element qtimetadata; + + private QItemTypeDAO itemTypeDao; + private TaxonomyLevelDAO taxonomyLevelDao; + private QEducationalContextDAO educationalContextDao; + + QTIMetadata(Element qtimetadata) { + this.qtimetadata = qtimetadata; + } + + QTIMetadata(Element qtimetadata, QItemTypeDAO itemTypeDao, + TaxonomyLevelDAO taxonomyLevelDao, QEducationalContextDAO educationalContextDao) { + this.qtimetadata = qtimetadata; + this.itemTypeDao = itemTypeDao; + this.taxonomyLevelDao = taxonomyLevelDao; + this.educationalContextDao = educationalContextDao; + } + + private QItemType toType(String itemType) { + QItemType type = itemTypeDao.loadByType(itemType); + if(type == null) { + type = itemTypeDao.create(itemType, true); + } + return type; + } + + private QLicense toLicense(String str) { + return null; + } + + private TaxonomyLevel toTaxonomy(String str) { + String[] path = str.split("/"); + List<String> cleanedPath = new ArrayList<>(path.length); + for(String segment:path) { + if(StringHelper.containsNonWhitespace(segment)) { + cleanedPath.add(segment); + } + } + + TaxonomyLevel lowerLevel = null; + if(path != null && path.length > 0) { + for(String field :cleanedPath) { + TaxonomyLevel level = taxonomyLevelDao.loadLevelBy(lowerLevel, field); + if(level == null) { + level = taxonomyLevelDao.createAndPersist(lowerLevel, field); + } + lowerLevel = level; + } + } + return lowerLevel; + } + + private QEducationalContext toEducationalContext(String txt) { + QEducationalContext context = educationalContextDao.loadByLevel(txt); + if(context == null) { + context = educationalContextDao.create(txt, true); + } + return context; + } + + protected void toQuestion(QuestionItemImpl fullItem) { + String addInfos = this.getMetadataEntry("additional_informations"); + if(StringHelper.containsNonWhitespace(addInfos)) { + fullItem.setAdditionalInformations(addInfos); + } + String assessmentType = getMetadataEntry("oo_assessment_type"); + if(StringHelper.containsNonWhitespace(assessmentType)) { + fullItem.setAssessmentType(assessmentType); + } + String coverage = getMetadataEntry("coverage"); + if(StringHelper.containsNonWhitespace(coverage)) { + fullItem.setCoverage(coverage); + } + String description = getMetadataEntry("description"); + if(description != null) { + fullItem.setDescription(description); + } + String differentiation = getMetadataEntry("oo_differentiation"); + if(StringHelper.containsNonWhitespace(differentiation)) { + fullItem.setDifferentiation(toBigDecimal(differentiation)); + } + String difficulty = getMetadataEntry("qmd_levelofdifficulty"); + if(StringHelper.containsNonWhitespace(difficulty)) { + fullItem.setDifficulty(toBigDecimal(difficulty)); + } + String vendor = getMetadataEntry("qmd_toolvendor"); + if(vendor != null) { + fullItem.setEditor(vendor); + } + String editorVersion = getMetadataEntry("oo_toolvendor_version"); + if(StringHelper.containsNonWhitespace(editorVersion)) { + fullItem.setEditorVersion(editorVersion); + } + String learningTime = getMetadataEntry("oo_education_learning_time"); + if(StringHelper.containsNonWhitespace(learningTime)) { + fullItem.setEducationalLearningTime(learningTime); + } + String format = getMetadataEntry("format"); + if(StringHelper.containsNonWhitespace(format)) { + fullItem.setFormat(format); + } + String identifier = getMetadataEntry("oo_identifier"); + if(StringHelper.containsNonWhitespace(identifier)) { + fullItem.setMasterIdentifier(identifier); + } + String itemType = getMetadataEntry("type"); + if(StringHelper.containsNonWhitespace(itemType)) { + fullItem.setType(toType(itemType)); + } + String version = getMetadataEntry("version"); + if(StringHelper.containsNonWhitespace(version)) { + fullItem.setItemVersion(version); + } + String keywords = getMetadataEntry("keywords"); + if(StringHelper.containsNonWhitespace(keywords)) { + fullItem.setKeywords(keywords); + } + String language = getMetadataEntry("language"); + if(StringHelper.containsNonWhitespace(language)) { + fullItem.setLanguage(language); + } + String numOfAnswers = getMetadataEntry("oo_num_of_answer_alternatives"); + if(StringHelper.containsNonWhitespace(numOfAnswers)) { + fullItem.setNumOfAnswerAlternatives(toInt(numOfAnswers)); + } + String status = getMetadataEntry("status"); + if(StringHelper.containsNonWhitespace(status) && validStatus(status)) { + fullItem.setStatus(status); + } + String stdDevDifficulty = getMetadataEntry("oo_std_dev_difficulty"); + if(StringHelper.containsNonWhitespace(stdDevDifficulty)) { + fullItem.setStdevDifficulty(toBigDecimal(stdDevDifficulty)); + } + String title = getMetadataEntry("title"); + if(StringHelper.containsNonWhitespace(title)) { + fullItem.setTitle(title); + } + String license = getMetadataEntry("license"); + if(StringHelper.containsNonWhitespace(license)) { + fullItem.setLicense(toLicense(license)); + } + String taxonomy = getMetadataEntry("oo_taxonomy"); + if(StringHelper.containsNonWhitespace(taxonomy)) { + fullItem.setTaxonomyLevel(toTaxonomy(taxonomy)); + } + String educationalContext = getMetadataEntry("oo_educational_context"); + if(StringHelper.containsNonWhitespace(educationalContext)) { + fullItem.setEducationalContext(toEducationalContext(educationalContext)); + } + } + + protected void toXml(QuestionItemFull fullItem) { + addMetadataField("additional_informations", fullItem.getAdditionalInformations(), qtimetadata); + addMetadataField("oo_assessment_type", fullItem.getAssessmentType(), qtimetadata); + addMetadataField("coverage", fullItem.getCoverage(), qtimetadata); + addMetadataField("description", fullItem.getDescription(), qtimetadata); + addMetadataField("oo_differentiation", fullItem.getDifferentiation(), qtimetadata); + addMetadataField("qmd_levelofdifficulty", fullItem.getDifficulty(), qtimetadata); + addMetadataField("qmd_toolvendor", fullItem.getEditor(), qtimetadata); + addMetadataField("oo_toolvendor_version", fullItem.getEditorVersion(), qtimetadata); + addMetadataField("oo_educational_context", fullItem.getEducationalContext(), qtimetadata); + addMetadataField("oo_education_learning_time", fullItem.getEducationalLearningTime(), qtimetadata); + addMetadataField("format", fullItem.getFormat(), qtimetadata); + addMetadataField("oo_identifier", fullItem.getIdentifier(), qtimetadata); + addMetadataField("type", fullItem.getItemType(), qtimetadata); + addMetadataField("version", fullItem.getItemVersion(), qtimetadata); + addMetadataField("keywords", fullItem.getKeywords(), qtimetadata); + addMetadataField("language", fullItem.getLanguage(), qtimetadata); + addMetadataField("license", fullItem.getLicense(), qtimetadata); + addMetadataField("oo_master", fullItem.getMasterIdentifier(), qtimetadata); + addMetadataField("oo_num_of_answer_alternatives", fullItem.getNumOfAnswerAlternatives(), qtimetadata); + addMetadataField("status", fullItem.getQuestionStatus(), qtimetadata); + addMetadataField("oo_std_dev_difficulty", fullItem.getStdevDifficulty(), qtimetadata); + addMetadataField("oo_taxonomy", fullItem.getTaxonomicPath(), qtimetadata); + //fullItem.getTaxonomicLevel(); + addMetadataField("title", fullItem.getTitle(), qtimetadata); + addMetadataField("oo_usage", fullItem.getUsage(), qtimetadata); + } + + private void addMetadataField(String label, int entry, Element qtimetadata) { + if(entry >= 0) { + addMetadataField(label, Integer.toString(entry), qtimetadata); + } + } + + private void addMetadataField(String label, QLicense entry, Element qtimetadata) { + if(entry != null) { + addMetadataField(label, entry.getLicenseText(), qtimetadata); + } + } + + private void addMetadataField(String label, QEducationalContext entry, Element qtimetadata) { + if(entry != null) { + addMetadataField(label, entry.getLevel(), qtimetadata); + } + } + + private void addMetadataField(String label, QuestionStatus entry, Element qtimetadata) { + if(entry != null) { + addMetadataField(label, entry.name(), qtimetadata); + } + } + + private void addMetadataField(String label, BigDecimal entry, Element qtimetadata) { + if(entry != null) { + addMetadataField(label, entry.toPlainString(), qtimetadata); + } + } + + private void addMetadataField(String label, String entry, Element qtimetadata) { + if(entry != null) { + Element qtimetadatafield = qtimetadata.addElement("qtimetadatafield"); + qtimetadatafield.addElement("fieldlabel").setText(label); + qtimetadatafield.addElement("fieldentry").setText(entry); + } + } + + private String getMetadataEntry(String label) { + String entry = null; + + @SuppressWarnings("unchecked") + List<Element> qtimetadatafields = qtimetadata.elements("qtimetadatafield"); + for(Element qtimetadatafield:qtimetadatafields) { + Element fieldlabel = qtimetadatafield.element("fieldlabel"); + if(fieldlabel != null && label.equals(fieldlabel.getText())) { + Element fieldentry = qtimetadatafield.element("fieldentry"); + if(fieldentry != null) { + entry = fieldentry.getText(); + } + } + } + + return entry; + } + + private BigDecimal toBigDecimal(String str) { + try { + return new BigDecimal(str); + } catch (Exception e) { + return null; + } + } + + private int toInt(String str) { + try { + return Integer.parseInt(str); + } catch (NumberFormatException e) { + return 0; + } + } + + private boolean validStatus(String str) { + try { + QuestionStatus.valueOf(str); + return true; + } catch (Exception e) { + return false; + } + } +} \ No newline at end of file diff --git a/src/main/java/org/olat/ims/qti/qpool/QTIQPoolServiceProvider.java b/src/main/java/org/olat/ims/qti/qpool/QTIQPoolServiceProvider.java index aae2920f58f..62cab9fbac5 100644 --- a/src/main/java/org/olat/ims/qti/qpool/QTIQPoolServiceProvider.java +++ b/src/main/java/org/olat/ims/qti/qpool/QTIQPoolServiceProvider.java @@ -71,6 +71,7 @@ import org.olat.modules.qpool.manager.QEducationalContextDAO; import org.olat.modules.qpool.manager.QItemTypeDAO; import org.olat.modules.qpool.manager.QPoolFileStorage; import org.olat.modules.qpool.manager.QuestionItemDAO; +import org.olat.modules.qpool.manager.TaxonomyLevelDAO; import org.olat.modules.qpool.model.DefaultExportFormat; import org.olat.modules.qpool.model.QuestionItemImpl; import org.olat.repository.RepositoryEntry; @@ -103,6 +104,8 @@ public class QTIQPoolServiceProvider implements QPoolSPI { private QuestionItemDAO questionItemDao; @Autowired private QEducationalContextDAO qEduContextDao; + @Autowired + private TaxonomyLevelDAO taxonomyLevelDao; private static final List<ExportFormatOptions> formats = new ArrayList<ExportFormatOptions>(2); static { @@ -185,7 +188,7 @@ public class QTIQPoolServiceProvider implements QPoolSPI { @Override public List<QuestionItem> importItems(Identity owner, Locale defaultLocale, String filename, File file) { QTIImportProcessor processor = new QTIImportProcessor(owner, defaultLocale, filename, file, - questionItemDao, qItemTypeDao, qEduContextDao, qpoolFileStorage); + questionItemDao, qItemTypeDao, qEduContextDao, taxonomyLevelDao, qpoolFileStorage, dbInstance); return processor.process(); } @@ -215,11 +218,11 @@ public class QTIQPoolServiceProvider implements QPoolSPI { item.setTitle(title); QTIImportProcessor processor = new QTIImportProcessor(owner, defaultLocale, - questionItemDao, qItemTypeDao, qEduContextDao, qpoolFileStorage); + questionItemDao, qItemTypeDao, qEduContextDao, taxonomyLevelDao, qpoolFileStorage, dbInstance); Document doc = QTIEditHelper.itemToXml(item); Element itemEl = (Element)doc.selectSingleNode("questestinterop/item"); - QuestionItemImpl qitem = processor.processItem(itemEl, "", null, "OpenOLAT", Settings.getVersion()); + QuestionItemImpl qitem = processor.processItem(itemEl, "", null, "OpenOLAT", Settings.getVersion(), null); //save to file System VFSContainer baseDir = qpoolFileStorage.getContainer(qitem.getDirectory()); VFSLeaf leaf = baseDir.createChildLeaf(qitem.getRootFilename()); @@ -229,7 +232,7 @@ public class QTIQPoolServiceProvider implements QPoolSPI { public void importBeecomItem(Identity owner, Item item, VFSContainer sourceDir, Locale defaultLocale) { QTIImportProcessor processor = new QTIImportProcessor(owner, defaultLocale, - questionItemDao, qItemTypeDao, qEduContextDao, qpoolFileStorage); + questionItemDao, qItemTypeDao, qEduContextDao, taxonomyLevelDao, qpoolFileStorage, dbInstance); String editor = null; String editorVersion = null; @@ -240,7 +243,7 @@ public class QTIQPoolServiceProvider implements QPoolSPI { Document doc = QTIEditHelper.itemToXml(item); Element itemEl = (Element)doc.selectSingleNode("questestinterop/item"); - QuestionItemImpl qitem = processor.processItem(itemEl, "", null, editor, editorVersion); + QuestionItemImpl qitem = processor.processItem(itemEl, "", null, editor, editorVersion, null); //save to file System VFSContainer baseDir = qpoolFileStorage.getContainer(qitem.getDirectory()); VFSLeaf leaf = baseDir.createChildLeaf(qitem.getRootFilename()); diff --git a/src/main/java/org/olat/modules/qpool/manager/TaxonomyLevelDAO.java b/src/main/java/org/olat/modules/qpool/manager/TaxonomyLevelDAO.java index b1f0bef9c7c..17a31c996ee 100644 --- a/src/main/java/org/olat/modules/qpool/manager/TaxonomyLevelDAO.java +++ b/src/main/java/org/olat/modules/qpool/manager/TaxonomyLevelDAO.java @@ -22,6 +22,8 @@ package org.olat.modules.qpool.manager; import java.util.Date; import java.util.List; +import javax.persistence.TypedQuery; + import org.olat.core.commons.persistence.DB; import org.olat.modules.qpool.TaxonomyLevel; import org.olat.modules.qpool.model.TaxonomyLevelImpl; @@ -126,6 +128,28 @@ public class TaxonomyLevelDAO { .setParameter("path", path + "%") .getResultList(); } + + public TaxonomyLevel loadLevelBy(TaxonomyLevel parent, String field) { + TypedQuery<TaxonomyLevel> query; + if(parent == null) { + String q = "select f from qtaxonomylevel f where f.field=:field and f.parentField is null"; + query = dbInstance.getCurrentEntityManager() + .createQuery(q, TaxonomyLevel.class); + + } else { + String q = "select f from qtaxonomylevel f where f.field=:field and f.parentField=:parent"; + query = dbInstance.getCurrentEntityManager() + .createQuery(q, TaxonomyLevel.class) + .setParameter("parent", parent); + } + List<TaxonomyLevel> fields = query + .setParameter("field", field) + .getResultList(); + if(fields.isEmpty()) { + return null; + } + return fields.get(0); + } public TaxonomyLevel loadLevelById(Long key) { diff --git a/src/test/java/org/olat/ims/qti/qpool/QTIExportProcessorTest.java b/src/test/java/org/olat/ims/qti/qpool/QTIExportProcessorTest.java index c34cd93ded8..b49f5c16fef 100644 --- a/src/test/java/org/olat/ims/qti/qpool/QTIExportProcessorTest.java +++ b/src/test/java/org/olat/ims/qti/qpool/QTIExportProcessorTest.java @@ -40,10 +40,11 @@ import org.olat.core.commons.persistence.DB; import org.olat.core.id.Identity; import org.olat.modules.qpool.QuestionItem; import org.olat.modules.qpool.QuestionItemFull; -import org.olat.modules.qpool.manager.QPoolFileStorage; import org.olat.modules.qpool.manager.QEducationalContextDAO; import org.olat.modules.qpool.manager.QItemTypeDAO; +import org.olat.modules.qpool.manager.QPoolFileStorage; import org.olat.modules.qpool.manager.QuestionItemDAO; +import org.olat.modules.qpool.manager.TaxonomyLevelDAO; import org.olat.test.JunitTestHelper; import org.olat.test.OlatTestCase; import org.springframework.beans.factory.annotation.Autowired; @@ -67,6 +68,8 @@ public class QTIExportProcessorTest extends OlatTestCase { @Autowired private QuestionItemDAO questionItemDao; @Autowired + private TaxonomyLevelDAO taxonomyLevelDao; + @Autowired private QEducationalContextDAO qEduContextDao; @Before @@ -83,7 +86,8 @@ public class QTIExportProcessorTest extends OlatTestCase { URL itemUrl = QTIExportProcessorTest.class.getResource("mchc_asmimr_106.zip"); Assert.assertNotNull(itemUrl); File itemFile = new File(itemUrl.toURI()); - QTIImportProcessor proc = new QTIImportProcessor(owner, Locale.ENGLISH, itemFile.getName(), itemFile, questionItemDao, qItemTypeDao, qEduContextDao, qpoolFileStorage); + QTIImportProcessor proc = new QTIImportProcessor(owner, Locale.ENGLISH, itemFile.getName(), itemFile, + questionItemDao, qItemTypeDao, qEduContextDao, taxonomyLevelDao, qpoolFileStorage, dbInstance); List<QuestionItem> items = proc.process(); Assert.assertNotNull(items); dbInstance.commitAndCloseSession(); diff --git a/src/test/java/org/olat/ims/qti/qpool/QTIImportProcessorTest.java b/src/test/java/org/olat/ims/qti/qpool/QTIImportProcessorTest.java index 95352b76fad..3d7dc08d5da 100644 --- a/src/test/java/org/olat/ims/qti/qpool/QTIImportProcessorTest.java +++ b/src/test/java/org/olat/ims/qti/qpool/QTIImportProcessorTest.java @@ -22,6 +22,7 @@ package org.olat.ims.qti.qpool; import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.math.BigDecimal; import java.net.URISyntaxException; import java.net.URL; import java.util.List; @@ -53,6 +54,7 @@ import org.olat.modules.qpool.manager.QEducationalContextDAO; import org.olat.modules.qpool.manager.QItemTypeDAO; import org.olat.modules.qpool.manager.QPoolFileStorage; import org.olat.modules.qpool.manager.QuestionItemDAO; +import org.olat.modules.qpool.manager.TaxonomyLevelDAO; import org.olat.modules.qpool.model.QEducationalContext; import org.olat.modules.qpool.model.QItemType; import org.olat.modules.qpool.model.QuestionItemImpl; @@ -79,6 +81,8 @@ public class QTIImportProcessorTest extends OlatTestCase { @Autowired private QuestionItemDAO questionItemDao; @Autowired + private TaxonomyLevelDAO taxonomyLevelDao; + @Autowired private QEducationalContextDAO qEduContextDao; @Before @@ -100,8 +104,13 @@ public class QTIImportProcessorTest extends OlatTestCase { File itemFile = new File(itemUrl.toURI()); //get the document informations - QTIImportProcessor proc = new QTIImportProcessor(owner, Locale.ENGLISH, itemFile.getName(), itemFile, questionItemDao, qItemTypeDao, qEduContextDao, qpoolFileStorage); - DocInfos docInfos = proc.getDocInfos(); + QTIImportProcessor proc = new QTIImportProcessor(owner, Locale.ENGLISH, itemFile.getName(), itemFile, + questionItemDao, qItemTypeDao, qEduContextDao, taxonomyLevelDao, qpoolFileStorage, dbInstance); + List<DocInfos> docInfoList = proc.getDocInfos(); + Assert.assertNotNull(docInfoList); + Assert.assertEquals(1, docInfoList.size()); + + DocInfos docInfos = docInfoList.get(0); Assert.assertNotNull(docInfos); Assert.assertNotNull(docInfos.getFilename()); Assert.assertNotNull(docInfos.getDocument()); @@ -116,7 +125,7 @@ public class QTIImportProcessorTest extends OlatTestCase { QuestionItemImpl item = proc.processItem(docInfos, itemInfos.get(0)); Assert.assertNotNull(item); dbInstance.commitAndCloseSession(); - proc.processFiles(item, itemInfos.get(0)); + proc.processFiles(item, itemInfos.get(0), null); //reload and check what is saved QuestionItemFull reloadItem = questionItemDao.loadById(item.getKey()); @@ -150,7 +159,8 @@ public class QTIImportProcessorTest extends OlatTestCase { File itemFile = new File(itemUrl.toURI()); //get the document informations - QTIImportProcessor proc = new QTIImportProcessor(owner, Locale.ENGLISH, itemFile.getName(), itemFile, questionItemDao, qItemTypeDao, qEduContextDao, qpoolFileStorage); + QTIImportProcessor proc = new QTIImportProcessor(owner, Locale.ENGLISH, itemFile.getName(), itemFile, + questionItemDao, qItemTypeDao, qEduContextDao, taxonomyLevelDao, qpoolFileStorage, dbInstance); List<QuestionItem> items = proc.process(); Assert.assertNotNull(items); Assert.assertEquals(1, items.size()); @@ -183,8 +193,13 @@ public class QTIImportProcessorTest extends OlatTestCase { File testFile = new File(testUrl.toURI()); //get the document informations - QTIImportProcessor proc = new QTIImportProcessor(owner, Locale.ENGLISH, testFile.getName(), testFile, questionItemDao, qItemTypeDao, qEduContextDao, qpoolFileStorage); - DocInfos docInfos = proc.getDocInfos(); + QTIImportProcessor proc = new QTIImportProcessor(owner, Locale.ENGLISH, testFile.getName(), testFile, + questionItemDao, qItemTypeDao, qEduContextDao, taxonomyLevelDao, qpoolFileStorage, dbInstance); + List<DocInfos> docInfoList = proc.getDocInfos(); + Assert.assertNotNull(docInfoList); + Assert.assertEquals(1, docInfoList.size()); + + DocInfos docInfos = docInfoList.get(0); Assert.assertNotNull(docInfos); Assert.assertNotNull(docInfos.getFilename()); Assert.assertNotNull(docInfos.getDocument()); @@ -203,7 +218,8 @@ public class QTIImportProcessorTest extends OlatTestCase { File itemFile = new File(itemUrl.toURI()); //get the document informations - QTIImportProcessor proc = new QTIImportProcessor(owner, Locale.ENGLISH, itemFile.getName(), itemFile, questionItemDao, qItemTypeDao, qEduContextDao, qpoolFileStorage); + QTIImportProcessor proc = new QTIImportProcessor(owner, Locale.ENGLISH, itemFile.getName(), itemFile, + questionItemDao, qItemTypeDao, qEduContextDao, taxonomyLevelDao, qpoolFileStorage, dbInstance); List<QuestionItem> items = proc.process(); Assert.assertNotNull(items); Assert.assertEquals(4, items.size()); @@ -265,7 +281,8 @@ public class QTIImportProcessorTest extends OlatTestCase { File itemFile = new File(itemUrl.toURI()); //get the document informations - QTIImportProcessor proc = new QTIImportProcessor(owner, Locale.ENGLISH, itemFile.getName(), itemFile, questionItemDao, qItemTypeDao, qEduContextDao, qpoolFileStorage); + QTIImportProcessor proc = new QTIImportProcessor(owner, Locale.ENGLISH, itemFile.getName(), itemFile, + questionItemDao, qItemTypeDao, qEduContextDao, taxonomyLevelDao, qpoolFileStorage, dbInstance); List<QuestionItem> items = proc.process(); Assert.assertNotNull(items); Assert.assertEquals(2, items.size()); @@ -311,7 +328,8 @@ public class QTIImportProcessorTest extends OlatTestCase { File itemFile = new File(itemUrl.toURI()); //get the document informations - QTIImportProcessor proc = new QTIImportProcessor(owner, Locale.ENGLISH, itemFile.getName(), itemFile, questionItemDao, qItemTypeDao, qEduContextDao, qpoolFileStorage); + QTIImportProcessor proc = new QTIImportProcessor(owner, Locale.ENGLISH, itemFile.getName(), itemFile, + questionItemDao, qItemTypeDao, qEduContextDao, taxonomyLevelDao, qpoolFileStorage, dbInstance); List<QuestionItem> items = proc.process(); Assert.assertNotNull(items); Assert.assertEquals(3, items.size()); @@ -357,6 +375,51 @@ public class QTIImportProcessorTest extends OlatTestCase { } } + @Test + public void testImport_QTI12_multipleItems() throws IOException, URISyntaxException { + URL itemsUrl = QTIImportProcessorTest.class.getResource("multiple_items.zip"); + Assert.assertNotNull(itemsUrl); + File itemFile = new File(itemsUrl.toURI()); + + //get the document informations + QTIImportProcessor proc = new QTIImportProcessor(owner, Locale.ENGLISH, itemFile.getName(), itemFile, + questionItemDao, qItemTypeDao, qEduContextDao, taxonomyLevelDao, qpoolFileStorage, dbInstance); + List<QuestionItem> items = proc.process(); + Assert.assertNotNull(items); + Assert.assertEquals(2, items.size()); + dbInstance.commitAndCloseSession(); + + //check the files + for(QuestionItem item:items) { + QuestionItemFull itemFull = (QuestionItemFull)item; + String dir = itemFull.getDirectory(); + String file = itemFull.getRootFilename(); + VFSContainer itemContainer = qpoolFileStorage.getContainer(dir); + Assert.assertNotNull(itemContainer); + VFSItem itemLeaf = itemContainer.resolve(file); + Assert.assertNotNull(itemLeaf); + Assert.assertTrue(itemLeaf instanceof VFSLeaf); + + //try to parse it + InputStream is = ((VFSLeaf)itemLeaf).getInputStream(); + XMLParser xmlParser = new XMLParser(new IMSEntityResolver()); + Document doc = xmlParser.parse(is, false); + Node itemNode = doc.selectSingleNode("questestinterop/item"); + Assert.assertNotNull(itemNode); + + //check the attachments + if("Export (blue)".equals(itemFull.getTitle())) { + Assert.assertTrue(exists(itemFull, "media/blue.png")); + Assert.assertFalse(exists(itemFull, "media/purple.png")); + } else if("Export (purple)".equals(itemFull.getTitle())) { + Assert.assertFalse(exists(itemFull, "media/blue.png")); + Assert.assertTrue(exists(itemFull, "media/purple.png")); + } else { + Assert.fail(); + } + } + } + @Test public void testImport_QTI12_metadata() throws IOException, URISyntaxException { URL itemUrl = QTIImportProcessorTest.class.getResource("mchc_i_001.xml"); @@ -364,7 +427,8 @@ public class QTIImportProcessorTest extends OlatTestCase { File itemFile = new File(itemUrl.toURI()); //get the document informations - QTIImportProcessor proc = new QTIImportProcessor(owner, Locale.ENGLISH, itemFile.getName(), itemFile, questionItemDao, qItemTypeDao, qEduContextDao, qpoolFileStorage); + QTIImportProcessor proc = new QTIImportProcessor(owner, Locale.ENGLISH, itemFile.getName(), itemFile, + questionItemDao, qItemTypeDao, qEduContextDao, taxonomyLevelDao, qpoolFileStorage, dbInstance); List<QuestionItem> items = proc.process(); Assert.assertNotNull(items); Assert.assertEquals(1, items.size()); @@ -381,6 +445,49 @@ public class QTIImportProcessorTest extends OlatTestCase { Assert.assertEquals("QTITools", item.getEditor()); } + @Test + public void testImport_QTI12_sidecarMetadata() throws IOException, URISyntaxException { + URL itemUrl = QTIImportProcessorTest.class.getResource("qitem_metadatas.zip"); + Assert.assertNotNull(itemUrl); + File itemFile = new File(itemUrl.toURI()); + + //get the document informations + QTIImportProcessor proc = new QTIImportProcessor(owner, Locale.ENGLISH, itemFile.getName(), itemFile, + questionItemDao, qItemTypeDao, qEduContextDao, taxonomyLevelDao, qpoolFileStorage, dbInstance); + List<QuestionItem> items = proc.process(); + Assert.assertNotNull(items); + Assert.assertEquals(1, items.size()); + dbInstance.commitAndCloseSession(); + + //reload and check metadata + QuestionItem item = questionItemDao.loadById(items.get(0).getKey()); + Assert.assertEquals("Une information en plus", item.getAdditionalInformations()); + Assert.assertEquals("formative", item.getAssessmentType()); + Assert.assertEquals("large", item.getCoverage()); + Assert.assertEquals(0, new BigDecimal("-0.1").compareTo(item.getDifferentiation())); + Assert.assertEquals(0, new BigDecimal("0.45").compareTo(item.getDifficulty())); + Assert.assertEquals("OpenOLAT", item.getEditor()); + Assert.assertEquals("9.4", item.getEditorVersion()); + QEducationalContext level = item.getEducationalContext(); + Assert.assertNotNull(level); + Assert.assertEquals("University", level.getLevel()); + Assert.assertEquals("P5DT4H3M2S", item.getEducationalLearningTime()); + Assert.assertEquals("IMS QTI 1.2", item.getFormat()); + Assert.assertEquals("6bae65ac-f333-40ba-bdd0-13b54d016d59", item.getMasterIdentifier()); + Assert.assertFalse("6bae65ac-f333-40ba-bdd0-13b54d016d59".equals(item.getIdentifier())); + Assert.assertEquals("sc", item.getItemType()); + Assert.assertEquals("1.01", item.getItemVersion()); + Assert.assertEquals("question export import Pluton", item.getKeywords()); + Assert.assertEquals("de", item.getLanguage()); + Assert.assertEquals(1, item.getNumOfAnswerAlternatives()); + Assert.assertNotNull(item.getQuestionStatus()); + Assert.assertEquals("review", item.getQuestionStatus().name()); + Assert.assertEquals(0, new BigDecimal("0.56").compareTo(item.getStdevDifficulty())); + Assert.assertEquals("/Physique/Astronomie/Astrophysique", item.getTaxonomicPath()); + Assert.assertEquals("Une question sur Pluton", item.getTitle()); + Assert.assertEquals(0, item.getUsage()); + } + @Test public void testImport_QTI12_film() throws IOException, URISyntaxException { URL itemUrl = QTIImportProcessorTest.class.getResource("sc_with_film.xml"); @@ -388,11 +495,16 @@ public class QTIImportProcessorTest extends OlatTestCase { File itemFile = new File(itemUrl.toURI()); //get the document informations - QTIImportProcessor proc = new QTIImportProcessor(owner, Locale.ENGLISH, itemFile.getName(), itemFile, questionItemDao, qItemTypeDao, qEduContextDao, qpoolFileStorage); + QTIImportProcessor proc = new QTIImportProcessor(owner, Locale.ENGLISH, itemFile.getName(), itemFile, + questionItemDao, qItemTypeDao, qEduContextDao, taxonomyLevelDao, qpoolFileStorage, dbInstance); List<QuestionItem> items = proc.process(); Assert.assertNotNull(items); - DocInfos docInfos = proc.getDocInfos(); + List<DocInfos> docInfoList = proc.getDocInfos(); + Assert.assertNotNull(docInfoList); + Assert.assertEquals(1, docInfoList.size()); + + DocInfos docInfos = docInfoList.get(0); List<ItemInfos> itemInfos = proc.getItemList(docInfos); Assert.assertNotNull(itemInfos); Assert.assertEquals(1, itemInfos.size()); diff --git a/src/test/java/org/olat/ims/qti/qpool/multiple_items.zip b/src/test/java/org/olat/ims/qti/qpool/multiple_items.zip new file mode 100644 index 0000000000000000000000000000000000000000..6e23a54a87e790b09586467c3323bb78c6e6b7b0 GIT binary patch literal 14107 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 T?pT6x`>MY^&<auA{q;Wp4^wJ4 literal 0 HcmV?d00001 diff --git a/src/test/java/org/olat/ims/qti/qpool/qitem_metadatas.zip b/src/test/java/org/olat/ims/qti/qpool/qitem_metadatas.zip new file mode 100644 index 0000000000000000000000000000000000000000..aa7abd90c694eea11c1dd5e5218462ea4ef99cb5 GIT binary patch literal 1758 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 O8w9<t>+etP58c1`So<je literal 0 HcmV?d00001 -- GitLab