* @author Bertrand Chevrier * @author Jerome Bogaerts * @package taoQtiTest */ class taoQtiTest_models_classes_QtiTestService extends TestService { const CONFIG_QTITEST_FILESYSTEM = 'qtiTestFolder'; const CONFIG_QTITEST_ACCEPTABLE_LATENCY = 'qtiAcceptableLatency'; const QTI_TEST_DEFINITION_INDEX = '.index/qti-test.txt'; const PROPERTY_QTI_TEST_IDENTIFIER = 'http://www.tao.lu/Ontologies/TAOTest.rdf#QtiTestIdentifier'; const INSTANCE_TEST_MODEL_QTI = 'http://www.tao.lu/Ontologies/TAOTest.rdf#QtiTestModel'; const TAOQTITEST_FILENAME = 'tao-qtitest-testdefinition.xml'; const METADATA_GUARDIAN_CONTEXT_NAME = 'tao-qtitest'; const INSTANCE_FORMAL_PARAM_TEST_DEFINITION = 'http://www.tao.lu/Ontologies/TAOTest.rdf#FormalParamQtiTestDefinition'; const INSTANCE_FORMAL_PARAM_TEST_COMPILATION = 'http://www.tao.lu/Ontologies/TAOTest.rdf#FormalParamQtiTestCompilation'; const TEST_COMPILED_FILENAME = 'compact-test'; const TEST_COMPILED_META_FILENAME = 'test-meta'; const TEST_COMPILED_METADATA_FILENAME = 'test-metadata.json'; const TEST_COMPILED_INDEX = 'test-index.json'; const TEST_COMPILED_HREF_INDEX_FILE_PREFIX = 'assessment-item-ref-href-index-'; const TEST_COMPILED_HREF_INDEX_FILE_EXTENSION = '.idx'; const TEST_REMOTE_FOLDER = 'tao-qtitest-remote'; const TEST_RENDERING_STATE_NAME = 'taoQtiTestState'; const TEST_BASE_PATH_NAME = 'taoQtiBasePath'; const TEST_PLACEHOLDER_BASE_URI = 'tao://qti-directory'; const TEST_VIEWS_NAME = 'taoQtiViews'; const XML_TEST_PART = 'testPart'; const XML_ASSESSMENT_SECTION = 'assessmentSection'; const XML_ASSESSMENT_ITEM_REF = 'assessmentItemRef'; /** * @var MetadataImporter Service to manage Lom metadata during package import */ protected $metadataImporter; /** * @var bool If true, it will guard and check metadata that comes from package. */ protected $useMetadataGuardians = true; /** * @var bool If true, items contained in the test must be all found by one metadata guardian. */ protected $itemMustExist = false; /** * @var bool If true, items found by metadata guardians will be overwritten. */ protected $itemMustBeOverwritten = false; /** * @var bool If true, registered validators will be invoked for each test item to be imported. */ protected $useMetadataValidators = true; public function enableMetadataGuardians() { $this->useMetadataGuardians = true; } public function disableMetadataGuardians() { $this->useMetadataGuardians = false; } public function enableMetadataValidators() { $this->useMetadataValidators = true; } public function disableMetadataValidators() { $this->useMetadataValidators = false; } public function enableItemMustExist() { $this->itemMustExist = true; } public function disableItemMustExist() { $this->itemMustExist = false; } public function enableItemMustBeOverwritten() { $this->itemMustBeOverwritten = true; } public function disableItemMustBeOverwritten() { $this->itemMustBeOverwritten = false; } /** * Get the QTI Test document formated in JSON. * * @param core_kernel_classes_Resource $test * * @return string the json * * @throws taoQtiTest_models_classes_QtiTestServiceException */ public function getJsonTest(core_kernel_classes_Resource $test): string { $doc = $this->getDoc($test); $converter = new taoQtiTest_models_classes_QtiTestConverter($doc); return $converter->toJson(); } /** * @inheritDoc */ protected function setDefaultModel($test): void { $this->setTestModel($test, $this->getResource(self::INSTANCE_TEST_MODEL_QTI)); } /** * Save the json formated test into the test resource. * * @param core_kernel_classes_Resource $test * @param string $json * * @return bool true if saved * * @throws taoQtiTest_models_classes_QtiTestServiceException * @throws taoQtiTest_models_classes_QtiTestConverterException */ public function saveJsonTest(core_kernel_classes_Resource $test, $json): bool { $saved = false; if (! empty($json)) { $this->verifyItemPermissions($test, $json); $doc = $this->getDoc($test); $converter = new taoQtiTest_models_classes_QtiTestConverter($doc); $converter->fromJson($json); $saved = $this->saveDoc($test, $doc); $this->getEventManager()->trigger(new TestUpdatedEvent($test->getUri())); } return $saved; } public function fromJson($json) { $doc = new XmlDocument('2.1'); $converter = new taoQtiTest_models_classes_QtiTestConverter($doc); $converter->fromJson($json); return $doc; } /** * Get the items that are part of a given $test. * * @param core_kernel_classes_Resource $test A Resource describing a QTI Assessment Test. * @return array An array of core_kernel_classes_Resource objects. The array is associative. Its keys are actually the assessmentItemRef identifiers. */ public function getItems(core_kernel_classes_Resource $test) { return $this->getDocItems($this->getDoc($test)); } /** * Assign items to a test and save it. * @param core_kernel_classes_Resource $test * @param array $items * @return boolean true if set * @throws taoQtiTest_models_classes_QtiTestServiceException */ public function setItems(core_kernel_classes_Resource $test, array $items) { $doc = $this->getDoc($test); $bound = $this->setItemsToDoc($doc, $items); if ($this->saveDoc($test, $doc)) { return $bound == count($items); } return false; } /** * Save the QTI test : set the items sequence and some options. * * @param core_kernel_classes_Resource $test A Resource describing a QTI Assessment Test. * @param array $items the items sequence * @param array $options the test's options * @return boolean if nothing goes wrong * @throws StorageException If an error occurs while serializing/unserializing QTI-XML content. */ public function save(core_kernel_classes_Resource $test, array $items) { try { $doc = $this->getDoc($test); $this->setItemsToDoc($doc, $items); $saved = $this->saveDoc($test, $doc); } catch (StorageException $e) { throw new taoQtiTest_models_classes_QtiTestServiceException( "An error occured while dealing with the QTI-XML test: " . $e->getMessage(), taoQtiTest_models_classes_QtiTestServiceException::TEST_WRITE_ERROR ); } return $saved; } /** * Get an identifier for a component of $qtiType. * This identifier must be unique across the whole document. * * @param XmlDocument $doc * @param string $qtiType the type name * @return string the identifier */ public function getIdentifierFor(XmlDocument $doc, $qtiType) { $components = $doc->getDocumentComponent()->getIdentifiableComponents(); $index = 1; do { $identifier = $this->generateIdentifier($doc, $qtiType, $index); $index++; } while (! $this->isIdentifierUnique($components, $identifier)); return $identifier; } /** * Check whether an identifier is unique against a list of components * * @param QtiComponentCollection $components * @param string $identifier * @return boolean */ private function isIdentifierUnique(QtiComponentCollection $components, $identifier) { foreach ($components as $component) { if ($component->getIdentifier() == $identifier) { return false; } } return true; } /** * Generate an identifier from a qti type, using the syntax "qtitype-index" * * @param XmlDocument $doc * @param string $qtiType * @param int $offset * @return string the identifier */ private function generateIdentifier(XmlDocument $doc, $qtiType, $offset = 1) { $typeList = $doc->getDocumentComponent()->getComponentsByClassName($qtiType); return $qtiType . '-' . (count($typeList) + $offset); } /** * Import a QTI Test Package containing one or more QTI Test definitions. * * @param core_kernel_classes_Class $targetClass The Target RDFS class where you want the Test Resources to be created. * @param string|File $file The path to the IMS archive you want to import tests from. * @return common_report_Report An import report. * @throws common_exception * @throws common_exception_Error * @throws common_exception_FileSystemError */ public function importMultipleTests(core_kernel_classes_Class $targetClass, $file) { $testClass = $targetClass; $report = new common_report_Report(common_report_Report::TYPE_INFO); $validPackage = false; $validManifest = false; $testsFound = false; $qtiPackageImportPreprocessingService = $this->getQtiPackageImportPreprocessing(); $preprocessingReport = $qtiPackageImportPreprocessingService->run($file); if ($preprocessingReport) { $report->add($preprocessingReport); } // Validate the given IMS Package itself (ZIP integrity, presence of an 'imsmanifest.xml' file. $invalidArchiveMsg = __("The provided archive is invalid. Make sure it is not corrupted and that it contains an 'imsmanifest.xml' file."); try { $qtiPackageParser = new taoQtiTest_models_classes_PackageParser($file); $qtiPackageParser->validate(); $validPackage = true; } catch (Exception $e) { $report->add(common_report_Report::createFailure($invalidArchiveMsg)); } // Validate the manifest (well formed XML, valid against the schema). if ($validPackage === true) { $folder = $qtiPackageParser->extract(); if (is_dir($folder) === false) { $report->add(common_report_Report::createFailure($invalidArchiveMsg)); } else { $qtiManifestParser = new taoQtiTest_models_classes_ManifestParser($folder . 'imsmanifest.xml'); $this->propagate($qtiManifestParser); $qtiManifestParser->validate(); if ($qtiManifestParser->isValid() === true) { $validManifest = true; $tests = []; foreach (Resource::getTestTypes() as $type) { $tests = array_merge($tests, $qtiManifestParser->getResources($type)); } $testsFound = (count($tests) !== 0); if ($testsFound !== true) { $report->add(common_report_Report::createFailure(__("Package is valid but no tests were found. Make sure that it contains valid QTI tests."))); } else { $alreadyImportedQtiResources = []; foreach ($tests as $qtiTestResource) { $importTestReport = $this->importTest($testClass, $qtiTestResource, $qtiManifestParser, $folder, $alreadyImportedQtiResources); $report->add($importTestReport); if ($data = $importTestReport->getData()) { $alreadyImportedQtiResources = array_unique( array_merge( $alreadyImportedQtiResources, $data->itemQtiResources ) ); } } } } else { $msg = __("The 'imsmanifest.xml' file found in the archive is not valid."); $report->add(common_report_Report::createFailure($msg)); } // Cleanup the folder where the archive was extracted. tao_helpers_File::deltree($folder); } } if ($report->containsError() === true) { $report->setMessage(__('The IMS QTI Test Package could not be imported.')); $report->setType(common_report_Report::TYPE_ERROR); } else { $report->setMessage(__('IMS QTI Test Package successfully imported.')); $report->setType(common_report_Report::TYPE_SUCCESS); } if ($report->containsError() === true && $validPackage === true && $validManifest === true && $testsFound === true) { $this->clearRelatedResources($report); } return $report; } /** * @param common_report_Report $report * * @throws common_exception_Error * @throws common_exception_FileSystemError */ public function clearRelatedResources(common_report_Report $report): void { // We consider a test package as an atomic component, we then rollback it. $itemService = $this->getServiceLocator()->get(taoItems_models_classes_ItemsService::class); foreach ($report as $r) { $data = $r->getData(); // -- Rollback all items. // 1. Simply delete items that were not involved in overwriting. foreach ($data->newItems as $item) { if ( !$item instanceof MetadataGuardianResource && !array_key_exists($item->getUri(), $data->overwrittenItems) ) { common_Logger::d("Rollbacking new item '" . $item->getUri() . "'..."); @$itemService->deleteResource($item); } } // 2. Restore overwritten item contents. foreach ($data->overwrittenItems as $overwrittenItemId => $backupName) { common_Logger::d("Restoring content for item '${overwrittenItemId}'..."); @Service::singleton()->restoreContentByRdfItem( new core_kernel_classes_Resource($overwrittenItemId), $backupName ); } // Delete all created classes (by registered class lookups). foreach ($data->createdClasses as $createdClass) { @$createdClass->delete(); } // Delete the target Item RDFS class. common_Logger::t("Rollbacking Items target RDFS class '" . $data->itemClass->getLabel() . "'..."); @$data->itemClass->delete(); // Delete test definition. common_Logger::t("Rollbacking test '" . $data->rdfsResource->getLabel() . "..."); @$this->deleteTest($data->rdfsResource); if (count($data->newItems) > 0) { $msg = __("The resources related to the IMS QTI Test referenced as \"%s\" in the IMS Manifest file were rolled back.", $data->manifestResource->getIdentifier()); $report->add(new common_report_Report(common_report_Report::TYPE_WARNING, $msg)); } } } /** * Import a QTI Test and its dependent Items into the TAO Platform. * * @param core_kernel_classes_Class $targetClass The RDFS Class where Ontology resources must be created. * @param oat\taoQtiItem\model\qti\Resource $qtiTestResource The QTI Test Resource representing the IMS QTI Test to be imported. * @param taoQtiTest_models_classes_ManifestParser $manifestParser The parser used to retrieve the IMS Manifest. * @param string $folder The absolute path to the folder where the IMS archive containing the test content * @param oat\taoQtiItem\model\qti\Resource[] $ignoreQtiResources An array of QTI Manifest Resources to be ignored at import time. * @return common_report_Report A report about how the importation behaved. */ protected function importTest(core_kernel_classes_Class $targetClass, Resource $qtiTestResource, taoQtiTest_models_classes_ManifestParser $manifestParser, $folder, array $ignoreQtiResources = []) { /** @var ImportService $itemImportService */ $itemImportService = $this->getServiceLocator()->get(ImportService::SERVICE_ID); $testClass = $targetClass; $qtiTestResourceIdentifier = $qtiTestResource->getIdentifier(); // Create an RDFS resource in the knowledge base that will hold // the information about the imported QTI Test. $testResource = $this->createInstance($testClass, 'in progress'); $qtiTestModelResource = $this->getResource(self::INSTANCE_TEST_MODEL_QTI); $modelProperty = $this->getProperty(TestService::PROPERTY_TEST_TESTMODEL); $testResource->editPropertyValues($modelProperty, $qtiTestModelResource); // Setting qtiIdentifier property $qtiIdentifierProperty = $this->getProperty(self::PROPERTY_QTI_TEST_IDENTIFIER); $testResource->editPropertyValues($qtiIdentifierProperty, $qtiTestResourceIdentifier); // Create the report that will hold information about the import // of $qtiTestResource in TAO. $report = new common_report_Report(common_report_Report::TYPE_INFO); // The class where the items that belong to the test will be imported. $itemClass = $this->getClass(TaoOntology::CLASS_URI_ITEM); $targetClass = $itemClass->createSubClass($testResource->getLabel()); // Load and validate the manifest $qtiManifestParser = new taoQtiTest_models_classes_ManifestParser($folder . 'imsmanifest.xml'); $this->propagate($qtiManifestParser); $qtiManifestParser->validate(); $domManifest = new DOMDocument('1.0', 'UTF-8'); $domManifest->load($folder . 'imsmanifest.xml'); $metadataValues = $this->getMetadataImporter()->extract($domManifest); // Note: without this fix, metadata guardians do not work. $this->getMetadataImporter()->setMetadataValues($metadataValues); // Set up $report with useful information for client code (especially for rollback). $reportCtx = new stdClass(); $reportCtx->manifestResource = $qtiTestResource; $reportCtx->rdfsResource = $testResource; $reportCtx->itemClass = $targetClass; $reportCtx->items = []; $reportCtx->newItems = []; $reportCtx->overwrittenItems = []; $reportCtx->itemQtiResources = []; $reportCtx->testMetadata = isset($metadataValues[$qtiTestResourceIdentifier]) ? $metadataValues[$qtiTestResourceIdentifier] : []; $reportCtx->createdClasses = []; // 'uriResource' key is needed by javascript in tao/views/templates/form/import.tpl $reportCtx->uriResource = $testResource->getUri(); $report->setData($reportCtx); // Expected test.xml file location. $expectedTestFile = $folder . str_replace('/', DIRECTORY_SEPARATOR, $qtiTestResource->getFile()); // Already imported test items (qti xml file paths). $alreadyImportedTestItemFiles = []; // -- Check if the file referenced by the test QTI resource exists. if (is_readable($expectedTestFile) === false) { $report->add(common_report_Report::createFailure(__('No file found at location "%s".', $qtiTestResource->getFile()))); } else { // -- Load the test in a QTISM flavour. $testDefinition = new XmlDocument(); try { $testDefinition->load($expectedTestFile, true); // If any, assessmentSectionRefs will be resolved and included as part of the main test definition. $testDefinition->includeAssessmentSectionRefs(true); // -- Load all items related to test. $itemError = false; // discover test's base path. $dependencies = taoQtiTest_helpers_Utils::buildAssessmentItemRefsTestMap($testDefinition, $manifestParser, $folder); // Build a DOM version of the fully resolved AssessmentTest for later usage. $transitionalDoc = new DOMDocument('1.0', 'UTF-8'); $transitionalDoc->loadXML($testDefinition->saveToString()); /** @var CatService $service */ $service = $this->getServiceLocator()->get(CatService::SERVICE_ID); $service->importCatSectionIdsToRdfTest($testResource, $testDefinition->getDocumentComponent(), $expectedTestFile); if (count($dependencies['items']) > 0) { // Stores shared files across multiple items to avoid duplicates. $sharedFiles = []; foreach ($dependencies['items'] as $assessmentItemRefId => $qtiDependency) { if ($qtiDependency !== false) { if (Resource::isAssessmentItem($qtiDependency->getType())) { $resourceIdentifier = $qtiDependency->getIdentifier(); if (!array_key_exists($resourceIdentifier, $ignoreQtiResources)) { $qtiFile = $folder . str_replace('/', DIRECTORY_SEPARATOR, $qtiDependency->getFile()); // If metadata should be aware of the test context... foreach ($this->getMetadataImporter()->getExtractors() as $extractor) { if ($extractor instanceof MetadataTestContextAware) { $metadataValues = array_merge( $metadataValues, $extractor->contextualizeWithTest( $qtiTestResource->getIdentifier(), $transitionalDoc, $resourceIdentifier, $metadataValues ) ); } } // Skip if $qtiFile already imported (multiple assessmentItemRef "hrefing" the same file). if (array_key_exists($qtiFile, $alreadyImportedTestItemFiles) === false) { $createdClasses = []; $itemReport = $itemImportService->importQtiItem( $folder, $qtiDependency, $targetClass, $sharedFiles, $dependencies['dependencies'], $metadataValues, $createdClasses, $this->useMetadataGuardians, $this->useMetadataValidators, $this->itemMustExist, $this->itemMustBeOverwritten, $reportCtx->overwrittenItems ); $reportCtx->createdClasses = array_merge($reportCtx->createdClasses, $createdClasses); $rdfItem = $itemReport->getData(); if ($rdfItem) { $reportCtx->items[$assessmentItemRefId] = $rdfItem; $reportCtx->newItems[$assessmentItemRefId] = $rdfItem; $reportCtx->itemQtiResources[$resourceIdentifier] = $rdfItem; $alreadyImportedTestItemFiles[$qtiFile] = $rdfItem; } else { if (!$itemReport->getMessage()) { $itemReport->setMessage(__('IMS QTI Item referenced as "%s" in the IMS Manifest file could not be imported.', $resourceIdentifier)); } $itemReport->setType(common_report_Report::TYPE_ERROR); $itemError = ($itemError === false) ? true : $itemError; } $report->add($itemReport); } else { $reportCtx->items[$assessmentItemRefId] = $alreadyImportedTestItemFiles[$qtiFile]; } } else { // Ignored (possibily because imported in another test of the same package). $reportCtx->items[$assessmentItemRefId] = $ignoreQtiResources[$resourceIdentifier]; $report->add( new common_report_Report( common_report_Report::TYPE_SUCCESS, __('IMS QTI Item referenced as "%s" in the IMS Manifest file successfully imported.', $resourceIdentifier) ) ); } } } else { $msg = __('The dependency to the IMS QTI AssessmentItemRef "%s" in the IMS Manifest file could not be resolved.', $assessmentItemRefId); $report->add(common_report_Report::createFailure($msg)); $itemError = ($itemError === false) ? true : $itemError; } } // If items did not produce errors, we import the test definition. if ($itemError === false) { common_Logger::i("Importing test with manifest identifier '${qtiTestResourceIdentifier}'..."); // Second step is to take care of the test definition and the related media (auxiliary files). // 1. Import test definition (i.e. the QTI-XML Test file). $testContent = $this->importTestDefinition($testResource, $testDefinition, $qtiTestResource, $reportCtx->items, $folder, $report); if ($testContent !== false) { // 2. Import test auxilliary files (e.g. stylesheets, images, ...). $this->importTestAuxiliaryFiles($testContent, $qtiTestResource, $folder, $report); // 3. Give meaningful names to resources. $testResource->setLabel($testDefinition->getDocumentComponent()->getTitle()); $targetClass->setLabel($testDefinition->getDocumentComponent()->getTitle()); // 4. Import metadata for the resource (use same mechanics as item resources). // Metadata will be set as property values. $this->getMetadataImporter()->inject($qtiTestResource->getIdentifier(), $testResource); // 5. if $targetClass does not contain any instances (because everything resolved by class lookups), // Just delete it. if ($targetClass->countInstances() == 0) { $targetClass->delete(); } } } else { $msg = __("One or more dependent IMS QTI Items could not be imported."); $report->add(common_report_Report::createFailure($msg)); } } else { // No depencies found (i.e. no item resources bound to the test). $msg = __("No reference to any IMS QTI Item found."); $report->add(common_report_Report::createFailure($msg)); } } catch (StorageException $e) { // Source of the exception = $testDefinition->load() // What is the reason ? $eStrs = []; if (($libXmlErrors = $e->getErrors()) !== null) { foreach ($libXmlErrors as $libXmlError) { $eStrs[] = __('XML error at line %1$d column %2$d "%3$s".', $libXmlError->line, $libXmlError->column, trim($libXmlError->message)); } } $finalErrorString = implode("\n", $eStrs); if (empty($finalErrorString) === true) { common_Logger::e($e->getMessage()); // Not XML malformation related. No info from LibXmlErrors extracted. if (($previous = $e->getPrevious()) != null) { // Useful information could be found here. $finalErrorString = $previous->getMessage(); if ($previous instanceof UnmarshallingException) { $domElement = $previous->getDOMElement(); $finalErrorString = __('Inconsistency at line %1d:', $domElement->getLineNo()) . ' ' . $previous->getMessage(); } } elseif ($e->getMessage() !== '') { $finalErrorString = $e->getMessage(); } else { $finalErrorString = __("Unknown error."); } } $msg = __("Error found in the IMS QTI Test:\n%s", $finalErrorString); $report->add(common_report_Report::createFailure($msg)); } catch (CatEngineNotFoundException $e) { $report->add( new common_report_Report( common_report_Report::TYPE_ERROR, __('No CAT Engine configured for CAT Endpoint "%s".', $e->getRequestedEndpoint()) ) ); } catch (AdaptiveSectionInjectionException $e) { $report->add( new common_report_Report( common_report_Report::TYPE_ERROR, __("Items with assessmentItemRef identifiers \"%s\" are not registered in the related CAT endpoint.", implode(', ', $e->getInvalidItemIdentifiers())) ) ); } } if ($report->containsError() === false) { $report->setType(common_report_Report::TYPE_SUCCESS); $msg = __("IMS QTI Test referenced as \"%s\" in the IMS Manifest file successfully imported.", $qtiTestResource->getIdentifier()); $report->setMessage($msg); } else { $report->setType(common_report_Report::TYPE_ERROR); $msg = __("The IMS QTI Test referenced as \"%s\" in the IMS Manifest file could not be imported.", $qtiTestResource->getIdentifier()); $report->setMessage($msg); } return $report; } /** * Import the Test itself by importing its QTI-XML definition into the system, after * the QTI Items composing the test were also imported. * * The $itemMapping argument makes the implementation of this method able to know * what are the items that were imported. The $itemMapping is an associative array * where keys are the assessmentItemRef's identifiers and the values are the core_kernel_classes_Resources of * the items that are now stored in the system. * * When this method returns false, it means that an error occured at the level of the content of the imported test * itself e.g. an item referenced by the test is not present in the content package. In this case, $report might * contain useful information to return to the client. * * @param core_kernel_classes_Resource $testResource A Test Resource the new content must be bind to. * @param XmlDocument $testDefinition An XmlAssessmentTestDocument object. * @param Resource $qtiResource The manifest resource describing the test to be imported. * @param array $itemMapping An associative array that represents the mapping between assessmentItemRef elements and the imported items. * @param string $extractionFolder The absolute path to the temporary folder containing the content of the imported IMS QTI Package Archive. * @param common_report_Report $report A Report object to be filled during the import. * @return Directory The newly created test content. * @throws taoQtiTest_models_classes_QtiTestServiceException If an unexpected runtime error occurs. */ protected function importTestDefinition(core_kernel_classes_Resource $testResource, XmlDocument $testDefinition, Resource $qtiResource, array $itemMapping, $extractionFolder, common_report_Report $report) { foreach ($itemMapping as $itemRefId => $itemResource) { $itemRef = $testDefinition->getDocumentComponent()->getComponentByIdentifier($itemRefId); $itemRef->setHref($itemResource->getUri()); } $oldFile = $this->getQtiTestFile($testResource); $oldFile->delete(); $ds = DIRECTORY_SEPARATOR; $path = dirname($qtiResource->getFile()) . $ds . self::TAOQTITEST_FILENAME; $dir = $this->getQtiTestDir($testResource); $newFile = $dir->getFile($path); $newFile->write($testDefinition->saveToString()); $this->setQtiIndexFile($dir, $path); return $this->getQtiTestDir($testResource); } /** * * @param Directory $dir * @param $path * @return bool */ protected function setQtiIndexFile(Directory $dir, $path) { $newFile = $dir->getFile(self::QTI_TEST_DEFINITION_INDEX); return $newFile->put($path); } /** * @param Directory $dir * @return false|string */ protected function getQtiDefinitionPath(Directory $dir) { $index = $dir->getFile(self::QTI_TEST_DEFINITION_INDEX); if ($index->exists()) { return $index->read(); } return false; } /** * Imports the auxiliary files (file elements contained in the resource test element to be imported) into * the TAO Test Content directory. * * If some file cannot be copied, warnings will be committed. * * @param Directory $testContent The pointer to the TAO Test Content directory where auxilliary files will be stored. * @param Resource $qtiResource The manifest resource describing the test to be imported. * @param string $extractionFolder The absolute path to the temporary folder containing the content of the imported IMS QTI Package Archive. * @param common_report_Report A report about how the importation behaved. */ protected function importTestAuxiliaryFiles(Directory $testContent, Resource $qtiResource, $extractionFolder, common_report_Report $report) { foreach ($qtiResource->getAuxiliaryFiles() as $aux) { try { taoQtiTest_helpers_Utils::storeQtiResource($testContent, $aux, $extractionFolder); } catch (common_Exception $e) { $report->add(new common_report_Report(common_report_Report::TYPE_WARNING, __('Auxiliary file not found at location "%s".', $aux))); } } } /** * Get the File object corresponding to the location * of the test content (a directory!) on the file system. * * @param core_kernel_classes_Resource $test * @return null|File * @throws taoQtiTest_models_classes_QtiTestServiceException */ public function getTestFile(core_kernel_classes_Resource $test) { $testModel = $test->getOnePropertyValue($this->getProperty(TestService::PROPERTY_TEST_TESTMODEL)); if (is_null($testModel) || $testModel->getUri() != self::INSTANCE_TEST_MODEL_QTI) { throw new taoQtiTest_models_classes_QtiTestServiceException( 'The selected test is not a QTI test', taoQtiTest_models_classes_QtiTestServiceException::TEST_READ_ERROR ); } $file = $test->getOnePropertyValue($this->getProperty(TestService::PROPERTY_TEST_CONTENT)); if (!is_null($file)) { return $this->getFileReferenceSerializer()->unserializeFile($file->getUri()); } return null; } /** * Get the QTI reprensentation of a test content. * * @param core_kernel_classes_Resource $test the test to get the content from * @return XmlDocument the QTI representation from the test content * @throws taoQtiTest_models_classes_QtiTestServiceException */ public function getDoc(core_kernel_classes_Resource $test) { $doc = new XmlDocument('2.1'); $doc->loadFromString($this->getQtiTestFile($test)->read()); return $doc; } /** * Get the path of the QTI XML test definition of a given $test resource. * * @param core_kernel_classes_Resource $test * @throws Exception If no QTI-XML or multiple QTI-XML test definition were found. * @return string The absolute path to the QTI XML Test definition related to $test. */ public function getDocPath(core_kernel_classes_Resource $test) { $file = $this->getQtiTestFile($test); return $file->getBasename(); } /** * Get the items from a QTI test document. * * @param \qtism\data\storage\xml\XmlDocument $doc The QTI XML document to be inspected to retrieve the items. * @return core_kernel_classes_Resource[] An array of core_kernel_classes_Resource object indexed by assessmentItemRef->identifier (string). */ private function getDocItems(XmlDocument $doc) { $itemArray = []; foreach ($doc->getDocumentComponent()->getComponentsByClassName('assessmentItemRef') as $itemRef) { $itemArray[$itemRef->getIdentifier()] = $this->getResource($itemRef->getHref()); } return $itemArray; } /** * Assign items to a QTI test. * @param XmlDocument $doc * @param array $items * @return int * @throws taoQtiTest_models_classes_QtiTestServiceException */ private function setItemsToDoc(XmlDocument $doc, array $items, $sectionIndex = 0) { $sections = $doc->getDocumentComponent()->getComponentsByClassName('assessmentSection'); if (!isset($sections[$sectionIndex])) { throw new taoQtiTest_models_classes_QtiTestServiceException( 'No section found in test at index : ' . $sectionIndex, taoQtiTest_models_classes_QtiTestServiceException::TEST_READ_ERROR ); } $section = $sections[$sectionIndex]; $itemRefs = new SectionPartCollection(); $itemRefIdentifiers = []; foreach ($items as $itemResource) { $itemDoc = new XmlDocument(); try { $itemDoc->loadFromString(Service::singleton()->getXmlByRdfItem($itemResource)); } catch (StorageException $e) { // We consider the item not compliant with QTI, let's try the next one. continue; } $itemRefIdentifier = $itemDoc->getDocumentComponent()->getIdentifier(); //enable more than one reference if (array_key_exists($itemRefIdentifier, $itemRefIdentifiers)) { $itemRefIdentifiers[$itemRefIdentifier] += 1; $itemRefIdentifier .= '-' . $itemRefIdentifiers[$itemRefIdentifier]; } else { $itemRefIdentifiers[$itemRefIdentifier] = 0; } $itemRefs[] = new AssessmentItemRef($itemRefIdentifier, $itemResource->getUri()); } $section->setSectionParts($itemRefs); return count($itemRefs); } /** * Get root qti test directory or crate if not exists * * @param core_kernel_classes_Resource $test * @param boolean $createTestFile Whether or not create an empty QTI XML test file. Default is * (boolean) true. * * @return Directory * * @throws taoQtiTest_models_classes_QtiTestServiceException * @throws common_exception_InconsistentData * @throws core_kernel_persistence_Exception */ public function getQtiTestDir(core_kernel_classes_Resource $test, $createTestFile = true) { $testModel = $this->getServiceLocator()->get(TestService::class)->getTestModel($test); if ($testModel->getUri() !== self::INSTANCE_TEST_MODEL_QTI) { throw new taoQtiTest_models_classes_QtiTestServiceException( 'The selected test is not a QTI test', taoQtiTest_models_classes_QtiTestServiceException::TEST_READ_ERROR ); } $dir = $test->getOnePropertyValue($this->getProperty(TestService::PROPERTY_TEST_CONTENT)); if (null !== $dir) { /** @noinspection PhpIncompatibleReturnTypeInspection */ return $this->getFileReferenceSerializer()->unserialize($dir); } return $this->createContent($test, $createTestFile); } protected function searchInTestDirectory(Directory $dir) { $iterator = $dir->getFlyIterator(Directory::ITERATOR_RECURSIVE | Directory::ITERATOR_FILE); $files = []; /** * @var File $file */ foreach ($iterator as $file) { if ($file->getBasename() === self::TAOQTITEST_FILENAME) { $files[] = $file; break; } } if (empty($files)) { throw new Exception('No QTI-XML test file found.'); } $file = current($files); $fileName = str_replace($dir->getPrefix() . '/', '', $file->getPrefix()); $this->setQtiIndexFile($dir, $fileName); return $file; } /** * Return the File containing the test definition * If it doesn't exist, it will be created * * @param core_kernel_classes_Resource $test * @throws \Exception If file is not found. * @return File */ public function getQtiTestFile(core_kernel_classes_Resource $test) { $dir = $this->getQtiTestDir($test); $file = $this->getQtiDefinitionPath($dir); if (!empty($file)) { return $dir->getFile($file); } return $this->searchInTestDirectory($dir); } /** * * @param core_kernel_classes_Resource $test * @throws Exception * @return string */ public function getRelTestPath(core_kernel_classes_Resource $test) { $testRootDir = $this->getQtiTestDir($test); return $testRootDir->getRelPath($this->getQtiTestFile($test)); } /** * Save the content of test from a QTI Document * @param core_kernel_classes_Resource $test * @param qtism\data\storage\xml\XmlDocument $doc * @return boolean true if saved * @throws taoQtiTest_models_classes_QtiTestServiceException */ private function saveDoc(core_kernel_classes_Resource $test, XmlDocument $doc) { $file = $this->getQtiTestFile($test); return $file->update($doc->saveToString()); } /** * Create the default content directory of a QTI test. * * @param core_kernel_classes_Resource $test * @param boolean $createTestFile Whether or not create an empty QTI XML test file. Default is (boolean) true. * @param boolean $preventOverride Prevent data to be overriden Default is (boolean) true. * * @return Directory the content directory * @throws FileExistsException * @throws XmlStorageException * @throws common_Exception * @throws common_exception_Error * @throws common_exception_InconsistentData In case of trying to override existing data. * @throws common_ext_ExtensionException * @throws taoQtiTest_models_classes_QtiTestServiceException If a runtime error occurs while creating the test content. */ public function createContent(core_kernel_classes_Resource $test, $createTestFile = true, $preventOverride = true) { $dir = $this->getDefaultDir()->getDirectory(md5($test->getUri())); if ($dir->exists() && $preventOverride === true) { throw new common_exception_InconsistentData('Data directory for test ' . $test->getUri() . ' already exists.'); } $file = $dir->getFile(self::TAOQTITEST_FILENAME); if ($createTestFile === true) { /** @var AssessmentTestXmlFactory $xmlBuilder */ $xmlBuilder = $this->getServiceLocator()->get(AssessmentTestXmlFactory::class); $testLabel = $test->getLabel(); $identifier = $this->createTestIdentifier($testLabel); $xml = $xmlBuilder->create($identifier, $testLabel); if (!$file->write($xml)) { throw new taoQtiTest_models_classes_QtiTestServiceException( 'Unable to write raw QTI Test template.', taoQtiTest_models_classes_QtiTestServiceException::TEST_WRITE_ERROR ); } common_Logger::t("Created QTI Test content for test '" . $test->getUri() . "'."); } elseif ($file->exists()) { $doc = new DOMDocument('1.0', 'UTF-8'); $doc->loadXML($file->read()); // Label update only. $doc->documentElement->setAttribute('title', $test->getLabel()); if (!$file->update($doc->saveXML())) { $msg = 'Unable to update QTI Test file.'; throw new taoQtiTest_models_classes_QtiTestServiceException($msg, taoQtiTest_models_classes_QtiTestServiceException::TEST_WRITE_ERROR); } } $directory = $this->getFileReferenceSerializer()->serialize($dir); $test->editPropertyValues($this->getProperty(TestService::PROPERTY_TEST_CONTENT), $directory); return $dir; } private function createTestIdentifier(string $testLabel): string { $identifier = Format::sanitizeIdentifier($testLabel); $identifier = str_replace('_', '-', $identifier); if (preg_match('/^\d/', $identifier)) { $identifier = '_' . $identifier; } return $identifier; } /** * Delete the content of a QTI test * @param core_kernel_classes_Resource $test * @throws common_exception_Error */ public function deleteContent(core_kernel_classes_Resource $test) { $content = $test->getOnePropertyValue($this->getProperty(TestService::PROPERTY_TEST_CONTENT)); if (!is_null($content)) { $dir = $this->getFileReferenceSerializer()->unserialize($content); $dir->deleteSelf(); $this->getFileReferenceSerializer()->cleanUp($content); $test->removePropertyValue($this->getProperty(TestService::PROPERTY_TEST_CONTENT), $content); } } /** * Set the directory where the tests' contents are stored. * @param string $fsId */ public function setQtiTestFileSystem($fsId) { $ext = common_ext_ExtensionsManager::singleton()->getExtensionById('taoQtiTest'); $ext->setConfig(self::CONFIG_QTITEST_FILESYSTEM, $fsId); } /** * Get the default directory where the tests' contents are stored. * replaces getQtiTestFileSystem * * @return Directory */ public function getDefaultDir() { $ext = $this->getServiceLocator()->get(common_ext_ExtensionsManager::SERVICE_ID)->getExtensionById('taoQtiTest'); $fsId = $ext->getConfig(self::CONFIG_QTITEST_FILESYSTEM); return $this->getServiceLocator()->get(FileSystemService::SERVICE_ID)->getDirectory($fsId); } /** * Set the acceptable latency time (applied on qti:timeLimits->minTime, qti:timeLimits:maxTime). * * @param string $duration An ISO 8601 Duration. * @see http://www.php.net/manual/en/dateinterval.construct.php PHP's interval_spec format (based on ISO 8601). */ public function setQtiTestAcceptableLatency($duration) { $ext = common_ext_ExtensionsManager::singleton()->getExtensionById('taoQtiTest'); $ext->setConfig(self::CONFIG_QTITEST_ACCEPTABLE_LATENCY, $duration); } /** * Get the acceptable latency time (applied on qti:timeLimits->minTime, qti:timeLimits->maxTime). * * @throws common_Exception If no value can be found as the acceptable latency in the extension's configuration file. * @return string An ISO 8601 Duration. * @see http://www.php.net/manual/en/dateinterval.construct.php PHP's interval_spec format (based on ISO 8601). */ public function getQtiTestAcceptableLatency() { $ext = $this->getServiceLocator()->get(common_ext_ExtensionsManager::SERVICE_ID) ->getExtensionById('taoQtiTest'); $latency = $ext->getConfig(self::CONFIG_QTITEST_ACCEPTABLE_LATENCY); if (empty($latency)) { // Default duration for legacy code or missing config. return 'PT5S'; } return $latency; } /** * * @deprecated * * Get the content of the QTI Test template file as an XML string. * * @return string|boolean The QTI Test template file content or false if it could not be read. */ public function getQtiTestTemplateFileAsString() { $ext = $this->getServiceLocator()->get(common_ext_ExtensionsManager::SERVICE_ID)->getExtensionById('taoQtiTest'); return file_get_contents($ext->getDir() . 'models' . DIRECTORY_SEPARATOR . 'templates' . DIRECTORY_SEPARATOR . 'qtiTest.xml'); } /** * Get the lom metadata importer * * @return MetadataImporter */ protected function getMetadataImporter() { if (! $this->metadataImporter) { $this->metadataImporter = $this->getServiceLocator()->get(MetadataService::SERVICE_ID)->getImporter(); } return $this->metadataImporter; } private function getSecureResourceService(): SecureResourceServiceInterface { return $this->getServiceLocator()->get(SecureResourceServiceInterface::SERVICE_ID); } /** * @param core_kernel_classes_Resource $oldTest * @param string $json * * @throws ResourceAccessDeniedException */ private function verifyItemPermissions(core_kernel_classes_Resource $oldTest, string $json): void { $array = json_decode($json, true); $ids = []; $oldItemIds = []; foreach ($this->getTestItems($oldTest) as $item) { $oldItemIds[] = $item->getUri(); } foreach ($array['testParts'] ?? [] as $testPart) { foreach ($testPart['assessmentSections'] ?? [] as $assessmentSection) { foreach ($assessmentSection['sectionParts'] ?? [] as $item) { if (isset($item['href']) && !in_array($item['href'], $oldItemIds) && $item['qti-type'] ?? '' === self::XML_ASSESSMENT_ITEM_REF) { $ids[] = $item['href']; } } } } $this->getSecureResourceService()->validatePermissions($ids, ['READ']); } /** * @return QtiPackageImportPreprocessing */ private function getQtiPackageImportPreprocessing() { return $this->getServiceLocator()->get(QtiPackageImportPreprocessing::SERVICE_ID); } }