/** * <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.manager; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.math.BigDecimal; import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.Collection; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import javax.xml.parsers.DocumentBuilder; import javax.xml.transform.Transformer; import javax.xml.transform.TransformerException; import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; import org.apache.commons.io.IOUtils; import org.olat.basesecurity.IdentityRef; import org.olat.core.gui.components.form.flexible.impl.MultipartFileInfos; import org.olat.core.helpers.Settings; import org.olat.core.id.Identity; import org.olat.core.id.Persistable; import org.olat.core.id.User; import org.olat.core.logging.OLATRuntimeException; import org.olat.core.logging.OLog; import org.olat.core.logging.Tracing; import org.olat.core.util.FileUtils; import org.olat.core.util.Formatter; import org.olat.core.util.StringHelper; import org.olat.core.util.cache.CacheWrapper; import org.olat.core.util.coordinate.Cacher; import org.olat.core.util.coordinate.CoordinatorManager; import org.olat.core.util.crypto.CryptoUtil; import org.olat.core.util.crypto.X509CertificatePrivateKeyPair; import org.olat.core.util.mail.MailBundle; import org.olat.core.util.mail.MailManager; import org.olat.core.util.xml.XMLDigitalSignatureUtil; import org.olat.core.util.xml.XStreamHelper; import org.olat.fileresource.FileResourceManager; import org.olat.fileresource.types.ImsQTI21Resource; import org.olat.fileresource.types.ImsQTI21Resource.PathResourceLocator; import org.olat.ims.qti21.AssessmentItemSession; import org.olat.ims.qti21.AssessmentResponse; import org.olat.ims.qti21.AssessmentSessionAuditLogger; import org.olat.ims.qti21.AssessmentTestHelper; import org.olat.ims.qti21.AssessmentTestMarks; import org.olat.ims.qti21.AssessmentTestSession; import org.olat.ims.qti21.QTI21AssessmentResultsOptions; import org.olat.ims.qti21.QTI21Constants; import org.olat.ims.qti21.QTI21ContentPackage; import org.olat.ims.qti21.QTI21DeliveryOptions; import org.olat.ims.qti21.QTI21Module; import org.olat.ims.qti21.QTI21Service; import org.olat.ims.qti21.manager.audit.AssessmentSessionAuditFileLog; import org.olat.ims.qti21.manager.audit.AssessmentSessionAuditOLog; import org.olat.ims.qti21.model.DigitalSignatureOptions; import org.olat.ims.qti21.model.DigitalSignatureValidation; import org.olat.ims.qti21.model.InMemoryAssessmentTestMarks; import org.olat.ims.qti21.model.InMemoryAssessmentTestSession; import org.olat.ims.qti21.model.ParentPartItemRefs; import org.olat.ims.qti21.model.ResponseLegality; import org.olat.ims.qti21.model.audit.CandidateEvent; import org.olat.ims.qti21.model.audit.CandidateItemEventType; import org.olat.ims.qti21.model.audit.CandidateTestEventType; import org.olat.modules.assessment.AssessmentEntry; import org.olat.modules.assessment.manager.AssessmentEntryDAO; import org.olat.repository.RepositoryEntry; import org.olat.repository.RepositoryEntryRef; import org.olat.user.UserDataDeletable; import org.springframework.beans.factory.DisposableBean; import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.w3c.dom.Document; import org.w3c.dom.Node; import com.thoughtworks.xstream.XStream; import uk.ac.ed.ph.jqtiplus.JqtiExtensionManager; import uk.ac.ed.ph.jqtiplus.JqtiExtensionPackage; import uk.ac.ed.ph.jqtiplus.QtiConstants; import uk.ac.ed.ph.jqtiplus.node.AssessmentObject; import uk.ac.ed.ph.jqtiplus.node.QtiNode; import uk.ac.ed.ph.jqtiplus.node.result.AbstractResult; import uk.ac.ed.ph.jqtiplus.node.result.AssessmentResult; import uk.ac.ed.ph.jqtiplus.node.result.ItemResult; import uk.ac.ed.ph.jqtiplus.node.result.ItemVariable; import uk.ac.ed.ph.jqtiplus.node.result.OutcomeVariable; import uk.ac.ed.ph.jqtiplus.notification.NotificationRecorder; import uk.ac.ed.ph.jqtiplus.reading.AssessmentObjectXmlLoader; import uk.ac.ed.ph.jqtiplus.reading.QtiObjectReadResult; import uk.ac.ed.ph.jqtiplus.reading.QtiObjectReader; import uk.ac.ed.ph.jqtiplus.reading.QtiXmlInterpretationException; import uk.ac.ed.ph.jqtiplus.reading.QtiXmlReader; import uk.ac.ed.ph.jqtiplus.resolution.ResolvedAssessmentItem; import uk.ac.ed.ph.jqtiplus.resolution.ResolvedAssessmentObject; import uk.ac.ed.ph.jqtiplus.resolution.ResolvedAssessmentTest; import uk.ac.ed.ph.jqtiplus.serialization.QtiSerializer; import uk.ac.ed.ph.jqtiplus.state.ItemSessionState; import uk.ac.ed.ph.jqtiplus.state.TestPlanNodeKey; import uk.ac.ed.ph.jqtiplus.state.TestSessionState; import uk.ac.ed.ph.jqtiplus.state.marshalling.ItemSessionStateXmlMarshaller; import uk.ac.ed.ph.jqtiplus.state.marshalling.TestSessionStateXmlMarshaller; import uk.ac.ed.ph.jqtiplus.types.Identifier; import uk.ac.ed.ph.jqtiplus.types.ResponseData.ResponseDataType; import uk.ac.ed.ph.jqtiplus.value.BooleanValue; import uk.ac.ed.ph.jqtiplus.value.NumberValue; import uk.ac.ed.ph.jqtiplus.value.RecordValue; import uk.ac.ed.ph.jqtiplus.value.SingleValue; import uk.ac.ed.ph.jqtiplus.value.Value; import uk.ac.ed.ph.jqtiplus.xmlutils.XmlFactories; import uk.ac.ed.ph.jqtiplus.xmlutils.XmlResourceNotFoundException; import uk.ac.ed.ph.jqtiplus.xmlutils.locators.ClassPathResourceLocator; import uk.ac.ed.ph.jqtiplus.xmlutils.locators.ResourceLocator; import uk.ac.ed.ph.jqtiplus.xmlutils.xslt.XsltSerializationOptions; import uk.ac.ed.ph.jqtiplus.xmlutils.xslt.XsltStylesheetCache; import uk.ac.ed.ph.jqtiplus.xmlutils.xslt.XsltStylesheetManager; import uk.ac.ed.ph.qtiworks.mathassess.GlueValueBinder; import uk.ac.ed.ph.qtiworks.mathassess.MathAssessConstants; import uk.ac.ed.ph.qtiworks.mathassess.MathAssessExtensionPackage; /** * * Initial date: 12.05.2015<br> * @author srosse, stephane.rosse@frentix.com, http://www.frentix.com * */ @Service public class QTI21ServiceImpl implements QTI21Service, UserDataDeletable, InitializingBean, DisposableBean { private static final OLog log = Tracing.createLoggerFor(QTI21ServiceImpl.class); private static XStream configXstream = XStreamHelper.createXStreamInstance(); static { configXstream.alias("deliveryOptions", QTI21DeliveryOptions.class); configXstream.alias("assessmentResultsOptions", QTI21AssessmentResultsOptions.class); } @Autowired private AssessmentTestSessionDAO testSessionDao; @Autowired private AssessmentItemSessionDAO itemSessionDao; @Autowired private AssessmentResponseDAO testResponseDao; @Autowired private AssessmentTestMarksDAO testMarksDao; @Autowired private AssessmentEntryDAO assessmentEntryDao; @Autowired private QTI21Module qtiModule; @Autowired private CoordinatorManager coordinatorManager; @Autowired private MailManager mailManager; private JqtiExtensionManager jqtiExtensionManager; private XsltStylesheetManager xsltStylesheetManager; private InfinispanXsltStylesheetCache xsltStylesheetCache; private CacheWrapper<File,ResolvedAssessmentTest> assessmentTestsCache; private CacheWrapper<File,ResolvedAssessmentItem> assessmentItemsCache; private final ConcurrentMap<String,URI> resourceToTestURI = new ConcurrentHashMap<>(); @Autowired public QTI21ServiceImpl(InfinispanXsltStylesheetCache xsltStylesheetCache) { this.xsltStylesheetCache = xsltStylesheetCache; } @Override public void afterPropertiesSet() throws Exception { final List<JqtiExtensionPackage<?>> extensionPackages = new ArrayList<JqtiExtensionPackage<?>>(); /* Enable MathAssess extensions if requested */ if (qtiModule.isMathAssessExtensionEnabled()) { log.info("Enabling the MathAssess extensions"); extensionPackages.add(new MathAssessExtensionPackage(xsltStylesheetCache)); } jqtiExtensionManager = new JqtiExtensionManager(extensionPackages); xsltStylesheetManager = new XsltStylesheetManager(new ClassPathResourceLocator(), xsltStylesheetCache); jqtiExtensionManager.init(); Cacher cacher = coordinatorManager.getInstance().getCoordinator().getCacher(); assessmentTestsCache = cacher.getCache("QTIWorks", "assessmentTests"); assessmentItemsCache = cacher.getCache("QTIWorks", "assessmentItems"); } @Override public void destroy() throws Exception { if(jqtiExtensionManager != null) { jqtiExtensionManager.destroy(); } } @Override public XsltStylesheetCache getXsltStylesheetCache() { return xsltStylesheetCache; } @Override public XsltStylesheetManager getXsltStylesheetManager() { return xsltStylesheetManager; } @Override public JqtiExtensionManager jqtiExtensionManager() { return jqtiExtensionManager; } @Override public QtiSerializer qtiSerializer() { return new QtiSerializer(jqtiExtensionManager()); } @Override public QtiXmlReader qtiXmlReader() { return new QtiXmlReader(jqtiExtensionManager()); } @Override public QTI21DeliveryOptions getDeliveryOptions(RepositoryEntry testEntry) { FileResourceManager frm = FileResourceManager.getInstance(); File reFolder = frm.getFileResourceRoot(testEntry.getOlatResource()); File configXml = new File(reFolder, PACKAGE_CONFIG_FILE_NAME); QTI21DeliveryOptions config; if(configXml.exists()) { config = (QTI21DeliveryOptions)configXstream.fromXML(configXml); } else { //set default config config = QTI21DeliveryOptions.defaultSettings(); setDeliveryOptions(testEntry, config); } return config; } @Override public void setDeliveryOptions(RepositoryEntry testEntry, QTI21DeliveryOptions options) { FileResourceManager frm = FileResourceManager.getInstance(); File reFolder = frm.getFileResourceRoot(testEntry.getOlatResource()); File configXml = new File(reFolder, PACKAGE_CONFIG_FILE_NAME); if(options == null) { if(configXml.exists()) { configXml.delete(); } } else { try (OutputStream out = new FileOutputStream(configXml)) { configXstream.toXML(options, out); } catch (IOException e) { log.error("", e); } } } @Override public boolean isAssessmentTestActivelyUsed(RepositoryEntry testEntry) { return testSessionDao.hasActiveTestSession(testEntry); } @Override public ResolvedAssessmentTest loadAndResolveAssessmentTest(File resourceDirectory, boolean replace, boolean debugInfo) { URI assessmentObjectSystemId = createAssessmentTestUri(resourceDirectory); if(assessmentObjectSystemId == null) { return null; } File resourceFile = new File(assessmentObjectSystemId); if(replace) { ResolvedAssessmentTest resolvedAssessmentTest = internalLoadAndResolveAssessmentTest(resourceDirectory, assessmentObjectSystemId); assessmentTestsCache.replace(resourceFile, resolvedAssessmentTest); return resolvedAssessmentTest; } return assessmentTestsCache.computeIfAbsent(resourceFile, file -> { return internalLoadAndResolveAssessmentTest(resourceDirectory, assessmentObjectSystemId); }); } private ResolvedAssessmentTest internalLoadAndResolveAssessmentTest(File resourceDirectory, URI assessmentObjectSystemId) { QtiXmlReader qtiXmlReader = new QtiXmlReader(jqtiExtensionManager()); ResourceLocator fileResourceLocator = new PathResourceLocator(resourceDirectory.toPath()); ResourceLocator inputResourceLocator = ImsQTI21Resource.createResolvingResourceLocator(fileResourceLocator); AssessmentObjectXmlLoader assessmentObjectXmlLoader = new AssessmentObjectXmlLoader(qtiXmlReader, inputResourceLocator); return assessmentObjectXmlLoader.loadAndResolveAssessmentTest(assessmentObjectSystemId); } @Override public ResolvedAssessmentItem loadAndResolveAssessmentItem(URI assessmentObjectSystemId, File resourceDirectory) { File resourceFile = new File(assessmentObjectSystemId); return assessmentItemsCache.computeIfAbsent(resourceFile, (file) -> { return loadAndResolveAssessmentItemForCopy(assessmentObjectSystemId, resourceDirectory); }); } @Override public ResolvedAssessmentItem loadAndResolveAssessmentItemForCopy(URI assessmentObjectSystemId, File resourceDirectory) { QtiXmlReader qtiXmlReader = new QtiXmlReader(jqtiExtensionManager()); ResourceLocator fileResourceLocator = new PathResourceLocator(resourceDirectory.toPath()); ResourceLocator inputResourceLocator = ImsQTI21Resource.createResolvingResourceLocator(fileResourceLocator); AssessmentObjectXmlLoader assessmentObjectXmlLoader = new AssessmentObjectXmlLoader(qtiXmlReader, inputResourceLocator); return assessmentObjectXmlLoader.loadAndResolveAssessmentItem(assessmentObjectSystemId); } @Override public boolean updateAssesmentObject(File resourceFile, ResolvedAssessmentObject<?> resolvedAssessmentObject) { AssessmentObject assessmentObject; if(resolvedAssessmentObject instanceof ResolvedAssessmentItem) { assessmentObject = ((ResolvedAssessmentItem)resolvedAssessmentObject) .getItemLookup().getRootNodeHolder().getRootNode(); } else if(resolvedAssessmentObject instanceof ResolvedAssessmentTest) { assessmentObject = ((ResolvedAssessmentTest)resolvedAssessmentObject) .getTestLookup().getRootNodeHolder().getRootNode(); } else { return false; } return persistAssessmentObject(resourceFile, assessmentObject); } @Override public boolean persistAssessmentObject(File resourceFile, AssessmentObject assessmentObject) { try(FileOutputStream out = new FileOutputStream(resourceFile)) { qtiSerializer().serializeJqtiObject(assessmentObject, out); //TODO qti assessmentTestsCache.remove(resourceFile); assessmentItemsCache.remove(resourceFile); return true; } catch(Exception e) { log.error("", e); return false; } } @Override public boolean needManualCorrection(RepositoryEntry testEntry) { FileResourceManager frm = FileResourceManager.getInstance(); File fUnzippedDirRoot = frm.unzipFileResource(testEntry.getOlatResource()); ResolvedAssessmentTest resolvedAssessmentTest = loadAndResolveAssessmentTest(fUnzippedDirRoot, false, false); return AssessmentTestHelper.needManualCorrection(resolvedAssessmentTest); } @Override public URI createAssessmentTestUri(final File resourceDirectory) { final String key = resourceDirectory.getAbsolutePath(); try { return resourceToTestURI.computeIfAbsent(key, (directoryAbsolutPath) -> { File manifestPath = new File(resourceDirectory, "imsmanifest.xml"); QTI21ContentPackage cp = new QTI21ContentPackage(manifestPath.toPath()); try { Path testPath = cp.getTest(); return testPath.toUri(); } catch (IOException e) { log.error("", e); return null; } }); } catch (RuntimeException e) { log.error("", e); return null; } } @Override public int deleteUserDataPriority() { return 10; } @Override public void deleteUserData(Identity identity, String newDeletedUserName, File archivePath) { List<AssessmentTestSession> sessions = testSessionDao.getUserTestSessions(identity); for(AssessmentTestSession session:sessions) { testSessionDao.deleteTestSession(session); } } @Override public boolean deleteAssessmentTestSession(List<Identity> identities, RepositoryEntryRef testEntry, RepositoryEntryRef entry, String subIdent) { Set<AssessmentEntry> entries = new HashSet<>(); for(Identity identity:identities) { List<AssessmentTestSession> sessions = testSessionDao.getTestSessions(testEntry, entry, subIdent, identity); for(AssessmentTestSession session:sessions) { if(session.getAssessmentEntry() != null) { entries.add(session.getAssessmentEntry()); } File fileStorage = testSessionDao.getSessionStorage(session); testSessionDao.deleteTestSession(session); FileUtils.deleteDirsAndFiles(fileStorage, true, true); } } for(AssessmentEntry assessmentEntry:entries) { assessmentEntryDao.resetAssessmentEntry(assessmentEntry); } return true; } @Override public boolean deleteAuthorAssessmentTestSession(RepositoryEntryRef testEntry) { List<AssessmentTestSession> sessions = testSessionDao.getAuthorAssessmentTestSession(testEntry); for(AssessmentTestSession session:sessions) { File fileStorage = testSessionDao.getSessionStorage(session); testSessionDao.deleteTestSession(session); FileUtils.deleteDirsAndFiles(fileStorage, true, true); } return true; } @Override public boolean deleteAssessmentTestSession(AssessmentTestSession testSession) { if(testSession == null || testSession.getKey() == null) return false; int rows = testSessionDao.deleteTestSession(testSession); return rows > 0; } @Override public AssessmentSessionAuditLogger getAssessmentSessionAuditLogger(AssessmentTestSession session, boolean authorMode) { if(authorMode) { return new AssessmentSessionAuditOLog(); } if(session.getIdentity() == null && StringHelper.containsNonWhitespace(session.getAnonymousIdentifier())) { return new AssessmentSessionAuditOLog(); } try { File userStorage = testSessionDao.getSessionStorage(session); File auditLog = new File(userStorage, "audit.log"); FileOutputStream outputStream = new FileOutputStream(auditLog, true); return new AssessmentSessionAuditFileLog(outputStream); } catch (IOException e) { log.error("Cannot open the user specific log audit, fall back to OLog", e); return new AssessmentSessionAuditOLog(); } } @Override public AssessmentTestSession createAssessmentTestSession(Identity identity, String anonymousIdentifier, AssessmentEntry assessmentEntry, RepositoryEntry entry, String subIdent, RepositoryEntry testEntry, boolean authorMode) { return testSessionDao.createAndPersistTestSession(testEntry, entry, subIdent, assessmentEntry, identity, anonymousIdentifier, authorMode); } @Override public AssessmentTestSession createInMemoryAssessmentTestSession(Identity identity) { InMemoryAssessmentTestSession candidateSession = new InMemoryAssessmentTestSession(); candidateSession.setIdentity(identity); candidateSession.setStorage(testSessionDao.createSessionStorage(candidateSession)); return candidateSession; } @Override public AssessmentTestSession getResumableAssessmentTestSession(Identity identity, String anonymousIdentifier, RepositoryEntry entry, String subIdent, RepositoryEntry testEntry, boolean authorMode) { AssessmentTestSession session = testSessionDao.getLastTestSession(testEntry, entry, subIdent, identity, anonymousIdentifier, authorMode); if(session == null || session.isExploded() || session.getFinishTime() != null || session.getTerminationTime() != null) { session = null; } else { File sessionFile = getTestSessionStateFile(session); if(sessionFile == null || !sessionFile.exists()) { session = null; } } return session; } @Override public AssessmentTestSession reloadAssessmentTestSession(AssessmentTestSession session) { return testSessionDao.loadByKey(session.getKey()); } @Override public AssessmentTestSession updateAssessmentTestSession(AssessmentTestSession session) { return testSessionDao.update(session); } @Override public AssessmentTestSession getAssessmentTestSession(Long assessmentTestSessionKey) { return testSessionDao.loadFullByKey(assessmentTestSessionKey); } @Override public List<AssessmentTestSession> getAssessmentTestSessions(RepositoryEntryRef courseEntry, String subIdent, IdentityRef identity) { return testSessionDao.getUserTestSessions(courseEntry, subIdent, identity); } @Override public AssessmentTestSession getLastAssessmentTestSessions(RepositoryEntryRef courseEntry, String subIdent, RepositoryEntry testEntry, IdentityRef identity) { return testSessionDao.getLastUserTestSession(courseEntry, subIdent, testEntry, identity); } @Override public List<AssessmentTestSession> getAssessmentTestSessions(RepositoryEntryRef courseEntry, String subIdent, RepositoryEntry testEntry) { return testSessionDao.getTestSessions(courseEntry, subIdent, testEntry); } @Override public boolean isRunningAssessmentTestSession(RepositoryEntry entry, String subIdent, RepositoryEntry testEntry) { return testSessionDao.hasRunningTestSessions(entry, subIdent, testEntry); } @Override public List<AssessmentTestSession> getRunningAssessmentTestSession(RepositoryEntry entry, String subIdent, RepositoryEntry testEntry) { return testSessionDao.getRunningTestSessions(entry, subIdent, testEntry); } @Override public TestSessionState loadTestSessionState(AssessmentTestSession candidateSession) { Document document = loadStateDocument(candidateSession); return document == null ? null: TestSessionStateXmlMarshaller.unmarshal(document.getDocumentElement()); } private Document loadStateDocument(AssessmentTestSession candidateSession) { File sessionFile = getTestSessionStateFile(candidateSession); if(sessionFile.exists()) { try { DocumentBuilder documentBuilder = XmlFactories.newDocumentBuilder(); return documentBuilder.parse(sessionFile); } catch (final Exception e) { throw new OLATRuntimeException("Could not parse serailized state XML. This is an internal error as we currently don't expose this data to clients", e); } } return null; } @Override public AssessmentTestMarks getMarks(Identity identity, RepositoryEntry entry, String subIdent, RepositoryEntry testEntry) { return testMarksDao.loadTestMarks(testEntry, entry, subIdent, identity); } @Override public AssessmentTestMarks createMarks(Identity identity, RepositoryEntry entry, String subIdent, RepositoryEntry testEntry, String marks) { return testMarksDao.createAndPersistTestMarks(testEntry, entry, subIdent, identity, marks); } @Override public AssessmentTestMarks updateMarks(AssessmentTestMarks marks) { if(marks instanceof InMemoryAssessmentTestMarks) { return marks; } return testMarksDao.merge(marks); } @Override public AssessmentItemSession getOrCreateAssessmentItemSession(AssessmentTestSession assessmentTestSession, ParentPartItemRefs parentParts, String assessmentItemIdentifier) { AssessmentItemSession itemSession = itemSessionDao.getAssessmentItemSession(assessmentTestSession, assessmentItemIdentifier); if(itemSession == null) { itemSession = itemSessionDao.createAndPersistAssessmentItemSession(assessmentTestSession, parentParts, assessmentItemIdentifier); } return itemSession; } @Override public AssessmentItemSession updateAssessmentItemSession(AssessmentItemSession itemSession) { return itemSessionDao.merge(itemSession); } @Override public List<AssessmentItemSession> getAssessmentItemSessions(AssessmentTestSession candidateSession) { return itemSessionDao.getAssessmentItemSessions(candidateSession); } @Override public List<AssessmentItemSession> getAssessmentItemSessions(RepositoryEntryRef courseEntry, String subIdent, RepositoryEntry testEntry, String itemRef) { return itemSessionDao.getAssessmentItemSessions(courseEntry, subIdent, testEntry, itemRef); } @Override public AssessmentResponse createAssessmentResponse(AssessmentTestSession assessmentTestSession, AssessmentItemSession assessmentItemSession, String responseIdentifier, ResponseLegality legality, ResponseDataType type) { return testResponseDao.createAssessmentResponse(assessmentTestSession, assessmentItemSession, responseIdentifier, legality, type); } @Override public Map<Identifier, AssessmentResponse> getAssessmentResponses(AssessmentItemSession assessmentItemSession) { List<AssessmentResponse> responses = testResponseDao.getResponses(assessmentItemSession); Map<Identifier, AssessmentResponse> responseMap = new HashMap<>(); for(AssessmentResponse response:responses) { responseMap.put(Identifier.assumedLegal(response.getResponseIdentifier()), response); } return responseMap; } @Override public void recordTestAssessmentResponses(AssessmentItemSession itemSession, Collection<AssessmentResponse> responses) { testResponseDao.save(responses); if(itemSession instanceof Persistable) { itemSessionDao.merge(itemSession); } } @Override public AssessmentTestSession recordTestAssessmentResult(AssessmentTestSession candidateSession, TestSessionState testSessionState, AssessmentResult assessmentResult, AssessmentSessionAuditLogger auditLogger) { // First record full result XML to filesystem if(candidateSession.getFinishTime() == null) { storeAssessmentResultFile(candidateSession, assessmentResult); } // Then record test outcome variables to DB recordOutcomeVariables(candidateSession, assessmentResult.getTestResult(), auditLogger); // Set duration candidateSession.setDuration(testSessionState.getDurationAccumulated()); if(candidateSession instanceof Persistable) { return testSessionDao.update(candidateSession); } return candidateSession; } @Override public void signAssessmentResult(AssessmentTestSession candidateSession, DigitalSignatureOptions signatureOptions, Identity assessedIdentity) { if(!qtiModule.isDigitalSignatureEnabled() || !signatureOptions.isDigitalSignature()) return;//nothing to do try { File resultFile = getAssessmentResultFile(candidateSession); File signatureFile = new File(resultFile.getParentFile(), "assessmentResultSignature.xml"); File certificateFile = qtiModule.getDigitalSignatureCertificateFile(); X509CertificatePrivateKeyPair kp =CryptoUtil.getX509CertificatePrivateKeyPairPfx( certificateFile, qtiModule.getDigitalSignatureCertificatePassword()); StringBuilder uri = new StringBuilder(); uri.append(Settings.getServerContextPathURI()).append("/") .append("RepositoryEntry/").append(candidateSession.getRepositoryEntry().getKey()); if(StringHelper.containsNonWhitespace(candidateSession.getSubIdent())) { uri.append("/CourseNode/").append(candidateSession.getSubIdent()); } uri.append("/TestSession/").append(candidateSession.getKey()) .append("/assessmentResult.xml"); Document signatureDoc = createSignatureDocumentWrapper(uri.toString(), assessedIdentity, signatureOptions); XMLDigitalSignatureUtil.signDetached(uri.toString(), resultFile, signatureFile, signatureDoc, certificateFile.getName(), kp.getX509Cert(), kp.getPrivateKey()); if(signatureOptions.isDigitalSignature() && signatureOptions.getMailBundle() != null) { MailBundle mail = signatureOptions.getMailBundle(); List<File> attachments = new ArrayList<>(2); attachments.add(signatureFile); mail.getContent().setAttachments(attachments); mailManager.sendMessageAsync(mail); } } catch (Exception e) { log.error("", e); } } private Document createSignatureDocumentWrapper(String url, Identity assessedIdentity, DigitalSignatureOptions signatureOptions) { try { Document signatureDocument = XMLDigitalSignatureUtil.createDocument(); Node rootNode = signatureDocument.appendChild(signatureDocument.createElement("assessmentTestSignature")); Node urlNode = rootNode.appendChild(signatureDocument.createElement("url")); urlNode.appendChild(signatureDocument.createTextNode(url)); Node dateNode = rootNode.appendChild(signatureDocument.createElement("date")); dateNode.appendChild(signatureDocument.createTextNode(Formatter.formatDatetime(new Date()))); if(signatureOptions.getEntry() != null) { Node courseNode = rootNode.appendChild(signatureDocument.createElement("course")); courseNode.appendChild(signatureDocument.createTextNode(signatureOptions.getEntry().getDisplayname())); } if(signatureOptions.getSubIdentName() != null) { Node courseNodeNode = rootNode.appendChild(signatureDocument.createElement("courseNode")); courseNodeNode.appendChild(signatureDocument.createTextNode(signatureOptions.getSubIdentName())); } if(signatureOptions.getTestEntry() != null) { Node testNode = rootNode.appendChild(signatureDocument.createElement("test")); testNode.appendChild(signatureDocument.createTextNode(signatureOptions.getTestEntry().getDisplayname())); } if(assessedIdentity != null && assessedIdentity.getUser() != null) { User user = assessedIdentity.getUser(); Node firstNameNode = rootNode.appendChild(signatureDocument.createElement("firstName")); firstNameNode.appendChild(signatureDocument.createTextNode(user.getFirstName())); Node lastNameNode = rootNode.appendChild(signatureDocument.createElement("lastName")); lastNameNode.appendChild(signatureDocument.createTextNode(user.getLastName())); } return signatureDocument; } catch ( Exception e) { log.error("", e); return null; } } @Override public DigitalSignatureValidation validateAssessmentResult(File xmlSignature) { try { Document signature = XMLDigitalSignatureUtil.getDocument(xmlSignature); String uri = XMLDigitalSignatureUtil.getReferenceURI(signature); //URI looks like: http://localhost:8081/olat/RepositoryEntry/688455680/CourseNode/95134692149905/TestSession/3231/assessmentResult.xml String keyName = XMLDigitalSignatureUtil.getKeyName(signature); int end = uri.indexOf("/assessmentResult"); if(end <= 0) { return new DigitalSignatureValidation(DigitalSignatureValidation.Message.sessionNotFound, false); } int start = uri.lastIndexOf('/', end - 1); if(start <= 0) { return new DigitalSignatureValidation(DigitalSignatureValidation.Message.sessionNotFound, false); } String testSessionKey = uri.substring(start + 1, end); AssessmentTestSession testSession = getAssessmentTestSession(new Long(testSessionKey)); if(testSession == null) { return new DigitalSignatureValidation(DigitalSignatureValidation.Message.sessionNotFound, false); } File assessmentResult = getAssessmentResultFile(testSession); File certificateFile = qtiModule.getDigitalSignatureCertificateFile(); X509CertificatePrivateKeyPair kp = null; if(keyName != null && keyName.equals(certificateFile.getName())) { kp = CryptoUtil.getX509CertificatePrivateKeyPairPfx( certificateFile, qtiModule.getDigitalSignatureCertificatePassword()); } else if(keyName != null) { File olderCertificateFile = new File(certificateFile.getParentFile(), keyName); if(olderCertificateFile.exists()) { kp = CryptoUtil.getX509CertificatePrivateKeyPairPfx( olderCertificateFile, qtiModule.getDigitalSignatureCertificatePassword()); } } if(kp == null) { // validate document against signature if(XMLDigitalSignatureUtil.validate(uri, assessmentResult, xmlSignature)) { return new DigitalSignatureValidation(DigitalSignatureValidation.Message.validItself, true); } } else if(XMLDigitalSignatureUtil.validate(uri, assessmentResult, xmlSignature, kp.getX509Cert().getPublicKey())) { // validate document against signature but use the public key of the certificate return new DigitalSignatureValidation(DigitalSignatureValidation.Message.validCertificate, true); } } catch (Exception e) { log.error("", e); } return new DigitalSignatureValidation(DigitalSignatureValidation.Message.notValid, false); } @Override public File getAssessmentResultSignature(AssessmentTestSession candidateSession) { File resultFile = getAssessmentResultFile(candidateSession); File signatureFile = new File(resultFile.getParentFile(), "assessmentResultSignature.xml"); return signatureFile.exists() ? signatureFile : null; } @Override public Date getAssessmentResultSignatureIssueDate(AssessmentTestSession candidateSession) { Date issueDate = null; File signatureFile = null; try { signatureFile = getAssessmentResultSignature(candidateSession); if(signatureFile != null) { Document doc = XMLDigitalSignatureUtil.getDocument(signatureFile); if(doc != null) { String date = XMLDigitalSignatureUtil.getElementText(doc, "date"); if(StringHelper.containsNonWhitespace(date)) { issueDate = Formatter.parseDatetime(date); } } } } catch (Exception e) { log.error("Cannot read the issue date of the signature: " + signatureFile, e); } return issueDate; } @Override public AssessmentTestSession finishTestSession(AssessmentTestSession candidateSession, TestSessionState testSessionState, AssessmentResult assessmentResult, Date timestamp, DigitalSignatureOptions digitalSignature, Identity assessedIdentity) { /* Mark session as finished */ candidateSession.setFinishTime(timestamp); // Set duration candidateSession.setDuration(testSessionState.getDurationAccumulated()); /* Also nullify LIS result info for session. These will be updated later, if pre-conditions match for sending the result back */ //candidateSession.setLisOutcomeReportingStatus(null); //candidateSession.setLisScore(null); if(candidateSession instanceof Persistable) { candidateSession = testSessionDao.update(candidateSession); } storeAssessmentResultFile(candidateSession, assessmentResult); if(qtiModule.isDigitalSignatureEnabled() && digitalSignature.isDigitalSignature()) { signAssessmentResult(candidateSession, digitalSignature, assessedIdentity); } /* Finally schedule LTI result return (if appropriate and sane) */ //maybeScheduleLtiOutcomes(candidateSession, assessmentResult); return candidateSession; } /** * Cancel delete the test session, related items session and their responses, the * assessment result file, the test plan file. * */ @Override public void cancelTestSession(AssessmentTestSession candidateSession, TestSessionState testSessionState) { final File myStore = testSessionDao.getSessionStorage(candidateSession); final File sessionState = new File(myStore, "testSessionState.xml"); final File resultFile = getAssessmentResultFile(candidateSession); testSessionDao.deleteTestSession(candidateSession); if(sessionState != null && sessionState.exists()) { sessionState.delete(); } if(resultFile != null && resultFile.exists()) { resultFile.delete(); } } private void recordOutcomeVariables(AssessmentTestSession candidateSession, AbstractResult resultNode, AssessmentSessionAuditLogger auditLogger) { //preserve the order Map<Identifier,String> outcomes = new LinkedHashMap<>(); for (final ItemVariable itemVariable : resultNode.getItemVariables()) { if (itemVariable instanceof OutcomeVariable) { recordOutcomeVariable(candidateSession, (OutcomeVariable)itemVariable, outcomes); } } if(auditLogger != null) { auditLogger.logCandidateOutcomes(candidateSession, outcomes); } } private void recordOutcomeVariable(AssessmentTestSession candidateSession, OutcomeVariable outcomeVariable, Map<Identifier,String> outcomes) { Identifier identifier = outcomeVariable.getIdentifier(); Value computedValue = outcomeVariable.getComputedValue(); if (QtiConstants.VARIABLE_DURATION_IDENTIFIER.equals(identifier)) { log.audit(candidateSession.getKey() + " :: " + outcomeVariable.getIdentifier() + " - " + stringifyQtiValue(computedValue)); } else if (QTI21Constants.SCORE_IDENTIFIER.equals(identifier)) { if (computedValue instanceof NumberValue) { double score = ((NumberValue) computedValue).doubleValue(); candidateSession.setScore(new BigDecimal(score)); } } else if (QTI21Constants.PASS_IDENTIFIER.equals(identifier)) { if (computedValue instanceof BooleanValue) { boolean pass = ((BooleanValue) computedValue).booleanValue(); candidateSession.setPassed(pass); } } try { outcomes.put(identifier, stringifyQtiValue(computedValue)); } catch (Exception e) { log.error("", e); } } private String stringifyQtiValue(final Value value) { if (qtiModule.isMathAssessExtensionEnabled() && GlueValueBinder.isMathsContentRecord(value)) { /* This is a special MathAssess "Maths Content" variable. In this case, we'll record * just the ASCIIMath input form or the Maxima form, if either are available. */ final RecordValue mathsValue = (RecordValue) value; final SingleValue asciiMathInput = mathsValue.get(MathAssessConstants.FIELD_CANDIDATE_INPUT_IDENTIFIER); if (asciiMathInput!=null) { return "ASCIIMath[" + asciiMathInput.toQtiString() + "]"; } final SingleValue maximaForm = mathsValue.get(MathAssessConstants.FIELD_MAXIMA_IDENTIFIER); if (maximaForm!=null) { return "Maxima[" + maximaForm.toQtiString() + "]"; } } /* Just convert to QTI string in the usual way */ return value.toQtiString(); } private void storeAssessmentResultFile(final AssessmentTestSession candidateSession, final QtiNode resultNode) { final File resultFile = getAssessmentResultFile(candidateSession); try(OutputStream resultStream = FileUtils.getBos(resultFile);) { qtiSerializer().serializeJqtiObject(resultNode, resultStream); } catch (final Exception e) { throw new OLATRuntimeException("Unexpected", e); } } public File getAssessmentResultFile(final AssessmentTestSession candidateSession) { File myStore = testSessionDao.getSessionStorage(candidateSession); return new File(myStore, "assessmentResult.xml"); } @Override public CandidateEvent recordCandidateTestEvent(AssessmentTestSession candidateSession, RepositoryEntryRef testEntry, RepositoryEntryRef entry, CandidateTestEventType textEventType, TestSessionState testSessionState, NotificationRecorder notificationRecorder) { return recordCandidateTestEvent(candidateSession, testEntry, entry, textEventType, null, null, testSessionState, notificationRecorder); } @Override public CandidateEvent recordCandidateTestEvent(AssessmentTestSession candidateSession, RepositoryEntryRef testEntry, RepositoryEntryRef entry, CandidateTestEventType textEventType, CandidateItemEventType itemEventType, TestPlanNodeKey itemKey, TestSessionState testSessionState, NotificationRecorder notificationRecorder) { CandidateEvent event = new CandidateEvent(candidateSession, testEntry, entry); event.setTestEventType(textEventType); event.setItemEventType(itemEventType); if (itemKey != null) { event.setTestItemKey(itemKey.toString()); } storeTestSessionState(event, testSessionState); return event; } private void storeTestSessionState(CandidateEvent candidateEvent, TestSessionState testSessionState) { Document stateDocument = TestSessionStateXmlMarshaller.marshal(testSessionState); File sessionFile = getTestSessionStateFile(candidateEvent); storeStateDocument(stateDocument, sessionFile); } private File getTestSessionStateFile(CandidateEvent candidateEvent) { AssessmentTestSession candidateSession = candidateEvent.getCandidateSession(); return getTestSessionStateFile(candidateSession); } private File getTestSessionStateFile(AssessmentTestSession candidateSession) { File myStore = testSessionDao.getSessionStorage(candidateSession); return new File(myStore, "testSessionState.xml"); } @Override public CandidateEvent recordCandidateItemEvent(AssessmentTestSession candidateSession, RepositoryEntryRef testEntry, RepositoryEntryRef entry, CandidateItemEventType itemEventType, ItemSessionState itemSessionState) { return recordCandidateItemEvent(candidateSession, testEntry, entry, itemEventType, itemSessionState, null); } @Override public CandidateEvent recordCandidateItemEvent(AssessmentTestSession candidateSession, RepositoryEntryRef testEntry, RepositoryEntryRef entry, CandidateItemEventType itemEventType, ItemSessionState itemSessionState, NotificationRecorder notificationRecorder) { CandidateEvent event = new CandidateEvent(candidateSession, testEntry, entry); event.setItemEventType(itemEventType); return event; } @Override public AssessmentResult getAssessmentResult(AssessmentTestSession candidateSession) { File assessmentResultFile = getAssessmentResultFile(candidateSession); ResourceLocator fileResourceLocator = new PathResourceLocator(assessmentResultFile.getParentFile().toPath()); ResourceLocator inputResourceLocator = ImsQTI21Resource.createResolvingResourceLocator(fileResourceLocator); URI assessmentResultUri = assessmentResultFile.toURI(); QtiObjectReader qtiObjectReader = qtiXmlReader().createQtiObjectReader(inputResourceLocator, false, false); try { QtiObjectReadResult<AssessmentResult> result = qtiObjectReader.lookupRootNode(assessmentResultUri, AssessmentResult.class); return result.getRootNode(); } catch (XmlResourceNotFoundException | QtiXmlInterpretationException | ClassCastException e) { log.error("", e); return null; } } public void storeItemSessionState(CandidateEvent candidateEvent, ItemSessionState itemSessionState) { Document stateDocument = ItemSessionStateXmlMarshaller.marshal(itemSessionState); File sessionFile = getItemSessionStateFile(candidateEvent); storeStateDocument(stateDocument, sessionFile); } private File getItemSessionStateFile(CandidateEvent candidateEvent) { AssessmentTestSession candidateSession = candidateEvent.getCandidateSession(); File myStore = testSessionDao.getSessionStorage(candidateSession); return new File(myStore, "itemSessionState.xml"); } private void storeStateDocument(Document stateXml, File sessionFile) { XsltSerializationOptions xsltSerializationOptions = new XsltSerializationOptions(); xsltSerializationOptions.setIndenting(true); xsltSerializationOptions.setIncludingXMLDeclaration(false); Transformer serializer = XsltStylesheetManager.createSerializer(xsltSerializationOptions); try(OutputStream resultStream = new FileOutputStream(sessionFile)) { serializer.transform(new DOMSource(stateXml), new StreamResult(resultStream)); } catch (TransformerException | IOException e) { throw new OLATRuntimeException("Unexpected Exception serializing state DOM", e); } } @Override public AssessmentTestSession finishItemSession(AssessmentTestSession candidateSession, AssessmentResult assessmentResult, Date timestamp) { /* Mark session as finished */ candidateSession.setFinishTime(timestamp); /* Also nullify LIS result info for session. These will be updated later, if pre-conditions match for sending the result back */ //candidateSession.setLisOutcomeReportingStatus(null); //candidateSession.setLisScore(null); if(candidateSession instanceof Persistable) { candidateSession = testSessionDao.update(candidateSession); } /* Finally schedule LTI result return (if appropriate and sane) */ //maybeScheduleLtiOutcomes(candidateSession, assessmentResult); return candidateSession; } @Override public void recordItemAssessmentResult(AssessmentTestSession candidateSession, AssessmentResult assessmentResult, AssessmentSessionAuditLogger auditLogger) { //preserve the order Map<Identifier,String> outcomes = new LinkedHashMap<>(); for (final ItemResult itemResult:assessmentResult.getItemResults()) { for (final ItemVariable itemVariable : itemResult.getItemVariables()) { if (itemVariable instanceof OutcomeVariable) { if (itemVariable instanceof OutcomeVariable) { recordOutcomeVariable(candidateSession, (OutcomeVariable)itemVariable, outcomes); } } } } if(auditLogger != null) { auditLogger.logCandidateOutcomes(candidateSession, outcomes); } } @Override public File getSubmissionDirectory(AssessmentTestSession candidateSession) { File myStore = testSessionDao.getSessionStorage(candidateSession); File submissionDir = new File(myStore, "submissions"); if(!submissionDir.exists()) { submissionDir.mkdir(); } return submissionDir; } @Override public File importFileSubmission(AssessmentTestSession candidateSession, String filename, byte[] data) { File submissionDir = getSubmissionDirectory(candidateSession); FileOutputStream out = null; try { //add the date in the file String extension = FileUtils.getFileSuffix(filename); if(extension != null && extension.length() > 0) { filename = filename.substring(0, filename.length() - extension.length() - 1); extension = "." + extension; } else { extension = ""; } String date = testSessionDao.formatDate(new Date()); String datedFilename = FileUtils.normalizeFilename(filename) + "_" + date + extension; //make sure we don't overwrite an existing file File submittedFile = new File(submissionDir, datedFilename); String renamedFile = FileUtils.rename(submittedFile); if(!datedFilename.equals(renamedFile)) { submittedFile = new File(submissionDir, datedFilename); } out = new FileOutputStream(submittedFile); out.write(data); return submittedFile; } catch (IOException e) { log.error("", e); return null; } finally { IOUtils.closeQuietly(out); } } @Override public File importFileSubmission(AssessmentTestSession candidateSession, MultipartFileInfos multipartFile) { File submissionDir = getSubmissionDirectory(candidateSession); try { //add the date in the file String filename = multipartFile.getFileName(); String extension = FileUtils.getFileSuffix(filename); if(extension != null && extension.length() > 0) { filename = filename.substring(0, filename.length() - extension.length() - 1); extension = "." + extension; } else { extension = ""; } String date = testSessionDao.formatDate(new Date()); String datedFilename = FileUtils.normalizeFilename(filename) + "_" + date + extension; //make sure we don't overwrite an existing file File submittedFile = new File(submissionDir, datedFilename); String renamedFile = FileUtils.rename(submittedFile); if(!datedFilename.equals(renamedFile)) { submittedFile = new File(submissionDir, datedFilename); } Files.move(multipartFile.getFile().toPath(), submittedFile.toPath(), StandardCopyOption.REPLACE_EXISTING); return submittedFile; } catch (IOException e) { log.error("", e); return null; } } }