* @author Jérôme Bogaerts * @package taoQtiTest * @deprecated old testrunner is deprecated. use taoQtiTest_actions_Runner instead * @license GPLv2 http://www.opensource.org/licenses/gpl-2.0.php */ class taoQtiTest_actions_TestRunner extends tao_actions_ServiceModule { /** * The current AssessmentTestSession object. * * @var AssessmentTestSession */ private $testSession = null; /** * The current AssessmentTest definition object. * * @var AssessmentTest */ private $testDefinition = null; /** * The current AbstractStorage object. * * @var AbstractStorage */ private $storage = null; /** * The error that occured during the current request. * */ private $currentError = -1; /** * The compilation directory. * * @var string */ private $compilationDirectory; /** * The meta data about the test definition * being executed. * * @var array */ private $testMeta; /** * The index of compiled items. * * @var QtiTestCompilerIndex */ private $itemIndex; /** * Testr session metadata manager * * @var TestSessionMetaData */ private $metaDataHandler; /** * Get the current assessment test session. * * @return AssessmentTestSession An AssessmentTestSession object. */ protected function getTestSession() { return $this->testSession; } /** * Set the current assessment test session. * * @param AssessmentTestSession $testSession An AssessmentTestSession object. */ protected function setTestSession(AssessmentTestSession $testSession) { $this->testSession = $testSession; } /** * Get the current test definition. * * @return AssessmentTest An AssessmentTest object. */ protected function getTestDefinition() { return $this->testDefinition; } /** * Set the current test defintion. * * @param AssessmentTest $testDefinition An AssessmentTest object. */ protected function setTestDefinition(AssessmentTest $testDefinition) { $this->testDefinition = $testDefinition; } /** * Get the QtiSm AssessmentTestSession Storage Service. * * @return AbstractStorage An AssessmentTestSession Storage Service. */ protected function getStorage() { return $this->storage; } /** * Set the QtiSm AssessmentTestSession Storage Service. * * @param AbstractStorage $storage An AssessmentTestSession Storage Service. */ protected function setStorage(AbstractStorage $storage) { $this->storage = $storage; } /** * Get the error that occured during the previous request. * * @return integer */ protected function getPreviousError() { return $this->getStorage()->getLastError(); } /** * Set the error that occured during the current request. * * @param integer $error */ protected function setCurrentError($currentError) { $this->currentError = $currentError; } /** * Get the error that occured during the current request. * * @return integer */ protected function getCurrentError() { return $this->currentError; } /** * Set the path to the directory where the test is compiled. * * @param string $compilationDirectory An absolute path. */ protected function setCompilationDirectory($compilationDirectory) { $this->compilationDirectory = $compilationDirectory; } /** * Get the path to the directory where the test is compiled. * * @return tao_models_classes_service_StorageDirectory */ protected function getCompilationDirectory() { return $this->compilationDirectory; } /** * Set the meta-data array about the test definition * being executed. * * @param array $testMeta */ protected function setTestMeta(array $testMeta) { $this->testMeta = $testMeta; } /** * Get the meta-data array about the test definition * being executed. * * @return array */ protected function getTestMeta() { return $this->testMeta; } /** * @return QtiTestCompilerIndex */ protected function getItemIndex() { return $this->itemIndex; } /** * @param QtiTestCompilerIndex $itemIndex * @return taoQtiTest_actions_TestRunner */ protected function setItemIndex($itemIndex) { $this->itemIndex = $itemIndex; return $this; } /** * Print an error report into the response. * After you have called this method, you must prevent other actions to be processed and must close the response. * @param string $message * @param int $code */ protected function notifyError($message, $code = 0) { $ctx = [ 'success' => false, 'state' => $this->getTestSession()->getState(), 'message' => $message, 'code' => $code, ]; $this->setData('assessmentTestContext', $ctx); if (\tao_helpers_Request::isAjax()) { $this->returnJson($ctx); } } /** * Common stuff processessed on almost all actions. * If something goes wrong, print a report and return false, otherwise return true. * @param bool $notifyError Allow to print error message if needed * @return bool Returns a flag telling whether or not the action can be processed * @throws \common_Exception * @throws \InvalidArgumentException * @throws common_exception_Error * @throws common_exception_InconsistentData * @throws common_ext_ExtensionException */ protected function beforeAction($notifyError = true) { // Controller initialization. $this->retrieveTestDefinition($this->getRequestParameter('QtiTestCompilation')); /** @var DeliveryServerService $deliveryServerService */ $deliveryServerService = $this->getServiceManager()->get(DeliveryServerService::SERVICE_ID); $resultStore = $deliveryServerService->getResultStoreWrapper($this->getRequestParameter('serviceCallId')); // Initialize storage and test session. $testResource = new core_kernel_classes_Resource($this->getRequestParameter('QtiTestDefinition')); $sessionManager = new taoQtiTest_helpers_SessionManager($resultStore, $testResource); $userUri = common_session_SessionManager::getSession()->getUserUri(); $seeker = new BinaryAssessmentTestSeeker($this->getTestDefinition()); $config = \common_ext_ExtensionsManager::singleton()->getExtensionById('taoQtiTest')->getConfig('testRunner'); $storageClassName = $config['test-session-storage']; $this->setStorage(new $storageClassName($sessionManager, $seeker, $userUri)); $this->retrieveTestSession(); // @TODO: use some storage to get the potential reason of the state (close/suspended) $session = $this->getTestSession(); $state = $session->getState(); if ($state == AssessmentTestSessionState::CLOSED) { if ($notifyError) { $this->notifyError(__('The assessment has been terminated. You cannot interact with it anymore.'), $state); } return false; } // @TODO: maybe use an option to enable this behavior if ($state == AssessmentTestSessionState::SUSPENDED) { if ($notifyError) { $this->notifyError(__('The assessment has been suspended. To resume your assessment, please relaunch it and contact your proctor if required.'), $state); } return false; } $sessionStateService = $this->getServiceManager()->get('taoQtiTest/SessionStateService'); $sessionStateService->resumeSession($session); $this->retrieveTestMeta(); $this->retrieveItemIndex(); // Prevent anything to be cached by the client. taoQtiTest_helpers_TestRunnerUtils::noHttpClientCache(); $metaData = $this->getMetaDataHandler()->getData(); if (!empty($metaData)) { $this->getMetaDataHandler()->save($metaData); } return true; } /** * Get instance og session metadata handler * * @return TestSessionMetaData */ protected function getMetaDataHandler() { if ($this->metaDataHandler === null) { $this->metaDataHandler = new TestSessionMetaData($this->getTestSession()); } return $this->metaDataHandler; } /** * Does some complementary stuff to finish the action. Builds the test context object and binds it to the response. * @param bool $withContext * @throws \qtism\runtime\storage\common\StorageException */ protected function afterAction($withContext = true) { $testSession = $this->getTestSession(); $sessionId = $testSession->getSessionId(); // Build assessment test context. $ctx = taoQtiTest_helpers_TestRunnerUtils::buildAssessmentTestContext( $this->getTestSession(), $this->getTestMeta(), $this->getItemIndex(), $this->getRequestParameter('QtiTestDefinition'), $this->getRequestParameter('QtiTestCompilation'), $this->getRequestParameter('standalone'), $this->getCompilationDirectory() ); // add a flag to allow distinction with error responses $ctx['success'] = true; // Put the assessment test context in request data. $this->setData('assessmentTestContext', $ctx); if ($withContext === true) { // Output only if requested by client-code. echo json_encode($ctx); } common_Logger::t("Persisting QTI Assessment Test Session '${sessionId}'..."); $this->getStorage()->persist($testSession); $this->getServiceManager()->get(StorageManager::SERVICE_ID)->persist(); } /** * Main action of the TestRunner module. * */ public function index() { $config = \common_ext_ExtensionsManager::singleton()->getExtensionById('taoQtiTest')->getConfig('testRunner'); $noError = $this->beforeAction(); // this part is only accessible if beforeAction did not return an error if ($noError) { $session = $this->getTestSession(); /** @var \oat\taoQtiTest\models\SessionStateService $sessionStateService */ $sessionStateService = $this->getServiceManager()->get('taoQtiTest/SessionStateService'); $resetTimerAfterResume = isset($config['reset-timer-after-resume']) && $config['reset-timer-after-resume']; if ($resetTimerAfterResume) { $sessionStateService->updateTimeReference($session); } $this->setData( 'client_session_state_service', $sessionStateService->getClientImplementation($resetTimerAfterResume) ); if ($session->getState() === AssessmentTestSessionState::INITIAL) { // The test has just been instantiated. $session->beginTestSession(); common_Logger::i("Assessment Test Session begun."); } if (taoQtiTest_helpers_TestRunnerUtils::isTimeout($session) === false) { taoQtiTest_helpers_TestRunnerUtils::beginCandidateInteraction($session); } } // loads the specific config // this part must be processed no matter if beforeAction returned an error: // the context object is provided through the view $this->setData('review_screen', !empty($config['test-taker-review'])); $this->setData('review_region', isset($config['test-taker-review-region']) ? $config['test-taker-review-region'] : ''); $this->setData('client_config_url', $this->getClientConfigUrl()); $this->setData('client_timeout', $this->getClientTimeout()); $this->setView('test_runner.tpl'); // this part is only accessible if beforeAction did not return an error if ($noError) { $this->afterAction(false); } } /** * Keep item activity time up to date * @throws \oat\oatbox\service\ServiceNotFoundException * @throws common_Exception * @throws common_ext_ExtensionException * @throws \qtism\runtime\storage\common\StorageException */ public function keepItemTimed() { if ($this->beforeAction()) { $config = \common_ext_ExtensionsManager::singleton()->getExtensionById('taoQtiTest')->getConfig('testRunner'); if (isset($config['reset-timer-after-resume']) && $config['reset-timer-after-resume'] && $this->hasRequestParameter('duration')) { $session = $this->getTestSession(); // originally in milliseconds, but we have to convert to seconds now $durationInSeconds = (int) ($this->getRequestParameter('duration') / 1000); $time = new \DateTime('now', new \DateTimeZone('UTC')); $duration = new DateInterval('PT' . $durationInSeconds . 'S'); $time->sub($duration); /** @var \oat\taoQtiTest\models\SessionStateService $sessionStateService */ $sessionStateService = $this->getServiceManager()->get('taoQtiTest/SessionStateService'); $sessionStateService->updateTimeReference($session, $time); $this->afterAction(); } } } /** * Mark an item for review in the Assessment Test Session flow. * */ public function markForReview() { if ($this->beforeAction()) { $testSession = $this->getTestSession(); $sessionId = $testSession->getSessionId(); try { if ($this->hasRequestParameter('position')) { $itemPosition = intval($this->getRequestParameter('position')); } else { $itemPosition = $testSession->getRoute()->getPosition(); } if ($this->hasRequestParameter('flag')) { $flag = $this->getRequestParameter('flag'); if (is_numeric($flag)) { $flag = !!(intval($flag)); } else { $flag = 'false' != strtolower($flag); } } else { $flag = true; } taoQtiTest_helpers_TestRunnerUtils::setItemFlag($testSession, $itemPosition, $flag); $this->returnJson([ 'success' => true, 'position' => $itemPosition, 'flag' => $flag ]); } catch (AssessmentTestSessionException $e) { $this->handleAssessmentTestSessionException($e); } common_Logger::t("Persisting QTI Assessment Test Session '${sessionId}'..."); $this->getStorage()->persist($testSession); } } /** * Jump to an item in the Assessment Test Session flow. * */ public function jumpTo() { if ($this->beforeAction()) { $session = $this->getTestSession(); $nextPosition = intval($this->getRequestParameter('position')); try { $this->endTimedSection($nextPosition); $session->jumpTo($nextPosition); if ($session->isRunning() === true && taoQtiTest_helpers_TestRunnerUtils::isTimeout($session) === false) { taoQtiTest_helpers_TestRunnerUtils::beginCandidateInteraction($session); } } catch (AssessmentTestSessionException $e) { $this->handleAssessmentTestSessionException($e); } $this->afterAction(); } } protected function endTimedSection($nextPosition) { $config = \common_ext_ExtensionsManager::singleton()->getExtensionById('taoQtiTest')->getConfig('testRunner'); if (empty($config['keep-timer-up-to-timeout'])) { $isJumpOutOfSection = false; $session = $this->getTestSession(); $section = $session->getCurrentAssessmentSection(); $route = $session->getRoute(); if (($nextPosition >= 0) && ($nextPosition < $route->count())) { $nextSection = $route->getRouteItemAt($nextPosition); $isJumpOutOfSection = ($section->getIdentifier() !== $nextSection->getAssessmentSection()->getIdentifier()); } $limits = $section->getTimeLimits(); //ensure that jumping out and section is timed if ($isJumpOutOfSection && $limits != null && $limits->hasMaxTime()) { $components = $section->getComponents(); foreach ($components as $object) { if ($object instanceof \qtism\data\ExtendedAssessmentItemRef) { $items = $session->getAssessmentItemSessions($object->getIdentifier()); foreach ($items as $item) { if ($item instanceof \qtism\runtime\tests\AssessmentItemSession) { $item->endItemSession(); } } } } } } } /** * Move forward in the Assessment Test Session flow. * */ public function moveForward() { if ($this->beforeAction()) { $session = $this->getTestSession(); $nextPosition = $session->getRoute()->getPosition() + 1; try { $this->endTimedSection($nextPosition); $session->moveNext(); if ($session->isRunning() === true && taoQtiTest_helpers_TestRunnerUtils::isTimeout($session) === false) { taoQtiTest_helpers_TestRunnerUtils::beginCandidateInteraction($session); } } catch (AssessmentTestSessionException $e) { $this->handleAssessmentTestSessionException($e); } $this->afterAction(); } } /** * Move backward in the Assessment Test Session flow. * */ public function moveBackward() { if ($this->beforeAction()) { $session = $this->getTestSession(); $nextPosition = $session->getRoute()->getPosition() - 1; try { $this->endTimedSection($nextPosition); $session->moveBack(); if (taoQtiTest_helpers_TestRunnerUtils::isTimeout($session) === false) { taoQtiTest_helpers_TestRunnerUtils::beginCandidateInteraction($session); } } catch (AssessmentTestSessionException $e) { $this->handleAssessmentTestSessionException($e); } $this->afterAction(); } } /** * Moves to the next available section in the Assessment Test Session flow. * */ public function nextSection() { if ($this->beforeAction()) { $session = $this->getTestSession(); try { $session->moveNextAssessmentSection(); if ($session->isRunning() === true && taoQtiTest_helpers_TestRunnerUtils::isTimeout($session) === false) { taoQtiTest_helpers_TestRunnerUtils::beginCandidateInteraction($session); } } catch (AssessmentTestSessionException $e) { $this->handleAssessmentTestSessionException($e); } $this->afterAction(); } } /** * Skip the current item in the Assessment Test Session flow. * */ public function skip() { if ($this->beforeAction()) { $session = $this->getTestSession(); try { $session->skip(); $session->moveNext(); if ($session->isRunning() === true && taoQtiTest_helpers_TestRunnerUtils::isTimeout($session) === false) { taoQtiTest_helpers_TestRunnerUtils::beginCandidateInteraction($session); } } catch (AssessmentTestSessionException $e) { $this->handleAssessmentTestSessionException($e); } $this->afterAction(); } } /** * Action to call when a structural QTI component times out in linear mode. * */ public function timeout() { if ($this->beforeAction()) { $session = $this->getTestSession(); try { $session->checkTimeLimits(false, true, false); } catch (AssessmentTestSessionException $e) { $this->onTimeout($e); } // If we are here, without executing onTimeout() there is an inconsistency. Simply respond // to the client with the actual assessment test context. Maybe the client will be able to // continue... $this->afterAction(); } } /** * Action to end test session */ public function endTestSession() { if ($this->beforeAction()) { $session = $this->getTestSession(); $sessionId = $session->getSessionId(); common_Logger::i("The user has requested termination of the test session '{$sessionId}'"); $session->endTestSession(); $this->afterAction(); } } /** * Stuff to be undertaken when the Assessment Item presented to the candidate * times out. * * @param AssessmentTestSessionException $timeOutException The AssessmentTestSessionException object thrown to indicate the timeout. */ protected function onTimeout(AssessmentTestSessionException $timeOutException) { $session = $this->getTestSession(); if ($session->getCurrentNavigationMode() === NavigationMode::LINEAR) { switch ($timeOutException->getCode()) { case AssessmentTestSessionException::ASSESSMENT_TEST_DURATION_OVERFLOW: $session->endTestSession(); break; case AssessmentTestSessionException::TEST_PART_DURATION_OVERFLOW: $session->moveNextTestPart(); break; case AssessmentTestSessionException::ASSESSMENT_SECTION_DURATION_OVERFLOW: $session->moveNextAssessmentSection(); break; case AssessmentTestSessionException::ASSESSMENT_ITEM_DURATION_OVERFLOW: $session->moveNextAssessmentItem(); break; } if ($session->isRunning() === true && taoQtiTest_helpers_TestRunnerUtils::isTimeout($session) === false) { taoQtiTest_helpers_TestRunnerUtils::beginCandidateInteraction($session); } } else { $itemSession = $session->getCurrentAssessmentItemSession(); $itemSession->endItemSession(); } } /** * Action called when a QTI Item embedded in a QTI Test submit responses. * */ public function storeItemVariableSet() { if ($this->beforeAction()) { // --- Deal with provided responses. $jsonPayload = taoQtiCommon_helpers_Utils::readJsonPayload(); $responses = new State(); $currentItem = $this->getTestSession()->getCurrentAssessmentItemRef(); $currentOccurence = $this->getTestSession()->getCurrentAssessmentItemRefOccurence(); if ($currentItem === false) { $msg = "Trying to store item variables but the state of the test session is INITIAL or CLOSED.\n"; $msg .= "Session state value: " . $this->getTestSession()->getState() . "\n"; $msg .= "Session ID: " . $this->getTestSession()->getSessionId() . "\n"; $msg .= "JSON Payload: " . mb_substr(json_encode($jsonPayload), 0, 1000); common_Logger::e($msg); } $filler = new taoQtiCommon_helpers_PciVariableFiller( $currentItem, ServiceManager::getServiceManager()->get(QtiFlysystemFileManager::SERVICE_ID) ); if (is_array($jsonPayload)) { foreach ($jsonPayload as $id => $response) { try { $var = $filler->fill($id, $response); // Do not take into account QTI File placeholders. if (taoQtiCommon_helpers_Utils::isQtiFilePlaceHolder($var) === false) { $responses->setVariable($var); } } catch (OutOfRangeException $e) { common_Logger::d("Could not convert client-side value for variable '${id}'."); } catch (OutOfBoundsException $e) { common_Logger::d("Could not find variable with identifier '${id}' in current item."); } } } else { common_Logger::e('Invalid json payload'); } $displayFeedback = $this->getTestSession()->getCurrentSubmissionMode() !== SubmissionMode::SIMULTANEOUS; $stateOutput = new taoQtiCommon_helpers_PciStateOutput(); try { common_Logger::t('Responses sent from the client-side. The Response Processing will take place.'); $this->getTestSession()->endAttempt($responses, true); // Return the item session state to the client side. $itemSession = $this->getTestSession()->getAssessmentItemSessionStore()->getAssessmentItemSession($currentItem, $currentOccurence); foreach ($itemSession->getAllVariables() as $var) { $stateOutput->addVariable($var); } $itemCompilationDirectory = $this->getDirectory($this->getRequestParameter('itemDataPath')); $jsonReturn = ['success' => true, 'displayFeedback' => $displayFeedback, 'itemSession' => $stateOutput->getOutput(), 'feedbacks' => []]; if ($displayFeedback === true) { $jsonReturn['feedbacks'] = QtiRunner::getFeedbacks($itemCompilationDirectory, $itemSession); } echo json_encode($jsonReturn); } catch (AssessmentTestSessionException $e) { $this->handleAssessmentTestSessionException($e); } $this->afterAction(false); } } /** * Action to call to comment an item. * */ public function comment() { if ($this->beforeAction()) { $testSession = $this->getTestSession(); // prepare transmission Id for result server. $item = $testSession->getCurrentAssessmentItemRef()->getIdentifier(); $occurence = $testSession->getCurrentAssessmentItemRefOccurence(); $sessionId = $testSession->getSessionId(); $transmissionId = "${sessionId}.${item}.${occurence}"; // retrieve comment's intrinsic value. $comment = $this->getRequestParameter('comment'); /** @var DeliveryServerService $deliveryServerService */ $deliveryServerService = $this->getServiceManager()->get(DeliveryServerService::SERVICE_ID); $resultStore = $deliveryServerService->getResultStoreWrapper($sessionId); // build variable and send it. $itemUri = taoQtiTest_helpers_TestRunnerUtils::getCurrentItemUri($testSession); $testUri = $testSession->getTest()->getUri(); $variable = new ResponseVariable('comment', Cardinality::SINGLE, BaseType::STRING, new QtismString($comment)); $transmitter = new taoQtiCommon_helpers_ResultTransmitter($resultStore); $transmitter->transmitItemVariable($variable, $transmissionId, $itemUri, $testUri); } } /** * Retrieve the Test Definition the test session is built * from as an AssessmentTest object. This method * also retrieves the compilation directory. * * @param string $qtiTestCompilation (e.g. 'http://sample/first.rdf#i14363448108243883-|http://sample/first.rdf#i14363448109065884+') * * @return AssessmentTest The AssessmentTest object the current test session is built from. */ protected function retrieveTestDefinition($qtiTestCompilation) { $directoryIds = explode('|', $qtiTestCompilation); $directories = [ 'private' => $this->getDirectory($directoryIds[0]), 'public' => $this->getDirectory($directoryIds[1]) ]; $this->setCompilationDirectory($directories); $testDefinition = \taoQtiTest_helpers_Utils::getTestDefinition($qtiTestCompilation); $this->setTestDefinition($testDefinition); } /** * Retrieve the current test session as an AssessmentTestSession object from * persistent storage. * */ protected function retrieveTestSession() { $qtiStorage = $this->getStorage(); $sessionId = $this->getServiceCallId(); if ($qtiStorage->exists($sessionId) === false) { common_Logger::t("Instantiating QTI Assessment Test Session"); $this->setTestSession($qtiStorage->instantiate($this->getTestDefinition(), $sessionId)); $testTaker = \common_session_SessionManager::getSession()->getUser(); taoQtiTest_helpers_TestRunnerUtils::setInitialOutcomes($this->getTestSession(), $testTaker); } else { common_Logger::t("Retrieving QTI Assessment Test Session '${sessionId}'..."); $this->setTestSession($qtiStorage->retrieve($this->getTestDefinition(), $sessionId)); } taoQtiTest_helpers_TestRunnerUtils::preserveOutcomes($this->getTestSession()); } /** * Retrieve the QTI Test Definition meta-data array stored * into the private compilation directory. * * @return array * @throws common_exception_InconsistentData */ protected function retrieveTestMeta() { $directories = $this->getCompilationDirectory(); /** @var tao_models_classes_service_StorageDirectory $privateDirectory */ $privateDirectory = $directories['private']; /** @var CompilationDataService $compilationDataService */ $compilationDataService = $this->getServiceLocator()->get(CompilationDataService::SERVICE_ID); $this->setTestMeta($compilationDataService->readCompilationMetadata($privateDirectory)); } /** * Retrieves the index of compiled items. */ protected function retrieveItemIndex() { $this->setItemIndex(new QtiTestCompilerIndex()); try { $directories = $this->getCompilationDirectory(); /** @var tao_models_classes_service_StorageDirectory $privateDirectory */ $privateDirectory = $directories['private']; $data = $privateDirectory->read(taoQtiTest_models_classes_QtiTestService::TEST_COMPILED_INDEX); if ($data) { $this->getItemIndex()->unserialize($data); } } catch (\Exception $e) { \common_Logger::d('Ignoring file not found exception for Items Index'); } } protected function handleAssessmentTestSessionException(AssessmentTestSessionException $e) { switch ($e->getCode()) { case AssessmentTestSessionException::ASSESSMENT_TEST_DURATION_OVERFLOW: case AssessmentTestSessionException::TEST_PART_DURATION_OVERFLOW: case AssessmentTestSessionException::ASSESSMENT_SECTION_DURATION_OVERFLOW: case AssessmentTestSessionException::ASSESSMENT_ITEM_DURATION_OVERFLOW: $this->onTimeout($e); break; default: $msg = "Non managed QTI Test exception caught:\n"; do { $msg .= "[" . get_class($e) . "] " . $e->getMessage() . "\n"; } while ($e = $e->getPrevious()); common_Logger::e($msg); break; } } }