tao-test/app/taoQtiTest/models/classes/class.QtiTestService.php

1295 lines
54 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) 2013-2018 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT);
*
*/
use League\Flysystem\FileExistsException;
use oat\oatbox\filesystem\Directory;
use oat\oatbox\filesystem\File;
use oat\oatbox\filesystem\FileSystemService;
use oat\tao\model\resources\ResourceAccessDeniedException;
use oat\tao\model\resources\SecureResourceServiceInterface;
use oat\tao\model\TaoOntology;
use oat\taoQtiItem\model\qti\ImportService;
use oat\taoQtiItem\model\qti\metadata\importer\MetadataImporter;
use oat\taoQtiItem\model\qti\metadata\MetadataGuardianResource;
use oat\taoQtiItem\model\qti\metadata\MetadataService;
use oat\taoQtiItem\model\qti\Resource;
use oat\taoQtiItem\model\qti\Service;
use oat\taoQtiTest\models\cat\AdaptiveSectionInjectionException;
use oat\taoQtiTest\models\cat\CatEngineNotFoundException;
use oat\taoQtiTest\models\cat\CatService;
use oat\taoQtiTest\models\metadata\MetadataTestContextAware;
use oat\taoQtiTest\models\render\QtiPackageImportPreprocessing;
use oat\taoQtiTest\models\test\AssessmentTestXmlFactory;
use oat\taoTests\models\event\TestUpdatedEvent;
use qtism\common\utils\Format;
use qtism\data\AssessmentItemRef;
use qtism\data\QtiComponentCollection;
use qtism\data\SectionPartCollection;
use qtism\data\storage\StorageException;
use qtism\data\storage\xml\marshalling\UnmarshallingException;
use qtism\data\storage\xml\XmlDocument;
use qtism\data\storage\xml\XmlStorageException;
use taoTests_models_classes_TestsService as TestService;
/**
* the QTI TestModel service.
*
* @author Joel Bout <joel@taotesting.com>
* @author Bertrand Chevrier <bertrand@taotesting.com>
* @author Jerome Bogaerts <jerome@taotesting.com>
* @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);
}
}