tao-test/app/taoQtiTest/models/classes/runner/QtiRunnerService.php

2136 lines
78 KiB
PHP

<?php
/**
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; under version 2
* of the License (non-upgradable).
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* Copyright (c) 2016-2017 (original work) Open Assessment Technologies SA ;
*/
/**
* @author Jean-Sébastien Conan <jean-sebastien.conan@vesperiagroup.com>
*/
namespace oat\taoQtiTest\models\runner;
use common_exception_InvalidArgumentType as InvalidArgumentTypeException;
use common_persistence_AdvKeyValuePersistence;
use common_persistence_KeyValuePersistence;
use League\Flysystem\FileNotFoundException;
use oat\libCat\result\ItemResult;
use oat\libCat\result\ResultVariable;
use oat\oatbox\event\EventManager;
use oat\oatbox\service\ConfigurableService;
use oat\tao\model\theme\ThemeService;
use oat\taoDelivery\model\execution\Delete\DeliveryExecutionDeleteRequest;
use oat\taoDelivery\model\execution\DeliveryExecution;
use oat\taoDelivery\model\execution\DeliveryServerService;
use oat\taoDelivery\model\execution\ServiceProxy;
use oat\taoDelivery\model\RuntimeService;
use oat\taoItems\model\render\ItemAssetsReplacement;
use oat\taoQtiItem\model\portableElement\exception\PortableElementNotFoundException;
use oat\taoQtiItem\model\portableElement\exception\PortableModelMissing;
use oat\taoQtiItem\model\portableElement\PortableElementService;
use oat\taoQtiItem\model\QtiJsonItemCompiler;
use oat\taoQtiTest\models\cat\CatService;
use oat\taoQtiTest\models\cat\GetDeliveryExecutionsItems;
use oat\taoQtiTest\models\event\AfterAssessmentTestSessionClosedEvent;
use oat\taoQtiTest\models\event\QtiContinueInteractionEvent;
use oat\taoQtiTest\models\event\TestExitEvent;
use oat\taoQtiTest\models\event\TestInitEvent;
use oat\taoQtiTest\models\event\TestTimeoutEvent;
use oat\taoQtiTest\models\ExtendedStateService;
use oat\taoQtiTest\models\files\QtiFlysystemFileManager;
use oat\taoQtiTest\models\runner\config\QtiRunnerConfig;
use oat\taoQtiTest\models\runner\config\RunnerConfig;
use oat\taoQtiTest\models\runner\map\QtiRunnerMap;
use oat\taoQtiTest\models\runner\navigation\QtiRunnerNavigation;
use oat\taoQtiTest\models\runner\rubric\QtiRunnerRubric;
use oat\taoQtiTest\models\runner\session\TestSession;
use oat\taoQtiTest\models\runner\toolsStates\ToolsStateStorage;
use oat\taoQtiTest\models\TestSessionService;
use qtism\common\datatypes\QtiString as QtismString;
use qtism\common\enums\BaseType;
use qtism\common\enums\Cardinality;
use qtism\data\AssessmentItemRef;
use qtism\data\NavigationMode;
use qtism\data\SubmissionMode;
use qtism\runtime\common\ResponseVariable;
use qtism\runtime\common\State;
use qtism\runtime\common\Utils;
use qtism\runtime\tests\AssessmentItemSession;
use qtism\runtime\tests\AssessmentItemSessionState;
use qtism\runtime\tests\AssessmentTestSession;
use qtism\runtime\tests\AssessmentTestSessionException;
use qtism\runtime\tests\AssessmentTestSessionState;
use qtism\runtime\tests\RouteItem;
use qtism\runtime\tests\SessionManager;
use tao_models_classes_service_StateStorage;
use taoQtiTest_helpers_TestRunnerUtils as TestRunnerUtils;
/**
* Class QtiRunnerService
*
* QTI implementation service for the test runner
*
* @package oat\taoQtiTest\models
*/
class QtiRunnerService extends ConfigurableService implements RunnerService
{
public const SERVICE_ID = 'taoQtiTest/QtiRunnerService';
/**
* @deprecated use SERVICE_ID
*/
public const CONFIG_ID = 'taoQtiTest/QtiRunnerService';
public const TOOL_ITEM_THEME_SWITCHER = 'itemThemeSwitcher';
public const TOOL_ITEM_THEME_SWITCHER_KEY = 'taoQtiTest/runner/plugins/tools/itemThemeSwitcher/itemThemeSwitcher';
private const TIMEOUT_EXCEPTION_CODES = [
AssessmentTestSessionException::ASSESSMENT_TEST_DURATION_OVERFLOW,
AssessmentTestSessionException::TEST_PART_DURATION_OVERFLOW,
AssessmentTestSessionException::ASSESSMENT_SECTION_DURATION_OVERFLOW,
AssessmentTestSessionException::ASSESSMENT_ITEM_DURATION_OVERFLOW,
];
/**
* The test runner config
* @var RunnerConfig
*/
protected $testConfig;
/**
* Use to store retrieved item data, inside the same request
* @var array
*/
private $dataCache = [];
/**
* Get the data folder from a given item definition
* @param string $itemRef - formatted as itemURI|publicFolderURI|privateFolderURI
* @return array the path
* @throws \common_Exception
*/
private function loadItemData($itemRef, $path)
{
$cacheKey = $itemRef . $path;
if (! empty($cacheKey) && isset($this->dataCache[$itemRef . $path])) {
return $this->dataCache[$itemRef . $path];
}
$directoryIds = explode('|', $itemRef);
if (count($directoryIds) < 3) {
if (is_scalar($itemRef)) {
$itemRefInfo = gettype($itemRef) . ': ' . strval($itemRef);
} elseif (is_object($itemRef)) {
$itemRefInfo = gettype($itemRef) . ': ' . get_class($itemRef);
} else {
$itemRefInfo = gettype($itemRef);
}
throw new \common_exception_InconsistentData("The itemRef (value = '${itemRefInfo}') is not formatted correctly.");
}
$itemUri = $directoryIds[0];
$userDataLang = \common_session_SessionManager::getSession()->getDataLanguage();
$directory = \tao_models_classes_service_FileStorage::singleton()->getDirectoryById($directoryIds[2]);
if ($directory->has($userDataLang)) {
$lang = $userDataLang;
} elseif ($directory->has(DEFAULT_LANG)) {
\common_Logger::d(
$userDataLang . ' is not part of compilation directory for item : ' . $itemUri . ' use ' . DEFAULT_LANG
);
$lang = DEFAULT_LANG;
} else {
throw new \common_Exception(
'item : ' . $itemUri . 'is neither compiled in ' . $userDataLang . ' nor in ' . DEFAULT_LANG
);
}
try {
$content = $directory->read($lang . DIRECTORY_SEPARATOR . $path);
/** @var ItemAssetsReplacement $assetService */
$assetService = $this->getServiceManager()->get(ItemAssetsReplacement::SERVICE_ID);
$jsonContent = json_decode($content, true);
$jsonAssets = [];
if (isset($jsonContent['assets'])) {
foreach ($jsonContent['assets'] as $type => $assets) {
foreach ($assets as $key => $asset) {
$jsonAssets[$type][$key] = $assetService->postProcessAssets($asset);
}
}
$jsonContent["assets"] = $jsonAssets;
}
$this->dataCache[$cacheKey] = $jsonContent;
return $this->dataCache[$cacheKey];
} catch (FileNotFoundException $e) {
throw new \tao_models_classes_FileNotFoundException(
$path . ' for item reference ' . $itemRef
);
}
}
/**
* Gets the test session for a particular delivery execution
*
* This method is called before each action (moveNext, moveBack, pause, ...) call.
*
* @param string $testDefinitionUri The URI of the test
* @param string $testCompilationUri The URI of the compiled delivery
* @param string $testExecutionUri The URI of the delivery execution
* @param string $userUri User identifier. If null current user will be used
* @return QtiRunnerServiceContext
* @throws \common_Exception
*/
public function getServiceContext($testDefinitionUri, $testCompilationUri, $testExecutionUri, $userUri = null)
{
// create a service context based on the provided URI
// initialize the test session and related objects
$serviceContext = new QtiRunnerServiceContext($testDefinitionUri, $testCompilationUri, $testExecutionUri);
$this->propagate($serviceContext);
$serviceContext->setTestConfig($this->getTestConfig());
$serviceContext->setUserUri($userUri);
$sessionService = $this->getServiceManager()->get(TestSessionService::SERVICE_ID);
$sessionService->registerTestSession(
$serviceContext->getTestSession(),
$serviceContext->getStorage(),
$serviceContext->getCompilationDirectory()
);
return $serviceContext;
}
/**
* Checks the created context, then initializes it.
* @param RunnerServiceContext $context
* @return RunnerServiceContext
* @throws \common_Exception
*/
public function initServiceContext(RunnerServiceContext $context)
{
// will throw exception if the test session is not valid
$this->check($context);
// starts the context
$context->init();
return $context;
}
/**
* Persists the AssessmentTestSession into binary data.
* @param QtiRunnerServiceContext $context
*/
public function persist(QtiRunnerServiceContext $context)
{
$testSession = $context->getTestSession();
$sessionId = $testSession->getSessionId();
\common_Logger::d("Persisting QTI Assessment Test Session '${sessionId}'...");
$context->getStorage()->persist($testSession);
if ($this->isTerminated($context)) {
/** @var StorageManager $storageManager */
$storageManager = $this->getServiceManager()->get(StorageManager::SERVICE_ID);
$storageManager->persist();
$userId = \common_session_SessionManager::getSession()->getUser()->getIdentifier();
$eventManager = $this->getServiceManager()->get(EventManager::SERVICE_ID);
$eventManager->trigger(new AfterAssessmentTestSessionClosedEvent($testSession, $userId));
}
}
/**
* Initializes the delivery execution session
*
* This method is called whenever a candidate enters the test. This includes
*
* * Newly launched/instantiated test session.
* * The candidate refreshes the client (F5).
* * Resumed test sessions.
*
* @param RunnerServiceContext $context
* @return boolean
* @throws \common_Exception
*/
public function init(RunnerServiceContext $context)
{
if (!$context instanceof QtiRunnerServiceContext) {
throw new InvalidArgumentTypeException(
'QtiRunnerService',
'init',
0,
QtiRunnerServiceContext::class,
$context
);
}
/* @var TestSession $session */
$session = $context->getTestSession();
// code borrowed from the previous implementation, but the reset timers option has been discarded
if ($session->getState() === AssessmentTestSessionState::INITIAL) {
// The test has just been instantiated.
$session->beginTestSession();
$event = new TestInitEvent($session);
$this->getServiceManager()->get(EventManager::SERVICE_ID)->trigger($event);
\common_Logger::i(sprintf('Assessment Test Session begun. Session id: %s', $session->getSessionId()));
if ($context->isAdaptive()) {
\common_Logger::t("Very first item is adaptive.");
$nextCatItemId = $context->selectAdaptiveNextItem();
$context->persistCurrentCatItemId($nextCatItemId);
$context->persistSeenCatItemIds($nextCatItemId);
}
} elseif ($session->getState() === AssessmentTestSessionState::SUSPENDED) {
$session->resume();
}
$session->initItemTimer();
if ($session->isTimeout() === false) {
TestRunnerUtils::beginCandidateInteraction($session);
}
$this->getServiceManager()->get(ExtendedStateService::SERVICE_ID)->clearEvents($session->getSessionId());
return true;
}
/**
* Gets the test runner config
* @return RunnerConfig
* @throws \common_ext_ExtensionException
*/
public function getTestConfig()
{
if (is_null($this->testConfig)) {
$this->testConfig = $this->getServiceManager()->get(QtiRunnerConfig::SERVICE_ID);
}
return $this->testConfig;
}
/**
* Gets the test definition data
*
* @deprecated the testData is not necessary anymore
* if the config is given directly to the test runner configuration
*
* @param RunnerServiceContext $context
* @return array
* @throws \common_Exception
*/
public function getTestData(RunnerServiceContext $context)
{
if (!$context instanceof QtiRunnerServiceContext) {
throw new InvalidArgumentTypeException(
'QtiRunnerService',
'getTestData',
0,
QtiRunnerServiceContext::class,
$context
);
}
$testDefinition = $context->getTestDefinition();
$response['title'] = $testDefinition->getTitle();
$response['identifier'] = $testDefinition->getIdentifier();
$response['className'] = $testDefinition->getQtiClassName();
$response['toolName'] = $testDefinition->getToolName();
$response['exclusivelyLinear'] = $testDefinition->isExclusivelyLinear();
$response['hasTimeLimits'] = $testDefinition->hasTimeLimits();
//states that can be found in the context
$response['states'] = [
'initial' => AssessmentTestSessionState::INITIAL,
'interacting' => AssessmentTestSessionState::INTERACTING,
'modalFeedback' => AssessmentTestSessionState::MODAL_FEEDBACK,
'suspended' => AssessmentTestSessionState::SUSPENDED,
'closed' => AssessmentTestSessionState::CLOSED
];
$response['itemStates'] = [
'initial' => AssessmentItemSessionState::INITIAL,
'interacting' => AssessmentItemSessionState::INTERACTING,
'modalFeedback' => AssessmentItemSessionState::MODAL_FEEDBACK,
'suspended' => AssessmentItemSessionState::SUSPENDED,
'closed' => AssessmentItemSessionState::CLOSED,
'solution' => AssessmentItemSessionState::SOLUTION,
'review' => AssessmentItemSessionState::REVIEW,
'notSelected' => AssessmentItemSessionState::NOT_SELECTED
];
$timeLimits = $testDefinition->getTimeLimits();
if ($timeLimits) {
if ($timeLimits->hasMinTime()) {
$response['timeLimits']['minTime'] = [
'duration' => TestRunnerUtils::getDurationWithMicroseconds($timeLimits->getMinTime()),
'iso' => $timeLimits->getMinTime()->__toString(),
];
}
if ($timeLimits->hasMaxTime()) {
$response['timeLimits']['maxTime'] = [
'duration' => TestRunnerUtils::getDurationWithMicroseconds($timeLimits->getMaxTime()),
'iso' => $timeLimits->getMaxTime()->__toString(),
];
}
}
$response['config'] = $this->getTestConfig()->getConfig();
if ($this->isThemeSwitcherEnabled()) {
$themeSwitcherPlugin = [
self::TOOL_ITEM_THEME_SWITCHER => [
"activeNamespace" => $this->getCurrentThemeId(),
],
];
$response["config"]["plugins"] = array_merge($response["config"]["plugins"], $themeSwitcherPlugin);
}
return $response;
}
/**
* Gets the test context object
* @param RunnerServiceContext $context
* @return array
* @throws \common_Exception
*/
public function getTestContext(RunnerServiceContext $context)
{
if (!$context instanceof QtiRunnerServiceContext) {
throw new InvalidArgumentTypeException(
'QtiRunnerService',
'getTestContext',
0,
QtiRunnerServiceContext::class,
$context
);
}
/* @var TestSession $session */
$session = $context->getTestSession();
// The state of the test session.
$response['state'] = $session->getState();
// Default values for the test session context.
$response['navigationMode'] = null;
$response['submissionMode'] = null;
$response['remainingAttempts'] = 0;
$response['isAdaptive'] = false;
// Context of interacting test
if ($session->getState() === AssessmentTestSessionState::INTERACTING) {
$config = $this->getTestConfig();
$route = $session->getRoute();
$currentItem = $route->current();
$itemSession = $session->getCurrentAssessmentItemSession();
$itemRef = $context->getCurrentAssessmentItemRef();
$reviewConfig = $config->getConfigValue('review');
$displaySubsectionTitle = isset($reviewConfig['displaySubsectionTitle']) ? (bool) $reviewConfig['displaySubsectionTitle'] : true;
if ($displaySubsectionTitle) {
$currentSection = $session->getCurrentAssessmentSection();
} else {
$sections = $currentItem->getAssessmentSections()->getArrayCopy();
$currentSection = $sections[0];
}
$testOptions = $config->getTestOptions($context);
// The navigation mode.
$response['navigationMode'] = $session->getCurrentNavigationMode();
$response['isLinear'] = $response['navigationMode'] == NavigationMode::LINEAR;
// The submission mode.
$response['submissionMode'] = $session->getCurrentSubmissionMode();
// The number of remaining attempts for the current item.
$response['remainingAttempts'] = $session->getCurrentRemainingAttempts();
// Whether or not the current step is timed out.
$response['isTimeout'] = $session->isTimeout();
// The identifier of the current item.
$response['itemIdentifier'] = $itemRef->getIdentifier();
// The number of current attempt (1 for the first time ...)
$response['attempt'] = ($context->isAdaptive()) ? $context->getCatAttempts($response['itemIdentifier']) + 1 : $itemSession['numAttempts']->getValue();
// The state of the current AssessmentTestSession.
$response['itemSessionState'] = $itemSession->getState();
// Whether the current item is adaptive.
$response['isAdaptive'] = $session->isCurrentAssessmentItemAdaptive();
// Whether the current section is adaptive.
$response['isCatAdaptive'] = $context->isAdaptive();
// Whether the test map must be updated.
// TODO: detect if the map need to be updated and set the flag
$response['needMapUpdate'] = false;
// Whether the current item is the very last one of the test.
$response['isLast'] = (!$context->isAdaptive()) ? $route->isLast() : false;
// The current position in the route.
$response['itemPosition'] = $context->getCurrentPosition();
// The current item flagged state
$response['itemFlagged'] = TestRunnerUtils::getItemFlag($session, $response['itemPosition'], $context);
// The current item answered state
$response['itemAnswered'] = $this->isItemCompleted($context, $currentItem, $itemSession);
// Time constraints.
$response['timeConstraints'] = $this->buildTimeConstraints($context);
// Test Part title.
$response['testPartId'] = $session->getCurrentTestPart()->getIdentifier();
// Current Section title.
$response['sectionId'] = $currentSection->getIdentifier();
$response['sectionTitle'] = $currentSection->getTitle();
// Number of items composing the test session.
$response['numberItems'] = $route->count();
// Number of items completed during the test session.
$response['numberCompleted'] = TestRunnerUtils::testCompletion($session);
// Number of items presented during the test session.
$response['numberPresented'] = $session->numberPresented();
// Whether or not the progress of the test can be inferred.
$response['considerProgress'] = TestRunnerUtils::considerProgress($session, $context->getTestMeta(), $config->getConfig());
// Whether or not the deepest current section is visible.
$response['isDeepestSectionVisible'] = $currentSection->isVisible();
// If the candidate is allowed to move backward e.g. first item of the test.
$response['canMoveBackward'] = $context->canMoveBackward();
//Number of rubric blocks
$response['numberRubrics'] = count($currentItem->getRubricBlockRefs());
//add rubic blocks
if ($response['numberRubrics'] > 0) {
$response['rubrics'] = $this->getRubrics($context, $session->getCurrentAssessmentItemRef());
}
//prevent the user from submitting empty (i.e. default or null) responses, feature availability
$response['enableAllowSkipping'] = $config->getConfigValue('enableAllowSkipping');
//contextual value
$response['allowSkipping'] = $testOptions['allowSkipping'];
//prevent the user from submitting an invalid response
$response['enableValidateResponses'] = $config->getConfigValue('enableValidateResponses');
//contextual value
$response['validateResponses'] = $testOptions['validateResponses'];
//does the item has modal feedbacks ?
$response['hasFeedbacks'] = $this->hasFeedbacks($context, $itemRef->getHref());
// append dynamic options
$response['options'] = $testOptions;
}
return $response;
}
/**
* Gets the map of the test items
* @param RunnerServiceContext $context
* @param bool $partial the full testMap or only the current section
* @return array
* @throws \common_Exception
*/
public function getTestMap(RunnerServiceContext $context, $partial = false)
{
if (!$context instanceof QtiRunnerServiceContext) {
throw new InvalidArgumentTypeException(
'QtiRunnerService',
'getTestMap',
0,
QtiRunnerServiceContext::class,
$context
);
}
$mapService = $this->getServiceLocator()->get(QtiRunnerMap::SERVICE_ID);
if ($partial) {
return $mapService->getScopedMap($context, $this->getTestConfig());
}
return $mapService->getMap($context, $this->getTestConfig());
}
/**
* Gets the rubrics related to the current session state.
* @param RunnerServiceContext $context
* @param AssessmentItemRef $itemRef (optional) otherwise use the current
* @return mixed
* @throws \common_Exception
*/
public function getRubrics(RunnerServiceContext $context, AssessmentItemRef $itemRef = null)
{
if (!$context instanceof QtiRunnerServiceContext) {
throw new InvalidArgumentTypeException(
'QtiRunnerService',
'getRubrics',
0,
QtiRunnerServiceContext::class,
$context
);
}
$rubricHelper = $this->getServiceLocator()->get(QtiRunnerRubric::SERVICE_ID);
return $rubricHelper->getRubrics($context, $itemRef);
}
/**
* Gets AssessmentItemRef's Href by AssessmentItemRef Identifier.
* @param RunnerServiceContext $context
* @param string $itemRef
* @return string
*/
public function getItemHref(RunnerServiceContext $context, $itemRef)
{
$mapService = $this->getServiceLocator()->get(QtiRunnerMap::SERVICE_ID);
return $mapService->getItemHref($context, $itemRef);
}
/**
* Gets definition data of a particular item
* @param RunnerServiceContext $context
* @param $itemRef
* @return mixed
* @throws \common_Exception
*/
public function getItemData(RunnerServiceContext $context, $itemRef)
{
if (!$context instanceof QtiRunnerServiceContext) {
throw new InvalidArgumentTypeException(
'QtiRunnerService',
'getItemData',
0,
QtiRunnerServiceContext::class,
$context
);
}
return $this->loadItemData($itemRef, QtiJsonItemCompiler::ITEM_FILE_NAME);
}
/**
* Gets the state identifier for a particular item
* @param QtiRunnerServiceContext $context
* @param string $itemRef The item identifier
* @return string The state identifier
*/
protected function getStateId(QtiRunnerServiceContext $context, $itemRef)
{
return $this->buildStorageItemKey($context->getTestExecutionUri(), $itemRef);
}
/**
* @param string $deliveryExecutionUri
* @param string $itemRef
* @return string
*/
private function buildStorageItemKey($deliveryExecutionUri, $itemRef)
{
return $deliveryExecutionUri . $itemRef;
}
/**
* Gets the state of a particular item
* @param RunnerServiceContext $context
* @param string $itemRef
* @return array|null
* @throws \common_Exception
*/
public function getItemState(RunnerServiceContext $context, $itemRef)
{
if (!$context instanceof QtiRunnerServiceContext) {
throw new InvalidArgumentTypeException(
'QtiRunnerService',
'getItemState',
0,
QtiRunnerServiceContext::class,
$context
);
}
$serviceService = $this->getServiceManager()->get(StorageManager::SERVICE_ID);
$userUri = \common_session_SessionManager::getSession()->getUserUri();
$stateId = $this->getStateId($context, $itemRef);
$state = is_null($userUri) ? null : $serviceService->get($userUri, $stateId);
if ($state) {
$state = json_decode($state, true);
if (is_null($state)) {
throw new \common_exception_InconsistentData('Unable to decode the state for the item ' . $itemRef);
}
}
return $state;
}
/**
* Sets the state of a particular item
* @param RunnerServiceContext $context
* @param $itemRef
* @param $state
* @return boolean
* @throws \common_Exception
*/
public function setItemState(RunnerServiceContext $context, $itemRef, $state)
{
if (!$context instanceof QtiRunnerServiceContext) {
throw new InvalidArgumentTypeException(
'QtiRunnerService',
'setItemState',
0,
QtiRunnerServiceContext::class,
$context
);
}
$serviceService = $this->getServiceManager()->get(StorageManager::SERVICE_ID);
$userUri = \common_session_SessionManager::getSession()->getUserUri();
$stateId = $this->getStateId($context, $itemRef);
if (!isset($state)) {
$state = '';
}
return is_null($userUri) ? false : $serviceService->set($userUri, $stateId, json_encode($state));
}
/**
* @param RunnerServiceContext $context
* @param $toolStates
* @throws \oat\oatbox\service\exception\InvalidServiceManagerException
*/
public function setToolsStates(RunnerServiceContext $context, $toolStates)
{
if ($context instanceof QtiRunnerServiceContext && is_array($toolStates)) {
/** @var ToolsStateStorage $toolsStateStorage */
$toolsStateStorage = $this->getServiceLocator()->get(ToolsStateStorage::SERVICE_ID);
$toolsStateStorage->storeStates($context->getTestExecutionUri(), $toolStates);
}
}
/**
* @param RunnerServiceContext $context
* @return array
* @throws \oat\oatbox\service\exception\InvalidServiceManagerException
* @throws \common_ext_ExtensionException
*/
public function getToolsStates(RunnerServiceContext $context)
{
$toolsStates = [];
// add those tools missing from the storage but presented on the config
$toolsEnabled = $this->getTestConfig()->getConfigValue('toolStateServerStorage');
if (count($toolsEnabled) === 0) {
return [];
}
if ($context instanceof QtiRunnerServiceContext) {
/** @var ToolsStateStorage $toolsStateStorage */
$toolsStateStorage = $this->getServiceLocator()->get(ToolsStateStorage::SERVICE_ID);
$toolsStates = $toolsStateStorage->getStates($context->getTestExecutionUri());
}
foreach ($toolsEnabled as $toolEnabled) {
if (!array_key_exists($toolEnabled, $toolsStates)) {
$toolsStates[$toolEnabled] = null;
}
}
return $toolsStates;
}
/**
* Parses the responses provided for a particular item
* @param RunnerServiceContext $context
* @param $itemRef
* @param $response
* @return mixed
* @throws \common_Exception
*/
public function parsesItemResponse(RunnerServiceContext $context, $itemRef, $response)
{
if (!$context instanceof QtiRunnerServiceContext) {
throw new InvalidArgumentTypeException(
'QtiRunnerService',
'storeItemResponse',
0,
QtiRunnerServiceContext::class,
$context
);
}
/** @var TestSession $session */
$session = $context->getTestSession();
$currentItem = $context->getCurrentAssessmentItemRef();
$responses = new State();
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: " . $session->getState() . "\n";
$msg .= "Session ID: " . $session->getSessionId() . "\n";
$msg .= "JSON Payload: " . mb_substr(json_encode($response), 0, 1000);
\common_Logger::e($msg);
}
$filler = new \taoQtiCommon_helpers_PciVariableFiller(
$currentItem,
$this->getServiceManager()->get(QtiFlysystemFileManager::SERVICE_ID)
);
if (is_array($response)) {
foreach ($response as $id => $responseData) {
try {
$var = $filler->fill($id, $responseData);
// 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');
}
return $responses;
}
/**
* Checks if the provided responses are empty
* @param RunnerServiceContext $context
* @param $responses
* @return mixed
* @throws \common_Exception
*/
public function emptyResponse(RunnerServiceContext $context, $responses)
{
if (!$context instanceof QtiRunnerServiceContext) {
throw new InvalidArgumentTypeException(
'QtiRunnerService',
'storeItemResponse',
0,
QtiRunnerServiceContext::class,
$context
);
}
$similar = 0;
/** @var ResponseVariable $responseVariable */
foreach ($responses as $responseVariable) {
$value = $responseVariable->getValue();
$default = $responseVariable->getDefaultValue();
// Similar to default ?
if (TestRunnerUtils::isQtiValueNull($value) === true) {
if (TestRunnerUtils::isQtiValueNull($default) === true) {
$similar++;
}
} elseif ($value->equals($default) === true) {
$similar++;
}
}
$respCount = count($responses);
return $respCount > 0 && $similar === $respCount;
}
/**
* Stores the response of a particular item
* @param RunnerServiceContext $context
* @param $itemRef
* @param $responses
* @return boolean
* @throws \common_Exception
*/
public function storeItemResponse(RunnerServiceContext $context, $itemRef, $responses)
{
if (!$context instanceof QtiRunnerServiceContext) {
throw new InvalidArgumentTypeException(
'QtiRunnerService',
'storeItemResponse',
0,
QtiRunnerServiceContext::class,
$context
);
}
$session = $this->getCurrentAssessmentSession($context);
try {
\common_Logger::t('Responses sent from the client-side. The Response Processing will take place.');
if ($context->isAdaptive()) {
$session->beginItemSession();
$session->beginAttempt();
$session->endAttempt($responses);
$assessmentItem = $session->getAssessmentItem();
$assessmentItemIdentifier = $assessmentItem->getIdentifier();
$score = $session->getVariable('SCORE');
$output = $context->getLastCatItemOutput();
if ($score !== null) {
$output[$assessmentItemIdentifier] = new ItemResult(
$assessmentItemIdentifier,
new ResultVariable(
$score->getIdentifier(),
BaseType::getNameByConstant($score->getBaseType()),
$score->getValue()->getValue(),
null,
$score->getCardinality()
),
microtime(true)
);
} else {
\common_Logger::i("No 'SCORE' outcome variable for item '${assessmentItemIdentifier}' involved in an adaptive section.");
}
$context->persistLastCatItemOutput($output);
// Send results to TAO Results.
$resultTransmitter = new \taoQtiCommon_helpers_ResultTransmitter($context->getSessionManager()->getResultServer());
$hrefParts = explode('|', $assessmentItem->getHref());
$sessionId = $context->getTestSession()->getSessionId();
$itemIdentifier = $assessmentItem->getIdentifier();
// Deal with attempts.
$attempt = $context->getCatAttempts($itemIdentifier);
$transmissionId = "${sessionId}.${itemIdentifier}.${attempt}";
$attempt++;
foreach ($session->getAllVariables() as $var) {
if ($var->getIdentifier() === 'numAttempts') {
$var->setValue(new \qtism\common\datatypes\QtiInteger($attempt));
}
$variables[] = $var;
}
$resultTransmitter->transmitItemVariable($variables, $transmissionId, $hrefParts[0], $hrefParts[2]);
$context->persistCatAttempts($itemIdentifier, $attempt);
$context->getTestSession()->endAttempt(new State(), true);
} else {
// Non adaptive case.
$session->endAttempt($responses, true);
}
return true;
} catch (AssessmentTestSessionException $e) {
\common_Logger::w($e->getMessage());
return false;
}
}
/**
* Should we display feedbacks
* @param RunnerServiceContext $context
* @return boolean
* @throws \common_exception_InvalidArgumentType
*/
public function displayFeedbacks(RunnerServiceContext $context)
{
if (!$context instanceof QtiRunnerServiceContext) {
throw new InvalidArgumentTypeException(
'QtiRunnerService',
'displayFeedbacks',
0,
QtiRunnerServiceContext::class,
$context
);
}
/* @var TestSession $session */
$session = $context->getTestSession();
return $session->getCurrentSubmissionMode() !== SubmissionMode::SIMULTANEOUS;
}
/**
* Get feedback definitions
*
* @param RunnerServiceContext $context
* @param string $itemRef the item reference
* @return array the feedbacks data
* @throws \common_Exception
* @throws \common_exception_InvalidArgumentType
* @deprecated since version 30.7.0, to be removed in 31.0.0. Use getItemVariableElementsData() instead
*/
public function getFeedbacks(RunnerServiceContext $context, $itemRef)
{
return $this->getItemVariableElementsData($context, $itemRef);
}
/**
* @param RunnerServiceContext $context
* @param $itemRef
* @return array
* @throws \common_Exception
* @throws \common_exception_InvalidArgumentType
*/
public function getItemVariableElementsData(RunnerServiceContext $context, $itemRef)
{
$this->assertQtiRunnerServiceContext($context);
return $this->loadItemData($itemRef, QtiJsonItemCompiler::VAR_ELT_FILE_NAME);
}
/**
* Does the given item has feedbacks
*
* @param RunnerServiceContext $context
* @param string $itemRef the item reference
* @return boolean
* @throws \common_Exception
* @throws \common_exception_InconsistentData
* @throws \common_exception_InvalidArgumentType
* @throws \tao_models_classes_FileNotFoundException
*/
public function hasFeedbacks(RunnerServiceContext $context, $itemRef)
{
$hasFeedbacks = false;
$displayFeedbacks = $this->displayFeedbacks($context);
if ($displayFeedbacks) {
$feedbacks = $this->getFeedbacks($context, $itemRef);
foreach ($feedbacks as $entry) {
if (isset($entry['feedbackRules'])) {
if (count($entry['feedbackRules']) > 0) {
$hasFeedbacks = true;
}
break;
}
}
}
return $hasFeedbacks;
}
/**
* Should we display feedbacks
* @param RunnerServiceContext $context
* @return array the item session
* @throws \common_exception_InvalidArgumentType
*/
public function getItemSession(RunnerServiceContext $context)
{
if (!$context instanceof QtiRunnerServiceContext) {
throw new InvalidArgumentTypeException(
'QtiRunnerService',
'getItemSession',
0,
QtiRunnerServiceContext::class,
$context
);
}
/* @var TestSession $session */
$session = $context->getTestSession();
$currentItem = $session->getCurrentAssessmentItemRef();
$currentOccurrence = $session->getCurrentAssessmentItemRefOccurence();
$itemSession = $session->getAssessmentItemSessionStore()->getAssessmentItemSession($currentItem, $currentOccurrence);
$stateOutput = new \taoQtiCommon_helpers_PciStateOutput();
foreach ($itemSession->getAllVariables() as $var) {
$stateOutput->addVariable($var);
}
$output = $stateOutput->getOutput();
// The current item answered state
$route = $session->getRoute();
$position = $route->getPosition();
$output['itemAnswered'] = TestRunnerUtils::isItemCompleted($route->getRouteItemAt($position), $itemSession);
return $output;
}
/**
* Moves the current position to the provided scoped reference.
* @param RunnerServiceContext $context
* @param $direction
* @param $scope
* @param $ref
* @return boolean
* @throws \common_Exception
*/
public function move(RunnerServiceContext $context, $direction, $scope, $ref)
{
$result = true;
if (!$context instanceof QtiRunnerServiceContext) {
throw new InvalidArgumentTypeException(
'QtiRunnerService',
'move',
0,
QtiRunnerServiceContext::class,
$context
);
}
try {
$result = QtiRunnerNavigation::move($direction, $scope, $context, $ref);
} catch (AssessmentTestSessionException $e) {
} finally {
if ($result && (!isset($e) || in_array($e->getCode(), self::TIMEOUT_EXCEPTION_CODES, true))) {
$this->continueInteraction($context);
}
}
return $result;
}
/**
* Skips the current position to the provided scoped reference
* @param RunnerServiceContext $context
* @param $scope
* @param $ref
* @return boolean
* @throws \common_Exception
*/
public function skip(RunnerServiceContext $context, $scope, $ref)
{
return $this->move($context, 'skip', $scope, $ref);
}
/**
* Handles a test timeout
* @param RunnerServiceContext $context
* @param $scope
* @param $ref
* @param $late
* @return boolean
* @throws \common_Exception
*/
public function timeout(RunnerServiceContext $context, $scope, $ref, $late = false)
{
if (!$context instanceof QtiRunnerServiceContext) {
throw new InvalidArgumentTypeException(
'QtiRunnerService',
'timeout',
0,
QtiRunnerServiceContext::class,
$context
);
}
/* @var TestSession $session */
$session = $context->getTestSession();
if ($context->isAdaptive()) {
\common_Logger::t("Select next item before timeout");
$context->selectAdaptiveNextItem();
}
try {
$session->closeTimer($ref, $scope);
if ($late) {
if ($scope == 'assessmentTest') {
$code = AssessmentTestSessionException::ASSESSMENT_TEST_DURATION_OVERFLOW;
} elseif ($scope == 'testPart') {
$code = AssessmentTestSessionException::TEST_PART_DURATION_OVERFLOW;
} elseif ($scope == 'assessmentSection') {
$code = AssessmentTestSessionException::ASSESSMENT_SECTION_DURATION_OVERFLOW;
} else {
$code = AssessmentTestSessionException::ASSESSMENT_ITEM_DURATION_OVERFLOW;
}
throw new AssessmentTestSessionException("Maximum duration of ${scope} '${ref}' not respected.", $code);
} else {
$session->checkTimeLimits(false, true, false);
}
} catch (AssessmentTestSessionException $e) {
$this->onTimeout($context, $e);
}
return true;
}
/**
* Exits the test before its end
* @param RunnerServiceContext $context
* @return boolean
* @throws \common_Exception
*/
public function exitTest(RunnerServiceContext $context)
{
if (!$context instanceof QtiRunnerServiceContext) {
throw new InvalidArgumentTypeException(
'QtiRunnerService',
'exitTest',
0,
QtiRunnerServiceContext::class,
$context
);
}
/* @var TestSession $session */
$session = $context->getTestSession();
$sessionId = $session->getSessionId();
\common_Logger::i("The user has requested termination of the test session '{$sessionId}'");
if ($context->isAdaptive()) {
\common_Logger::t("Select next item before test exit");
$context->selectAdaptiveNextItem();
}
$event = new TestExitEvent($session);
$this->getServiceManager()->get(EventManager::SERVICE_ID)->trigger($event);
$session->endTestSession();
$this->finish($context, $this->getStateAfterExit());
return true;
}
/**
* Finishes the test
* @param RunnerServiceContext $context
* @param string $finalState
* @return boolean
* @throws \common_Exception
*/
public function finish(RunnerServiceContext $context, $finalState = DeliveryExecution::STATE_FINISHED)
{
if (!$context instanceof QtiRunnerServiceContext) {
throw new InvalidArgumentTypeException(
'QtiRunnerService',
'finish',
0,
QtiRunnerServiceContext::class,
$context
);
}
$executionUri = $context->getTestExecutionUri();
$userUri = \common_session_SessionManager::getSession()->getUserUri();
$executionService = ServiceProxy::singleton();
$deliveryExecution = $executionService->getDeliveryExecution($executionUri);
if ($deliveryExecution->getUserIdentifier() == $userUri) {
\common_Logger::i("Finishing the delivery execution {$executionUri}");
$result = $deliveryExecution->setState($finalState);
} else {
\common_Logger::w("Non owner {$userUri} tried to finish deliveryExecution {$executionUri}");
$result = false;
}
$this->getServiceManager()->get(ExtendedStateService::SERVICE_ID)->clearEvents($executionUri);
return $result;
}
/**
* Sets the test to paused state
* @param RunnerServiceContext $context
* @return boolean
* @throws \common_Exception
*/
public function pause(RunnerServiceContext $context)
{
if (!$context instanceof QtiRunnerServiceContext) {
throw new InvalidArgumentTypeException(
'QtiRunnerService',
'pause',
0,
QtiRunnerServiceContext::class,
$context
);
}
$context->getTestSession()->suspend();
$this->persist($context);
return true;
}
/**
* Resumes the test from paused state
* @param RunnerServiceContext $context
* @return boolean
* @throws \common_Exception
*/
public function resume(RunnerServiceContext $context)
{
if (!$context instanceof QtiRunnerServiceContext) {
throw new InvalidArgumentTypeException(
'QtiRunnerService',
'resume',
0,
QtiRunnerServiceContext::class,
$context
);
}
$context->getTestSession()->resume();
$this->persist($context);
return true;
}
/**
* Checks if the test is still valid
* @param RunnerServiceContext $context
* @return boolean
* @throws \common_Exception
* @throws QtiRunnerClosedException
*/
public function check(RunnerServiceContext $context)
{
$state = $context->getTestSession()->getState();
if ($state == AssessmentTestSessionState::CLOSED) {
throw new QtiRunnerClosedException();
}
return true;
}
/**
* Checks if an item has been completed
* @param RunnerServiceContext $context
* @param RouteItem $routeItem
* @param AssessmentItemSession $itemSession
* @param bool $partially (optional) Whether or not consider partially responded sessions as responded.
* @return bool
* @throws \common_Exception
*/
public function isItemCompleted(RunnerServiceContext $context, $routeItem, $itemSession, $partially = true)
{
if ($context instanceof QtiRunnerServiceContext && $context->isAdaptive()) {
$itemIdentifier = $context->getCurrentAssessmentItemRef()->getIdentifier();
$itemState = $this->getItemState($context, $itemIdentifier);
if ($itemState !== null) {
// as the item comes from a CAT section, it is simpler to load the responses from the state
$itemResponse = [];
foreach ($itemState as $key => $value) {
if (isset($value['response'])) {
$itemResponse[$key] = $value['response'];
}
}
$responses = $this->parsesItemResponse($context, $itemIdentifier, $itemResponse);
// fork of AssessmentItemSession::isResponded()
$excludedResponseVariables = ['numAttempts', 'duration'];
foreach ($responses as $var) {
if ($var instanceof ResponseVariable && in_array($var->getIdentifier(), $excludedResponseVariables) === false) {
$value = $var->getValue();
$defaultValue = $var->getDefaultValue();
if (Utils::isNull($value) === true) {
if (Utils::isNull($defaultValue) === (($partially) ? false : true)) {
return (($partially) ? true : false);
}
} else {
if ($value->equals($defaultValue) === (($partially) ? false : true)) {
return (($partially) ? true : false);
}
}
}
}
}
return (($partially) ? false : true);
} else {
return TestRunnerUtils::isItemCompleted($routeItem, $itemSession, $partially);
}
}
/**
* Checks if the test is in paused state
* @param RunnerServiceContext $context
* @return boolean
*/
public function isPaused(RunnerServiceContext $context)
{
return $context->getTestSession()->getState() == AssessmentTestSessionState::SUSPENDED;
}
/**
* Checks if the test is in terminated state
* @param RunnerServiceContext $context
* @return boolean
*/
public function isTerminated(RunnerServiceContext $context)
{
return $context->getTestSession()->getState() == AssessmentTestSessionState::CLOSED;
}
/**
* Get the base url to the item public directory
* @param RunnerServiceContext $context
* @param $itemRef
* @return string
* @throws \common_Exception
* @throws \common_exception_Error
* @throws \common_exception_InvalidArgumentType
*/
public function getItemPublicUrl(RunnerServiceContext $context, $itemRef)
{
if (!$context instanceof QtiRunnerServiceContext) {
throw new InvalidArgumentTypeException(
'QtiRunnerService',
'getItemPublicUrl',
0,
QtiRunnerServiceContext::class,
$context
);
}
$directoryIds = explode('|', $itemRef);
$userDataLang = \common_session_SessionManager::getSession()->getDataLanguage();
$directory = \tao_models_classes_service_FileStorage::singleton()->getDirectoryById($directoryIds[1]);
// do fallback in case userlanguage is not default language
if ($userDataLang != DEFAULT_LANG && !$directory->has($userDataLang) && $directory->has(DEFAULT_LANG)) {
$userDataLang = DEFAULT_LANG;
}
return $directory->getPublicAccessUrl() . $userDataLang . '/';
}
/**
* Comment the test
* @param RunnerServiceContext $context
* @param string $comment
* @return bool
*/
public function comment(RunnerServiceContext $context, $comment)
{
// prepare transmission Id for result server.
$testSession = $context->getTestSession();
$item = $testSession->getCurrentAssessmentItemRef()->getIdentifier();
$occurrence = $testSession->getCurrentAssessmentItemRefOccurence();
$sessionId = $testSession->getSessionId();
$transmissionId = "${sessionId}.${item}.${occurrence}";
/** @var DeliveryServerService $deliveryServerService */
$deliveryServerService = $this->getServiceManager()->get(DeliveryServerService::SERVICE_ID);
$resultStore = $deliveryServerService->getResultStoreWrapper($sessionId);
$transmitter = new \taoQtiCommon_helpers_ResultTransmitter($resultStore);
// build variable and send it.
$itemUri = TestRunnerUtils::getCurrentItemUri($testSession);
$testUri = $testSession->getTest()->getUri();
$variable = new ResponseVariable('comment', Cardinality::SINGLE, BaseType::STRING, new QtismString($comment));
$transmitter->transmitItemVariable($variable, $transmissionId, $itemUri, $testUri);
return true;
}
/**
* Continue the test interaction if possible
* @param RunnerServiceContext $context
* @return bool
*/
protected function continueInteraction(RunnerServiceContext $context)
{
$continue = false;
/* @var TestSession $session */
$session = $context->getTestSession();
if ($session->isRunning() === true && $session->isTimeout() === false) {
$event = new QtiContinueInteractionEvent($context, $this);
$this->getServiceManager()->get(EventManager::SERVICE_ID)->trigger($event);
TestRunnerUtils::beginCandidateInteraction($session);
$continue = true;
} else {
$this->finish($context);
}
return $continue;
}
/**
* Stuff to be undertaken when the Assessment Item presented to the candidate
* times out.
*
* @param RunnerServiceContext $context
* @param AssessmentTestSessionException $timeOutException The AssessmentTestSessionException object thrown to indicate the timeout.
*/
protected function onTimeout(RunnerServiceContext $context, AssessmentTestSessionException $timeOutException)
{
/* @var TestSession $session */
$session = $context->getTestSession();
$event = new TestTimeoutEvent($session, $timeOutException->getCode(), true);
$this->getServiceManager()->get(EventManager::SERVICE_ID)->trigger($event);
$isLinear = $session->getCurrentNavigationMode() === NavigationMode::LINEAR;
switch ($timeOutException->getCode()) {
case AssessmentTestSessionException::ASSESSMENT_TEST_DURATION_OVERFLOW:
\common_Logger::i('TIMEOUT: closing the assessment test session');
$session->moveThroughAndEndTestSession();
break;
case AssessmentTestSessionException::TEST_PART_DURATION_OVERFLOW:
if ($isLinear) {
\common_Logger::i('TIMEOUT: moving to the next test part');
$session->moveNextTestPart();
} else {
\common_Logger::i('TIMEOUT: closing the assessment test part');
$session->closeTestPart();
}
break;
case AssessmentTestSessionException::ASSESSMENT_SECTION_DURATION_OVERFLOW:
if ($isLinear) {
\common_Logger::i('TIMEOUT: moving to the next assessment section');
$session->moveNextAssessmentSection();
} else {
\common_Logger::i('TIMEOUT: closing the assessment section session');
$session->closeAssessmentSection();
}
break;
case AssessmentTestSessionException::ASSESSMENT_ITEM_DURATION_OVERFLOW:
if ($isLinear) {
\common_Logger::i('TIMEOUT: moving to the next item');
$session->moveNextAssessmentItem();
} else {
\common_Logger::i('TIMEOUT: closing the assessment item session');
$session->closeAssessmentItem();
}
break;
}
$event = new TestTimeoutEvent($session, $timeOutException->getCode(), false);
$this->getServiceManager()->get(EventManager::SERVICE_ID)->trigger($event);
$this->continueInteraction($context);
}
/**
* Build an array where each cell represent a time constraint (a.k.a. time limits)
* in force. Each cell is actually an array with two keys:
*
* * 'source': The identifier of the QTI component emitting the constraint (e.g. AssessmentTest, TestPart, AssessmentSection, AssessmentItemRef).
* * 'seconds': The number of remaining seconds until it times out.
*
* @param RunnerServiceContext $context
* @return array
*/
protected function buildTimeConstraints(RunnerServiceContext $context)
{
$constraints = [];
$session = $context->getTestSession();
foreach ($session->getRegularTimeConstraints() as $constraint) {
if ($constraint->getMaximumRemainingTime() != false || $constraint->getMinimumRemainingTime() != false) {
$constraints[] = $constraint;
}
}
return $constraints;
}
/**
* Stores trace variable related to an item, a test or a section
*
* @param RunnerServiceContext $context
* @param $itemUri
* @param $variableIdentifier
* @param $variableValue
* @return boolean
* @throws \common_Exception
*/
public function storeTraceVariable(RunnerServiceContext $context, $itemUri, $variableIdentifier, $variableValue)
{
$this->assertQtiRunnerServiceContext($context);
$metaVariable = $this->getTraceVariable($variableIdentifier, $variableValue);
return $this->storeVariable($context, $itemUri, $metaVariable);
}
/**
* Create a trace variable from variable identifier and value
*
* @param $variableIdentifier
* @param $variableValue
* @return \taoResultServer_models_classes_TraceVariable
* @throws \common_exception_InvalidArgumentType
*/
public function getTraceVariable($variableIdentifier, $variableValue)
{
if (!is_string($variableValue) && !is_numeric($variableValue)) {
$variableValue = json_encode($variableValue);
}
$metaVariable = new \taoResultServer_models_classes_TraceVariable();
$metaVariable->setIdentifier($variableIdentifier);
$metaVariable->setBaseType('string');
$metaVariable->setCardinality(Cardinality::getNameByConstant(Cardinality::SINGLE));
$metaVariable->setTrace($variableValue);
return $metaVariable;
}
/**
* Stores outcome variable related to an item, a test or a section
*
* @param RunnerServiceContext $context
* @param $itemUri
* @param $variableIdentifier
* @param $variableValue
* @return boolean
* @throws \common_Exception
*/
public function storeOutcomeVariable(RunnerServiceContext $context, $itemUri, $variableIdentifier, $variableValue)
{
$this->assertQtiRunnerServiceContext($context);
$metaVariable = $this->getOutcomeVariable($variableIdentifier, $variableValue);
return $this->storeVariable($context, $itemUri, $metaVariable);
}
/**
* Create an outcome variable from variable identifier and value
*
* @param $variableIdentifier
* @param $variableValue
* @return \taoResultServer_models_classes_OutcomeVariable
* @throws \common_exception_InvalidArgumentType
*/
public function getOutcomeVariable($variableIdentifier, $variableValue)
{
if (!is_string($variableValue) && !is_numeric($variableValue)) {
$variableValue = json_encode($variableValue);
}
$metaVariable = new \taoResultServer_models_classes_OutcomeVariable();
$metaVariable->setIdentifier($variableIdentifier);
$metaVariable->setBaseType('string');
$metaVariable->setCardinality(Cardinality::getNameByConstant(Cardinality::SINGLE));
$metaVariable->setValue($variableValue);
return $metaVariable;
}
/**
* Stores response variable related to an item, a test or a section
*
* @param RunnerServiceContext $context
* @param $itemUri
* @param $variableIdentifier
* @param $variableValue
* @return boolean
* @throws \common_Exception
*/
public function storeResponseVariable(RunnerServiceContext $context, $itemUri, $variableIdentifier, $variableValue)
{
$this->assertQtiRunnerServiceContext($context);
$metaVariable = $this->getResponseVariable($variableIdentifier, $variableValue);
return $this->storeVariable($context, $itemUri, $metaVariable);
}
/**
* Create a response variable from variable identifier and value
*
* @param $variableIdentifier
* @param $variableValue
* @return \taoResultServer_models_classes_ResponseVariable
* @throws \common_exception_InvalidArgumentType
*/
public function getResponseVariable($variableIdentifier, $variableValue)
{
if (!is_string($variableValue) && !is_numeric($variableValue)) {
$variableValue = json_encode($variableValue);
}
$metaVariable = new \taoResultServer_models_classes_ResponseVariable();
$metaVariable->setIdentifier($variableIdentifier);
$metaVariable->setBaseType('string');
$metaVariable->setCardinality(Cardinality::getNameByConstant(Cardinality::SINGLE));
$metaVariable->setValue($variableValue);
return $metaVariable;
}
/**
* Store a set of result variables to the result server
*
* @param QtiRunnerServiceContext $context
* @param string $itemUri This is the item uri
* @param \taoResultServer_models_classes_Variable[] $metaVariables
* @param null $itemId The assessment item ref id (optional)
* @return bool
* @throws \Exception
* @throws \common_exception_NotImplemented If the given $itemId is not the current assessment item ref
*/
public function storeVariables(
QtiRunnerServiceContext $context,
$itemUri,
$metaVariables,
$itemId = null
) {
$sessionId = $context->getTestSession()->getSessionId();
/** @var DeliveryServerService $deliveryServerService */
$deliveryServerService = $this->getServiceManager()->get(DeliveryServerService::SERVICE_ID);
$resultStore = $deliveryServerService->getResultStoreWrapper($sessionId);
$testUri = $context->getTestDefinitionUri();
if (!is_null($itemUri)) {
$resultStore->storeItemVariables($testUri, $itemUri, $metaVariables, $this->getTransmissionId($context, $itemId));
} else {
$resultStore->storeTestVariables($testUri, $metaVariables, $sessionId);
}
return true;
}
/**
* Store a result variable to the result server
*
* @param QtiRunnerServiceContext $context
* @param string $itemUri This is the item identifier
* @param \taoResultServer_models_classes_Variable $metaVariable
* @param null $itemId The assessment item ref id (optional)
* @return bool
* @throws \common_exception_NotImplemented If the given $itemId is not the current assessment item ref
*/
protected function storeVariable(
QtiRunnerServiceContext $context,
$itemUri,
\taoResultServer_models_classes_Variable $metaVariable,
$itemId = null
) {
$sessionId = $context->getTestSession()->getSessionId();
$testUri = $context->getTestDefinitionUri();
/** @var DeliveryServerService $deliveryServerService */
$deliveryServerService = $this->getServiceManager()->get(DeliveryServerService::SERVICE_ID);
$resultStore = $deliveryServerService->getResultStoreWrapper($sessionId);
if (!is_null($itemUri)) {
$resultStore->storeItemVariable($testUri, $itemUri, $metaVariable, $this->getTransmissionId($context, $itemId));
} else {
$resultStore->storeTestVariable($testUri, $metaVariable, $sessionId);
}
return true;
}
/**
* Build the transmission based on context and item ref id to store Item variables
*
* @param QtiRunnerServiceContext $context
* @param null $itemId The item ref identifier
* @return string The transmission id to store item variables
* @throws \common_exception_NotImplemented If the given $itemId is not the current assessment item ref
*/
protected function getTransmissionId(QtiRunnerServiceContext $context, $itemId = null)
{
if (is_null($itemId)) {
$itemId = $context->getCurrentAssessmentItemRef();
} elseif ($itemId != $context->getCurrentAssessmentItemRef()) {
throw new \common_exception_NotImplemented('Item variables can be stored only for the current item');
}
$sessionId = $context->getTestSession()->getSessionId();
$currentOccurrence = $context->getTestSession()->getCurrentAssessmentItemRefOccurence();
return $sessionId . '.' . $itemId . '.' . $currentOccurrence;
}
/**
* Check if the given RunnerServiceContext is a QtiRunnerServiceContext
*
* @param RunnerServiceContext $context
* @throws \common_exception_InvalidArgumentType
*/
public function assertQtiRunnerServiceContext(RunnerServiceContext $context)
{
if (!$context instanceof QtiRunnerServiceContext) {
throw new InvalidArgumentTypeException(
__CLASS__,
__FUNCTION__,
0,
QtiRunnerServiceContext::class,
$context
);
}
}
/**
* Starts the timer for the current item in the TestSession
*
* @param RunnerServiceContext $context
* @param float $timestamp allow to start the timer at a specific time, or use current when it's null
* @return bool
* @throws \common_exception_InvalidArgumentType
*/
public function startTimer(RunnerServiceContext $context, $timestamp = null)
{
if (!$context instanceof QtiRunnerServiceContext) {
throw new InvalidArgumentTypeException(
'QtiRunnerService',
'startTimer',
0,
QtiRunnerServiceContext::class,
$context
);
}
/* @var TestSession $session */
$session = $context->getTestSession();
if ($session->getState() === AssessmentTestSessionState::INTERACTING) {
$session->startItemTimer($timestamp);
}
return true;
}
/**
* Ends the timer for the current item in the TestSession
*
* @param RunnerServiceContext $context
* @param float $duration The client side duration to adjust the timer
* @param float $timestamp allow to end the timer at a specific time, or use current when it's null
* @return bool
* @throws \common_exception_InvalidArgumentType
*/
public function endTimer(RunnerServiceContext $context, $duration = null, $timestamp = null)
{
if (!$context instanceof QtiRunnerServiceContext) {
throw new InvalidArgumentTypeException(
'QtiRunnerService',
'endTimer',
0,
QtiRunnerServiceContext::class,
$context
);
}
/* @var TestSession $session */
$session = $context->getTestSession();
$session->endItemTimer($duration, $timestamp);
return true;
}
/**
* Switch the received client store ids. Put the received id if different from the last stored.
* This enables us to check wether the stores has been changed during a test session.
* @param RunnerServiceContext $context
* @param string $receivedStoreId The identifier of the client side store
* @return string the identifier of the LAST saved client side store
* @throws \common_exception_InvalidArgumentType
*/
public function switchClientStoreId(RunnerServiceContext $context, $receivedStoreId)
{
if (!$context instanceof QtiRunnerServiceContext) {
throw new InvalidArgumentTypeException(
'QtiRunnerService',
'switchClientStoreId',
0,
QtiRunnerServiceContext::class,
$context
);
}
/* @var TestSession $session */
$session = $context->getTestSession();
$sessionId = $session->getSessionId();
$stateService = $this->getServiceManager()->get(ExtendedStateService::SERVICE_ID);
$lastStoreId = $stateService->getStoreId($sessionId);
if ($lastStoreId == false || $lastStoreId != $receivedStoreId) {
$stateService->setStoreId($sessionId, $receivedStoreId);
}
return $lastStoreId;
}
/**
* Get Current Assessment Session.
*
* Depending on the context (adaptive or not), it will return an appropriate Assessment Object to deal with.
*
* In case of the context is not adaptive, an AssessmentTestSession corresponding to the current test $context is returned.
*
* Otherwise, an AssessmentItemSession to deal with is returned.
*
* @param \oat\taoQtiTest\models\runner\RunnerServiceContext $context
* @return \qtism\runtime\tests\AssessmentTestSession|\qtism\runtime\tests\AssessmentItemSession
*/
public function getCurrentAssessmentSession(RunnerServiceContext $context)
{
if ($context->isAdaptive()) {
return new AssessmentItemSession($context->getCurrentAssessmentItemRef(), new SessionManager());
} else {
return $context->getTestSession();
}
}
/**
* @param TestSession $session
* @param string $qtiClassName
* @return null|string
*/
public function getTimeLimitsFromSession(TestSession $session, $qtiClassName)
{
$maxTimeSeconds = null;
$item = null;
switch ($qtiClassName) {
case 'assessmentTest':
$item = $session->getAssessmentTest();
break;
case 'testPart':
$item = $session->getCurrentTestPart();
break;
case 'assessmentSection':
$item = $session->getCurrentAssessmentSection();
break;
case 'assessmentItemRef':
$item = $session->getCurrentAssessmentItemRef();
break;
}
if ($item && $limits = $item->getTimeLimits()) {
$maxTimeSeconds = $limits->hasMaxTime()
? $limits->getMaxTime()->getSeconds(true)
: $maxTimeSeconds;
}
return $maxTimeSeconds;
}
/**
* @inheritdoc
*/
public function deleteDeliveryExecutionData(DeliveryExecutionDeleteRequest $request)
{
/** @var StorageManager $storage */
$storage = $this->getServiceLocator()->get(StorageManager::SERVICE_ID);
$userUri = $request->getDeliveryExecution()->getUserIdentifier();
/** @var TestSessionService $testSessionService */
$testSessionService = $this->getServiceLocator()->get(TestSessionService::SERVICE_ID);
$session = $testSessionService->getTestSession($request->getDeliveryExecution(), false);
if ($session === null) {
$status = $this->deleteExecutionStates(
$request->getDeliveryExecution()->getIdentifier(),
$userUri,
$storage
);
} else {
$status = $this->deleteExecutionStatesBasedOnSession($request, $storage, $userUri, $session);
}
/** @var ToolsStateStorage $toolsStateStorage */
$toolsStateStorage = $this->getServiceLocator()->get(ToolsStateStorage::SERVICE_ID);
$toolsStateStorage->deleteStates($request->getDeliveryExecution()->getIdentifier());
return $status;
}
/**
* @param RunnerServiceContext $context
* @param $itemRef
* @return array|string
* @throws \common_Exception
* @throws \common_exception_InconsistentData
*/
public function getItemPortableElements(RunnerServiceContext $context, $itemRef)
{
$portableElementService = new PortableElementService();
$portableElementService->setServiceLocator($this->getServiceLocator());
$portableElements = [];
try {
$portableElements = $this->loadItemData($itemRef, QtiJsonItemCompiler::PORTABLE_ELEMENT_FILE_NAME);
foreach ($portableElements as $portableModel => &$elements) {
foreach ($elements as $typeIdentifier => &$versions) {
foreach ($versions as &$portableData) {
try {
$portableElementService->setBaseUrlToPortableData($portableData);
} catch (PortableElementNotFoundException $e) {
\common_Logger::w('the portable element version does not exist in delivery server');
} catch (PortableModelMissing $e) {
\common_Logger::w('the portable element model does not exist in delivery server');
}
}
}
}
} catch (\tao_models_classes_FileNotFoundException $e) {
\common_Logger::i('old delivery that does not contain the compiled portable element data in the item ' . $itemRef);
}
return $portableElements;
}
/**
* @param $itemRef
* @return array|mixed|string
* @throws \common_Exception
*/
public function getItemMetadataElements($itemRef)
{
$metadataElements = [];
try {
$metadataElements = $this->loadItemData($itemRef, QtiJsonItemCompiler::METADATA_FILE_NAME);
} catch (\tao_models_classes_FileNotFoundException $e) {
\common_Logger::i('Old delivery that does not contain the compiled portable element data in the item ' . $itemRef . '. Original message: ' . $e->getMessage());
} catch (\Exception $e) {
\common_Logger::w('An exception caught during fetching item metadata elements. Original message: ' . $e->getMessage());
}
return $metadataElements;
}
/**
* @param $deUri
* @param $userUri
* @param $storage
* @return mixed
*/
protected function deleteExecutionStates($deUri, $userUri, StorageManager $storage)
{
$stateStorage = $storage->getStorage();
$persistence = common_persistence_KeyValuePersistence::getPersistence(
$stateStorage->getOption(tao_models_classes_service_StateStorage::OPTION_PERSISTENCE)
);
$driver = $persistence->getDriver();
if ($driver instanceof common_persistence_AdvKeyValuePersistence) {
$keys = $driver->keys(tao_models_classes_service_StateStorage::KEY_NAMESPACE . '*' . $deUri . '*');
foreach ($keys as $key) {
$driver->del($key);
}
return $storage->persist($userUri);
}
return false;
}
/**
* @param DeliveryExecutionDeleteRequest $request
* @param StorageManager $storage
* @param $userUri
* @param AssessmentTestSession $session
* @return bool
* @throws \common_exception_NotFound
*/
protected function deleteExecutionStatesBasedOnSession(DeliveryExecutionDeleteRequest $request, StorageManager $storage, $userUri, AssessmentTestSession $session)
{
$itemsRefs = $this->getItemsRefs($request, $session);
foreach ($itemsRefs as $itemRef) {
$stateId = $this->buildStorageItemKey(
$request->getDeliveryExecution()->getIdentifier(),
$itemRef
);
if ($storage->has($userUri, $stateId)) {
$storage->del($userUri, $stateId);
}
}
return $storage->persist($userUri);
}
/**
* @param DeliveryExecutionDeleteRequest $request
* @param AssessmentTestSession $session
* @return array
*/
protected function getItemsRefs(DeliveryExecutionDeleteRequest $request, AssessmentTestSession $session)
{
try {
$itemsRefs = (new GetDeliveryExecutionsItems(
$this->getServiceLocator()->get(RuntimeService::SERVICE_ID),
$this->getServiceLocator()->get(CatService::SERVICE_ID),
\tao_models_classes_service_FileStorage::singleton(),
$request->getDeliveryExecution(),
$session
))->getItemsRefs();
} catch (\Exception $exception) {
$itemsRefs = [];
}
return $itemsRefs;
}
/**
* Get state of delivery execution after exit triggered by test taker
* @return string
*/
protected function getStateAfterExit()
{
return DeliveryExecution::STATE_FINISHED;
}
/**
* Returns that the Theme Switcher Plugin is enabled or not
*
* @return bool
* @throws \common_ext_ExtensionException
*/
private function isThemeSwitcherEnabled()
{
/** @var \common_ext_ExtensionsManager $extensionsManager */
$extensionsManager = $this->getServiceLocator()->get(\common_ext_ExtensionsManager::SERVICE_ID);
$config = $extensionsManager->getExtensionById("taoTests")->getConfig("test_runner_plugin_registry");
return array_key_exists(self::TOOL_ITEM_THEME_SWITCHER_KEY, $config)
&& $config[self::TOOL_ITEM_THEME_SWITCHER_KEY]["active"] === true;
}
/**
* Returns the ID of the current theme
*
* @return string
* @throws \common_exception_InconsistentData
*/
private function getCurrentThemeId()
{
/** @var ThemeService $themeService */
$themeService = $this->getServiceLocator()->get(ThemeService::SERVICE_ID);
return $themeService->getTheme()->getId();
}
}