/**
* <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.qti21.pool;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.zip.ZipOutputStream;
import org.olat.core.gui.UserRequest;
import org.olat.core.gui.control.Controller;
import org.olat.core.gui.control.WindowControl;
import org.olat.core.gui.media.MediaResource;
import org.olat.core.gui.translator.Translator;
import org.olat.core.helpers.Settings;
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.StringHelper;
import org.olat.core.util.Util;
import org.olat.core.util.i18n.I18nModule;
import org.olat.core.util.vfs.LocalImpl;
import org.olat.core.util.vfs.VFSContainer;
import org.olat.core.util.vfs.VFSItem;
import org.olat.core.util.vfs.VFSLeaf;
import org.olat.core.util.vfs.VFSManager;
import org.olat.fileresource.FileResourceManager;
import org.olat.fileresource.types.ImsQTI21Resource;
import org.olat.ims.qti.QTIConstants;
import org.olat.ims.qti.editor.QTIEditHelper;
import org.olat.ims.qti.editor.QTIEditorPackage;
import org.olat.ims.qti.editor.beecom.objects.Item;
import org.olat.ims.qti21.QTI21Constants;
import org.olat.ims.qti21.QTI21Service;
import org.olat.ims.qti21.model.QTI21QuestionType;
import org.olat.ims.qti21.model.xml.AssessmentItemBuilder;
import org.olat.ims.qti21.model.xml.AssessmentItemMetadata;
import org.olat.ims.qti21.model.xml.ManifestBuilder;
import org.olat.ims.qti21.model.xml.ManifestMetadataBuilder;
import org.olat.ims.qti21.model.xml.interactions.DrawingAssessmentItemBuilder;
import org.olat.ims.qti21.model.xml.interactions.EssayAssessmentItemBuilder;
import org.olat.ims.qti21.model.xml.interactions.FIBAssessmentItemBuilder;
import org.olat.ims.qti21.model.xml.interactions.FIBAssessmentItemBuilder.EntryType;
import org.olat.ims.qti21.model.xml.interactions.HotspotAssessmentItemBuilder;
import org.olat.ims.qti21.model.xml.interactions.HottextAssessmentItemBuilder;
import org.olat.ims.qti21.model.xml.interactions.KPrimAssessmentItemBuilder;
import org.olat.ims.qti21.model.xml.interactions.MatchAssessmentItemBuilder;
import org.olat.ims.qti21.model.xml.interactions.MultipleChoiceAssessmentItemBuilder;
import org.olat.ims.qti21.model.xml.interactions.SingleChoiceAssessmentItemBuilder;
import org.olat.ims.qti21.model.xml.interactions.UploadAssessmentItemBuilder;
import org.olat.ims.qti21.questionimport.AssessmentItemAndMetadata;
import org.olat.ims.qti21.ui.editor.AssessmentTestComposerController;
import org.olat.ims.resources.IMSEntityResolver;
import org.olat.imscp.xml.manifest.ResourceType;
import org.olat.modules.qpool.ExportFormatOptions;
import org.olat.modules.qpool.ExportFormatOptions.Outcome;
import org.olat.modules.qpool.QItemFactory;
import org.olat.modules.qpool.QPoolSPI;
import org.olat.modules.qpool.QPoolService;
import org.olat.modules.qpool.QuestionItem;
import org.olat.modules.qpool.QuestionItemFull;
import org.olat.modules.qpool.QuestionItemShort;
import org.olat.modules.qpool.manager.QEducationalContextDAO;
import org.olat.modules.qpool.manager.QItemTypeDAO;
import org.olat.modules.qpool.manager.QLicenseDAO;
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;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.xml.sax.InputSource;
import org.xml.sax.XMLReader;
import org.xml.sax.helpers.XMLReaderFactory;
import uk.ac.ed.ph.jqtiplus.node.item.AssessmentItem;
import uk.ac.ed.ph.jqtiplus.node.test.AssessmentItemRef;
import uk.ac.ed.ph.jqtiplus.resolution.ResolvedAssessmentItem;
import uk.ac.ed.ph.jqtiplus.resolution.ResolvedAssessmentTest;
import uk.ac.ed.ph.jqtiplus.resolution.RootNodeLookup;
/**
*
* Initial date: 05.02.2016<br>
* @author srosse, stephane.rosse@frentix.com, http://www.frentix.com
*
*/
@Service("qti21PoolServiceProvider")
public class QTI21QPoolServiceProvider implements QPoolSPI {
private static final OLog log = Tracing.createLoggerFor(QTI21QPoolServiceProvider.class);
public static final String QTI_12_OO_TEST = "OpenOLAT Test";
@Autowired
private QTI21Service qtiService;
@Autowired
private QPoolService qpoolService;
@Autowired
private QPoolFileStorage qpoolFileStorage;
@Autowired
private QLicenseDAO qLicenseDao;
@Autowired
private QItemTypeDAO qItemTypeDao;
@Autowired
private QuestionItemDAO questionItemDao;
@Autowired
private QEducationalContextDAO qEduContextDao;
@Autowired
private TaxonomyLevelDAO taxonomyLevelDao;
private static final List<ExportFormatOptions> formats = new ArrayList<ExportFormatOptions>(4);
static {
formats.add(DefaultExportFormat.ZIP_EXPORT_FORMAT);
formats.add(DefaultExportFormat.DOCX_EXPORT_FORMAT);
formats.add(new DefaultExportFormat(QTI21Constants.QTI_21_FORMAT, Outcome.download, null));
formats.add(new DefaultExportFormat(QTI21Constants.QTI_21_FORMAT, Outcome.repository, ImsQTI21Resource.TYPE_NAME));
}
public QTI21QPoolServiceProvider() {
//
}
@Override
public int getPriority() {
return 10;
}
@Override
public String getFormat() {
return QTI21Constants.QTI_21_FORMAT;
}
@Override
public List<ExportFormatOptions> getTestExportFormats() {
return Collections.unmodifiableList(formats);
}
@Override
public boolean isCompatible(String filename, File file) {
boolean ok = new AssessmentItemFileResourceValidator().validate(filename, file);
return ok;
}
@Override
public boolean isCompatible(String filename, VFSLeaf file) {
boolean ok = new AssessmentItemFileResourceValidator().validate(filename, file);
return ok;
}
@Override
public boolean isConversionPossible(QuestionItemShort item) {
if(QTIConstants.QTI_12_FORMAT.equals(item.getFormat())) {
VFSLeaf leaf = qpoolService.getRootLeaf(item);
if(leaf == null) {
return false;
} else {
Item qtiItem = QTIEditHelper.readItemXml(leaf);
return qtiItem != null && !qtiItem.isAlient();
}
}
return false;
}
@Override
public List<QItemFactory> getItemfactories() {
List<QItemFactory> factories = new ArrayList<QItemFactory>();
for(QTI21QuestionType type:QTI21QuestionType.values()) {
if(type.hasEditor()) {
factories.add(new QTI21AssessmentItemFactory(type));
}
}
return factories;
}
@Override
public String extractTextContent(QuestionItemFull item) {
String content = null;
if(item.getRootFilename() != null) {
String dir = item.getDirectory();
VFSContainer container = qpoolFileStorage.getContainer(dir);
VFSItem file = container.resolve(item.getRootFilename());
if(file instanceof VFSLeaf) {
VFSLeaf leaf = (VFSLeaf)file;
QTI21SAXHandler handler = new QTI21SAXHandler();
try(InputStream is = leaf.getInputStream()) {
XMLReader parser = XMLReaderFactory.createXMLReader();
parser.setContentHandler(handler);
parser.setEntityResolver(new IMSEntityResolver());
parser.setFeature("http://xml.org/sax/features/validation", false);
parser.parse(new InputSource(is));
} catch (Exception e) {
log.error("", e);
}
return handler.toString();
}
}
return content;
}
@Override
public List<QuestionItem> importItems(Identity owner, Locale defaultLocale, String filename, File file) {
QTI21ImportProcessor processor = new QTI21ImportProcessor(owner, defaultLocale,
questionItemDao, qItemTypeDao, qEduContextDao, taxonomyLevelDao, qLicenseDao, qpoolFileStorage, qtiService);
return processor.process(file);
}
public List<QuestionItem> importRepositoryEntry(Identity owner, RepositoryEntry repositoryEntry, Locale defaultLocale) {
FileResourceManager frm = FileResourceManager.getInstance();
File unzippedDirRoot = frm.unzipFileResource(repositoryEntry.getOlatResource());
ResolvedAssessmentTest resolvedAssessmentTest = qtiService.loadAndResolveAssessmentTest(unzippedDirRoot, false, true);
ManifestBuilder clonedManifestBuilder = ManifestBuilder.read(new File(unzippedDirRoot, "imsmanifest.xml"));
List<AssessmentItemRef> itemRefs = resolvedAssessmentTest.getAssessmentItemRefs();
List<QuestionItem> importedItems = new ArrayList<>(itemRefs.size());
for(AssessmentItemRef itemRef:itemRefs) {
ResolvedAssessmentItem resolvedAssessmentItem = resolvedAssessmentTest.getResolvedAssessmentItem(itemRef);
RootNodeLookup<AssessmentItem> rootNode = resolvedAssessmentItem.getItemLookup();
URI itemUri = rootNode.getSystemId();
File itemFile = new File(itemUri);
String relativePathToManifest = unzippedDirRoot.toPath().relativize(itemFile.toPath()).toString();
AssessmentItem assessmentItem = resolvedAssessmentItem.getRootNodeLookup().extractIfSuccessful();
ResourceType resource = clonedManifestBuilder.getResourceTypeByHref(relativePathToManifest);
ManifestMetadataBuilder metadata = clonedManifestBuilder.getMetadataBuilder(resource, true);
QuestionItem qitem = importAssessmentItemRef(owner, assessmentItem, itemFile, metadata, defaultLocale);
importedItems.add(qitem);
}
return importedItems;
}
@Override
public MediaResource exportTest(List<QuestionItemShort> items, ExportFormatOptions format, Locale locale) {
if(QTI21Constants.QTI_21_FORMAT.equals(format.getFormat())) {
return new QTI21ExportTestResource("UTF-8", locale, items, this);
} else if(DefaultExportFormat.DOCX_EXPORT_FORMAT.getFormat().equals(format.getFormat())) {
return new QTI12And21PoolWordExport(items, I18nModule.getDefaultLocale(), "UTF-8", questionItemDao, qpoolFileStorage);
}
return null;
}
@Override
public void exportItem(QuestionItemFull item, ZipOutputStream zout, Locale locale, Set<String> names) {
QTI21ExportProcessor processor = new QTI21ExportProcessor(qtiService, qpoolFileStorage, locale);
processor.process(item, zout);
}
@Override
public void copyItem(QuestionItemFull original, QuestionItemFull copy) {
VFSContainer originalDir = qpoolFileStorage.getContainer(original.getDirectory());
VFSContainer copyDir = qpoolFileStorage.getContainer(copy.getDirectory());
VFSManager.copyContent(originalDir, copyDir);
}
@Override
public QuestionItem convert(Identity owner, QuestionItemShort itemToConvert, Locale locale) {
if(QTIConstants.QTI_12_FORMAT.equals(itemToConvert.getFormat())) {
VFSLeaf leaf = qpoolService.getRootLeaf(itemToConvert);
if(leaf == null) {
return null;
} else {
Item qtiItem = QTIEditHelper.readItemXml(leaf);
if(qtiItem != null && !qtiItem.isAlient()) {
QuestionItemImpl original = questionItemDao.loadById(itemToConvert.getKey());
QuestionItemImpl copy = questionItemDao.copy(original);
copy.setTitle(original.getTitle());
copy.setFormat(getFormat());
VFSContainer originalDir = qpoolFileStorage.getContainer(original.getDirectory());
File copyDir = qpoolFileStorage.getDirectory(copy.getDirectory());
QTI12To21Converter converter = new QTI12To21Converter(copyDir, locale);
if(converter.convert(copy, qtiItem, originalDir)) {
questionItemDao.persist(owner, copy);
return copy;
}
}
}
}
return null;
}
@Override
public Controller getPreviewController(UserRequest ureq, WindowControl wControl, QuestionItem item, boolean summary) {
return new QTI21PreviewController(ureq, wControl, item);
}
@Override
public boolean isTypeEditable() {
return true;
}
@Override
public Controller getEditableController(UserRequest ureq, WindowControl wControl, QuestionItem qitem) {
Controller editorCtrl = new QTI21EditorController(ureq, wControl, qitem);
return editorCtrl;
}
public QuestionItem createItem(Identity identity, QTI21QuestionType type, String title, Locale locale) {
AssessmentItemBuilder itemBuilder = null;
Translator translator = Util.createPackageTranslator(AssessmentTestComposerController.class, locale);
switch(type) {
case sc: itemBuilder = new SingleChoiceAssessmentItemBuilder(translator.translate("new.sc"), translator.translate("new.answer"), qtiService.qtiSerializer()); break;
case mc: itemBuilder = new MultipleChoiceAssessmentItemBuilder(translator.translate("new.mc"), translator.translate("new.answer"), qtiService.qtiSerializer()); break;
case kprim: itemBuilder = new KPrimAssessmentItemBuilder(translator.translate("new.kprim"), translator.translate("new.answer"), qtiService.qtiSerializer()); break;
case match: itemBuilder = new MatchAssessmentItemBuilder(translator.translate("new.match"), qtiService.qtiSerializer()); break;
case fib: itemBuilder = new FIBAssessmentItemBuilder(translator.translate("new.fib"), EntryType.text, qtiService.qtiSerializer()); break;
case numerical: itemBuilder = new FIBAssessmentItemBuilder(translator.translate("new.fib.numerical"), EntryType.numerical, qtiService.qtiSerializer()); break;
case essay: itemBuilder = new EssayAssessmentItemBuilder(translator.translate("new.essay"), qtiService.qtiSerializer()); break;
case upload: itemBuilder = new UploadAssessmentItemBuilder(translator.translate("new.upload"), qtiService.qtiSerializer()); break;
case drawing: itemBuilder = new DrawingAssessmentItemBuilder(translator.translate("new.drawing"), qtiService.qtiSerializer()); break;
case hotspot: itemBuilder = new HotspotAssessmentItemBuilder(translator.translate("new.hotspot"), qtiService.qtiSerializer()); break;
case hottext: itemBuilder = new HottextAssessmentItemBuilder(translator.translate("new.hottext"), translator.translate("new.hottext.start"), translator.translate("new.hottext.text"), qtiService.qtiSerializer()); break;
default: return null;
}
AssessmentItem assessmentItem = itemBuilder.getAssessmentItem();
assessmentItem.setLabel(title);
assessmentItem.setTitle(title);
AssessmentItemMetadata itemMetadata = new AssessmentItemMetadata();
itemMetadata.setQuestionType(type);
QTI21ImportProcessor processor = new QTI21ImportProcessor(identity, locale,
questionItemDao, qItemTypeDao, qEduContextDao, taxonomyLevelDao, qLicenseDao, qpoolFileStorage, qtiService);
QuestionItemImpl qitem = processor.processItem(assessmentItem, "", null, "OpenOLAT", Settings.getVersion(), itemMetadata);
VFSContainer baseDir = qpoolFileStorage.getContainer(qitem.getDirectory());
VFSLeaf leaf = baseDir.createChildLeaf(qitem.getRootFilename());
File itemFile = ((LocalImpl)leaf).getBasefile();
qtiService.persistAssessmentObject(itemFile, assessmentItem);
//create imsmanifest
ManifestBuilder manifest = ManifestBuilder.createAssessmentItemBuilder();
manifest.appendAssessmentItem(itemFile.getName());
manifest.write(new File(itemFile.getParentFile(), "imsmanifest.xml"));
return qitem;
}
public QuestionItemImpl importExcelItem(Identity owner, AssessmentItemAndMetadata itemAndMetadata, Locale defaultLocale) {
QTI21ImportProcessor processor = new QTI21ImportProcessor(owner, defaultLocale,
questionItemDao, qItemTypeDao, qEduContextDao, taxonomyLevelDao, qLicenseDao, qpoolFileStorage, qtiService);
String editor = itemAndMetadata.getEditor();
String editorVersion = itemAndMetadata.getEditorVersion();
AssessmentItemBuilder itemBuilder = itemAndMetadata.getItemBuilder();
itemBuilder.build();
AssessmentItem assessmentItem = itemBuilder.getAssessmentItem();
QuestionItemImpl qitem = processor.processItem(assessmentItem, null, null,
editor, editorVersion, itemAndMetadata);
String originalItemFilename = qitem.getRootFilename();
File itemStorage = qpoolFileStorage.getDirectory(qitem.getDirectory());
File itemFile = new File(itemStorage, originalItemFilename);
qtiService.persistAssessmentObject(itemFile, assessmentItem);
//create manifest
ManifestBuilder manifest = ManifestBuilder.createAssessmentItemBuilder();
ResourceType resource = manifest.appendAssessmentItem(UUID.randomUUID().toString(), originalItemFilename);
ManifestMetadataBuilder metadataBuilder = manifest.getMetadataBuilder(resource, true);
itemAndMetadata.toBuilder(metadataBuilder, defaultLocale);
manifest.write(new File(itemStorage, "imsmanifest.xml"));
return qitem;
}
/**
* Very important, the ManifestMetadataBuilder will be changed, it need to be a clone
*
* @param owner The future owner of the question
* @param assessmentItem The assessment item to convert
* @param itemFile The file where the assessment item is saved
* @param clonedMetadataBuilder The metadata builder need to be a clone!
* @param fUnzippedDirRoot The directory of the assessment item or the assessment test.
* @param defaultLocale The locale used by some translation
* @return
*/
public QuestionItem importAssessmentItemRef(Identity owner, AssessmentItem assessmentItem,
File itemFile, ManifestMetadataBuilder clonedMetadataBuilder, Locale defaultLocale) {
QTI21ImportProcessor processor = new QTI21ImportProcessor(owner, defaultLocale,
questionItemDao, qItemTypeDao, qEduContextDao, taxonomyLevelDao, qLicenseDao, qpoolFileStorage, qtiService);
AssessmentItemMetadata metadata = new AssessmentItemMetadata(clonedMetadataBuilder);
String editor = null;
String editorVersion = null;
if(StringHelper.containsNonWhitespace(assessmentItem.getToolName())) {
editor = assessmentItem.getToolName();
}
if(StringHelper.containsNonWhitespace(assessmentItem.getToolVersion())) {
editorVersion = assessmentItem.getToolVersion();
}
String originalItemFilename = itemFile.getName();
QuestionItemImpl qitem = processor.processItem(assessmentItem, null, originalItemFilename,
editor, editorVersion, metadata);
//storage
File itemStorage = qpoolFileStorage.getDirectory(qitem.getDirectory());
FileUtils.copyDirContentsToDir(itemFile, itemStorage, false, "QTI21 import item xml in pool");
//create manifest
ManifestBuilder manifest = ManifestBuilder.createAssessmentItemBuilder();
ResourceType resource = manifest.appendAssessmentItem(UUID.randomUUID().toString(), originalItemFilename);
ManifestMetadataBuilder exportedMetadataBuilder = manifest.getMetadataBuilder(resource, true);
exportedMetadataBuilder.setMetadata(clonedMetadataBuilder.getMetadata());
manifest.write(new File(itemStorage, "imsmanifest.xml"));
//process material
File materialDirRoot = itemFile.getParentFile();
List<String> materials = ImportExportHelper.getMaterials(assessmentItem);
for(String material:materials) {
if(material.indexOf("://") < 0) {// material can be an external URL
try {
File materialFile = new File(materialDirRoot, material);
if(materialFile.isFile() && materialFile.exists()) {
File itemMaterialFile = new File(itemStorage, material);
if(!itemMaterialFile.getParentFile().exists()) {
itemMaterialFile.getParentFile().mkdirs();
}
Files.copy(materialFile.toPath(), itemMaterialFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
}
} catch (IOException e) {
log.error("", e);
}
}
}
return qitem;
}
/**
* Export to QTI editor an item from the pool. The ident of the item
* is always regenerated as an UUID.
* @param qitem
* @param editorContainer
* @return
*/
public AssessmentItem exportToQTIEditor(QuestionItemShort qitem, Locale locale, File editorContainer) throws IOException {
QTI21ExportProcessor processor = new QTI21ExportProcessor(qtiService, qpoolFileStorage, locale);
QuestionItemFull fullItem = questionItemDao.loadById(qitem.getKey());
ResolvedAssessmentItem resolvedAssessmentItem = processor.exportToQTIEditor(fullItem, editorContainer);
AssessmentItem assessmentItem = resolvedAssessmentItem.getItemLookup().extractAssumingSuccessful();
assessmentItem.setIdentifier(QTI21QuestionType.generateNewIdentifier(assessmentItem.getIdentifier()));
return assessmentItem;
}
public void assembleTest(List<QuestionItemShort> items, Locale locale, ZipOutputStream zout) {
List<QuestionItemFull> fullItems = loadQuestionFullItems(items);
QTI21ExportProcessor processor = new QTI21ExportProcessor(qtiService, qpoolFileStorage, locale);
processor.assembleTest(fullItems, zout);
}
public void exportToEditorPackage(File exportDir, List<QuestionItemShort> items, Locale locale) {
List<QuestionItemFull> fullItems = loadQuestionFullItems(items);
QTI21ExportProcessor processor = new QTI21ExportProcessor(qtiService, qpoolFileStorage, locale);
processor.assembleTest(fullItems, exportDir);
}
private List<QuestionItemFull> loadQuestionFullItems(List<QuestionItemShort> items) {
List<Long> itemKeys = toKeys(items);
List<QuestionItemFull> fullItems = questionItemDao.loadByIds(itemKeys);
Map<Long, QuestionItemFull> fullItemMap = new HashMap<>();
for(QuestionItemFull fullItem:fullItems) {
fullItemMap.put(fullItem.getKey(), fullItem);
}
//reorder the fullItems;
List<QuestionItemFull> reorderedFullItems = new ArrayList<>(fullItems.size());
for(QuestionItemShort item:items) {
QuestionItemFull itemFull = fullItemMap.get(item.getKey());
if(itemFull != null) {
reorderedFullItems.add(itemFull);
}
}
return reorderedFullItems;
}
/**
* Convert from QTI 1.2 to 2.1
*
* @param qtiEditorPackage
*/
public boolean convertFromEditorPackage(QTIEditorPackage qtiEditorPackage, File unzippedDirRoot, Locale locale) {
try {
QTI12To21Converter converter = new QTI12To21Converter(unzippedDirRoot, locale);
converter.convert(qtiEditorPackage);
return true;
} catch (URISyntaxException e) {
log.error("", e);
return false;
}
}
private List<Long> toKeys(List<? extends QuestionItemShort> items) {
List<Long> keys = new ArrayList<Long>(items.size());
for(QuestionItemShort item:items) {
keys.add(item.getKey());
}
return keys;
}
}