diff --git a/src/main/java/org/olat/ims/qti21/QTI21Service.java b/src/main/java/org/olat/ims/qti21/QTI21Service.java index a397deb79769aee9e9f7b79e20d90b9d48b737c4..57135c1b9ca123af5054bc2062eb4ee1fe1356b3 100644 --- a/src/main/java/org/olat/ims/qti21/QTI21Service.java +++ b/src/main/java/org/olat/ims/qti21/QTI21Service.java @@ -55,8 +55,12 @@ public interface QTI21Service { public UserTestSession createTestSession(RepositoryEntry testEntry, RepositoryEntry courseEntry, String subIdent, Identity identity); + public UserTestSession getResumableTestSession(RepositoryEntry testEntry, RepositoryEntry courseEntry, String subIdent, Identity identity); + public UserTestSession updateTestSession(UserTestSession session); + public TestSessionState loadTestSessionState(UserTestSession session); + /** * Retrieve the sessions of a user. * diff --git a/src/main/java/org/olat/ims/qti21/manager/EventDAO.java b/src/main/java/org/olat/ims/qti21/manager/EventDAO.java index 45d473469e3f8be6eee11e94f851e02966176f3d..a1e251fdf6d6f803a8513b398cf15170ed231e86 100644 --- a/src/main/java/org/olat/ims/qti21/manager/EventDAO.java +++ b/src/main/java/org/olat/ims/qti21/manager/EventDAO.java @@ -19,15 +19,13 @@ */ package org.olat.ims.qti21.manager; +import org.olat.ims.qti21.UserTestSession; import org.olat.ims.qti21.model.CandidateItemEventType; import org.olat.ims.qti21.model.CandidateTestEventType; import org.olat.ims.qti21.model.jpa.CandidateEvent; import org.springframework.stereotype.Service; -import org.w3c.dom.Document; -import uk.ac.ed.ph.jqtiplus.state.ItemSessionState; import uk.ac.ed.ph.jqtiplus.state.TestPlanNodeKey; -import uk.ac.ed.ph.jqtiplus.state.marshalling.ItemSessionStateXmlMarshaller; /** * @@ -38,10 +36,11 @@ import uk.ac.ed.ph.jqtiplus.state.marshalling.ItemSessionStateXmlMarshaller; @Service public class EventDAO { - public CandidateEvent create(CandidateTestEventType textEventType, + public CandidateEvent create(UserTestSession candidateSession, CandidateTestEventType textEventType, CandidateItemEventType itemEventType, TestPlanNodeKey itemKey) { CandidateEvent event = new CandidateEvent(); + event.setCandidateSession(candidateSession); event.setTestEventType(textEventType); event.setItemEventType(itemEventType); if (itemKey != null) { @@ -49,27 +48,16 @@ public class EventDAO { } return event; } - - - public CandidateEvent create(CandidateItemEventType itemEventType, ItemSessionState itemSessionState) { - final CandidateEvent event = new CandidateEvent(); - //event.setCandidateSession(candidateSession); + + public CandidateEvent create(UserTestSession candidateSession, CandidateItemEventType itemEventType) { + + CandidateEvent event = new CandidateEvent(); + event.setCandidateSession(candidateSession); event.setItemEventType(itemEventType); //event.setTimestamp(requestTimestampContext.getCurrentRequestTimestamp()); /* Store event */ //candidateEventDao.persist(event); - - /* Save current ItemSessionState */ - storeItemSessionState(event, itemSessionState); - - return event; } - - public void storeItemSessionState(CandidateEvent candidateEvent, ItemSessionState itemSessionState) { - Document stateDocument = ItemSessionStateXmlMarshaller.marshal(itemSessionState); - //TODO storeStateDocument(candidateEvent, stateDocument); - } - -} +} \ No newline at end of file diff --git a/src/main/java/org/olat/ims/qti21/manager/QTI21ServiceImpl.java b/src/main/java/org/olat/ims/qti21/manager/QTI21ServiceImpl.java index 8e72ff532a2e4f6b26277df3c800d1ae7fe4ba9b..49a2c08a89b54bcf7103ff38199c7f047c3b7074 100644 --- a/src/main/java/org/olat/ims/qti21/manager/QTI21ServiceImpl.java +++ b/src/main/java/org/olat/ims/qti21/manager/QTI21ServiceImpl.java @@ -20,6 +20,7 @@ package org.olat.ims.qti21.manager; import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.net.URI; @@ -29,6 +30,12 @@ import java.util.ArrayList; import java.util.Date; import java.util.List; +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.olat.basesecurity.IdentityRef; import org.olat.core.commons.persistence.DB; import org.olat.core.gui.components.form.flexible.impl.MultipartFileInfos; @@ -46,11 +53,13 @@ import org.olat.ims.qti21.UserTestSession; import org.olat.ims.qti21.model.CandidateItemEventType; import org.olat.ims.qti21.model.CandidateTestEventType; import org.olat.ims.qti21.model.jpa.CandidateEvent; +import org.olat.ims.qti21.ui.rendering.XmlUtilities; import org.olat.repository.RepositoryEntry; import org.olat.repository.RepositoryEntryRef; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Service; +import org.w3c.dom.Document; import uk.ac.ed.ph.jqtiplus.JqtiExtensionManager; import uk.ac.ed.ph.jqtiplus.JqtiExtensionPackage; @@ -69,10 +78,14 @@ 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.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.locators.ResourceLocator; +import uk.ac.ed.ph.jqtiplus.xmlutils.xslt.XsltSerializationOptions; +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; @@ -165,6 +178,39 @@ public class QTI21ServiceImpl implements QTI21Service { return testSessionDao.createTestSession(testEntry, courseEntry, courseSubIdent, identity); } + @Override + public UserTestSession getResumableTestSession(RepositoryEntry testEntry, RepositoryEntry courseEntry, String subIdent, Identity identity) { + UserTestSession session = testSessionDao.getLastTestSession(testEntry, courseEntry, subIdent, identity); + if(session == null || session.isExploded() || session.getTerminationTime() != null) { + session = null; + } else { + File sessionFile = getTestSessionStateFile(session); + if(sessionFile == null || !sessionFile.exists()) { + session = null; + } + } + return session; + } + + @Override + public TestSessionState loadTestSessionState(UserTestSession candidateSession) { + Document document = loadStateDocument(candidateSession); + return document == null ? null: TestSessionStateXmlMarshaller.unmarshal(document.getDocumentElement()); + } + + private Document loadStateDocument(UserTestSession candidateSession) { + File sessionFile = getTestSessionStateFile(candidateSession); + if(sessionFile.exists()) { + DocumentBuilder documentBuilder = XmlUtilities.createNsAwareDocumentBuilder(); + try { + 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 UserTestSession updateTestSession(UserTestSession session) { return testSessionDao.update(session); @@ -250,8 +296,8 @@ public class QTI21ServiceImpl implements QTI21Service { @Override public CandidateEvent recordCandidateTestEvent(UserTestSession candidateSession, CandidateTestEventType textEventType, CandidateItemEventType itemEventType, TestSessionState testSessionState, NotificationRecorder notificationRecorder) { - CandidateEvent event = new CandidateEvent(); + event.setCandidateSession(candidateSession); event.setTestEventType(textEventType); return recordCandidateTestEvent(candidateSession, textEventType, itemEventType, null, testSessionState, notificationRecorder); } @@ -259,22 +305,65 @@ public class QTI21ServiceImpl implements QTI21Service { @Override public CandidateEvent recordCandidateTestEvent(UserTestSession candidateSession, CandidateTestEventType textEventType, CandidateItemEventType itemEventType, TestPlanNodeKey itemKey, TestSessionState testSessionState, NotificationRecorder notificationRecorder) { - return eventDao.create(textEventType, itemEventType, itemKey); + CandidateEvent event = eventDao.create(candidateSession, textEventType, itemEventType, itemKey); + storeTestSessionState(event, testSessionState); + return event; + } + + private void storeTestSessionState(CandidateEvent candidateEvent, TestSessionState testSessionState) { + Document stateDocument = TestSessionStateXmlMarshaller.marshal(testSessionState); + File sessionFile = getTestSessionStateFile(candidateEvent); + storeStateDocument(stateDocument, sessionFile); + System.out.println("Store state: " + sessionFile); } + + private File getTestSessionStateFile(CandidateEvent candidateEvent) { + UserTestSession candidateSession = candidateEvent.getCandidateSession(); + return getTestSessionStateFile(candidateSession); + } + + private File getTestSessionStateFile(UserTestSession candidateSession) { + File myStore = storage.getDirectory(candidateSession.getStorage()); + return new File(myStore, "testSessionState.xml"); + } + @Override + public CandidateEvent recordCandidateItemEvent(UserTestSession candidateSession, CandidateItemEventType itemEventType, + ItemSessionState itemSessionState) { + return recordCandidateItemEvent(candidateSession, itemEventType, itemSessionState, null); + } + @Override public CandidateEvent recordCandidateItemEvent(UserTestSession candidateSession, CandidateItemEventType itemEventType, ItemSessionState itemSessionState, NotificationRecorder notificationRecorder) { - return eventDao.create(itemEventType, itemSessionState); + return eventDao.create(candidateSession, itemEventType); } - + public void storeItemSessionState(CandidateEvent candidateEvent, ItemSessionState itemSessionState) { + Document stateDocument = ItemSessionStateXmlMarshaller.marshal(itemSessionState); + File sessionFile = getItemSessionStateFile(candidateEvent); + storeStateDocument(stateDocument, sessionFile); + } + + private File getItemSessionStateFile(CandidateEvent candidateEvent) { + UserTestSession candidateSession = candidateEvent.getCandidateSession(); + File myStore = storage.getDirectory(candidateSession.getStorage()); + 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 CandidateEvent recordCandidateItemEvent( UserTestSession candidateSession, CandidateItemEventType itemEventType, - ItemSessionState itemSessionState) { - return recordCandidateItemEvent(candidateSession, itemEventType, itemSessionState, null); - } @Override public UserTestSession finishItemSession(UserTestSession candidateSession, AssessmentResult assessmentResult, Date timestamp) { diff --git a/src/main/java/org/olat/ims/qti21/manager/SessionDAO.java b/src/main/java/org/olat/ims/qti21/manager/SessionDAO.java index 1de61e3171f63828d7b994e50c1175cf01c75708..13560f89ebb71043c62c1822ba76466d61ae52aa 100644 --- a/src/main/java/org/olat/ims/qti21/manager/SessionDAO.java +++ b/src/main/java/org/olat/ims/qti21/manager/SessionDAO.java @@ -22,6 +22,8 @@ package org.olat.ims.qti21.manager; import java.util.Date; import java.util.List; +import javax.persistence.TypedQuery; + import org.olat.basesecurity.IdentityRef; import org.olat.core.commons.persistence.DB; import org.olat.core.id.Identity; @@ -65,6 +67,40 @@ public class SessionDAO { return testSession; } + public UserTestSession getLastTestSession(RepositoryEntryRef testEntry, + RepositoryEntryRef courseEntry, String courseSubIdent, IdentityRef identity) { + + StringBuilder sb = new StringBuilder(); + sb.append("select session from qtitestsession session ") + .append("where session.testEntry.key=:testEntryKey and session.identity.key=:identityKey"); + if(courseEntry != null) { + sb.append(" and session.courseEntry.key=:courseEntryKey"); + } else { + sb.append(" and session.courseEntry.key is null"); + } + + if(courseSubIdent != null) { + sb.append(" and session.courseSubIdent=:courseSubIdent"); + } else { + sb.append(" and session.courseSubIdent is null"); + } + sb.append(" order by session.creationDate desc"); + + TypedQuery<UserTestSession> query = dbInstance.getCurrentEntityManager() + .createQuery(sb.toString(), UserTestSession.class) + .setParameter("testEntryKey", testEntry.getKey()) + .setParameter("identityKey", identity.getKey()); + if(courseEntry != null) { + query.setParameter("courseEntryKey", courseEntry.getKey()); + } + if(courseSubIdent != null) { + query.setParameter("courseSubIdent", courseSubIdent); + } + + List<UserTestSession> lastSessions = query.setMaxResults(1).getResultList(); + return lastSessions == null || lastSessions.isEmpty() ? null : lastSessions.get(0); + } + public UserTestSession update(UserTestSession testSession) { ((UserTestSessionImpl)testSession).setLastModified(new Date()); return dbInstance.getCurrentEntityManager().merge(testSession); diff --git a/src/main/java/org/olat/ims/qti21/model/jpa/CandidateEvent.java b/src/main/java/org/olat/ims/qti21/model/jpa/CandidateEvent.java index 4420515701aa7ed9da8c5e51b603b0cd6ed3be70..f985f33f93f9e602e61ce7ede8439012784bf97b 100644 --- a/src/main/java/org/olat/ims/qti21/model/jpa/CandidateEvent.java +++ b/src/main/java/org/olat/ims/qti21/model/jpa/CandidateEvent.java @@ -21,6 +21,7 @@ package org.olat.ims.qti21.model.jpa; import java.util.Date; +import org.olat.ims.qti21.UserTestSession; import org.olat.ims.qti21.model.CandidateItemEventType; import org.olat.ims.qti21.model.CandidateTestEventType; @@ -32,16 +33,17 @@ import org.olat.ims.qti21.model.CandidateTestEventType; */ public class CandidateEvent { - + private Date timestamp; + private String testItemKey; + + private UserTestSession candidateSession; private CandidateTestEventType testEventType; - private CandidateItemEventType itemEventType; - - private String testItemKey; - - private Date timestamp; - + + public CandidateEvent() { + // + } public Date getTimestamp() { return timestamp; @@ -74,9 +76,13 @@ public class CandidateEvent { public void setTestItemKey(String testItemKey) { this.testItemKey = testItemKey; } - - - - + public UserTestSession getCandidateSession() { + return candidateSession; + } + + public void setCandidateSession(UserTestSession candidateSession) { + this.candidateSession = candidateSession; + } + } diff --git a/src/main/java/org/olat/ims/qti21/ui/AssessmentTestDisplayController.java b/src/main/java/org/olat/ims/qti21/ui/AssessmentTestDisplayController.java index 81e12b7faf63baa76317c8379cbf1fe5f1e86ab6..43ecd8e5235743a0d4e4257c694bc1237a1be898 100644 --- a/src/main/java/org/olat/ims/qti21/ui/AssessmentTestDisplayController.java +++ b/src/main/java/org/olat/ims/qti21/ui/AssessmentTestDisplayController.java @@ -128,13 +128,22 @@ public class AssessmentTestDisplayController extends BasicController implements FileResourceManager frm = FileResourceManager.getInstance(); fUnzippedDirRoot = frm.unzipFileResource(entry.getOlatResource()); + mapperUri = registerCacheableMapper(null, "QTI21Resources::" + entry.getKey(), new ResourcesMapper()); currentRequestTimestamp = ureq.getRequestTimestamp(); - candidateSession = qtiService.createTestSession(entry, courseRe, courseSubIdent, getIdentity()); - mapperUri = registerCacheableMapper(null, "QTI21Resources::" + entry.getKey(), new ResourcesMapper()); - - testSessionController = enterSession(ureq); + UserTestSession lastSession = qtiService.getResumableTestSession(entry, courseRe, courseSubIdent, getIdentity()); + if(lastSession == null) { + candidateSession = qtiService.createTestSession(entry, courseRe, courseSubIdent, getIdentity()); + testSessionController = enterSession(ureq); + } else { + candidateSession = lastSession; + lastEvent = new CandidateEvent(); + lastEvent.setCandidateSession(candidateSession); + lastEvent.setTestEventType(CandidateTestEventType.ITEM_EVENT); + + testSessionController = resumeSession(); + } /* Handle immediate end of test session */ if (testSessionController.getTestSessionState().isEnded()) { @@ -673,6 +682,37 @@ public class AssessmentTestDisplayController extends BasicController implements return result; } + private TestSessionController resumeSession() { + final NotificationRecorder notificationRecorder = new NotificationRecorder(NotificationLevel.INFO); + return createTestSessionController(notificationRecorder); + } + + private TestSessionController createTestSessionController(NotificationRecorder notificationRecorder) { + final TestSessionState testSessionState = qtiService.loadTestSessionState(candidateSession); + return createTestSessionController(testSessionState, notificationRecorder); + } + + public TestSessionController createTestSessionController(TestSessionState testSessionState, NotificationRecorder notificationRecorder) { + /* Try to resolve the underlying JQTI+ object */ + final TestProcessingMap testProcessingMap = getTestProcessingMap(); + if (testProcessingMap == null) { + return null; + } + + /* Create config for TestSessionController */ + final TestSessionControllerSettings testSessionControllerSettings = new TestSessionControllerSettings(); + testSessionControllerSettings.setTemplateProcessingLimit(computeTemplateProcessingLimit()); + + /* Create controller and wire up notification recorder (if passed) */ + final TestSessionController result = new TestSessionController(jqtiExtensionManager, + testSessionControllerSettings, testProcessingMap, testSessionState); + if (notificationRecorder!=null) { + result.addNotificationListener(notificationRecorder); + } + + return result; + } + private AssessmentResult computeAndRecordTestAssessmentResult(UserTestSession candidateSession, TestSessionController testSessionController, boolean submit) { AssessmentResult assessmentResult = computeTestAssessmentResult(candidateSession, testSessionController);