diff --git a/components/ILIAS/Export/classes/ExportHandler/Info/Export/Component/Handler.php b/components/ILIAS/Export/classes/ExportHandler/Info/Export/Component/Handler.php index 6d6e5146d674..949003fb0670 100644 --- a/components/ILIAS/Export/classes/ExportHandler/Info/Export/Component/Handler.php +++ b/components/ILIAS/Export/classes/ExportHandler/Info/Export/Component/Handler.php @@ -58,7 +58,7 @@ protected function init(): void throw new ilExportException('Export class file "' . $export_class_file . '" not found.'); } } - $this->sv = $this->getMinimalComponentExporter()->determineSchemaVersion($component, $this->getTarget()->getTargetRelease()); + $this->sv = $this->getMinimalComponentExporter()->determineSchemaVersion($this->getTarget()->getType(), $this->getTarget()->getTargetRelease()); $this->sv["uses_dataset"] ??= false; $this->sv['xsd_file'] ??= ''; } diff --git a/components/ILIAS/Export/xml/SchemaValidation/ilias_md_10_0.xsd b/components/ILIAS/Export/xml/SchemaValidation/ilias_md_10_0.xsd index df685edf75cd..f9a02f452173 100644 --- a/components/ILIAS/Export/xml/SchemaValidation/ilias_md_10_0.xsd +++ b/components/ILIAS/Export/xml/SchemaValidation/ilias_md_10_0.xsd @@ -197,7 +197,7 @@ - + diff --git a/components/ILIAS/Filesystem/src/Stream/ReattachableStream.php b/components/ILIAS/Filesystem/src/Stream/ReattachableStream.php index 085e899b9456..78d83c7a520f 100644 --- a/components/ILIAS/Filesystem/src/Stream/ReattachableStream.php +++ b/components/ILIAS/Filesystem/src/Stream/ReattachableStream.php @@ -25,15 +25,23 @@ */ class ReattachableStream extends Stream { - /** - * Checks if the stream is attached to the wrapper. - * If not, the stream is reattached. - */ + private ?string $reattach_uri = null; + private ?string $reattach_mode = null; + + #[\Override] + public function detach() + { + $this->reattach_uri = $this->uri; + $this->reattach_mode = $this->_mode; + + return parent::detach(); + } + #[\Override] protected function assertStreamAttached(): void { if ($this->stream === null) { - $this->stream = fopen($this->uri, $this->_mode); + $this->stream = fopen($this->reattach_uri, $this->reattach_mode); } } } diff --git a/components/ILIAS/Test/classes/class.ilObjTestGUI.php b/components/ILIAS/Test/classes/class.ilObjTestGUI.php index 32579977450f..c341b9c32e68 100755 --- a/components/ILIAS/Test/classes/class.ilObjTestGUI.php +++ b/components/ILIAS/Test/classes/class.ilObjTestGUI.php @@ -18,6 +18,7 @@ declare(strict_types=1); +use ILIAS\Test\ExportImport\Import\PersistStage; use ILIAS\Test\Logging\AdditionalInformationGenerator; use ILIAS\Skill\Service\SkillUsageService; use ILIAS\Test\Results\Data\Repository as TestResultRepository; @@ -61,6 +62,14 @@ use ILIAS\Test\Results\Toplist\TestTopListRepository; use ILIAS\Test\ExportImport\Factory as ExportImportFactory; use ILIAS\Test\ExportImport\DBRepository as ExportRepository; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportContext; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportSessionRepository; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportStageRunner; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\StageResultType; +use ILIAS\TestQuestionPool\ExportImport\Import\CleanupStage; +use ILIAS\TestQuestionPool\ExportImport\Import\DetectLegacyImportStage; +use ILIAS\TestQuestionPool\ExportImport\Import\QuestionSelectionStage; +use ILIAS\TestQuestionPool\ExportImport\Import\UploadValidationStage; use ILIAS\TestQuestionPool\Questions\GeneralQuestionPropertiesRepository; use ILIAS\TestQuestionPool\RequestDataCollector as QPLRequestDataCollector; use ILIAS\TestQuestionPool\Import\TestQuestionsImportTrait; @@ -81,6 +90,7 @@ use ILIAS\Style\Content\Service as ContentStyle; use ILIAS\User\Profile\PublicProfileGUI; use ILIAS\Test\GUIFactory; +use Psr\Log\LoggerInterface; /** * Class ilObjTestGUI @@ -180,6 +190,8 @@ class ilObjTestGUI extends ilObjectGUI implements ilCtrlBaseClassInterface, ilDe protected TaxonomyService $taxonomy; protected GUIFactory $gui_factory; protected SkillUsageService $skill_usage_service; + protected ImportSessionRepository $import_session_repository; + protected LoggerInterface $import_logger; protected bool $create_question_mode; @@ -234,6 +246,8 @@ public function __construct() $this->mark_schema_factory = $local_dic['marks.factory']; $this->additional_information_generator = $local_dic['logging.information_generator']; $this->personal_settings_exporter = $local_dic['settings.personal_templates.exporter']; + $this->import_session_repository = $local_dic['exportimport.session']; + $this->import_logger = $local_dic['exportimport.logging'](); $ref_id = 0; if ($this->testrequest->hasRefId() && is_numeric($this->testrequest->getRefId())) { @@ -1362,81 +1376,116 @@ public function runObject() $this->ctrl->redirectByClass([ilRepositoryGUI::class, self::class, ilInfoScreenGUI::class]); } + protected function importFile(string $file_to_import, string $path_to_uploaded_file_in_temp_dir): void { - list($subdir, $importdir, $xmlfile, $qtifile) = $this->buildImportDirectoriesFromImportFile($file_to_import); - - $options = (new ILIAS\Filesystem\Util\Archive\UnzipOptions()) - ->withZipOutputPath($this->getImportTempDirectory()); + $this->import_session_repository->clear(); - $unzip = $this->archives->unzip(Streams::ofResource(fopen($file_to_import, 'r')), $options); - $unzip->extract(); + $context = new ImportContext([UploadValidationStage::FILE_TO_IMPORT => $file_to_import]); + $this->import_session_repository->setContext($context); + $this->import_session_repository->setCurrentStageIndex(0); - if (!is_file($qtifile)) { - ilFileUtils::delDir($importdir); - $this->deleteUploadedImportFile($path_to_uploaded_file_in_temp_dir); - $this->tpl->setOnScreenMessage('failure', $this->lng->txt('tst_import_non_ilias_zip'), true); - } - $qtiParser = new ilQTIParser($importdir, $qtifile, ilQTIParser::IL_MO_VERIFY_QTI, 0, [], [], true); - try { - $qtiParser->startParsing(); - } catch (ilSaxParserException) { - $this->tpl->setOnScreenMessage('failure', $this->lng->txt('import_file_not_valid'), true); - $this->ctrl->redirect($this, 'create'); - } - $founditems = $qtiParser->getFoundItems(); - - $complete = 0; - $incomplete = 0; - foreach ($founditems as $item) { - if ($item["type"] !== '') { - $complete++; - } else { - $incomplete++; - } - } + $this->ctrl->redirectByClass(self::class, 'processImport'); + } - if (count($founditems) && $complete == 0) { - ilFileUtils::delDir($importdir); - $this->deleteUploadedImportFile($path_to_uploaded_file_in_temp_dir); - $this->tpl->setOnScreenMessage('info', $this->lng->txt('qpl_import_non_ilias_files')); + public function processImportObject(): void + { + $permission = $this->creation_mode ? 'create' : 'read'; + if (!$this->checkPermissionBool($permission, '', $this->object->getType())) { + $this->redirectAfterMissingWrite(); return; } - ilSession::set('path_to_import_file', $file_to_import); - ilSession::set('path_to_uploaded_file_in_temp_dir', $path_to_uploaded_file_in_temp_dir); + $runner = $this->buildImportStageRunner(); + $result = $runner->run(); - if ($qtiParser->getQuestionSetType() !== ilObjTest::QUESTION_SET_TYPE_FIXED - || file_exists($this->buildResultsFilePath($importdir, $subdir)) - || $founditems === []) { - $this->importVerifiedFileObject(true); - return; + switch ($result->type) { + case StageResultType::INTERACT: + $this->tpl->setContent( + $this->ui_renderer->render($result->components) + ); + break; + + case StageResultType::ADVANCE: + $this->ctrl->redirectByClass(self::class, 'processImport'); + break; + + case StageResultType::ERROR: + $this->tpl->setOnScreenMessage('failure', $result->error_message, true); + break; + + case StageResultType::COMPLETE: + $this->afterImportCompleted($result->context); + break; } + } - $form = $this->buildImportQuestionsSelectionForm( - 'importVerifiedFile', - $importdir, - $qtifile, - $file_to_import, - $path_to_uploaded_file_in_temp_dir - ); + private function afterImportCompleted(ImportContext $context): void + { + $new_obj = new ilObjTest(0, false); + $new_obj->setId($context->get('test_obj_id')); + $new_obj->setRefId($context->get('test_ref_id')); - if ($form === null) { - return; + if ($new_obj->getTestLogger()->isLoggingEnabled()) { + $new_obj->getTestLogger()->logTestAdministrationInteraction( + $new_obj->getTestLogger()->getInteractionFactory()->buildTestAdministrationInteraction( + $new_obj->getRefId(), + $this->user->getId(), + TestAdministrationInteractionTypes::NEW_TEST_CREATED, + [] + ) + ); } - $panel = $this->ui_factory->panel()->standard( - $this->lng->txt('import_tst'), + $question_skill_assignments_import_fails = new ilAssQuestionSkillAssignmentImportFails($new_obj->getId()); + if ($question_skill_assignments_import_fails->failedImportsRegistered()) { + $this->tpl->setOnScreenMessage( + 'info', + $question_skill_assignments_import_fails->getFailedImportsMessage($this->lng), + true + ); + } + + $this->tpl->setOnScreenMessage('success', $this->lng->txt('object_imported'), true); + $this->ctrl->setParameterByClass(ilObjTestGUI::class, 'ref_id', $new_obj->getRefId()); + $this->ctrl->redirectByClass(self::class, self::SHOW_QUESTIONS_CMD); + } + + private function buildImportStageRunner(): ImportStageRunner + { + $form_action = $this->ctrl->getFormActionByClass(self::class, 'processImport'); + + return new ImportStageRunner( [ - $this->ui_factory->legacy()->content($this->lng->txt('qpl_import_verify_found_questions')), - $form - ] + new UploadValidationStage( + $this->archives, + $this->lng, + $this->import_logger, + 'components/ILIAS/Test' + ), + new DetectLegacyImportStage($this->import_logger), + new QuestionSelectionStage( + $this->lng, + $this->import_logger, + $this->component_factory, + $this->ui_factory, + $this->request, + $form_action, + $this->lng->txt('import_tst') + ), + new PersistStage( + $this->lng, + $this->import_logger, + $this->requested_ref_id, + $this->import_session_repository + ), + ], + $this->import_session_repository, + new CleanupStage($this->import_logger) ); - $this->tpl->setContent($this->ui_renderer->render($panel)); - $this->tpl->printToStdout(); - exit; } + /** * save object * @access public @@ -1478,97 +1527,6 @@ public function getTestObject(): ?ilObjTest return $this->object; } - /** - * imports question(s) into the questionpool (after verification) - */ - public function importVerifiedFileObject( - bool $skip_retrieve_selected_questions = false - ): void { - if (!$this->checkPermissionBool('create', '', 'tst')) { - $this->tpl->setOnScreenMessage('failure', $this->lng->txt('no_permission'), true); - $this->ctrl->returnToParent($this); - } - $file_to_import = ilSession::get('path_to_import_file'); - $path_to_uploaded_file_in_temp_dir = ilSession::get('path_to_uploaded_file_in_temp_dir'); - list($subdir, $importdir, $xmlfile, $qtifile) = $this->buildImportDirectoriesFromImportFile($file_to_import); - - $new_obj = new ilObjTest(0, true); - $new_obj->setTitle('dummy'); - $new_obj->setDescription('test import'); - $new_obj->create(true); - $new_obj->createReference(); - $new_obj->putInTree($this->testrequest->getRefId()); - $new_obj->setPermissions($this->testrequest->getRefId()); - $new_obj->saveToDb(); - - $selected_questions = []; - if (!$skip_retrieve_selected_questions) { - $selected_questions = $this->retrieveSelectedQuestionsFromImportQuestionsSelectionForm( - 'importVerifiedFile', - $importdir, - $qtifile, - $this->request - ); - } - - ilSession::set('tst_import_selected_questions', $selected_questions); - - $imp = new ilImport($this->testrequest->getRefId()); - $map = $imp->getMapping(); - $map->addMapping('components/ILIAS/Test', 'tst', 'new_id', (string) $new_obj->getId()); - - /** - * 2025-03-22, sk: This is now only needed for legacy exports as - * now also exports with results do contain a manifest.xml. - */ - if (is_file($importdir . DIRECTORY_SEPARATOR . '/manifest.xml')) { - $imp->importObject($new_obj, $file_to_import, basename($file_to_import), 'tst', 'components/ILIAS/Test', true); - } else { - $test_importer = new ilTestImporter(); - $test_importer->setImport($imp); - $test_importer->setInstallId(IL_INST_ID); - $test_importer->setImportDirectory($importdir . '/' . $subdir); - $test_importer->init(); - - $test_importer->importXmlRepresentation( - '', - '', - '', - $map, - ); - } - - if ($new_obj->getTestLogger()->isLoggingEnabled()) { - $new_obj->getTestLogger()->logTestAdministrationInteraction( - $new_obj->getTestLogger()->getInteractionFactory()->buildTestAdministrationInteraction( - $new_obj->getRefId(), - $this->user->getId(), - TestAdministrationInteractionTypes::NEW_TEST_CREATED, - [] - ) - ); - } - - ilFileUtils::delDir($importdir); - $this->deleteUploadedImportFile($path_to_uploaded_file_in_temp_dir); - ilSession::clear('path_to_import_file'); - ilSession::clear('path_to_uploaded_file_in_temp_dir'); - - $this->tpl->setOnScreenMessage('success', $this->lng->txt("object_imported"), true); - - $question_skill_assignments_import_fails = new ilAssQuestionSkillAssignmentImportFails($new_obj->getId()); - if ($question_skill_assignments_import_fails->failedImportsRegistered()) { - $this->tpl->setOnScreenMessage( - 'info', - $question_skill_assignments_import_fails->getFailedImportsMessage($this->lng), - true - ); - } - - $this->ctrl->setParameterByClass(ilObjTestGUI::class, 'ref_id', $new_obj->getRefId()); - $this->ctrl->redirectByClass(ilObjTestGUI::class); - } - /** * download file */ @@ -2362,6 +2320,7 @@ public function addLocatorItems(): void ); break; case "importFile": + case "processImport": case "cloneAll": case "importVerifiedFile": case "cancelImport": diff --git a/components/ILIAS/Test/classes/class.ilTestExportGUI.php b/components/ILIAS/Test/classes/class.ilTestExportGUI.php index 88e324fde351..15946674465a 100755 --- a/components/ILIAS/Test/classes/class.ilTestExportGUI.php +++ b/components/ILIAS/Test/classes/class.ilTestExportGUI.php @@ -20,13 +20,11 @@ use ILIAS\Test\Scoring\Manual\TestScoring; use ILIAS\Test\ExportImport\DBRepository; -use ILIAS\Test\ExportImport\ResultsExportStakeholder; use ILIAS\Test\Results\Data\Repository as TestResultsRepository; use ILIAS\UI\Factory as UIFactory; use ILIAS\UI\Renderer as UIRenderer; use ILIAS\ResourceStorage\Services as IRSS; use ILIAS\Filesystem\Filesystem; -use ILIAS\Test\ExportImport\Types as ExportImportTypes; use Psr\Http\Message\ServerRequestInterface; /** @@ -56,62 +54,6 @@ public function __construct( parent::__construct($parent_gui, null); } - /** - * Create test export file - */ - public function createTestExportWithResults() - { - $this->ctrl->setParameterByClass(self::class, 'export_results', 1); - $manager = $this->export_handler->manager()->handler(); - $export_info = $manager->getExportInfoWithObject( - $this->obj, - time(), - $this->export_handler->consumer()->exportConfig()->allExportConfigs() - ); - $element = $manager->createExport( - $this->il_user->getId(), - $export_info, - '' - ); - - $file_name = $element->getIRSSInfo()->getFileName(); - $this->temp_file_system->writeStream( - $file_name, - $this->irss->consume()->stream($element->getIRSSInfo()->getResourceId())->getStream() - ); - $temp_stream = $this->temp_file_system->readStream($file_name); - $rid = $this->irss->manage()->stream( - $temp_stream, - new ResultsExportStakeholder(), - $element->getIRSSInfo()->getFileName() - ); - - $temp_stream->close(); - - $this->temp_file_system->delete($file_name); - - $this->export_repository->store( - $this->obj->getId(), - ExportImportTypes::XML_WITH_RESULTS, - $rid - ); - $this->export_options->getById('expxml')->onDeleteFiles( - $this->context, - $this->export_handler->consumer()->file()->identifier()->collection()->withElement( - $this->export_handler->consumer()->file()->identifier()->handler()->withIdentifier( - $element->getIRSSInfo()->getResourceIdSerialized() - ) - ) - ); - - $this->tpl->setOnScreenMessage( - ilGlobalTemplateInterface::MESSAGE_TYPE_SUCCESS, - $this->lng->txt("exp_file_created"), - true - ); - $this->ctrl->redirect($this, self::CMD_LIST_EXPORT_FILES); - } - public function createTestArchiveExport() { if ($this->access->checkAccess('write', '', $this->obj->getRefId())) { diff --git a/components/ILIAS/Test/classes/class.ilTestExportOptionXMLRES.php b/components/ILIAS/Test/classes/class.ilTestExportOptionXMLRES.php deleted file mode 100644 index 154460c6622a..000000000000 --- a/components/ILIAS/Test/classes/class.ilTestExportOptionXMLRES.php +++ /dev/null @@ -1,152 +0,0 @@ -lng = $DIC['lng']; - $this->irss = $DIC['resource_storage']; - $this->data_factory = new DataFactory(); - $this->repository = TestDIC::dic()['exportimport.repository']; - } - - public function getExportType(): string - { - return 'ZIP Results'; - } - - public function getExportOptionId(): string - { - return self::OPTIONS_ID; - } - - public function getSupportedRepositoryObjectTypes(): array - { - return ['tst']; - } - - public function getLabel(): string - { - $this->lng->loadLanguageModule('exp'); - $this->lng->loadLanguageModule('assessment'); - return $this->lng->txt("exp_format_dropdown-xml") . " (" . $this->lng->txt('ass_create_export_file_with_results') . ")"; - } - - public function onDeleteFiles( - ilExportHandlerConsumerContextInterface $context, - ilExportHandlerConsumerFileIdentifierCollectionInterface $file_identifiers - ): void { - $object_id = new ObjectId($context->exportObject()->getId()); - foreach ($file_identifiers as $file_identifier) { - $rid = $this->irss->manage()->find($file_identifier->getIdentifier()); - $this->repository->delete($rid); - $this->irss->manage()->remove($rid, new ResultsExportStakeholder()); - } - } - - public function onDownloadFiles( - ilExportHandlerConsumerContextInterface $context, - ilExportHandlerConsumerFileIdentifierCollectionInterface $file_identifiers - ): void { - $object_id = new ObjectId($context->exportObject()->getId()); - foreach ($file_identifiers as $file_identifier) { - $this->irss->consume()->download( - $this->irss->manage()->find($file_identifier->getIdentifier()) - )->run(); - } - } - - public function onDownloadWithLink( - ReferenceId $reference_id, - ilExportHandlerConsumerFileIdentifierInterface $file_identifier - ): void { - $this->irss->consume()->download($reference_id)->run(); - } - - public function getFiles( - ilExportHandlerConsumerContextInterface $context - ): ilExportHandlerFileInfoCollectionInterface { - return $this->buildElements( - $context, - $this->data_factory->objId($context->exportObject()->getId()) - ); - } - - public function getFileSelection( - ilExportHandlerConsumerContextInterface $context, - ilExportHandlerConsumerFileIdentifierCollectionInterface $file_identifiers - ): ilExportHandlerFileInfoCollectionInterface { - return $this->buildElements( - $context, - $this->data_factory->objId($context->exportObject()->getId()), - $file_identifiers->toStringArray() - ); - } - - public function onExportOptionSelected( - ilExportHandlerConsumerContextInterface $context - ): void { - $context->exportGUIObject()->createTestExportWithResults(); - } - - protected function buildElements( - ilExportHandlerConsumerContextInterface $context, - ObjectId $object_id, - ?array $file_identifiers = null - ): ilExportHandlerFileInfoCollectionInterface { - if ($file_identifiers === null) { - $file_identifiers = array_map( - static fn(array $v): string => $v['rid'], - $this->repository->getFor($object_id->toInt()) - ); - } - $collection_builder = $context->fileCollectionBuilder(); - foreach ($file_identifiers as $file_identifier) { - $collection_builder = $collection_builder->withResourceIdentifier( - $this->irss->manage()->find($file_identifier), - $object_id, - $this - ); - } - return $collection_builder->collection(); - } -} diff --git a/components/ILIAS/Test/classes/class.ilTestExporter.php b/components/ILIAS/Test/classes/class.ilTestExporter.php index 3cfc9ff2026d..27ef39a891c4 100755 --- a/components/ILIAS/Test/classes/class.ilTestExporter.php +++ b/components/ILIAS/Test/classes/class.ilTestExporter.php @@ -18,176 +18,70 @@ declare(strict_types=1); +use ILIAS\Export\ExportHandler\Factory as ExportHandler; +use ILIAS\Test\ExportImport\Types; use ILIAS\Test\TestDIC; -use ILIAS\TestQuestionPool\Questions\GeneralQuestionPropertiesRepository; -use ILIAS\Test\Logging\TestLogger; -use ILIAS\Test\ExportImport\Factory as ExportImportFactory; -use ILIAS\Test\ExportImport\Types as ExportImportTypes; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Bridge\XmlExporterBridge; -/** - * Used for container export with tests - * - * @author Stefan Meyer - * @version $Id$ - * @ingroup components\ILIASTest - */ class ilTestExporter extends ilXmlExporter { - private readonly ilLanguage $lng; - private readonly ExportImportFactory $export_factory; - private readonly TestLogger $logger; - private readonly ilTree $tree; - private readonly ilCtrl $ctrl; - private readonly ilComponentRepository $component_repository; - private readonly GeneralQuestionPropertiesRepository $questionrepository; + use XmlExporterBridge; - public function __construct() + public function init(): void { - global $DIC; - $this->lng = $DIC['lng']; $local_dic = TestDIC::dic(); - $this->export_factory = $local_dic['exportimport.factory']; - $this->logger = $local_dic['logging.logger']; - $this->questionrepository = $local_dic['question.general_properties.repository']; - $this->tree = $DIC['tree']; - $this->ctrl = $DIC['ilCtrl']; - $this->component_repository = $DIC['component.repository']; - parent::__construct(); + $this->export_handler = new ExportHandler(); + $this->state_holder = $local_dic['exportimport.state_holder']; + $this->exporter = $local_dic['exportimport.exporter']; + $this->logger = $local_dic['exportimport.logging'](); } /** - * Initialisation + * Returns the final XML content for the test. + * + * This method is called after `getXmlExportTailDependencies()`. At this point the export writer and export + * directory are available, so the preprocessed export can be written to disk and returned as xml. */ - public function init(): void - { - } - public function getXmlRepresentation(string $a_entity, string $a_schema_version, string $id): string { - $parameters = $this->ctrl->getParameterArrayByClass(ilTestExportGUI::class); - $export_type = ExportImportTypes::XML; - if (!empty($parameters['export_results'])) { - $export_type = ExportImportTypes::XML_WITH_RESULTS; - $this->ctrl->clearParameterByClass(ilTestExportGUI::class, 'export_results'); - } - $tst = new ilObjTest((int) $id, false); - $tst->read(); - $zip = $this->export_factory->getExporter($tst, $export_type) - ->withExportDirInfo($this->getAbsoluteExportDirectory()) - ->write(); - - $this->logger->info(__METHOD__ . ': Created zip file ' . $zip); - return ''; - } - - public function getXmlExportHeadDependencies(string $entity, string $target_release, array $ids): array - { - if ($entity === 'tst') { - $mobs = []; - $files = []; - foreach ($ids as $id) { - $tst = new ilObjTest((int) $id, false); - $tst->read(); - - $intro_page_id = $tst->getMainSettings()->getIntroductionSettings()->getIntroductionPageId(); - if ($intro_page_id !== null) { - $mobs = array_merge($mobs, ilObjMediaObject::_getMobsOfObject('tst:pg', $intro_page_id)); - $files = array_merge($files, ilObjFile::_getFilesOfObject('tst:pg', $intro_page_id)); - } - - $concluding_remarks_page_id = $tst->getMainSettings()->getFinishingSettings()->getConcludingRemarksPageId(); - if ($concluding_remarks_page_id !== null) { - $mobs = array_merge($mobs, ilObjMediaObject::_getMobsOfObject('tst:pg', $concluding_remarks_page_id)); - $files = array_merge($files, ilObjFile::_getFilesOfObject('tst:pg', $concluding_remarks_page_id)); - } - } - - return [ - [ - 'component' => 'components/ILIAS/MediaObjects', - 'entity' => 'mob', - 'ids' => $mobs - ], - [ - 'component' => 'components/ILIAS/File', - 'entity' => 'file', - 'ids' => $files - ] - ]; + if ($a_entity !== 'tst') { + throw new InvalidArgumentException("Invalid entity for test export: {$a_entity}"); } - return parent::getXmlExportTailDependencies($entity, $target_release, $ids); + return $this->finalizeExport()->getContent(); } /** - * @param array ids - * @return array array of array with keys 'component', 'entity', 'ids' + * Collects export tail dependencies for the test. + * + * The export framework calls this method before `getXmlRepresentation()`. Therefore this method only prepares and + * processes the export in memory using the export state. The export state is created if it does not exist yet. */ public function getXmlExportTailDependencies(string $a_entity, string $a_target_release, array $a_ids): array { - if ($a_entity == 'tst') { - $deps = []; - - $tax_ids = $this->getDependingTaxonomyIds($a_ids); - - if (count($tax_ids)) { - $deps[] = [ - 'component' => 'components/ILIAS/Taxonomy', - 'entity' => 'tax', - 'ids' => $tax_ids - ]; - } - - $deps[] = [ - 'component' => 'components/ILIAS/ILIASObject', - 'entity' => 'common', - 'ids' => $a_ids - ]; - - - $md_ids = []; - foreach ($a_ids as $id) { - $md_ids[] = $id . ':0:tst'; - } - if ($md_ids !== []) { - $deps[] = [ - 'component' => 'components/ILIAS/MetaData', - 'entity' => 'md', - 'ids' => $md_ids - ]; - } - - return $deps; + if ($a_entity !== 'tst') { + throw new InvalidArgumentException("Invalid entity for test export: {$a_entity}"); } - return parent::getXmlExportTailDependencies($a_entity, $a_target_release, $a_ids); - } - - /** - * @param array $testObjIds - * @return array $taxIds - */ - private function getDependingTaxonomyIds(array $test_obj_ids): array - { - $tax_ids = []; - - foreach ($test_obj_ids as $test_obj_id) { - foreach (ilObjTaxonomy::getUsageOfObject($test_obj_id) as $tax_id) { - $tax_ids[$tax_id] = $tax_id; - } + // If the default export option was used, the state is not initialized yet. + if ($this->state_holder->exists() === false) { + $this->initExportState( + 'components/ILIAS/Test', + $a_target_release, + $a_entity, + $a_ids, + Types::XML->value + ); } - return $tax_ids; + return $this->processExport()->getDependencies(); } /** - * Returns schema versions that the component can export to. - * ILIAS chooses the first one, that has min/max constraints which - * fit to the target release. Please put the newest on top. - * @param string $a_entity - * @return array - */ + * Returns schema versions that the component can export to. ILIAS chooses the first one, that has min/max + * constraints which fit to the target release. + */ public function getValidSchemaVersions(string $a_entity): array { return [ diff --git a/components/ILIAS/Test/classes/class.ilTestImporter.php b/components/ILIAS/Test/classes/class.ilTestImporter.php index 6c195c2836ca..7922e637ec52 100755 --- a/components/ILIAS/Test/classes/class.ilTestImporter.php +++ b/components/ILIAS/Test/classes/class.ilTestImporter.php @@ -18,38 +18,38 @@ declare(strict_types=1); -use ILIAS\ResourceStorage\Services as ResourceStorage; -use ILIAS\TestQuestionPool\Import\TestQuestionsImportTrait; +use ILIAS\Data\ReferenceId; +use ILIAS\Test\ExportImport\Import\TestImporter; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportSessionRepository; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Serializing\XMLMemoryDeserializer; +use ILIAS\TestQuestionPool\ExportImport\Import\DetectLegacyImportStage; use ILIAS\Test\TestDIC; -use ILIAS\Test\Logging\TestLogger; -/** - * Importer class for files - * - * @author Stefan Meyer - * @version $Id$ - * @ingroup components\ILIASLearningModule - */ class ilTestImporter extends ilXmlImporter { - use TestQuestionsImportTrait; - /** - * @var array - */ - public static $finallyProcessedTestsRegistry = []; - - private readonly TestLogger $logger; - private readonly ilDBInterface $db; - private readonly ResourceStorage $irss; + protected readonly ImportSessionRepository $session; + protected readonly TestImporter $importer; + protected readonly ilTestLegacyImporter $legacy_importer; public function __construct() { - global $DIC; - $this->logger = TestDIC::dic()['logging.logger']; - $this->db = $DIC['ilDB']; - $this->irss = $DIC['resource_storage']; - parent::__construct(); + $this->legacy_importer = new ilTestLegacyImporter(); + + $local_dic = TestDIC::dic(); + $this->session = $local_dic['exportimport.session']; + $this->importer = $local_dic['exportimport.importer']; + } + + public function init(): void + { + /** @var ilCOPageImportConfig $co_config */ + $co_config = $this->imp->getConfig('components/ILIAS/COPage'); + $co_config->setUpdateIfExists(true); + + $this->legacy_importer->setImport($this->getImport()); + $this->legacy_importer->setImportDirectory($this->getImportDirectory()); + $this->legacy_importer->init(); } public function importXmlRepresentation( @@ -58,292 +58,58 @@ public function importXmlRepresentation( string $a_xml, ilImportMapping $a_mapping ): void { - $results_file_path = null; - if ($new_id = (int) $a_mapping->getMapping('components/ILIAS/Container', 'objs', $a_id)) { - // container content - $new_obj = ilObjectFactory::getInstanceByObjId((int) $new_id, false); - $new_obj->saveToDb(); - - [$importdir, $xmlfile, $qtifile] = $this->buildImportDirectoriesFromContainerImport( - $this->getImportDirectory() - ); - $selected_questions = []; - } else { - // single object - $new_id = (int) $a_mapping->getMapping('components/ILIAS/Test', 'tst', 'new_id'); - $new_obj = ilObjectFactory::getInstanceByObjId($new_id, false); - - $selected_questions = ilSession::get('tst_import_selected_questions') ?? []; - [$subdir, $importdir, $xmlfile, $qtifile] = $this->buildImportDirectoriesFromImportFile( - ilSession::get('path_to_import_file') - ); - $results_file_path = $this->buildResultsFilePath($importdir, $subdir); - ilSession::clear('tst_import_selected_questions'); - } - - $new_obj->loadFromDb(); - - if (!file_exists($xmlfile)) { - $this->logger->error(__METHOD__ . ': Cannot find xml definition: ' . $xmlfile); - return; - } - if (!file_exists($qtifile)) { - $this->logger->error(__METHOD__ . ': Cannot find xml definition: ' . $qtifile); + // Check if forward to legacy importer is needed + $context = $this->session->getContext(); + if (DetectLegacyImportStage::isLegacyImport($context)) { + $this->legacy_importer->setInstallId($this->getInstallId()); + $this->legacy_importer->setInstallUrl($this->getInstallUrl()); + $this->legacy_importer->setSchemaVersion($this->getSchemaVersion()); + $this->legacy_importer->setSkipEntities($this->getSkipEntities()); + $this->legacy_importer->importXmlRepresentation($a_entity, $a_id, $a_xml, $a_mapping); return; } - // start parsing of QTI files - $qti_parser = new ilQTIParser( - $importdir, - $qtifile, - ilQTIParser::IL_MO_PARSE_QTI, - $new_obj->getId(), - $selected_questions, - $a_mapping->getAllMappings() - ); - $qti_parser->setTestObject($new_obj); - $qti_parser->startParsing(); - $new_obj = $qti_parser->getTestObject(); - - // import page data - $question_page_parser = new ilQuestionPageParser( - $new_obj, - $xmlfile, - $importdir - ); - $question_page_parser->setQuestionMapping($qti_parser->getImportMapping()); - $question_page_parser->startParsing(); - - $a_mapping = $this->addTaxonomyAndQuestionsMapping($qti_parser->getQuestionIdMapping(), $new_obj->getId(), $a_mapping); - - if ($new_obj->isRandomTest()) { - $this->importRandomQuestionSetConfig($new_obj, $xmlfile, $a_mapping); - } - - if ($results_file_path !== null && file_exists($results_file_path)) { - $results = new ilTestResultsImportParser($results_file_path, $new_obj, $this->db, $this->logger, $this->irss); - $results->setQuestionIdMapping($a_mapping->getMappingsOfEntity('components/ILIAS/Test', 'quest')); - $results->setSrcPoolDefIdMapping($a_mapping->getMappingsOfEntity('components/ILIAS/Test', 'rnd_src_pool_def')); - $results->startParsing(); - } - - $new_obj->saveToDb(); // this creates test_fi - $new_obj->update(); // this saves ilObject data - - $this->importSkillLevelThresholds( + $result = $this->importer->import( + new XMLMemoryDeserializer()->open($a_xml), $a_mapping, - $this->importQuestionSkillAssignments($a_mapping, $new_obj, $xmlfile), - $new_obj, - $xmlfile - ); - - $a_mapping->addMapping("components/ILIAS/Test", "tst", (string) $a_id, (string) $new_obj->getId()); - $a_mapping->addMapping( - "components/ILIAS/MetaData", - "md", - $a_id . ":0:tst", - $new_obj->getId() . ":0:tst" + new ReferenceId($a_mapping->getTargetId()), + $context, ); - } - - public function addTaxonomyAndQuestionsMapping( - array $question_id_mapping, - int $new_obj_id, - ilImportMapping $mapping - ): ilImportMapping { - foreach ($question_id_mapping as $old_question_id => $new_question_id) { - $mapping->addMapping( - 'components/ILIAS/Taxonomy', - 'tax_item', - "tst:quest:{$old_question_id}", - (string) $new_question_id - ); - - $mapping->addMapping( - 'components/ILIAS/Taxonomy', - 'tax_item_obj_id', - "tst:quest:{$old_question_id}", - (string) $new_obj_id - ); - - $mapping->addMapping( - 'components/ILIAS/Test', - 'quest', - (string) $old_question_id, - (string) $new_question_id - ); - } - - return $mapping; + $this->session->setContext($result); } public function finalProcessing(ilImportMapping $a_mapping): void { - $maps = $a_mapping->getMappingsOfEntity("components/ILIAS/Test", "tst"); - - foreach ($maps as $old => $new) { - if ($old == "new_id" || (int) $old <= 0) { - continue; - } - - if (isset(self::$finallyProcessedTestsRegistry[$new])) { - continue; - } - - $test_obj = ilObjectFactory::getInstanceByObjId((int) $new, false); - if ($test_obj->isRandomTest()) { - $this->finalRandomTestTaxonomyProcessing($a_mapping, (string) $old, $new, $test_obj); - } - - self::$finallyProcessedTestsRegistry[$new] = true; - } - } - - protected function finalRandomTestTaxonomyProcessing( - ilImportMapping $mapping, - string $old_tst_obj_id, - string $new_tst_obj_id, - ilObjTest $test_obj - ): void { - $new_tax_ids = $mapping->getMapping( - 'components/ILIAS/Taxonomy', - 'tax_usage_of_obj', - $old_tst_obj_id - ); - - if ($new_tax_ids !== null) { - foreach (explode(':', $new_tax_ids) as $tax_id) { - ilObjTaxonomy::saveUsage((int) $tax_id, (int) $new_tst_obj_id); - } + // Check if forward to legacy importer is needed + $context = $this->session->getContext(); + if (DetectLegacyImportStage::isLegacyImport($context)) { + $this->legacy_importer->finalProcessing($a_mapping); + return; } - $src_pool_def_list = new ilTestRandomQuestionSetSourcePoolDefinitionList( - $this->db, - $test_obj, - new ilTestRandomQuestionSetSourcePoolDefinitionFactory( - $this->db, - $test_obj - ) - ); - - $src_pool_def_list->loadDefinitions(); - - foreach ($src_pool_def_list as $definition) { - $mapped_taxonomy_filter = $definition->getMappedTaxonomyFilter(); - if ($mapped_taxonomy_filter === []) { - continue; - } - - $definition->setMappedTaxonomyFilter( - $this->getNewMappedTaxonomyFilter( - $mapping, - $mapped_taxonomy_filter - ) - ); - $definition->saveToDb(); - } + $this->importer->finalize($a_mapping); + $this->finalizeTaxonomyUsage($a_mapping); } - protected function getNewMappedTaxonomyFilter( - ilImportMapping $mapping, - array $mapped_filter - ): array { - $new_mapped_filter = []; - foreach ($mapped_filter as $tax_id => $tax_nodes) { - $new_tax_id = $mapping->getMapping( - 'components/ILIAS/Taxonomy', - 'tax', - (string) $tax_id - ); - - if ($new_tax_id === null) { - continue; - } - - $new_mapped_filter[$new_tax_id] = []; + private function finalizeTaxonomyUsage(ilImportMapping $a_mapping): void + { + $tst_mappings = $a_mapping->getMappingsOfEntity('components/ILIAS/Test', 'tst'); - foreach ($tax_nodes as $tax_node_id) { - $new_tax_node_id = $mapping->getMapping( + foreach ($tst_mappings as $old => $new) { + if ($old !== 'new_id' && (int) $old > 0) { + $new_tax_ids = $a_mapping->getMapping( 'components/ILIAS/Taxonomy', - 'tax_tree', - (string) $tax_node_id + 'tax_usage_of_obj', + (string) $old ); - if ($new_tax_node_id === null) { - continue; + if ($new_tax_ids !== null) { + $tax_ids = explode(':', $new_tax_ids); + foreach ($tax_ids as $tid) { + ilObjTaxonomy::saveUsage((int) $tid, (int) $new); + } } - - $new_mapped_filter[$new_tax_id][] = $new_tax_node_id; } } - - return $new_mapped_filter; - } - - public function importRandomQuestionSetConfig( - ilObjTest $test_obj, - ?string $xml_file, - \ilImportMapping $a_mapping - ): void { - $test_obj->questions = []; - $parser = new ilObjTestXMLParser($xml_file); - $parser->setTestOBJ($test_obj); - $parser->setImportMapping($a_mapping); - $parser->startParsing(); - } - - protected function importQuestionSkillAssignments( - ilImportMapping $mapping, - ilObjTest $test_obj, - ?string $xml_file - ): ilAssQuestionSkillAssignmentList { - $parser = new ilAssQuestionSkillAssignmentXmlParser($xml_file); - $parser->startParsing(); - - $importer = new ilAssQuestionSkillAssignmentImporter(); - $importer->setTargetParentObjId($test_obj->getId()); - $importer->setImportInstallationId((int) $this->getInstallId()); - $importer->setImportMappingRegistry($mapping); - $importer->setImportMappingComponent('components/ILIAS/Test'); - $importer->setImportAssignmentList($parser->getAssignmentList()); - - $importer->import(); - - if ($importer->getFailedImportAssignmentList()->assignmentsExist()) { - $qsaImportFails = new ilAssQuestionSkillAssignmentImportFails($test_obj->getId()); - $qsaImportFails->registerFailedImports($importer->getFailedImportAssignmentList()); - - $test_obj->getObjectProperties()->storePropertyIsOnline( - $test_obj->getObjectProperties()->getPropertyIsOnline()->withOffline() - ); - } - - return $importer->getSuccessImportAssignmentList(); - } - - protected function importSkillLevelThresholds( - ilImportMapping $mapping, - ilAssQuestionSkillAssignmentList $assignment_list, - ilObjTest $test_obj, - ?string $xml_file - ): void { - $parser = new ilTestSkillLevelThresholdXmlParser($xml_file); - $parser->initSkillLevelThresholdImportList(); - $parser->startParsing(); - - $importer = new ilTestSkillLevelThresholdImporter($this->db); - $importer->setTargetTestId($test_obj->getTestId()); - $importer->setImportInstallationId((int) $this->getInstallId()); - $importer->setImportMappingRegistry($mapping); - $importer->setImportedQuestionSkillAssignmentList($assignment_list); - $importer->setImportThresholdList($parser->getSkillLevelThresholdImportList()); - $importer->import(); - - if ($importer->getFailedThresholdImportSkillList()->skillsExist()) { - $sltImportFails = new ilTestSkillLevelThresholdImportFails($test_obj->getId()); - $sltImportFails->registerFailedImports($importer->getFailedThresholdImportSkillList()); - - $test_obj->setOfflineStatus(true); - } } } diff --git a/components/ILIAS/Test/classes/class.ilTestLegacyImporter.php b/components/ILIAS/Test/classes/class.ilTestLegacyImporter.php new file mode 100755 index 000000000000..8a44bafdc327 --- /dev/null +++ b/components/ILIAS/Test/classes/class.ilTestLegacyImporter.php @@ -0,0 +1,336 @@ +logger = $local_dic['logging.logger']; + $this->session = $local_dic['exportimport.session']; + $this->request_data_collector = $local_dic['request_data_collector']; + $this->db = $DIC['ilDB']; + $this->irss = $DIC['resource_storage']; + } + + public function importXmlRepresentation( + string $a_entity, + string $a_id, + string $a_xml, + ilImportMapping $a_mapping + ): void { + $new_obj = new ilObjTest(0, true); + $new_obj->setTitle('dummy'); + $new_obj->setDescription('test import'); + $new_obj->create(true); + $new_obj->createReference(); + $new_obj->putInTree($this->request_data_collector->getRefId()); + $new_obj->setPermissions($this->request_data_collector->getRefId()); + $new_obj->saveToDb(); + + $a_mapping->addMapping('components/ILIAS/Test', 'tst', 'new_id', (string) $new_obj->getId()); + + $context = $this->session->getContext(); + $import_base_dir = $context->get(UploadValidationStage::IMPORT_BASE_DIR); + $xml_file = $context->get(DetectLegacyImportStage::LEGACY_XML_FILE); + + // start parsing of QTI files + $qti_parser = new ilQTIParser( + $import_base_dir, + $context->get(DetectLegacyImportStage::LEGACY_QTI_FILE), + ilQTIParser::IL_MO_PARSE_QTI, + $new_obj->getId(), + $context->get('selected_questions'), + $a_mapping->getAllMappings() + ); + $qti_parser->setTestObject($new_obj); + $qti_parser->startParsing(); + $new_obj = $qti_parser->getTestObject(); + + // import page data + $question_page_parser = new ilQuestionPageParser( + $new_obj, + $xml_file, + $import_base_dir + ); + $question_page_parser->setQuestionMapping($qti_parser->getImportMapping()); + $question_page_parser->startParsing(); + + $a_mapping = $this->addTaxonomyAndQuestionsMapping($qti_parser->getQuestionIdMapping(), $new_obj->getId(), $a_mapping); + + if ($new_obj->isRandomTest()) { + $this->importRandomQuestionSetConfig($new_obj, $xml_file, $a_mapping); + } + + $results_file_path = str_replace('__tst', '__results', $xml_file); + if (file_exists($results_file_path)) { + $results = new ilTestResultsImportParser($results_file_path, $new_obj, $this->db, $this->logger, $this->irss); + $results->setQuestionIdMapping($a_mapping->getMappingsOfEntity('components/ILIAS/Test', 'quest')); + $results->setSrcPoolDefIdMapping($a_mapping->getMappingsOfEntity('components/ILIAS/Test', 'rnd_src_pool_def')); + $results->startParsing(); + } + + $new_obj->saveToDb(); // this creates test_fi + $new_obj->update(); // this saves ilObject data + + $this->importSkillLevelThresholds( + $a_mapping, + $this->importQuestionSkillAssignments($a_mapping, $new_obj, $xml_file), + $new_obj, + $xml_file + ); + + $a_mapping->addMapping( + "components/ILIAS/MetaData", + "md", + $a_id . ":0:tst", + $new_obj->getId() . ":0:tst" + ); + + $context = $context->with('test_obj_id', $new_obj->getId())->with('test_ref_id', $new_obj->getRefId()); + $this->session->setContext($context); + } + + public function addTaxonomyAndQuestionsMapping( + array $question_id_mapping, + int $new_obj_id, + ilImportMapping $mapping + ): ilImportMapping { + foreach ($question_id_mapping as $old_question_id => $new_question_id) { + $mapping->addMapping( + 'components/ILIAS/Taxonomy', + 'tax_item', + "tst:quest:{$old_question_id}", + (string) $new_question_id + ); + + $mapping->addMapping( + 'components/ILIAS/Taxonomy', + 'tax_item_obj_id', + "tst:quest:{$old_question_id}", + (string) $new_obj_id + ); + + $mapping->addMapping( + 'components/ILIAS/Test', + 'quest', + (string) $old_question_id, + (string) $new_question_id + ); + } + + return $mapping; + } + + public function finalProcessing(ilImportMapping $a_mapping): void + { + $maps = $a_mapping->getMappingsOfEntity("components/ILIAS/Test", "tst"); + + foreach ($maps as $old => $new) { + if ($old == "new_id" || (int) $old <= 0) { + continue; + } + + if (isset(self::$finallyProcessedTestsRegistry[$new])) { + continue; + } + + $test_obj = ilObjectFactory::getInstanceByObjId((int) $new, false); + if ($test_obj->isRandomTest()) { + $this->finalRandomTestTaxonomyProcessing($a_mapping, (string) $old, $new, $test_obj); + } + + self::$finallyProcessedTestsRegistry[$new] = true; + } + } + + protected function finalRandomTestTaxonomyProcessing( + ilImportMapping $mapping, + string $old_tst_obj_id, + string $new_tst_obj_id, + ilObjTest $test_obj + ): void { + $new_tax_ids = $mapping->getMapping( + 'components/ILIAS/Taxonomy', + 'tax_usage_of_obj', + $old_tst_obj_id + ); + + if ($new_tax_ids !== null) { + foreach (explode(':', $new_tax_ids) as $tax_id) { + ilObjTaxonomy::saveUsage((int) $tax_id, (int) $new_tst_obj_id); + } + } + + $src_pool_def_list = new ilTestRandomQuestionSetSourcePoolDefinitionList( + $this->db, + $test_obj, + new ilTestRandomQuestionSetSourcePoolDefinitionFactory( + $this->db, + $test_obj + ) + ); + + $src_pool_def_list->loadDefinitions(); + + foreach ($src_pool_def_list as $definition) { + $mapped_taxonomy_filter = $definition->getMappedTaxonomyFilter(); + if ($mapped_taxonomy_filter === []) { + continue; + } + + $definition->setMappedTaxonomyFilter( + $this->getNewMappedTaxonomyFilter( + $mapping, + $mapped_taxonomy_filter + ) + ); + $definition->saveToDb(); + } + } + + protected function getNewMappedTaxonomyFilter( + ilImportMapping $mapping, + array $mapped_filter + ): array { + $new_mapped_filter = []; + foreach ($mapped_filter as $tax_id => $tax_nodes) { + $new_tax_id = $mapping->getMapping( + 'components/ILIAS/Taxonomy', + 'tax', + (string) $tax_id + ); + + if ($new_tax_id === null) { + continue; + } + + $new_mapped_filter[$new_tax_id] = []; + + foreach ($tax_nodes as $tax_node_id) { + $new_tax_node_id = $mapping->getMapping( + 'components/ILIAS/Taxonomy', + 'tax_tree', + (string) $tax_node_id + ); + + if ($new_tax_node_id === null) { + continue; + } + + $new_mapped_filter[$new_tax_id][] = $new_tax_node_id; + } + } + + return $new_mapped_filter; + } + + public function importRandomQuestionSetConfig( + ilObjTest $test_obj, + ?string $xml_file, + \ilImportMapping $a_mapping + ): void { + $test_obj->questions = []; + $parser = new ilObjTestXMLParser($xml_file); + $parser->setTestOBJ($test_obj); + $parser->setImportMapping($a_mapping); + $parser->startParsing(); + } + + protected function importQuestionSkillAssignments( + ilImportMapping $mapping, + ilObjTest $test_obj, + ?string $xml_file + ): ilAssQuestionSkillAssignmentList { + $parser = new ilAssQuestionSkillAssignmentXmlParser($xml_file); + $parser->startParsing(); + + $importer = new ilAssQuestionSkillAssignmentImporter(); + $importer->setTargetParentObjId($test_obj->getId()); + $importer->setImportInstallationId((int) $this->getInstallId()); + $importer->setImportMappingRegistry($mapping); + $importer->setImportMappingComponent('components/ILIAS/Test'); + $importer->setImportAssignmentList($parser->getAssignmentList()); + + $importer->import(); + + if ($importer->getFailedImportAssignmentList()->assignmentsExist()) { + $qsaImportFails = new ilAssQuestionSkillAssignmentImportFails($test_obj->getId()); + $qsaImportFails->registerFailedImports($importer->getFailedImportAssignmentList()); + + $test_obj->getObjectProperties()->storePropertyIsOnline( + $test_obj->getObjectProperties()->getPropertyIsOnline()->withOffline() + ); + } + + return $importer->getSuccessImportAssignmentList(); + } + + protected function importSkillLevelThresholds( + ilImportMapping $mapping, + ilAssQuestionSkillAssignmentList $assignment_list, + ilObjTest $test_obj, + ?string $xml_file + ): void { + $parser = new ilTestSkillLevelThresholdXmlParser($xml_file); + $parser->initSkillLevelThresholdImportList(); + $parser->startParsing(); + + $importer = new ilTestSkillLevelThresholdImporter($this->db); + $importer->setTargetTestId($test_obj->getTestId()); + $importer->setImportInstallationId((int) $this->getInstallId()); + $importer->setImportMappingRegistry($mapping); + $importer->setImportedQuestionSkillAssignmentList($assignment_list); + $importer->setImportThresholdList($parser->getSkillLevelThresholdImportList()); + $importer->import(); + + if ($importer->getFailedThresholdImportSkillList()->skillsExist()) { + $sltImportFails = new ilTestSkillLevelThresholdImportFails($test_obj->getId()); + $sltImportFails->registerFailedImports($importer->getFailedThresholdImportSkillList()); + + $test_obj->setOfflineStatus(true); + } + } +} diff --git a/components/ILIAS/Test/classes/class.ilTestRandomQuestionSetSourcePoolDefinition.php b/components/ILIAS/Test/classes/class.ilTestRandomQuestionSetSourcePoolDefinition.php index be517642ae82..3f52b21035d6 100755 --- a/components/ILIAS/Test/classes/class.ilTestRandomQuestionSetSourcePoolDefinition.php +++ b/components/ILIAS/Test/classes/class.ilTestRandomQuestionSetSourcePoolDefinition.php @@ -18,13 +18,18 @@ declare(strict_types=1); +use ILIAS\Refinery\Transformation; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Envelopes\Id; + /** * @author Björn Heyser * @version $Id$ * * @package Modules/Test */ -class ilTestRandomQuestionSetSourcePoolDefinition +class ilTestRandomQuestionSetSourcePoolDefinition implements Normalizable { private ?int $id = null; private ?int $pool_id = null; @@ -450,4 +455,89 @@ public function getPoolInfoLabel(ilLanguage $lng): string } // ----------------------------------------------------------------------------------------------------------------- + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function () use ($tt): array { + $normalized = [ + 'id' => $tt->normalize(new Id($this->getId(), 'rnd_src_pool_def')), + 'pool_id' => $tt->normalize(new Id($this->getPoolId(), 'qpl')), + 'pool_title' => $this->getPoolTitle(), + 'pool_path' => $this->getPoolPath(), + 'quest_amount' => $this->getQuestionAmount(), + 'pool_quest_count' => $this->getPoolQuestionCount(), + 'position' => $this->getSequencePosition(), + 'type_filter' => $this->getTypeFilterAsTypeTags(), + 'lifecycle_filter' => $this->getLifecycleFilter(), + 'taxonomy_filter' => [], + 'mapped_taxonomy_filter' => [], + ]; + + foreach ($this->getOriginalTaxonomyFilter() as $tax_id => $node_ids) { + $normalized['taxonomy_filter'][] = [ + 'tax_id' => $tt->normalize(new Id($tax_id, 'tax')), + 'node_ids' => array_map( + fn($node_id) => $tt->normalize(new Id($node_id, 'tax_node')), + $node_ids + ), + ]; + } + + foreach ($this->getMappedTaxonomyFilter() as $tax_id => $node_ids) { + $normalized['mapped_taxonomy_filter'][] = [ + 'tax_id' => $tt->normalize(new Id($tax_id, 'mapped_tax')), + 'node_ids' => array_map( + fn($node_id) => $tt->normalize(new Id($node_id, 'mapped_tax_node')), + $node_ids + ), + ]; + } + + return $normalized; + }); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = clone $this; + $clone->setId($tt->denormalize($normalized['id'], Id::class)->getId()); + $clone->setPoolId($tt->denormalize($normalized['pool_id'], Id::class)->getId()); + $clone->setPoolTitle($tt->string($normalized['pool_title'])); + $clone->setPoolPath($tt->string($normalized['pool_path'])); + $clone->setQuestionAmount($tt->nullableInt($normalized['quest_amount'])); + $clone->setPoolQuestionCount($tt->nullableInt($normalized['pool_quest_count'])); + $clone->setSequencePosition($tt->int($normalized['position'])); + $clone->setTypeFilterFromTypeTags($normalized['type_filter']); + $clone->setLifecycleFilter($normalized['lifecycle_filter']); + + $taxonomy_filter = []; + foreach ($normalized['taxonomy_filter'] as $item) { + $tax_id = $tt->denormalize($item['tax_id'], Id::class)->getId(); + $taxonomy_filter[$tax_id] = array_map( + fn($node_id) => $tt->denormalize($node_id, Id::class)->getId(), + $item['node_ids'] + ); + } + $clone->setOriginalTaxonomyFilter($taxonomy_filter); + + $mapped_taxonomy_filter = []; + foreach (($normalized['mapped_taxonomy_filter'] ?? []) as $item) { + $tax_id = $tt->denormalize($item['tax_id'], Id::class)->getId(); + $mapped_taxonomy_filter[$tax_id] = array_map( + fn($node_id) => $tt->denormalize($node_id, Id::class)->getId(), + $item['node_ids'] + ); + } + $clone->setMappedTaxonomyFilter($mapped_taxonomy_filter); + + return $clone; + }); + } } diff --git a/components/ILIAS/Test/classes/class.ilTestResultsToXML.php b/components/ILIAS/Test/classes/class.ilTestResultsToXML.php deleted file mode 100755 index bc3a6d67888d..000000000000 --- a/components/ILIAS/Test/classes/class.ilTestResultsToXML.php +++ /dev/null @@ -1,300 +0,0 @@ -include_random_test_questions_enabled; - } - - public function setIncludeRandomTestQuestionsEnabled(bool $include_random_test_questions_enabled): void - { - $this->include_random_test_questions_enabled = $include_random_test_questions_enabled; - } - - protected function exportActiveIDs(): void - { - $user_criteria = (new ilSetting('assessment'))->get('user_criteria', 'usr_id'); - - $query = $this->test_obj->getAnonymity() - ? 'SELECT * FROM tst_active WHERE test_fi = %s' - : "SELECT tst_active.*, usr_data.{$user_criteria} FROM tst_active, usr_data WHERE tst_active.test_fi = %s AND tst_active.user_fi = usr_data.usr_id"; - $result = $this->db->queryF($query, [ilDBConstants::T_INTEGER], [$this->test_obj->getTestId()]); - - $test_participant_list = new ilTestParticipantList($this->test_obj, $this->user, $this->lng, $this->db); - $test_participant_list->initializeFromDbRows($this->test_obj->getTestParticipants()); - - $this->xmlStartTag('tst_active', null); - while ($row = $this->db->fetchAssoc($result)) { - $this->active_ids[] = $row['active_id']; - $participant = $test_participant_list->getParticipantByActiveId($row['active_id']); - - $attrs = [ - 'active_id' => $row['active_id'], - 'user_fi' => $this->test_obj->getAnonymity() ? '' : ($row['user_fi'] ?? ''), - 'fullname' => $participant ? $test_participant_list->buildFullname($participant) : '', - 'anonymous_id' => $row['anonymous_id'] ?? '', - 'test_fi' => $row['test_fi'], - 'lastindex' => $row['lastindex'] ?? '', - 'tries' => $row['tries'] ?? '', - 'last_started_pass' => $row['last_started_pass'] ?? '', - 'last_finished_pass' => $row['last_finished_pass'] ?? '', - 'submitted' => $row['submitted'] ?? '', - 'submittimestamp' => $row['submittimestamp'] ?? '', - 'tstamp' => $row['tstamp'] ?? '' - ]; - - if (!$this->test_obj->getAnonymity()) { - $attrs['user_criteria'] = $user_criteria; - $attrs[$user_criteria] = $row[$user_criteria]; - } - - $this->xmlElement('row', $attrs); - } - $this->xmlEndTag('tst_active'); - } - - protected function exportPassResult(): void - { - $query = 'SELECT * FROM tst_pass_result WHERE ' . $this->db->in('active_fi', $this->active_ids, false, 'integer') . ' ORDER BY active_fi, pass'; - $result = $this->db->query($query); - $this->xmlStartTag('tst_pass_result', null); - while ($row = $this->db->fetchAssoc($result)) { - $attrs = [ - 'active_fi' => $row['active_fi'], - 'pass' => $row['pass'] ?? '', - 'points' => $row['points'] ?? '', - 'maxpoints' => $row['maxpoints'] ?? '', - 'questioncount' => $row['questioncount'] ?? '', - 'answeredquestions' => $row['answeredquestions'] ?? '', - 'workingtime' => $row['workingtime'] ?? '', - 'tstamp' => $row['tstamp'] ?? '' - ]; - $this->xmlElement('row', $attrs); - } - $this->xmlEndTag('tst_pass_result'); - } - - protected function exportResultCache(): void - { - $query = 'SELECT * FROM tst_result_cache WHERE ' . $this->db->in('active_fi', $this->active_ids, false, 'integer') . ' ORDER BY active_fi'; - $result = $this->db->query($query); - $this->xmlStartTag('tst_result_cache', null); - while ($row = $this->db->fetchAssoc($result)) { - $attrs = [ - 'active_fi' => $row['active_fi'], - 'pass' => $row['pass'], - 'max_points' => $row['max_points'], - 'reached_points' => $row['reached_points'], - 'mark_short' => $row['mark_short'], - 'mark_official' => $row['mark_official'], - 'passed' => $row['passed'], - 'failed' => $row['failed'], - 'tstamp' => $row['tstamp'] - ]; - $this->xmlElement('row', $attrs); - } - $this->xmlEndTag('tst_result_cache'); - } - - protected function exportTestSequence(): void - { - $query = 'SELECT * FROM tst_sequence WHERE ' . $this->db->in('active_fi', $this->active_ids, false, 'integer') . ' ORDER BY active_fi, pass'; - $result = $this->db->query($query); - $this->xmlStartTag('tst_sequence', null); - while ($row = $this->db->fetchAssoc($result)) { - $attrs = [ - 'active_fi' => $row['active_fi'], - 'pass' => $row['pass'] ?? '', - 'sequence' => $row['sequence'] ?? '', - 'postponed' => $row['postponed'] ?? '', - 'hidden' => $row['hidden'] ?? '', - 'tstamp' => $row['tstamp'] ?? '' - ]; - $this->xmlElement('row', $attrs); - } - $this->xmlEndTag('tst_sequence'); - } - - protected function exportTestSolutions(): void - { - $query = 'SELECT * FROM tst_solutions WHERE ' . $this->db->in('active_fi', $this->active_ids, false, 'integer') . ' ORDER BY solution_id'; - $result = $this->db->query($query); - $this->xmlStartTag('tst_solutions', null); - while ($row = $this->db->fetchAssoc($result)) { - $attrs = [ - 'solution_id' => $row['solution_id'], - 'active_fi' => $row['active_fi'], - 'question_fi' => $row['question_fi'], - 'points' => $row['points'] ?? '', - 'pass' => $row['pass'] ?? '', - 'value1' => $row['value1'] ?? '', - 'value2' => $row['value2'] ?? '', - 'tstamp' => $row['tstamp'] ?? '' - ]; - $this->xmlElement('row', $attrs); - } - $this->xmlEndTag('tst_solutions'); - } - - protected function exportRandomTestQuestions(): void - { - $result = $this->db->query(" - SELECT * FROM tst_test_rnd_qst - WHERE {$this->db->in('active_fi', $this->active_ids, false, 'integer')} - ORDER BY test_random_question_id - "); - - $this->xmlStartTag('tst_test_rnd_qst', null); - while ($row = $this->db->fetchAssoc($result)) { - $attrs = []; - - foreach ($row as $field => $value) { - $attrs[$field] = $value; - } - - $this->xmlElement('row', $attrs); - } - $this->xmlEndTag('tst_test_rnd_qst'); - } - - - protected function exportTestResults(): void - { - $query = 'SELECT * FROM tst_test_result WHERE ' . $this->db->in('active_fi', $this->active_ids, false, 'integer') . ' ORDER BY active_fi'; - $result = $this->db->query($query); - $this->xmlStartTag('tst_test_result', null); - while ($row = $this->db->fetchAssoc($result)) { - $active_fi = $row['active_fi']; - $pass = $row['pass'] ?? ''; - $attrs = [ - 'test_result_id' => $row['test_result_id'], - 'active_fi' => $active_fi, - 'question_fi' => $row['question_fi'], - 'points' => $row['points'] ?? '', - 'pass' => $pass, - 'manual' => $row['manual'] ?? '', - 'tstamp' => $row['tstamp'] ?? '' - ]; - - if (($question = assQuestion::instantiateQuestion($row['question_fi'])) instanceof assFileUpload) { - $this->exportParticipantUploadedFiles($question->getUploadedFiles($active_fi, $pass)); - } - - $this->xmlElement('row', $attrs); - } - $this->xmlEndTag('tst_test_result'); - } - - /** - * @param array{value1: string, value2: string} $uploaded_files - */ - protected function exportParticipantUploadedFiles(array $uploaded_files): void - { - foreach ($uploaded_files as $uploaded_file) { - if ($uploaded_file['value2'] !== 'rid') { - continue; - } - - $rid_string = $uploaded_file['value1']; - $rid = $this->irss->manage()->find($rid_string); - if ($rid === null) { - continue; - } - - $target_dir = "$this->objects_export_directory/resources/$rid_string"; - ilFileUtils::makeDirParents($target_dir); - file_put_contents( - "$target_dir/{$this->irss->manage()->getCurrentRevision($rid)->getTitle()}", - $this->irss->consume()->stream($rid)->getStream(), - ); - } - } - - protected function exportTestTimes(): void - { - $query = 'SELECT * FROM tst_times WHERE ' . $this->db->in('active_fi', $this->active_ids, false, 'integer') . ' ORDER BY active_fi'; - $result = $this->db->query($query); - $this->xmlStartTag('tst_times', null); - while ($row = $this->db->fetchAssoc($result)) { - $attrs = [ - 'times_id' => $row['times_id'], - 'active_fi' => $row['active_fi'], - 'started' => $row['started'], - 'finished' => $row['finished'], - 'pass' => $row['pass'], - 'tstamp' => $row['tstamp'] - ]; - $this->xmlElement('row', $attrs); - } - $this->xmlEndTag('tst_times'); - } - - public function getXML(): void - { - $this->active_ids = []; - $this->xmlHeader(); - $attrs = ['version' => '4.1.0']; - $this->xmlStartTag('results', $attrs); - $this->exportActiveIDs(); - - if ($this->isIncludeRandomTestQuestionsEnabled()) { - $this->exportRandomTestQuestions(); - } - - $this->exportPassResult(); - $this->exportResultCache(); - $this->exportTestSequence(); - $this->exportTestSolutions(); - $this->exportTestResults(); - $this->exportTestTimes(); - $this->xmlEndTag('results'); - } - - public function xmlDumpMem(bool $format = true): string - { - $this->getXML(); - return parent::xmlDumpMem($format); - } - - public function xmlDumpFile(string $file, bool $format = true): void - { - $this->getXML(); - parent::xmlDumpFile($file, $format); - } -} diff --git a/components/ILIAS/Test/src/ExportImport/Envelopes/AdditionalWorkingTime.php b/components/ILIAS/Test/src/ExportImport/Envelopes/AdditionalWorkingTime.php new file mode 100644 index 000000000000..bb55f0f092fe --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Envelopes/AdditionalWorkingTime.php @@ -0,0 +1,72 @@ + $tt->normalize($this->user_id), + 'test_id' => $tt->normalize($this->test_id), + 'time' => $this->time, + 'timestamp' => $this->timestamp, + ]; + } + + /** + * @inheritDoc + */ + public static function fromArray(array $value, Transformations $tt): static + { + return new self( + $tt->denormalize($value['user_id'], Id::class), + $tt->denormalize($value['test_id'], Id::class), + $tt->int($value['time']), + $tt->int($value['timestamp']), + ); + } + + public static function fromRow(array $row): static + { + return new self( + new Id($row['user_fi'], 'user'), + new Id($row['test_fi'], 'tst'), + (int) $row['additionaltime'], + (int) $row['tstamp'], + ); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Envelopes/ManualFeedback.php b/components/ILIAS/Test/src/ExportImport/Envelopes/ManualFeedback.php new file mode 100644 index 000000000000..123c743a9032 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Envelopes/ManualFeedback.php @@ -0,0 +1,84 @@ + $tt->normalize($this->active_id), + 'question_id' => $tt->normalize($this->question_id), + 'attempt' => $this->attempt, + 'feedback' => $this->feedback, + 'finalized_evaluation' => $this->finalized_evaluation, + 'finalized_timestamp' => $this->finalized_timestamp, + 'finalized_by' => $tt->normalize($this->finalized_by), + ]; + } + + /** + * @inheritDoc + */ + public static function fromArray(array $value, Transformations $tt): static + { + return new self( + $tt->denormalize($value['active_id'], Id::class), + $tt->denormalize($value['question_id'], Id::class), + $tt->int($value['attempt']), + $tt->string($value['feedback']), + $tt->bool($value['finalized_evaluation']), + $tt->int($value['finalized_timestamp']), + $tt->denormalize($value['finalized_by'], Id::class), + ); + } + + public static function fromRow(array $row): static + { + return new self( + new Id($row['active_fi'], 'participant'), + new Id($row['question_fi'], 'question'), + (int) $row['pass'], + $row['feedback'], + (bool) $row['finalized_evaluation'], + (int) $row['finalized_tstamp'], + new Id($row['finalized_by_usr_id'], 'user'), + ); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Envelopes/QuestionResult.php b/components/ILIAS/Test/src/ExportImport/Envelopes/QuestionResult.php new file mode 100644 index 000000000000..dfe9877771ca --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Envelopes/QuestionResult.php @@ -0,0 +1,88 @@ + $tt->normalize($this->active_id), + 'question_id' => $tt->normalize($this->question_id), + 'attempt' => $this->attempt, + 'points' => $this->points, + 'answered' => $this->answered, + 'manual' => $this->manual, + 'step' => $this->step, + 'timestamp' => $this->timestamp, + ]; + } + + /** + * @inheritDoc + */ + public static function fromArray(array $value, Transformations $tt): static + { + return new self( + $tt->denormalize($value['active_id'], Id::class), + $tt->denormalize($value['question_id'], Id::class), + $tt->int($value['attempt']), + $tt->float($value['points']), + $tt->bool($value['answered']), + $tt->bool($value['manual']), + $tt->nullableInt($value['step']), + $tt->int($value['timestamp']), + ); + } + + public static function fromRow(array $row): static + { + return new self( + new Id($row['active_fi'], 'participant'), + new Id($row['question_fi'], 'question'), + (int) $row['pass'], + (float) $row['points'], + (bool) $row['answered'], + (bool) $row['manual'], + $row['step'] !== null ? (int) $row['step'] : null, + (int) $row['tstamp'], + ); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Envelopes/QuestionSetConfig.php b/components/ILIAS/Test/src/ExportImport/Envelopes/QuestionSetConfig.php new file mode 100644 index 000000000000..6e1a75dececb --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Envelopes/QuestionSetConfig.php @@ -0,0 +1,79 @@ + */ + private array $definitions = [], + /** @var array> */ + private array $staging_pools = [], + ) { + } + + public function getConfig(): ilTestQuestionSetConfig + { + return $this->config; + } + + public function isRandom(): bool + { + return $this->config instanceof ilTestRandomQuestionSetConfig; + } + + /** + * @return list + */ + public function getDefinitions(): array + { + return $this->definitions; + } + + /** + * @param list $definitions + */ + public function setDefinitions(array $definitions): void + { + $this->definitions = $definitions; + } + + /** + * @return array> + */ + public function getStagingPools(): array + { + return $this->staging_pools; + } + + /** + * @param list $questions + */ + public function addStagingPoolQuestions(int $pool_id, array $questions): void + { + $this->staging_pools[$pool_id] = $questions; + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Envelopes/RandomTestQuestion.php b/components/ILIAS/Test/src/ExportImport/Envelopes/RandomTestQuestion.php new file mode 100644 index 000000000000..e759fa26f9ed --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Envelopes/RandomTestQuestion.php @@ -0,0 +1,80 @@ + $tt->normalize($this->active_id), + 'question_id' => $tt->normalize($this->question_id), + 'sequence' => $this->sequence, + 'pass' => $this->pass, + 'timestamp' => $this->timestamp, + 'src_pool_def_id' => $tt->normalize($this->src_pool_def_id), + ]; + } + + /** + * @inheritDoc + */ + public static function fromArray(array $value, Transformations $tt): static + { + return new self( + $tt->denormalize($value['active_id'], Id::class), + $tt->denormalize($value['question_id'], Id::class), + $tt->int($value['sequence']), + $tt->int($value['pass']), + $tt->int($value['timestamp']), + $tt->denormalize($value['src_pool_def_id'], Id::class), + ); + } + + public static function fromRow(array $row): static + { + return new self( + new Id($row['active_fi'], 'participant'), + new Id($row['question_fi'], 'question'), + (int) $row['sequence'], + (int) $row['pass'], + (int) $row['tstamp'], + new Id($row['src_pool_def_fi'], 'rnd_src_pool_def'), + ); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Envelopes/Solution.php b/components/ILIAS/Test/src/ExportImport/Envelopes/Solution.php new file mode 100644 index 000000000000..5ef1747f5ceb --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Envelopes/Solution.php @@ -0,0 +1,101 @@ + $tt->normalize($this->active_id), + 'question_id' => $tt->normalize($this->question_id), + 'attempt' => $this->attempt, + 'points' => $this->points, + 'timestamp' => $this->timestamp, + 'value1' => $tt->normalize($this->value1), + 'value2' => $this->value2, + 'step' => $this->step, + 'authorized' => $this->authorized + ]; + } + + /** + * @inheritDoc + */ + public static function fromArray(array $value, Transformations $tt): static + { + return new self( + $tt->denormalize($value['active_id'], Id::class), + $tt->denormalize($value['question_id'], Id::class), + $tt->int($value['attempt']), + $tt->nullableFloat($value['points']), + $tt->int($value['timestamp']), + ResourceNormalizer::isResourceIdentification($value['value1']) + ? $tt->denormalize($value['value1'], ResourceIdentification::class) + : $tt->nullableString($value['value1']), + $tt->nullableString($value['value2']), + $tt->nullableInt($value['step']), + $tt->bool($value['authorized']), + ); + } + + public static function fromRow(array $row): static + { + $value1 = $row['value1']; + if ($row['value2'] === 'rid' && is_string($value1)) { + $value1 = new ResourceIdentification($value1); + } + + return new self( + new Id($row['active_fi'], 'participant'), + new Id($row['question_fi'], 'question'), + (int) $row['pass'], + $row['points'] ? (float) $row['points'] : null, + (int) $row['tstamp'], + $value1, + $row['value2'], + $row['step'] ? (int) $row['step'] : null, + (bool) $row['authorized'] + ); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Envelopes/WorkingTime.php b/components/ILIAS/Test/src/ExportImport/Envelopes/WorkingTime.php new file mode 100644 index 000000000000..97aeeeb4dc9b --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Envelopes/WorkingTime.php @@ -0,0 +1,72 @@ + $tt->normalize($this->active_id), + 'attempt' => $this->attempt, + 'started' => $this->started, + 'finished' => $this->finished, + ]; + } + + /** + * @inheritDoc + */ + public static function fromArray(array $value, Transformations $tt): static + { + return new self( + $tt->denormalize($value['active_id'], Id::class), + $tt->int($value['attempt']), + $tt->string($value['started']), + $tt->string($value['finished']), + ); + } + + public static function fromRow(array $row): static + { + return new self( + new Id($row['active_fi'], 'participant'), + (int) $row['pass'], + $row['started'], + $row['finished'], + ); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/ExportOptions/XMLWithResultsOption.php b/components/ILIAS/Test/src/ExportImport/ExportOptions/XMLWithResultsOption.php new file mode 100644 index 000000000000..0249ba8be06b --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/ExportOptions/XMLWithResultsOption.php @@ -0,0 +1,97 @@ +lng = $DIC->language(); + $this->state_holder = TestDIC::dic()['exportimport.state_holder']; + } + + public function getExportType(): string + { + return 'ZIP Results'; + } + + public function getExportOptionId(): string + { + return Types::XML_WITH_RESULTS->value; + } + + public function getSupportedRepositoryObjectTypes(): array + { + return ['tst']; + } + + public function getLabel(): string + { + $this->lng->loadLanguageModule('exp'); + $this->lng->loadLanguageModule('assessment'); + + return $this->lng->txt('exp_format_dropdown-xml') . ' (' . $this->lng->txt('ass_create_export_file_with_results') . ')'; + } + + public function onExportOptionSelected(ConsumerContext $context): void + { + $handler = new ExportHandlerLocator(); + $manager = $handler->manager()->handler(); + + $export_info = $manager->getExportInfoWithObject( + $context->exportObject(), + time(), + $handler->consumer()->exportConfig()->allExportConfigs() + ); + + // Prepare export state to bridge between export option and the xml exporter + $this->state_holder->create( + $export_info->getTarget(), + $handler->consumer()->exportConfig()->allExportConfigs(), + Types::XML_WITH_RESULTS->value + ); + + // Delegate the export to the manager which will call ilTestExporter + $manager->createExport( + 1, + $export_info, + '' + ); + + $this->ctrl->redirectByClass(ilExportGUI::class, ilExportGUI::CMD_LIST_EXPORT_FILES); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Import/PersistStage.php b/components/ILIAS/Test/src/ExportImport/Import/PersistStage.php new file mode 100644 index 000000000000..14b68aba884f --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Import/PersistStage.php @@ -0,0 +1,106 @@ +lng->txt('qpl_import_step_persist'); + } + + public function getDescription(): ?string + { + return ''; + } + + public function process(ImportContext $context): StageResult + { + if (!DetectLegacyImportStage::isLegacyImport($context)) { + if ($result = $this->importMappingsFile($context)) { + return $result; + } + } + + $importer = new ilImport($this->requested_ref_id); + $importer->importObject( + null, + $context->get(UploadValidationStage::FILE_TO_IMPORT), + basename($context->get(UploadValidationStage::FILE_TO_IMPORT)), + 'tst', + 'components/ILIAS/Test', + true, + ); + + // Context is updated by the TestImporter so we need to reload it + return StageResult::complete($this->session->getContext()); + } + + private function importMappingsFile(ImportContext $context): ?StageResult + { + $component_import_dir = dirname($context->get(UploadValidationStage::COMPONENT_IMPORT_FILE)); + $mappings_file = "{$component_import_dir}/mappings.xml"; + if (!file_exists($mappings_file) || !is_file($mappings_file)) { + $this->log->error("Mappings file not found: {$mappings_file}"); + return StageResult::error($context, $this->lng->txt('obj_import_file_error')); + } + + $deserializer = new XMLFileDeserializer()->open($mappings_file); + $deserializer->addHandler('mappings', function (array $mappings) use (&$context) { + $context = $context->with('mappings', $mappings); + }); + + $deserializer->process(); + $this->log->info("Processed mappings file: {$mappings_file}"); + + $this->session->setContext($context); + return null; + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Import/RandomTestConfigImporter.php b/components/ILIAS/Test/src/ExportImport/Import/RandomTestConfigImporter.php new file mode 100644 index 000000000000..f9726a6454ca --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Import/RandomTestConfigImporter.php @@ -0,0 +1,186 @@ +isRandom()) { + throw new \InvalidArgumentException('Expected random question set config'); + } + + $config->getConfig()->saveToDb(); + $this->log->debug("Imported random question set config for test {$test_object->getTestId()}"); + + foreach ($config->getStagingPools() as $pool_id => $questions) { + $this->importRandomQuestionStagingPool($pool_id, $questions, $mapping, $test_object); + } + + foreach ($config->getDefinitions() as $definition) { + $this->importSourcePoolDefinition($definition, $mapping); + } + } + + private function importRandomQuestionStagingPool( + int $old_pool_id, + array $questions, + ilImportMapping $mapping, + ilObjTest $test_object + ): void { + $new_pool_id = $this->database->nextId('object_data'); + $mapping->addMapping( + 'components/ILIAS/Test', + 'pool', + (string) $old_pool_id, + (string) $new_pool_id + ); + $this->log->debug("Imported random question staging pool: {$old_pool_id} -> {$new_pool_id}"); + + // QuestionID was mapped during question set config denormalization + foreach ($questions as $question_id) { + $question = new ilTestRandomQuestionSetStagingPoolQuestion($this->database); + $question->setTestId($test_object->getTestId()); + $question->setPoolId($new_pool_id); + $question->setQuestionId($question_id); + $question->saveQuestionStaging(); + $this->log->debug("Imported random question staging question: {$question_id}"); + } + } + + private function importSourcePoolDefinition( + ilTestRandomQuestionSetSourcePoolDefinition $definition, + ilImportMapping $mapping, + ): void { + // New PoolID was not available during denormalization, so we have to map it here + $old_pool_id = $definition->getPoolId(); + $new_pool_id = (int) $mapping->getMapping('components/ILIAS/Test', 'pool', (string) $old_pool_id); + $definition->setPoolId($new_pool_id); + + if ($old_pool_id !== $new_pool_id) { + $ref_ids = $this->data_factory->objId($new_pool_id)->toReferenceIds(); + if (count($ref_ids) > 0) { + $definition->setPoolRefId(current($ref_ids)->toInt()); + $this->log->debug("Derived source pool definition from Object ID: {$old_pool_id} -> {$definition->getPoolRefId()}"); + } + } + + $old_definition_id = $definition->getId(); + $definition->setId(0); + $definition->saveToDb(); + $this->log->debug("Imported source pool definition: {$old_definition_id} -> {$definition->getId()}"); + + $mapping->addMapping( + 'components/ILIAS/Test', + 'rnd_src_pool_def', + (string) $old_definition_id, + (string) $definition->getId() + ); + } + + /** + * Remap taxonomy IDs in the mapped_taxonomy_filter of all imported source pool definitions. + * Taxonomy mappings are only available after the Taxonomy component has finished its import, so this must run + * during finalProcessing(). + */ + public function finalizeTaxonomyFilters(ilImportMapping $mapping): void + { + $tst_mappings = $mapping->getMappingsOfEntity('components/ILIAS/Test', 'tst'); + + foreach ($tst_mappings as $old_test_id => $new_test_id) { + if ($old_test_id === 'new_id' || (int) $old_test_id <= 0) { + continue; + } + + $test_obj = new ilObjTest(0, false); + $test_obj->setTestId((int) $new_test_id); + + $definition_list = new ilTestRandomQuestionSetSourcePoolDefinitionList( + $this->database, + $test_obj, + new ilTestRandomQuestionSetSourcePoolDefinitionFactory($this->database, $test_obj) + ); + $definition_list->loadDefinitions(); + + foreach ($definition_list as $definition) { + $mapped_filter = $definition->getMappedTaxonomyFilter(); + if ($mapped_filter === []) { + continue; + } + + $new_filter = $this->remapTaxonomyFilter($mapping, $mapped_filter); + $definition->setMappedTaxonomyFilter($new_filter); + $definition->saveToDb(); + $this->log->debug("Remapped taxonomy filter for definition {$definition->getId()}"); + } + } + } + + private function remapTaxonomyFilter(ilImportMapping $mapping, array $filter): array + { + $remapped = []; + foreach ($filter as $tax_id => $node_ids) { + $new_tax_id = (int) $mapping->getMapping('components/ILIAS/Taxonomy', 'tax', (string) $tax_id); + if ($new_tax_id <= 0) { + continue; + } + + $remapped[$new_tax_id] = []; + foreach ($node_ids as $node_id) { + $new_node_id = (int) $mapping->getMapping('components/ILIAS/Taxonomy', 'tax_tree', (string) $node_id); + if ($new_node_id > 0) { + $remapped[$new_tax_id][] = $new_node_id; + } + } + } + + return $remapped; + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Import/SkillLevelThresholdsImporter.php b/components/ILIAS/Test/src/ExportImport/Import/SkillLevelThresholdsImporter.php new file mode 100644 index 000000000000..697e0f32e202 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Import/SkillLevelThresholdsImporter.php @@ -0,0 +1,124 @@ +> $normalized_thresholds + * @return array{failed: list, success: list} + */ + public function import( + array $normalized_thresholds, + int $import_install_id, + Transformations $transformations, + ilImportMapping $mapping, + ): array { + $result = ['failed' => [], 'success' => []]; + $threshold_list = new ilTestSkillLevelThresholdList($this->db); + + foreach ($normalized_thresholds as $item) { + // TestID and Skill BaseID/TRefID will be replaced by the mapping pipe + $threshold = $transformations->denormalize($item, ilTestSkillLevelThreshold::class); + + $local_level_id = $this->getLevelIdMapping($import_install_id, $threshold->getSkillLevelId()); + if ($local_level_id === null) { + $this->log->warning("Failed to find skill level id mapping for threshold: {$threshold->getSkillLevelId()}"); + $result['failed'][] = $this->buildResultData($threshold); + continue; + } + $this->log->debug("Found skill level id mapping for threshold: {$threshold->getSkillLevelId()} -> {$local_level_id}"); + + $mapping->addMapping( + $this->component, + 'skill_level', + (string) $threshold->getSkillLevelId(), + (string) $local_level_id, + ); + $threshold->setSkillLevelId($local_level_id); + + $threshold_list->addThreshold($threshold); + $result['success'][] = $this->buildResultData($threshold); + } + + $threshold_list->saveToDb(); + $this->log->debug("Saved skill level thresholds"); + + return $result; + } + + protected function getLevelIdMapping(int $import_install_id, int $import_level_id): ?int + { + if ($import_install_id === $this->local_install_id) { + return $import_level_id; + } + + $result = $this->skill_repo->getCommonSkillIdForImportId($import_install_id, $import_level_id); + $most_new_level_data = current($result); + if (!is_array($most_new_level_data)) { + return null; + } + + return $most_new_level_data['level_id']; + } + + /** + * @return ImportResultData + */ + private function buildResultData(ilTestSkillLevelThreshold $threshold): array + { + return [ + 'skill_base_id' => $threshold->getSkillBaseId() ?? 0, + 'skill_tref_id' => $threshold->getSkillTrefId() ?? 0, + 'skill_level_id' => $threshold->getSkillLevelId() ?? 0, + 'threshold' => $threshold->getThreshold() ?? 0, + ]; + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Import/TestImporter.php b/components/ILIAS/Test/src/ExportImport/Import/TestImporter.php new file mode 100644 index 000000000000..c737b428b1f7 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Import/TestImporter.php @@ -0,0 +1,448 @@ +irss, $this->log); + $id_mapping_pipe = new IdMappingPipe($mapping, 'components/ILIAS/Test', $this->log); + $question_images_pipe = new CollectQuestionImages(new UUIDFactory(), $this->data_factory->objId(0)); + + $tt = $this->builder->withAdditionalPipes(append: [$id_mapping_pipe, $question_images_pipe, $resource_pipe])->create(); + + /** @var ilObjTest|null $test_object */ + $test_object = null; + + $deserializer->addHandler( + 'general', + function (array $objects) use ($tt, $mapping, $parent_id, &$test_object): void { + $test_object = $this->importTest( + array_pop($objects), + $tt, + $mapping, + $parent_id + ); + } + ); + + $deserializer->addHandler( + 'settings', + function (array $settings) use ($tt, $mapping, &$test_object): void { + $this->importSettings( + $settings, + $tt, + $mapping, + $test_object + ); + } + ); + + $deserializer->addHandler( + 'questions', + function (array $normalized) use ($tt, $mapping, $context, &$test_object): void { + $this->importQuestions( + $normalized, + $tt, + $mapping, + $context, + $test_object + ); + } + ); + + $deserializer->addHandler( + 'question_set_config', + function (array $normalized) use ($tt, $mapping, $context, &$test_object): void { + $this->importQuestionSetConfig( + reset($normalized), + $tt, + $mapping, + $test_object + ); + } + ); + + $deserializer->addHandler( + 'skill_assignments', + function (array $assignments) use ($tt, $mapping, &$context): void { + $result = $this->skill_importer->import( + $assignments, + UploadValidationStage::getInstallId($context), + $tt, + $mapping, + ); + $context = $context->with('skill_assignments', $result); + } + ); + + $deserializer->addHandler( + 'skill_thresholds', + function (array $thresholds) use ($tt, $mapping, &$context): void { + $result = $this->skill_thresholds_importer->import( + $thresholds, + UploadValidationStage::getInstallId($context), + $tt, + $mapping, + ); + $context = $context->with('skill_thresholds', $result); + } + ); + + $deserializer->addHandler( + 'participants', + function (array $participants) use ($tt, $mapping): void { + $this->importParticipants( + $participants, + $tt, + $mapping, + ); + } + ); + + $deserializer->addHandler( + 'results', + function (array $results) use ($tt): void { + $this->test_results_importer->import( + $results, + $tt, + ); + } + ); + + $deserializer->addHandler( + 'additional_working_times', + function (array $times) use ($tt): void { + $this->test_results_importer->importAdditionalWorkingTimes( + $times, + $tt, + ); + } + ); + + $this->log->info('Importing users and resources mappings...'); + $this->importMappings($mapping, $resource_pipe, $context); + $this->log->info('...Finished importing users and resources mappings'); + + $this->log->info('Importing test export file...'); + $deserializer->process(); + $this->log->info('...Finished importing test export file'); + + $this->log->info('Importing question images...'); + $this->questions_importer->importQuestionImages( + $test_object->getId(), + $mapping, + $context, + $question_images_pipe + ); + $this->log->info('...Finished importing question images'); + + $this->log->info("Finished importing test {$test_object->getTestId()} (Test ID), {$test_object->getId()} (Object ID)"); + return $context->with('test_obj_id', $test_object->getId())->with('test_ref_id', $test_object->getRefId()); + } + + /** + * Finalize the import after all dependencies have been imported. + * It will replace the old question ids with the new question ids in the test pages and remap taxonomy IDs in random + * question set source pool definitions. + */ + public function finalize(ilImportMapping $mapping): void + { + $this->log->info('Finalizing test import...'); + $this->questions_importer->finalizeQuestionPages($mapping); + $this->random_test_config_importer->finalizeTaxonomyFilters($mapping); + $this->log->info('...Finished finalizing test'); + } + + + private function importMappings( + ilImportMapping $mapping, + CollectResources $resource_pipe, + ImportContext $context + ): void { + $mappings = $context->get('mappings'); + if (count($mappings) < 2) { + throw new RuntimeException('Invalid mappings: Expected at least 2 mappings, got ' . count($mappings)); + } + [$user_mapping, $resource_mapping] = $mappings; + + $this->log->info('Importing user mappings...'); + $user_resolver = new UserImportResolver($this->database, $this->log); + $imported_users = $user_resolver->resolve( + UserIdentifiers::from($user_mapping['identifier']), + $user_mapping['mapping'] + ); + $user_resolver->store($imported_users, $mapping); + $this->log->info('...Finished importing user mappings'); + + $this->log->info('Importing resources and storing mappings...'); + $import_dir = dirname($context->get(UploadValidationStage::COMPONENT_IMPORT_FILE)) . '/expDir_1'; + foreach ($resource_mapping as $resource) { + $clean_id = str_replace(['-', '_'], '', $resource['id']); + $resource_path = "$import_dir/resources/{$clean_id}.{$resource['suffix']}"; + if (!file_exists($resource_path)) { + $this->log->error("Imported resource path does not exist: {$resource_path}, skipping"); + continue; + } + + $new_id = $this->irss->manage()->stream( + Streams::ofResource(fopen($resource_path, 'rb')), + new assFileUploadStakeholder(), + $resource['title'] + ); + $resource_pipe->storeMapping($resource['id'], $new_id); + $this->log->debug("Imported resource: {$resource_path} -> {$new_id->serialize()}"); + } + $this->log->info('...Finished importing resources and storing mappings'); + } + + private function importTest( + array $normalized, + Transformations $tt, + ilImportMapping $mapping, + ReferenceId $parent_id + ): ilObjTest { + $test_object = $tt->denormalize($normalized, ilObjTest::class); + $old_obj_id = $test_object->getId(); + $old_test_id = $test_object->getTestId(); + + $test_object->setTestId(-1); + $test_object->setTitle("{$test_object->getTitle()} (Imported)"); //TODO: Remove after testing + $new_obj_id = $test_object->create(); + $test_object->saveToDb(true); + $this->log->debug("Created new test object: {$old_test_id} -> {$test_object->getTestId()} (Test ID), {$old_obj_id} -> {$new_obj_id} (Object ID)"); + + $test_object->createReference(); + $test_object->putInTree($parent_id->toInt()); + $test_object->setPermissions($parent_id->toInt()); + $this->log->debug("Stored test object in tree: {$parent_id->toInt()} (Parent Ref) -> {$test_object->getRefId()} (Test Ref)"); + + $mapping->addMapping('components/ILIAS/Test', 'tst', (string) $old_test_id, (string) $test_object->getTestId()); + $mapping->addMapping('components/ILIAS/Test', 'object', (string) $old_obj_id, (string) $new_obj_id); + $mapping->addMapping('components/ILIAS/MetaData', 'md', "{$old_obj_id}:0:tst", "{$new_obj_id}:0:tst"); + + return $test_object; + } + + private function importSettings( + array $list, + Transformations $tt, + ilImportMapping $mapping, + ilObjTest $test_object + ): void { + $settings_id = $test_object->getMainSettings()->getId(); + + $main_settings = $tt->denormalize($list[0], MainSettings::class)->withId($settings_id); + $scoring_settings = $tt->denormalize($list[1], ScoreSettings::class)->withId($settings_id); + $mark_schema = $tt->denormalize($list[2], MarkSchema::class)->withTestId($test_object->getTestId()); + + if ($intro_page_id = $main_settings->getIntroductionSettings()->getIntroductionPageId()) { + $new_page_id = $this->createPage($intro_page_id, $test_object->getId(), $mapping); + $main_settings = $main_settings->withIntroductionSettings( + $main_settings->getIntroductionSettings()->withIntroductionPageId($new_page_id) + ); + $this->log->debug("Imported introduction page: {$intro_page_id} -> {$new_page_id}"); + } + + if ($concluding_page_id = $main_settings->getFinishingSettings()->getConcludingRemarksPageId()) { + $new_page_id = $this->createPage($concluding_page_id, $test_object->getId(), $mapping); + $main_settings = $main_settings->withFinishingSettings( + $main_settings->getFinishingSettings()->withConcludingRemarksPageId($new_page_id) + ); + $this->log->debug("Imported concluding remarks page: {$concluding_page_id} -> {$new_page_id}"); + } + + $test_object->getMainSettingsRepository()->store($main_settings); + $test_object->getScoreSettingsRepository()->store($scoring_settings); + $this->marks_repository->storeMarkSchema($mark_schema); + $this->log->debug("Imported test settings and mark schema: {$settings_id} (Settings ID)"); + } + + private function createPage(int $imported_page_id, int $parent_id, ilImportMapping $mapping): int + { + $page = new ilTestPage(); + $page->setParentId($parent_id); + $page->createPageWithNextId(); + + $mapping->addMapping( + 'components/ILIAS/COPage', + 'pg', + "tst:{$imported_page_id}", + "tst:{$page->getId()}" + ); + + return $page->getId(); + } + + private function importQuestions( + array $list, + Transformations $tt, + ilImportMapping $mapping, + ImportContext $context, + ilObjTest $test_object + ): void { + $selected_questions = QuestionSelectionStage::getSelectedQuestions($context); + + foreach ($list as $normalized) { + $question = $this->questions_importer->importQuestion($normalized, $tt, $mapping, $selected_questions); + + if ($question && $normalized['sequence'] !== null) { + $sequence = $tt->int($normalized['sequence']); + $test_object->questions[$sequence] = $question->getId(); + $this->log->debug("Stored question {$question->getId()} at sequence {$sequence} in test"); + } + } + + $test_object->saveQuestionsToDb(); + $this->log->debug('Saved test questions to database'); + } + + private function importQuestionSetConfig( + array $normalized, + Transformations $tt, + ilImportMapping $mapping, + ilObjTest $test_object + ): void { + $config = $tt->denormalize($normalized, QuestionSetConfig::class); + + if ($config->isRandom()) { + $this->random_test_config_importer->import($config, $mapping, $test_object); + } + } + + private function importParticipants(array $list, Transformations $tt, ilImportMapping $mapping): void + { + foreach ($list as $normalized) { + if ($normalized['active_id'] === null) { + $this->importInvitedParticipant($normalized, $tt); + continue; + } + + $old_active_id = $tt->denormalize($normalized['active_id'], Id::class)->getId(); + $new_active_id = $this->database->nextId('tst_active'); + $mapping->addMapping('components/ILIAS/Test', 'participant', (string) $old_active_id, (string) $new_active_id); + $this->log->debug("Stored participant/test session mapping: {$old_active_id} -> {$new_active_id}"); + + // TestID, UserID and ActiveID will be replaced by the mapping pipe + $participant = $tt->denormalize($normalized, Participant::class); + + $this->database->insert( + 'tst_active', + [ + 'active_id' => [ilDBConstants::T_INTEGER, $new_active_id], + 'user_fi' => [ilDBConstants::T_INTEGER, $participant->getUserId()], + 'test_fi' => [ilDBConstants::T_INTEGER, $participant->getTestId()], + 'anonymous_id' => [ilDBConstants::T_TEXT, $participant->getAnonymousId()], + 'tries' => [ilDBConstants::T_INTEGER, $participant->getAttempts()], + 'submitted' => [ilDBConstants::T_INTEGER, $participant->getSubmitted() ? 1 : 0], + 'last_finished_pass' => [ilDBConstants::T_INTEGER, $participant->getLastFinishedAttempt()], + 'last_started_pass' => [ilDBConstants::T_INTEGER, $participant->getLastStartedAttempt()], + 'importname' => [ilDBConstants::T_TEXT, "{$participant->getFirstname()} {$participant->getLastname()}"], + 'tstamp' => [ilDBConstants::T_INTEGER, time()], + 'submittimestamp' => [ilDBConstants::T_TIMESTAMP, $tt->nullableString($normalized['submittimestamp'])], + 'lastindex' => [ilDBConstants::T_INTEGER, $tt->nullableInt($normalized['lastindex'])], + 'objective_container' => [ilDBConstants::T_INTEGER, $tt->nullableInt($normalized['objective_container'])], + 'start_lock' => [ilDBConstants::T_TEXT, $tt->nullableString($normalized['start_lock'])], + ] + ); + $this->log->debug("Stored test session in database: {$new_active_id} (Active ID)"); + } + } + + private function importInvitedParticipant(array $normalized, Transformations $tt): void + { + // TestID and UserID will be replaced by the mapping pipe + $participant = $tt->denormalize($normalized, Participant::class); + + $this->database->insert('tst_invited_user', [ + 'test_fi' => [ilDBConstants::T_INTEGER, $participant->getTestId()], + 'user_fi' => [ilDBConstants::T_INTEGER, $participant->getUserId()], + 'ip_range_from' => [ilDBConstants::T_TEXT, $participant->getClientIpFrom()], + 'ip_range_to' => [ilDBConstants::T_TEXT, $participant->getClientIpTo()], + 'tstamp' => [ilDBConstants::T_INTEGER, $participant->getInvitationDate()], + ]); + $this->log->debug("Stored invited participant in database: {$participant->getUserId()} (User ID), {$participant->getTestId()} (Test ID)"); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Import/TestResultsImporter.php b/components/ILIAS/Test/src/ExportImport/Import/TestResultsImporter.php new file mode 100644 index 000000000000..e01733bd9ca9 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Import/TestResultsImporter.php @@ -0,0 +1,283 @@ + $data) { + match($name) { + 'sequences' => $this->importTestSequences($data, $tt), + 'solutions' => $this->importSolutions($data, $tt), + 'results' => $this->importQuestionResults($data, $tt), + 'attempts' => $this->importAttemptResults($data, $tt), + 'test_result' => $this->importTestResult($data, $tt), + 'working_times' => $this->importWorkingTimes($data, $tt), + 'manual_feedback' => $this->importManualFeedback($data, $tt), + 'manual_scoring' => $this->importManualScoring($data, $tt), + 'questions' => $this->importRandomTestQuestions($data, $tt), + default => $this->log->warning("Invalid result type: {$name}"), + }; + } + } + } + + public function importTestSequences(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // ActiveID and QuestionIDs will be replaced by the mapping pipe + $sequence = $tt->denormalize($normalized, ilTestSequence::class); + $sequence->saveToDb(); + $this->log->debug("Stored test sequence in database: {$sequence->getActiveId()} (Active ID), {$sequence->getPass()} (Pass)"); + } + } + + public function importSolutions(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // ActiveID and QuestionID will be replaced by the mapping pipe + $solution = $tt->denormalize($normalized, Solution::class); + + $next_id = $this->database->nextId('tst_solutions'); + $this->database->insert( + 'tst_solutions', + [ + 'solution_id' => [ilDBConstants::T_INTEGER, $next_id], + 'active_fi' => [ilDBConstants::T_INTEGER, $solution->active_id->getId()], + 'question_fi' => [ilDBConstants::T_INTEGER, $solution->question_id->getId()], + 'pass' => [ilDBConstants::T_INTEGER, $solution->attempt], + 'value1' => [ilDBConstants::T_TEXT, $solution->value1 !== null ? (string) $solution->value1 : null], + 'value2' => [ilDBConstants::T_TEXT, $solution->value2], + 'points' => [ilDBConstants::T_FLOAT, $solution->points], + 'step' => [ilDBConstants::T_INTEGER, $solution->step], + 'authorized' => [ilDBConstants::T_INTEGER, $solution->authorized ? 1 : 0], + 'tstamp' => [ilDBConstants::T_INTEGER, time()], + ] + ); + $this->log->debug("Stored solution in database: {$next_id}"); + } + } + + public function importQuestionResults(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // ActiveID and QuestionID will be replaced by the mapping pipe + $result = $tt->denormalize($normalized, QuestionResult::class); + + $next_id = $this->database->nextId('tst_test_result'); + $this->database->insert( + 'tst_test_result', + [ + 'test_result_id' => [ilDBConstants::T_INTEGER, $next_id], + 'active_fi' => [ilDBConstants::T_INTEGER, $result->active_id->getId()], + 'question_fi' => [ilDBConstants::T_INTEGER, $result->question_id->getId()], + 'pass' => [ilDBConstants::T_INTEGER, $result->attempt], + 'points' => [ilDBConstants::T_FLOAT, $result->points], + 'manual' => [ilDBConstants::T_INTEGER, $result->manual ? 1 : 0], + 'tstamp' => [ilDBConstants::T_INTEGER, time()], + 'answered' => [ilDBConstants::T_INTEGER, $result->answered ? 1 : 0], + 'step' => [ilDBConstants::T_INTEGER, $result->step], + ] + ); + $this->log->debug("Stored question result in database: {$next_id}"); + } + } + public function importAttemptResults(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // ActiveID will be replaced by the mapping pipe + $attempt = $tt->denormalize($normalized, AttemptResult::class); + + $this->database->insert( + 'tst_pass_result', + [ + 'active_fi' => [ilDBConstants::T_INTEGER, $attempt->getActiveId()], + 'pass' => [ilDBConstants::T_INTEGER, $attempt->getAttempt()], + 'maxpoints' => [ilDBConstants::T_FLOAT, $attempt->getMaxPoints()], + 'points' => [ilDBConstants::T_FLOAT, $attempt->getReachedPoints()], + 'questioncount' => [ilDBConstants::T_INTEGER, $attempt->getQuestionCount()], + 'answeredquestions' => [ilDBConstants::T_INTEGER, $attempt->getAnsweredQuestions()], + 'workingtime' => [ilDBConstants::T_INTEGER, $attempt->getWorkingTime()], + 'exam_id' => [ilDBConstants::T_TEXT, $attempt->getExamId()], + 'finalized_by' => [ilDBConstants::T_TEXT, $attempt->getFinalizedBy()], + 'tstamp' => [ilDBConstants::T_INTEGER, time()], + ] + ); + $this->log->debug("Stored attempt result in database: {$attempt->getActiveId()} (Active ID), {$attempt->getAttempt()} (Pass)"); + } + } + + public function importTestResult(?array $normalized, Transformations $tt): void + { + if ($normalized === null) { + $this->log->warning("Missing test result, skipping"); + return; + } + + // ActiveID will be replaced by the mapping pipe + $result = $tt->denormalize($normalized, ParticipantResult::class); + + $this->database->insert( + 'tst_result_cache', + [ + 'active_fi' => [ilDBConstants::T_INTEGER, $result->getActiveId()], + 'pass' => [ilDBConstants::T_INTEGER, $result->getAttempt()], + 'max_points' => [ilDBConstants::T_FLOAT, $result->getMaxPoints()], + 'reached_points' => [ilDBConstants::T_FLOAT, $result->getReachedPoints()], + 'mark_short' => [ilDBConstants::T_TEXT, $result->getMark()->getShortName()], + 'mark_official' => [ilDBConstants::T_TEXT, $result->getMark()->getOfficialName()], + 'passed' => [ilDBConstants::T_INTEGER, $result->isPassed() ? 1 : 0], + 'failed' => [ilDBConstants::T_INTEGER, $result->isFailed() ? 1 : 0], + 'tstamp' => [ilDBConstants::T_INTEGER, time()], + ] + ); + $this->log->debug("Stored test result in database: {$result->getActiveId()} (Active ID), {$result->getAttempt()} (Pass)"); + } + + public function importWorkingTimes(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // ActiveID will be replaced by the mapping pipe + $working_time = $tt->denormalize($normalized, WorkingTime::class); + + $next_id = $this->database->nextId('tst_times'); + $this->database->insert( + 'tst_times', + [ + 'times_id' => [ilDBConstants::T_INTEGER, $next_id], + 'active_fi' => [ilDBConstants::T_INTEGER, $working_time->active_id->getId()], + 'pass' => [ilDBConstants::T_INTEGER, $working_time->attempt], + 'started' => [ilDBConstants::T_TIMESTAMP, $working_time->started], + 'finished' => [ilDBConstants::T_TIMESTAMP, $working_time->finished], + 'tstamp' => [ilDBConstants::T_INTEGER, time()], + ] + ); + $this->log->debug("Stored working time in database: {$next_id}"); + } + } + + public function importManualFeedback(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // ActiveID, QuestionID and UserID will be replaced by the mapping pipe + $manual_feedback = $tt->denormalize($normalized, ManualFeedback::class); + + $next_id = $this->database->nextId('tst_manual_fb'); + $this->database->insert( + 'tst_manual_fb', + [ + 'manual_feedback_id' => [ilDBConstants::T_INTEGER, $next_id], + 'active_fi' => [ilDBConstants::T_INTEGER, $manual_feedback->active_id->getId()], + 'question_fi' => [ilDBConstants::T_INTEGER, $manual_feedback->question_id->getId()], + 'pass' => [ilDBConstants::T_INTEGER, $manual_feedback->attempt], + 'feedback' => [ilDBConstants::T_TEXT, $manual_feedback->feedback], + 'finalized_evaluation' => [ilDBConstants::T_INTEGER, $manual_feedback->finalized_evaluation ? 1 : 0], + 'finalized_timestamp' => [ilDBConstants::T_INTEGER, $manual_feedback->finalized_timestamp], + 'finalized_by_usr_id' => [ilDBConstants::T_INTEGER, $manual_feedback->finalized_by->getId()], + 'tstamp' => [ilDBConstants::T_INTEGER, time()], + ] + ); + $this->log->debug("Stored manual feedback in database: {$next_id}"); + } + } + + public function importManualScoring(array $normalized, Transformations $tt): void + { + // ActiveID will be replaced by the mapping pipe + $active_id = $tt->denormalize($normalized['active_id'], Id::class)->getId(); + + new TestManScoringDoneHelper()->setDone($active_id, $tt->bool($normalized['done'])); + $this->log->debug("Stored manual scoring in database: {$active_id} (Active ID)"); + } + + public function importAdditionalWorkingTimes(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // UserID and TestID will be replaced by the mapping pipe + $time = $tt->denormalize($normalized, AdditionalWorkingTime::class); + + $this->database->insert( + 'tst_addtime', + [ + 'additionaltime' => [ilDBConstants::T_INTEGER, $time->time], + 'user_fi' => [ilDBConstants::T_INTEGER, $time->user_id->getId()], + 'test_fi' => [ilDBConstants::T_INTEGER, $time->test_id->getId()], + 'tstamp' => [ilDBConstants::T_TIMESTAMP, $time->timestamp], + ] + ); + $this->log->debug("Stored additional working time in database: {$time->user_id->getId()} (User ID)"); + } + } + + public function importRandomTestQuestions(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // ActiveID, QuestionID and SourcePoolDefinitionID will be replaced by the mapping pipe + $question = $tt->denormalize($normalized, RandomTestQuestion::class); + + $next_id = $this->database->nextId('tst_test_rnd_qst'); + $this->database->insert( + 'tst_test_rnd_qst', + [ + 'test_random_question_id' => [ilDBConstants::T_INTEGER, $next_id], + 'active_fi' => [ilDBConstants::T_INTEGER, $question->active_id->getId()], + 'question_fi' => [ilDBConstants::T_INTEGER, $question->question_id->getId()], + 'sequence' => [ilDBConstants::T_INTEGER, $question->sequence], + 'pass' => [ilDBConstants::T_INTEGER, $question->pass], + 'tstamp' => [ilDBConstants::T_INTEGER, time()], + 'src_pool_def_fi' => [ilDBConstants::T_INTEGER, $question->src_pool_def_id->getId()], + ] + ); + $this->log->debug("Stored random test question in database: {$next_id}"); + } + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Import/UserImportResolver.php b/components/ILIAS/Test/src/ExportImport/Import/UserImportResolver.php new file mode 100644 index 000000000000..90e35fa8c3e0 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Import/UserImportResolver.php @@ -0,0 +1,86 @@ + $users + * @return array + */ + public function resolve(UserIdentifiers $criteria, array $users): array + { + $this->log->debug("Resolving user import for criteria {$criteria->value}"); + + if (!$this->db->tableColumnExists('usr_data', $criteria->value)) { + $this->log->error("User criteria field {$criteria->value} does not exist in usr_data table, using anonymous user ID for all users"); + + return array_fill_keys(array_keys($users), ANONYMOUS_USER_ID); + } + + $in_clause = $this->db->in( + $criteria->value, + array_values($users), + false, + $criteria->getColumnType() + ); + $query = $this->db->query("SELECT usr_id, {$criteria->value} AS identifier FROM usr_data WHERE {$in_clause}"); + + $db_mapping = []; + foreach ($this->db->fetchAll($query) as $row) { + $db_mapping[$row['identifier']] = $row['usr_id']; + } + + $mapping = []; + foreach ($users as $original_id => $identifier) { + if (isset($db_mapping[$identifier])) { + $this->log->debug("User identifier {$identifier} found, mapping user {$original_id} to {$db_mapping[$identifier]}"); + $mapping[$original_id] = $db_mapping[$identifier]; + } else { + $this->log->warning("User identifier {$identifier} not found for user {$original_id}, using anonymous user ID"); + $mapping[$original_id] = ANONYMOUS_USER_ID; + } + } + + return $mapping; + } + + /** + * @param array $user_mapping + */ + public function store(array $user_mapping, ilImportMapping $import_mapping): void + { + foreach ($user_mapping as $original_id => $user_id) { + $import_mapping->addMapping('tst', 'user', (string) $original_id, (string) $user_id); + } + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Normalizer/AttemptResultNormalizer.php b/components/ILIAS/Test/src/ExportImport/Normalizer/AttemptResultNormalizer.php new file mode 100644 index 000000000000..15a1a4349b90 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalizer/AttemptResultNormalizer.php @@ -0,0 +1,86 @@ + + */ +#[Normalizes(AttemptResult::class)] +class AttemptResultNormalizer implements Normalizer +{ + public function __construct( + private readonly Transformations $tt, + ) { + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if (!$value instanceof AttemptResult) { + throw new NormalizingException('Invalid value', $value); + } + + return [ + 'active_id' => $this->tt->normalize(new Id($value->getActiveId(), 'participant')), + 'attempt' => $value->getAttempt(), + 'max_points' => $value->getMaxPoints(), + 'reached_points' => $value->getReachedPoints(), + 'question_count' => $value->getQuestionCount(), + 'answered_questions' => $value->getAnsweredQuestions(), + 'working_time' => $value->getWorkingTime(), + 'timestamp' => $value->getTimestamp(), + 'exam_id' => $value->getExamId(), + 'finalized_by' => $value->getFinalizedBy(), + ]; + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): AttemptResult + { + if ($type !== AttemptResult::class) { + throw new NormalizingException("Invalid type for AttemptResult: {$type}"); + } + + return new AttemptResult( + $this->tt->denormalize($value['active_id'], Id::class)->getId(), + $this->tt->int($value['attempt']), + $this->tt->float($value['max_points']), + $this->tt->float($value['reached_points']), + $this->tt->int($value['question_count']), + $this->tt->int($value['answered_questions']), + $this->tt->int($value['working_time']), + $this->tt->int($value['timestamp']), + $this->tt->string($value['exam_id']), + $this->tt->string($value['finalized_by']), + ); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Normalizer/ExportableNormalizer.php b/components/ILIAS/Test/src/ExportImport/Normalizer/ExportableNormalizer.php new file mode 100644 index 000000000000..1490132728dd --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalizer/ExportableNormalizer.php @@ -0,0 +1,63 @@ + + */ +#[Normalizes(Exportable::class)] +class ExportableNormalizer implements Normalizer +{ + public function __construct( + private readonly Transformations $tt, + ) { + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if (!$value instanceof Exportable) { + throw new NormalizingException('Invalid exportable value', $value); + } + + return $value->toExport(); + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): Exportable + { + if (!in_array(Exportable::class, class_implements($type))) { + throw new NormalizingException('Invalid exportable type', $type); + } + + return $type::fromExport($value); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Normalizer/ParticipantNormalizer.php b/components/ILIAS/Test/src/ExportImport/Normalizer/ParticipantNormalizer.php new file mode 100644 index 000000000000..c3e11a9f982b --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalizer/ParticipantNormalizer.php @@ -0,0 +1,113 @@ + + */ +#[Normalizes(Participant::class)] +class ParticipantNormalizer implements Normalizer +{ + public function __construct( + private readonly Transformations $tt, + ) { + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if (!$value instanceof Participant) { + throw new NormalizingException('Invalid value', $value); + } + + return [ + 'user_id' => $this->tt->normalize(new Id($value->getUserId(), 'user')), + 'active_id' => $value->getActiveId() !== null + ? $this->tt->normalize(new Id($value->getActiveId(), 'participant')) + : null, + 'test_id' => $this->tt->normalize(new Id($value->getTestId(), 'tst')), + 'anonymous_id' => $value->getAnonymousId(), + 'firstname' => $value->getFirstname(), + 'lastname' => $value->getLastname(), + 'login' => $value->getLogin(), + 'importname' => $value->getImportname(), + 'matriculation' => $value->getMatriculation(), + 'extra_time' => $value->getExtraTime(), + 'attempts' => $value->getAttempts(), + 'client_ip_from' => $value->getClientIpFrom(), + 'client_ip_to' => $value->getClientIpTo(), + 'invitation_date' => $value->getInvitationDate(), + 'submitted' => $value->getSubmitted(), + 'last_started_attempt' => $value->getLastStartedAttempt(), + 'last_finished_attempt' => $value->getLastFinishedAttempt(), + 'unfinished_attempts' => $value->hasUnfinishedAttempts(), + 'first_access' => $this->tt->normalize($value->getFirstAccess()), + 'last_access' => $this->tt->normalize($value->getLastAccess()), + 'scoring_finalized' => $value->isScoringFinalized(), + ]; + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): Participant + { + if ($type !== Participant::class) { + throw new NormalizingException("Invalid type for Participant: {$type}"); + } + + return new Participant( + $this->tt->denormalize($value['user_id'], Id::class)->getId(), + $value['active_id'] !== null + ? $this->tt->denormalize($value['active_id'], Id::class)->getId() + : null, + $this->tt->denormalize($value['test_id'], Id::class)->getId(), + $this->tt->nullableString($value['anonymous_id']), + $this->tt->string($value['firstname']), + $this->tt->string($value['lastname']), + $this->tt->string($value['login']), + $this->tt->nullableString($value['importname']), + $this->tt->string($value['matriculation']), + $this->tt->int($value['extra_time']), + $this->tt->int($value['attempts']), + $this->tt->nullableString($value['client_ip_from']), + $this->tt->nullableString($value['client_ip_to']), + $this->tt->nullableInt($value['invitation_date']), + $this->tt->nullableBool($value['submitted']), + $this->tt->nullableInt($value['last_started_attempt']), + $this->tt->nullableInt($value['last_finished_attempt']), + $this->tt->bool($value['unfinished_attempts']), + $this->tt->denormalize($value['first_access'], DateTimeImmutable::class), + $this->tt->denormalize($value['last_access'], DateTimeImmutable::class), + $this->tt->bool($value['scoring_finalized']), + ); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Normalizer/ParticipantResultNormalizer.php b/components/ILIAS/Test/src/ExportImport/Normalizer/ParticipantResultNormalizer.php new file mode 100644 index 000000000000..b31df4ca9e0a --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalizer/ParticipantResultNormalizer.php @@ -0,0 +1,81 @@ + + */ +#[Normalizes(ParticipantResult::class)] +class ParticipantResultNormalizer implements Normalizer +{ + public function __construct( + private readonly Transformations $tt, + ) { + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if (!$value instanceof ParticipantResult) { + throw new NormalizingException('Invalid value', $value); + } + + return [ + 'active_id' => $this->tt->normalize(new Id($value->getActiveId(), 'participant')), + 'attempt' => $value->getAttempt(), + 'max_points' => $value->getMaxPoints(), + 'reached_points' => $value->getReachedPoints(), + 'mark' => $this->tt->normalize($value->getMark()), + 'timestamp' => $value->getTimestamp(), + 'passed_once' => $value->isPassedOnce(), + ]; + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): ParticipantResult + { + if ($type !== ParticipantResult::class) { + throw new NormalizingException("Invalid type for ParticipantResult: {$type}"); + } + + return new ParticipantResult( + $this->tt->denormalize($value['active_id'], Id::class)->getId(), + $this->tt->int($value['attempt']), + $this->tt->float($value['max_points']), + $this->tt->float($value['reached_points']), + $this->tt->denormalize($value['mark'], Mark::class), + $this->tt->int($value['timestamp']), + $this->tt->bool($value['passed_once']), + ); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Normalizer/QuestionSetConfigNormalizer.php b/components/ILIAS/Test/src/ExportImport/Normalizer/QuestionSetConfigNormalizer.php new file mode 100644 index 000000000000..f91a2aff589d --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalizer/QuestionSetConfigNormalizer.php @@ -0,0 +1,219 @@ + + */ +#[Normalizes(QuestionSetConfig::class)] +class QuestionSetConfigNormalizer implements Normalizer +{ + public function __construct( + private readonly Transformations $tt, + private readonly Container $dic, + private readonly TestDIC $test_dic, + ) { + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if (!$value instanceof QuestionSetConfig) { + throw new NormalizingException('Invalid value', $value); + } + + $normalized = [ + 'config' => $this->normalizeQuestionSetConfig($value->getConfig()), + 'test_obj' => $this->normalizeTestObj($value->getConfig()), + ]; + + if ($value->isRandom()) { + $normalized['definitions'] = $this->tt->normalize($value->getDefinitions()); + $normalized['staging_pools'] = $this->normalizeStagingPools($value->getStagingPools()); + } + + return $normalized; + } + + private function normalizeQuestionSetConfig(ilTestQuestionSetConfig $config): array + { + if ($config instanceof ilTestFixedQuestionSetConfig) { + return [ + 'type' => ilObjTest::QUESTION_SET_TYPE_FIXED, + ]; + } + + if ($config instanceof ilTestRandomQuestionSetConfig) { + return [ + 'type' => ilObjTest::QUESTION_SET_TYPE_RANDOM, + 'homogeneous' => $config->arePoolsWithHomogeneousScoredQuestionsRequired(), + 'amount_mode' => $config->getQuestionAmountConfigurationMode(), + 'amount' => $config->getQuestionAmountPerTest(), + 'sync_timestamp' => $config->getLastQuestionSyncTimestamp(), + ]; + } + + throw new NormalizingException('Invalid value', $config); + } + + private function normalizeStagingPools(array $staging_pools): array + { + $normalized = []; + foreach ($staging_pools as $pool_id => $questions) { + $normalized[] = [ + 'pool_id' => $this->tt->normalize(new Id($pool_id, 'pool')), + 'questions' => array_map( + fn($question) => $this->tt->normalize( + new Id($question, 'question') + ), + $questions + ), + ]; + } + return $normalized; + } + + private function normalizeTestObj(ilTestQuestionSetConfig $config): array + { + $reflection = new ReflectionClass($config); + $property = $reflection->getProperty('test_obj'); + $property->setAccessible(true); + $test_obj = $property->getValue($config); + + if (!$test_obj instanceof ilObjTest) { + throw new NormalizingException('Invalid test object', $test_obj); + } + + return [ + 'obj_id' => $this->tt->normalize(new Id($test_obj->getId(), 'object')), + 'test_id' => $this->tt->normalize(new Id($test_obj->getTestId(), 'tst')), + ]; + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): mixed + { + if ($type !== QuestionSetConfig::class) { + throw new NormalizingException("Invalid type for QuestionSetConfig: {$type}"); + } + + $test_obj = $this->denormalizeTestObj($value['test_obj']); + $config = $this->denormalizeQuestionSetConfig($value['config'], $test_obj); + + if (!$config instanceof ilTestRandomQuestionSetConfig) { + return new QuestionSetConfig($config); + } + + $definitions = array_map( + fn($definition) => $this->denormalizeSourcePoolDefinition($definition, $test_obj), + $value['definitions'] + ); + $staging_pools = $this->denormalizeStagingPools($value['staging_pools']); + + return new QuestionSetConfig($config, $definitions, $staging_pools); + } + + private function denormalizeQuestionSetConfig(array $normalized, ilObjTest $test_obj): ilTestQuestionSetConfig + { + $class = $normalized['type'] === ilObjTest::QUESTION_SET_TYPE_RANDOM + ? ilTestRandomQuestionSetConfig::class + : ilTestFixedQuestionSetConfig::class; + + $config = new $class( + $this->dic->repositoryTree(), + $this->dic->database(), + $this->dic->language(), + $this->test_dic['logging.logger'], + $this->dic['component.repository'], + $test_obj, + $this->test_dic['question.general_properties.repository'] + ); + + if ($config instanceof ilTestFixedQuestionSetConfig) { + return $config; + } + + $amount_mode = $this->tt->string($normalized['amount_mode']); + if (!$config->isValidQuestionAmountConfigurationMode($amount_mode)) { + throw new ilTestException("Invalid random test question set config amount mode given: {$amount_mode}"); + } + + $config->setQuestionAmountConfigurationMode($amount_mode); + $config->setQuestionAmountPerTest($this->tt->nullableInt($normalized['amount'])); + $config->setPoolsWithHomogeneousScoredQuestionsRequired($this->tt->nullableBool($normalized['homogeneous'])); + $config->setLastQuestionSyncTimestamp($this->tt->nullableInt($normalized['sync_timestamp'])); + + return $config; + } + + private function denormalizeSourcePoolDefinition(array $normalized, ilObjTest $test_obj): ilTestRandomQuestionSetSourcePoolDefinition + { + $definition = new ilTestRandomQuestionSetSourcePoolDefinition( + $this->dic->database(), + $test_obj + ); + + return $this->tt->denormalize($normalized, $definition); + } + + private function denormalizeStagingPools(array $normalized): array + { + $staging_pools = []; + foreach ($normalized as $staging_pool) { + $pool_id = $this->tt->denormalize($staging_pool['pool_id'], Id::class)->getId(); + + $staging_pools[$pool_id] = array_map( + fn($question) => $this->tt->denormalize($question, Id::class)->getId(), + $staging_pool['questions'] + ); + } + return $staging_pools; + } + + private function denormalizeTestObj(array $normalized): ilObjTest + { + $test_obj = new ilObjTest(0, false); + $test_obj->setTestId($this->tt->denormalize($normalized['test_id'], Id::class)->getId()); + $test_obj->setId($this->tt->denormalize($normalized['obj_id'], Id::class)->getId()); + + return $test_obj; + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Normalizer/ilObjTestNormalizer.php b/components/ILIAS/Test/src/ExportImport/Normalizer/ilObjTestNormalizer.php new file mode 100644 index 000000000000..35b05ae5d353 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalizer/ilObjTestNormalizer.php @@ -0,0 +1,68 @@ + + */ +#[Normalizes(ilObjTest::class)] +class ilObjTestNormalizer extends IlObjectNormalizer implements Normalizer +{ + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if (!$value instanceof ilObjTest) { + throw new NormalizingException('Invalid value', $value); + } + + $normalized = parent::normalize($value); + $normalized['test_id'] = $this->tt->normalize(new Id($value->getTestId(), 'tst')); + + return $normalized; + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): ilObjTest + { + if ($type !== ilObjTest::class) { + throw new NormalizingException("Invalid type for ilObjTest: {$type}"); + } + + /** @var ilObjTest $object */ + $object = parent::denormalize($value, ilObjTest::class); + $object->setTestId( + $this->tt->denormalize($value['test_id'], Id::class)->getId() + ); + + return $object; + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Normalizer/ilTestSequenceNormalizer.php b/components/ILIAS/Test/src/ExportImport/Normalizer/ilTestSequenceNormalizer.php new file mode 100644 index 000000000000..00762cb1e893 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalizer/ilTestSequenceNormalizer.php @@ -0,0 +1,123 @@ + + */ +#[Normalizes(ilTestSequence::class)] +class ilTestSequenceNormalizer implements Normalizer +{ + private readonly ilDBInterface $db; + private readonly GeneralQuestionPropertiesRepository $repository; + + public function __construct( + private readonly Transformations $tt, + Container $dic, + TestDIC $local_dic, + ) { + $this->db = $dic->database(); + $this->repository = $local_dic['question.general_properties.repository']; + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if (!$value instanceof ilTestSequence) { + throw new NormalizingException('Invalid value', $value); + } + + return [ + 'active_id' => $this->tt->normalize(new Id($value->getActiveId(), 'participant')), + 'attempt' => $value->getPass(), + 'sequence' => $value->sequencedata['sequence'], + 'postponed' => $this->normalizeQuestions($value->sequencedata['postponed'] ?? []), + 'hidden' => $this->normalizeQuestions($value->sequencedata['hidden'] ?? []), + 'ans_opt_confirmed' => $value->isAnsweringOptionalQuestionsConfirmed(), + 'optional_questions' => $this->normalizeQuestions($value->getOptionalQuestions()), + ]; + } + + private function normalizeQuestions(array $questions): array + { + return array_map( + fn(int $question_id) => $this->tt->normalize(new Id($question_id, 'question')), + $questions + ); + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): ilTestSequence + { + if ($type !== ilTestSequence::class) { + throw new NormalizingException("Invalid type for ilTestSequence: {$type}"); + } + + $active_id = $this->tt->denormalize($value['active_id'], Id::class)->getId(); + $attempt = $this->tt->int($value['attempt']); + + $sequence = new ilTestSequence($this->db, $active_id, $attempt, $this->repository); + + $sequence->setAnsweringOptionalQuestionsConfirmed($this->tt->bool($value['ans_opt_confirmed'])); + $sequence->sequencedata['sequence'] = $this->denormalizeSequence($value['sequence']); + $sequence->sequencedata['postponed'] = $this->denormalizeQuestions($value['postponed']); + $sequence->sequencedata['hidden'] = $this->denormalizeQuestions($value['hidden']); + + $optional = $this->denormalizeQuestions($value['optional_questions']); + foreach ($optional as $question_id) { + $sequence->setQuestionOptional($question_id); + } + + return $sequence; + } + + private function denormalizeSequence(array $normalized): array + { + $sequence = []; + foreach ($normalized as $key => $item) { + $sequence[$this->tt->int($key)] = $this->tt->int($item); + } + return $sequence; + } + + private function denormalizeQuestions(array $normalized): array + { + return array_map( + fn(mixed $item) => $this->tt->denormalize($item, Id::class)->getId(), + $normalized + ); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Normalizer/ilTestSkillLevelThresholdNormalizer.php b/components/ILIAS/Test/src/ExportImport/Normalizer/ilTestSkillLevelThresholdNormalizer.php new file mode 100644 index 000000000000..e788922778ee --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalizer/ilTestSkillLevelThresholdNormalizer.php @@ -0,0 +1,83 @@ + + */ +#[Normalizes(ilTestSkillLevelThreshold::class)] +class ilTestSkillLevelThresholdNormalizer implements Normalizer +{ + private readonly ilDBInterface $db; + + public function __construct( + private readonly Transformations $tt, + Container $dic + ) { + $this->db = $dic->database(); + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if (!$value instanceof ilTestSkillLevelThreshold) { + throw new NormalizingException('Invalid value', $value); + } + + return [ + 'id' => $this->tt->normalize(new Id($value->getSkillLevelId(), 'skill_level')), + 'test_id' => $this->tt->normalize(new Id($value->getTestId(), 'tst')), + 'skill_base_id' => $this->tt->normalize(new Id($value->getSkillBaseId(), 'skill_base')), + 'skill_tref_id' => $this->tt->normalize(new Id($value->getSkillTrefId(), 'skill_tref')), + 'threshold' => $value->getThreshold(), + ]; + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): ilTestSkillLevelThreshold + { + if ($type !== ilTestSkillLevelThreshold::class) { + throw new NormalizingException("Invalid type for ilTestSkillLevelThreshold: {$type}"); + } + + $threshold = new ilTestSkillLevelThreshold($this->db); + $threshold->setSkillLevelId($this->tt->denormalize($value['id'], Id::class)->getId()); + $threshold->setTestId($this->tt->denormalize($value['test_id'], Id::class)->getId()); + $threshold->setSkillBaseId($this->tt->denormalize($value['skill_base_id'], Id::class)->getId()); + $threshold->setSkillTrefId($this->tt->denormalize($value['skill_tref_id'], Id::class)->getId()); + $threshold->setThreshold($this->tt->int($value['threshold'])); + + return $threshold; + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Pipes/CollectUserIds.php b/components/ILIAS/Test/src/ExportImport/Pipes/CollectUserIds.php new file mode 100644 index 000000000000..5f727a3fd49e --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Pipes/CollectUserIds.php @@ -0,0 +1,61 @@ + $ids + */ + private array $ids = []; + + /** + * @inheritDoc + */ + public function handle(mixed $passable, \Closure $next): mixed + { + if ($passable instanceof NormalizeCarry && $passable->value instanceof Id) { + if ($passable->value->getObject() === 'user') { + $this->ids[$passable->value->getId()] = true; + } + } + + return $next($passable); + } + + /** + * Get all user IDs collected during normalization. + * + * @return list + */ + public function getIds(): array + { + return array_keys($this->ids); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/TestCollector.php b/components/ILIAS/Test/src/ExportImport/TestCollector.php new file mode 100644 index 000000000000..538d194e3b6f --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/TestCollector.php @@ -0,0 +1,442 @@ + $questions */ + private ?array $questions = null; + private ?ilObjTest $test = null; + private ?array $participants = null; + + + public function __construct( + private readonly ParticipantRepository $participant_repository, + private readonly ResultsRepository $results_repository, + private readonly QuestionsRepository $questions_repository, + private readonly GeneralQuestionPropertiesRepository $general_questions_repository, + private readonly ilDBInterface $db, + private readonly ilTree $tree, + private readonly Language $lng, + private readonly TestLogger $logger, + private readonly ilComponentRepository $component_repository, + private readonly ObjectId $object_id + ) { + $this->manual_scoring = new TestManScoringDoneHelper($this->db); + } + + private function database(): ilDBInterface + { + return $this->db; + } + + public function getObjectId(): ObjectId + { + return $this->object_id; + } + + public function getTestId(): int + { + return $this->getObject()->getTestId(); + } + + public function getObject(): ilObjTest + { + if ($this->test === null) { + $this->test = new ilObjTest($this->object_id->toInt(), false); + } + + return $this->test; + } + + public function getSettings(): array + { + return [ + 'main' => $this->test->getMainSettings(), + 'scoring' => $this->test->getScoreSettings(), + 'marks' => $this->test->getMarkSchema(), + ]; + } + + /** + * Create a mapping of user IDs to the user identifier field specified in the test object's global settings. + * + * @param list $user_ids + * @return array{identifier: string, mapping: array} + */ + public function getUserMapping(array $user_ids): array + { + $export_identifier = $this->getObject()->getGlobalSettings()->getUserIdentifier(); + + $mapping = []; + if ($export_identifier === UserIdentifiers::USER_ID) { + foreach ($user_ids as $user_id) { + $mapping[$user_id] = $user_id; + } + } else { + $in_clause = $this->db->in('usr_id', $user_ids, false, ilDBConstants::T_INTEGER); + $query = $this->db->query("SELECT usr_id, {$export_identifier->value} FROM usr_data WHERE {$in_clause}"); + + foreach ($this->db->fetchAll($query) as $row) { + $mapping[$row['usr_id']] = $row[$export_identifier->value]; + } + }; + + return [ + 'identifier' => $export_identifier->value, + 'mapping' => $mapping, + ]; + } + + /* + Questions + */ + + /** + * @inheritDoc + */ + public function getQuestionProperties(): array + { + return array_map( + fn(Properties $property) => $property->getGeneralQuestionProperties(), + $this->getTestQuestionProperties() + ); + } + + /** + * @return array + */ + public function getTestQuestionProperties(): array + { + if ($this->questions === null) { + $this->questions = $this->questions_repository->getQuestionPropertiesForTest($this->getObject()); + } + return $this->questions; + } + + /** + * @return list + */ + public function getSkillLevelThresholds(): array + { + $threshold_list = new ilTestSkillLevelThresholdList($this->database()); + $threshold_list->setTestId($this->getTestId()); + $threshold_list->loadFromDb(); + + $thresholds = []; + foreach ($this->getSkillAssignments() as $assignment) { + $thresholds += $threshold_list->getThesholdsOfBaseAndTrefId( + $assignment->getSkillBaseId(), + $assignment->getSkillTrefId() + ); + } + + return $thresholds; + } + + /** + * Get the question set config. If it is a random question set config, also return the source pool sages and + * definitions. + */ + public function getQuestionSetConfig(): QuestionSetConfig + { + $factory = new ilTestQuestionSetConfigFactory( + $this->tree, + $this->db, + $this->lng, + $this->logger, + $this->component_repository, + $this->getObject(), + $this->general_questions_repository + ); + + $config = new QuestionSetConfig($factory->getQuestionSetConfig()); + if (!$config->isRandom()) { + return $config; + } + + $definition_factory = new ilTestRandomQuestionSetSourcePoolDefinitionFactory( + $this->db, + $this->getObject() + ); + + $definition_list = new ilTestRandomQuestionSetSourcePoolDefinitionList( + $this->db, + $this->getObject(), + $definition_factory + ); + + $definition_list->loadDefinitions(); + $config->setDefinitions(iterator_to_array($definition_list)); + + foreach ($definition_list->getInvolvedSourcePoolIds() as $pool_id) { + $config->addStagingPoolQuestions($pool_id, $this->getStagingPoolQuestions($pool_id)); + } + + return $config; + } + + /** + * @return list + */ + private function getStagingPoolQuestions(int $pool_id): array + { + $question_list = new ilTestRandomQuestionSetStagingPoolQuestionList( + $this->db, + $this->component_repository + ); + $question_list->setTestId($this->getTestId()); + $question_list->setPoolId($pool_id); + $question_list->loadQuestions(); + + return $question_list->getQuestions(); + } + + /* + Participants + */ + + /** + * @return Generator + */ + public function getParticipants(): Generator + { + return $this->participant_repository->getParticipants($this->getTestId()); + } + + /** + * @return list + */ + public function getParticipantsIds(): array + { + if ($this->participants === null) { + $this->participants = []; + foreach ($this->getParticipants() as $participant) { + if ($participant->getActiveId() !== null) { + $this->participants[] = $participant->getActiveId(); + } + } + } + return $this->participants; + } + + /** + * @param list $participant_ids + * @return array + */ + public function getAdditionalParticipantData(array $participant_ids): array + { + $in_clause = $this->db->in('active_id', $participant_ids, false, ilDBConstants::T_INTEGER); + $query = $this->db->query("SELECT active_id AS mapping_id, submittimestamp, lastindex, objective_container, start_lock FROM tst_active WHERE {$in_clause}"); + + $data = []; + while ($row = $this->db->fetchAssoc($query)) { + $data[$row['mapping_id']] = $row; + } + + return $data; + } + + /* + Results + */ + + public function getResults(int $participant_id): array + { + $attempt_results = $this->results_repository->getTestAttemptResults($participant_id); + $set = [ + 'sequences' => $this->getSequences($participant_id, array_keys($attempt_results)), + 'solutions' => $this->getSolutions($participant_id), + 'results' => $this->getQuestionResults($participant_id), + 'attempts' => $attempt_results, + 'test_result' => $this->results_repository->getTestResult($participant_id), + 'working_times' => $this->getWorkingTimes($participant_id), + 'manual_feedback' => $this->getManualFeedback($participant_id), + 'manual_scoring' => [ + 'active_id' => new Id($participant_id, 'participant'), + 'done' => $this->manual_scoring->isDone($participant_id), + ], + ]; + + if ($this->getObject()->isRandomTest()) { + $set['questions'] = $this->getRandomTestQuestions($participant_id); + } + return $set; + } + + /** + * @return list + */ + public function getSolutions(int $participant_id): array + { + $query = $this->db->queryF( + "SELECT * FROM tst_solutions WHERE active_fi = %s", + [ilDBConstants::T_INTEGER], + [$participant_id] + ); + + return array_map( + fn(array $row): Solution => Solution::fromRow($row), + $this->db->fetchAll($query) + ); + } + + /** + * @return list + */ + public function getQuestionResults(int $participant_id): array + { + $query = $this->db->queryF( + "SELECT * FROM tst_test_result WHERE active_fi = %s", + [ilDBConstants::T_INTEGER], + [$participant_id] + ); + + return array_map( + fn(array $row): QuestionResult => QuestionResult::fromRow($row), + $this->db->fetchAll($query) + ); + } + + /** + * @return list + */ + public function getWorkingTimes(int $participant_id): array + { + $query = $this->db->queryF( + "SELECT * FROM tst_times WHERE active_fi = %s", + [ilDBConstants::T_INTEGER], + [$participant_id] + ); + + return array_map( + fn(array $row): WorkingTime => WorkingTime::fromRow($row), + $this->db->fetchAll($query) + ); + } + + /** + * @return list + */ + public function getManualFeedback(int $participant_id): array + { + $query = $this->db->queryF( + "SELECT * FROM tst_manual_fb WHERE active_fi = %s", + [ilDBConstants::T_INTEGER], + [$participant_id] + ); + + return array_map( + fn(array $row): ManualFeedback => ManualFeedback::fromRow($row), + $this->db->fetchAll($query) + ); + } + + /** + * @param list $attempts + * @return list + */ + public function getSequences(int $participant_id, array $attempts): array + { + foreach ($attempts as $attempt) { + $test_sequence = new ilTestSequence($this->db, $participant_id, $attempt, $this->general_questions_repository); + $test_sequence->loadFromDb(); + $sequences[] = $test_sequence; + } + return $sequences; + } + + + /** + * @return list + */ + public function getAdditionalWorkingTimes(): array + { + $query = $this->db->queryF( + "SELECT * FROM tst_addtime WHERE test_fi = %s", + [ilDBConstants::T_INTEGER], + [$this->getTestId()] + ); + + return array_map( + fn(array $row): AdditionalWorkingTime => AdditionalWorkingTime::fromRow($row), + $this->db->fetchAll($query) + ); + } + + /** + * @return list + */ + public function getRandomTestQuestions(int $participant_id): array + { + $query = $this->db->queryF( + "SELECT * FROM tst_test_rnd_qst WHERE active_fi = %s", + [ilDBConstants::T_INTEGER], + [$participant_id] + ); + + return array_map( + fn(array $row): RandomTestQuestion => RandomTestQuestion::fromRow($row), + $this->db->fetchAll($query) + ); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/TestExporter.php b/components/ILIAS/Test/src/ExportImport/TestExporter.php new file mode 100644 index 000000000000..8df3d140781f --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/TestExporter.php @@ -0,0 +1,442 @@ +logger()->info('Preparing test export (1/3)...'); + $state->assertStep(ExportStep::INIT); + $state->setStep(ExportStep::PREPARE); + + $object_id = $this->extractObjectId($state); + if ($object_id === null) { + return; + } + + $collector = new TestCollector( + $this->participant_repository, + $this->results_repository, + $this->questions_repository, + $this->general_questions_repository, + $this->db, + $this->tree, + $this->lng, + $this->logger, + $this->component_repository, + $object_id + ); + $state->setCollector($collector); + + $transformations = $this->builder + ->withAdditionalPipes([ + new CollectUserIds(), + new CollectQuestionImages( + new UUIDFactory(), + $object_id + ), + new CollectResources( + $this->irss, + $this->logger + ), + ]) + ->create(); + + $state->setTransformations($transformations); + $state->logger()->info('...Finished preparing test export (1/3)'); + } + + private function extractObjectId(ExportState $state): ?ObjectId + { + $target_ids = $state->target()->getObjectIds(); + + if (count($target_ids) === 0) { + $state->logger()->warning('No target object IDs found for test export'); + return null; + } + + if (count($target_ids) > 1) { + $state->logger()->warning( + 'Multiple target object IDs found for test export. Only the first one will be used.' + ); + } + + return $this->data_factory->objId(array_shift($target_ids)); + } + + /** + * @inheritDoc + */ + public function process(ExportState $state): void + { + $state->logger()->info('Processing test export (2/3)...'); + $state->assertStep(ExportStep::PREPARE); + $state->setStep(ExportStep::PROCESS); + + $state->serializer()->group( + 'general', + fn() => $this->exportObject( + $state->collector(), + $state->transformations(), + $state->serializer(), + $state + ) + ); + $state->serializer()->group( + 'settings', + fn() => $this->exportSettings( + $state->collector(), + $state->transformations(), + $state->serializer(), + $state + ) + ); + $state->serializer()->group( + 'questions', + fn() => $this->exportQuestions( + $state->collector(), + $state->transformations(), + $state->serializer(), + $state + ) + ); + $state->serializer()->group( + 'question_set_config', + fn() => $this->exportQuestionSetConfig( + $state->collector(), + $state->transformations(), + $state->serializer(), + ) + ); + $state->serializer()->group( + 'additional_working_times', + fn() => $this->exportAdditionalWorkingTimes( + $state->collector(), + $state->transformations(), + $state->serializer(), + ) + ); + $state->serializer()->group( + 'skill_assignments', + fn() => $this->exportSkillAssignments( + $state->collector(), + $state->transformations(), + $state->serializer(), + ) + ); + $state->serializer()->group( + 'skill_thresholds', + fn() => $this->exportSkillLevelThresholds( + $state->collector(), + $state->transformations(), + $state->serializer(), + ) + ); + + if ($state->getOption() === Types::XML_WITH_RESULTS->value) { + $state->logger()->info('Processing test results export ...'); + $this->processResults($state); + $state->logger()->info('...Finished processing test results export'); + } + + $state->logger()->info('...Finished processing test export (2/3)'); + } + + private function processResults(ExportState $state): void + { + $state->serializer()->group( + 'participants', + fn() => $this->exportParticipants( + $state->collector(), + $state->transformations(), + $state->serializer() + ) + ); + $state->serializer()->group( + 'results', + fn() => $this->exportResults( + $state->collector(), + $state->transformations(), + $state->serializer() + ) + ); + } + + /** + * @inheritDoc + */ + public function write(ExportState $state): void + { + $state->logger()->info('Writing test export (3/3)...'); + $state->assertStep(ExportStep::PROCESS); + $state->setStep(ExportStep::WRITE); + + $export_dir = $state->path()->getPathToComponentExpDirInContainer(); + $question_image_pipe = $state->transformations()->context(CollectQuestionImages::class); + $resource_pipe = $state->transformations()->context(CollectResources::class); + + foreach ($question_image_pipe->getFiles() as $file) { + if (file_exists($file['from'])) { + $state->writer()->writeFileByFilePath( + $file['from'], + "{$export_dir}/" . $file['to'] + ); + $state->logger()->debug("Copied question image {$file['from']} to {$export_dir}/{$file['to']}"); + } else { + $state->logger()->warning('Question image file not found: ' . $file['from']); + } + } + + foreach ($resource_pipe->getResources() as $id => $resource) { + $clean_id = str_replace(['-', '_'], '', $id); + $file = "{$clean_id}.{$resource->getCurrentRevision()->getInformation()->getSuffix()}"; + + $state->writer()->writeFilesByResourceId( + $id, + "{$export_dir}/resources/{$file}" + ); + $state->logger()->debug("Copied resource {$id} to {$export_dir}/resources/{$file}"); + } + + $this->writeMappings( + $state->collector(), + $state->transformations(), + $state + ); + $state->logger()->debug('Stored test export mappings'); + + $state->logger()->info('...Finished writing test export (3/3)'); + } + + + private function exportObject( + TestCollector $collector, + Transformations $transformations, + Serializer $serializer, + ExportState $state + ): void { + $serializer->append('object', $transformations->normalize($collector->getObject())); + + $obj_id = $collector->getObjectId()->toInt(); + $state->addDependency('components/ILIAS/ILIASObject', 'common', [$obj_id]); + $state->addDependency('components/ILIAS/MetaData', 'tst', ["{$obj_id}:0:tst"]); + $state->addDependency( + 'components/ILIAS/Taxonomy', + 'tax', + $this->taxonomy->getUsageOfObject($obj_id) + ); + } + + private function exportSettings( + TestCollector $collector, + Transformations $transformations, + Serializer $serializer, + ExportState $state + ): void { + $test = $collector->getObject(); + $main_settings = $test->getMainSettings(); + + $serializer->append('main', $transformations->normalize($main_settings)); + $serializer->append('scoring', $transformations->normalize($test->getScoreSettings())); + $serializer->append('marks', $transformations->normalize($test->getMarkSchema())); + + if ($intro_page_id = $main_settings->getIntroductionSettings()->getIntroductionPageId()) { + $state->addDependency('components/ILIAS/COPage', 'pg', ["tst:{$intro_page_id}"]); + } + if ($concluding_page_id = $main_settings->getFinishingSettings()->getConcludingRemarksPageId()) { + $state->addDependency('components/ILIAS/COPage', 'pg', ["tst:{$concluding_page_id}"]); + } + } + + private function exportQuestions( + TestCollector $collector, + Transformations $transformations, + Serializer $serializer, + ExportState $state + ): void { + $question_properties = $collector->getTestQuestionProperties(); + + foreach ($collector->getQuestionObjects() as $question) { + $normalized = [ + ... $transformations->normalize($question), + 'feedback' => $transformations->normalize( + $collector->getFeedback($question) + ), + 'sequence' => $question_properties[$question->getId()]->getSequenceInformation()?->getPlaceInSequence(), + ]; + + if ($question instanceof assFormulaQuestion) { + $data = $collector->getUnitsAndCategories($question->getId()); + $normalized['formula_data'] = $transformations->normalize($data); + } + + $serializer->append('question', $normalized); + $state->addDependency('components/ILIAS/COPage', 'pg', ["qpl:{$question->getId()}"]); + } + } + + private function exportQuestionSetConfig( + TestCollector $collector, + Transformations $transformations, + Serializer $serializer, + ): void { + $serializer->append( + 'question_set_config', + $transformations->normalize($collector->getQuestionSetConfig()) + ); + } + + private function exportSkillAssignments( + TestCollector $collector, + Transformations $transformations, + Serializer $serializer, + ): void { + foreach ($collector->getSkillAssignments() as $assignment) { + $serializer->append('skill_assignment', $transformations->normalize($assignment)); + } + } + + private function exportSkillLevelThresholds( + TestCollector $collector, + Transformations $transformations, + Serializer $serializer, + ): void { + foreach ($collector->getSkillLevelThresholds() as $threshold) { + $serializer->append('skill_level_threshold', $transformations->normalize($threshold)); + } + } + + private function exportParticipants( + TestCollector $collector, + Transformations $transformations, + Serializer $serializer + ): void { + $additional_data = $collector->getAdditionalParticipantData($collector->getParticipantsIds()); + + foreach ($collector->getParticipants() as $participant) { + $normalized = $transformations->normalize($participant); + if ($participant->getActiveId() !== null) { + $normalized = array_merge($normalized, $additional_data[$participant->getActiveId()]); + } + + $serializer->append('participant', $normalized); + } + } + + private function exportResults( + TestCollector $collector, + Transformations $transformations, + Serializer $serializer + ): void { + foreach ($collector->getParticipantsIds() as $participant_id) { + $serializer->append( + 'set', + $transformations->normalize( + $collector->getResults($participant_id) + ) + ); + } + } + + private function exportAdditionalWorkingTimes( + TestCollector $collector, + Transformations $transformations, + Serializer $serializer, + ): void { + foreach ($collector->getAdditionalWorkingTimes() as $additional_working_time) { + $serializer->append('time', $transformations->normalize($additional_working_time)); + } + } + + private function writeMappings( + TestCollector $collector, + Transformations $transformations, + ExportState $state + ): void { + $serializer = new SimpleXMLSerializer()->open('memory'); + $serializer->createDocument('Test Export Mappings'); + $serializer->startGroup('mappings'); + + $user_ids = $transformations->context(CollectUserIds::class)->getIds(); + $serializer->append('users', $collector->getUserMapping($user_ids)); + + $resources = $transformations->context(CollectResources::class)->getResources(); + $serializer->append( + 'resources', + array_map($transformations->normalize(...), $resources) + ); + + $serializer->endGroup('mappings'); + + $state->writer()->writeFileByStream( + Streams::ofString($serializer->write()), + "{$state->path()->getPathToComponentDirInContainer()}/mappings.xml" + ); + } +} diff --git a/components/ILIAS/Test/src/Participants/Participant.php b/components/ILIAS/Test/src/Participants/Participant.php index 21bf4258b985..83b90b75e82b 100644 --- a/components/ILIAS/Test/src/Participants/Participant.php +++ b/components/ILIAS/Test/src/Participants/Participant.php @@ -145,6 +145,11 @@ public function withClientIpTo(?string $ip): self return $clone; } + public function getInvitationDate(): ?int + { + return $this->invitation_date; + } + public function isInvitedParticipant(): bool { return $this->invitation_date > 0; diff --git a/components/ILIAS/Test/src/Results/Data/Repository.php b/components/ILIAS/Test/src/Results/Data/Repository.php index 70c1499d41b4..f7168d7850d2 100644 --- a/components/ILIAS/Test/src/Results/Data/Repository.php +++ b/components/ILIAS/Test/src/Results/Data/Repository.php @@ -150,14 +150,29 @@ public function updateTestResultCache(int $active_id, ?\ilAssQuestionProcessLock return $result; } - public function getTestAttemptResult(int $active_id): ?AttemptResult + public function getTestAttemptResult(int $active_id, int $attempt): ?AttemptResult + { + $result = $this->db->queryF( + "SELECT * FROM tst_pass_result WHERE active_fi = %s AND pass = %s", + [\ilDBConstants::T_INTEGER, \ilDBConstants::T_INTEGER], + [$active_id, $attempt] + ); + return $this->toTestAttemptResult($this->db->fetchAssoc($result)); + } + + public function getTestAttemptResults(int $active_id): array { $result = $this->db->queryF( "SELECT * FROM tst_pass_result WHERE active_fi = %s", [\ilDBConstants::T_INTEGER], [$active_id] ); - return $this->toTestAttemptResult($this->db->fetchAssoc($result)); + + $results = []; + while ($row = $this->db->fetchAssoc($result)) { + $results[$row['pass']] = $this->toTestAttemptResult($row); + } + return $results; } public function updateTestAttemptResult( diff --git a/components/ILIAS/Test/src/Settings/GlobalSettings/UserIdentfiers.php b/components/ILIAS/Test/src/Settings/GlobalSettings/UserIdentfiers.php index ac8d187627fb..9910191d975b 100755 --- a/components/ILIAS/Test/src/Settings/GlobalSettings/UserIdentfiers.php +++ b/components/ILIAS/Test/src/Settings/GlobalSettings/UserIdentfiers.php @@ -27,4 +27,15 @@ enum UserIdentifiers: string case EMAIL = 'email'; case MATRICULATION = 'matriculation'; case EXTERNAL_ACCOUNT = 'ext_account'; + + public function getColumnType(): string + { + return match ($this) { + self::USER_ID => \ilDBConstants::T_INTEGER, + self::LOGIN => \ilDBConstants::T_TEXT, + self::EMAIL => \ilDBConstants::T_TEXT, + self::MATRICULATION => \ilDBConstants::T_TEXT, + self::EXTERNAL_ACCOUNT => \ilDBConstants::T_TEXT, + }; + } } diff --git a/components/ILIAS/Test/src/Settings/MainSettings/SettingsFinishing.php b/components/ILIAS/Test/src/Settings/MainSettings/SettingsFinishing.php index 3334bf32c421..9f7d6ded7c67 100755 --- a/components/ILIAS/Test/src/Settings/MainSettings/SettingsFinishing.php +++ b/components/ILIAS/Test/src/Settings/MainSettings/SettingsFinishing.php @@ -270,8 +270,8 @@ public static function fromExport(array $data): static return new self( (bool) $data['enable_examview'], (bool) $data['showfinalstatement'], - $data['concluding_remarks_page_id'], - RedirectionModes::from($data['redirection_mode']), + $data['concluding_remarks_page_id'] !== null ? (int) $data['concluding_remarks_page_id'] : null, + RedirectionModes::from((int) $data['redirection_mode']), $data['redirection_url'] ); } diff --git a/components/ILIAS/Test/src/Settings/MainSettings/SettingsIntroduction.php b/components/ILIAS/Test/src/Settings/MainSettings/SettingsIntroduction.php index 6ef0bded0d66..223c9ba82cc5 100755 --- a/components/ILIAS/Test/src/Settings/MainSettings/SettingsIntroduction.php +++ b/components/ILIAS/Test/src/Settings/MainSettings/SettingsIntroduction.php @@ -122,7 +122,7 @@ public static function fromExport(array $data): static { return new self( (bool) $data['intro_enabled'], - $data['introduction_page_id'], + $data['introduction_page_id'] !== null ? (int) $data['introduction_page_id'] : null, (bool) $data['conditions_checkbox_enabled'], ); } diff --git a/components/ILIAS/Test/src/Settings/ScoreReporting/SettingsResultSummary.php b/components/ILIAS/Test/src/Settings/ScoreReporting/SettingsResultSummary.php index be067e6d41a9..96357dd151d4 100755 --- a/components/ILIAS/Test/src/Settings/ScoreReporting/SettingsResultSummary.php +++ b/components/ILIAS/Test/src/Settings/ScoreReporting/SettingsResultSummary.php @@ -292,7 +292,7 @@ public function toExport(): array public static function fromExport(array $data): static { return (new self()) - ->withScoreReporting(ScoreReportingTypes::from($data['score_reporting'])) + ->withScoreReporting(ScoreReportingTypes::from((int) $data['score_reporting'])) ->withReportingDate($data['reporting_date'] ? new \DateTimeImmutable($data['reporting_date']) : null) ->withShowGradingStatusEnabled((bool) $data['show_grading_status']) ->withShowGradingMarkEnabled((bool) $data['show_grading_mark']) diff --git a/components/ILIAS/Test/src/TestDIC.php b/components/ILIAS/Test/src/TestDIC.php index 1fcef8585ec3..b87ad3516f36 100755 --- a/components/ILIAS/Test/src/TestDIC.php +++ b/components/ILIAS/Test/src/TestDIC.php @@ -21,6 +21,9 @@ namespace ILIAS\Test; use ILIAS\LegalDocuments\ConsumerToolbox\Setting; +use ILIAS\Test\ExportImport\Import\RandomTestConfigImporter; +use ILIAS\Test\ExportImport\Import\TestResultsImporter; +use ILIAS\Test\ExportImport\TestExporter; use ILIAS\Test\Participants\ParticipantRepository; use ILIAS\Test\Results\Data\Repository as TestResultRepository; use ILIAS\Test\Scoring\Marks\MarkSchemaFactory; @@ -50,10 +53,19 @@ use ILIAS\Test\Results\Data\Factory as ResultsDataFactory; use ILIAS\Test\Results\Presentation\Factory as ResultsPresentationFactory; use ILIAS\Test\Results\Toplist\TestTopListRepository; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Bridge\StateHolder; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Builder; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportSessionRepository; +use ILIAS\TestQuestionPool\ExportImport\Import\QuestionsImporter; +use ILIAS\TestQuestionPool\ExportImport\Import\SkillAssignmentsImporter; +use ILIAS\Test\ExportImport\Import\TestImporter; +use ILIAS\Test\ExportImport\Import\SkillLevelThresholdsImporter; +use ILIAS\TestQuestionPool\ExportImport\LoggingProvider; use ILIAS\TestQuestionPool\Questions\GeneralQuestionPropertiesRepository; use ILIAS\TestQuestionPool\RequestDataCollector as QPLRequestDataCollector; use ILIAS\Data\Factory as DataFactory; use ILIAS\DI\Container as ILIASContainer; +use ilLoggerFactory; use Pimple\Container as PimpleContainer; class TestDIC extends PimpleContainer @@ -233,6 +245,93 @@ protected static function buildDIC(ILIASContainer $DIC): self $DIC['ilDB'] ); + $dic['exportimport.logging'] = static fn($c): LoggingProvider => + new LoggingProvider(); + + $dic['exportimport.state_holder'] = static fn($c): StateHolder => + new StateHolder(); + + $dic['exportimport.session'] = static fn($c): ImportSessionRepository => + new ImportSessionRepository('tst'); + + $dic['exportimport.builder'] = static fn($c): Builder => + new Builder( + $DIC, + $c + ); + + $dic['exportimport.exporter'] = static fn($c): TestExporter => + new TestExporter( + $c['exportimport.builder'], + new DataFactory(), + $DIC->database(), + $DIC->repositoryTree(), + $DIC->language(), + $c['logging.logger'], + $DIC['component.repository'], + $DIC->resourceStorage(), + $c['participant.repository'], + $c['results.data.repository'], + $c['questions.properties.repository'], + $c['question.general_properties.repository'], + $DIC->taxonomy()->domain() + ); + + $dic['exportimport.skill_assignments_importer'] = static fn($c): SkillAssignmentsImporter => + new SkillAssignmentsImporter( + $c['exportimport.logging'](), + $DIC->skills()->internal()->repo()->getTreeRepo(), + $DIC->skills()->usage(), + 'components/ILIAS/Test', + (int) $DIC->settings()->get('inst_id', '0') + ); + + $dic['exportimport.skill_level_thresholds_importer'] = static fn($c): SkillLevelThresholdsImporter => + new SkillLevelThresholdsImporter( + $c['exportimport.logging'](), + $DIC->database(), + $DIC->skills()->internal()->repo()->getTreeRepo(), + 'components/ILIAS/Test', + (int) $DIC->settings()->get('inst_id', '0') + ); + + $dic['exportimport.questions_importer'] = static fn($c): QuestionsImporter => + new QuestionsImporter( + 'components/ILIAS/Test', + 'tst', + $DIC->ctrl(), + $DIC->database(), + $DIC->language(), + $c['exportimport.logging'](), + $DIC->fileConverters()->images(), + $DIC->filesystem() + ); + $dic['exportimport.test_results_importer'] = static fn($c): TestResultsImporter => + new TestResultsImporter( + $DIC->database(), + $c['exportimport.logging']() + ); + $dic['exportimport.random_test_config_importer'] = static fn($c): RandomTestConfigImporter => + new RandomTestConfigImporter( + $DIC->database(), + $c['exportimport.logging'](), + new DataFactory() + ); + $dic['exportimport.importer'] = static fn($c): TestImporter => + new TestImporter( + $c['exportimport.builder'], + $DIC->database(), + $c['exportimport.logging'](), + $DIC->resourceStorage(), + new DataFactory(), + $c['exportimport.questions_importer'], + $c['exportimport.random_test_config_importer'], + $c['exportimport.test_results_importer'], + $c['exportimport.skill_assignments_importer'], + $c['exportimport.skill_level_thresholds_importer'], + $c['marks.repository'] + ); + $dic['questions.properties.repository'] = static fn($c): TestQuestionsRepository => new TestQuestionsDatabaseRepository( $DIC['ilDB'], diff --git a/components/ILIAS/Test/tests/ExportImport/ExportFixedQuestionSetTest.php b/components/ILIAS/Test/tests/ExportImport/ExportFixedQuestionSetTest.php deleted file mode 100755 index 7266ca3cab9d..000000000000 --- a/components/ILIAS/Test/tests/ExportImport/ExportFixedQuestionSetTest.php +++ /dev/null @@ -1,60 +0,0 @@ - - */ -class ExportFixedQuestionSetTest extends \ilTestBaseTestCase -{ - private ExportFixedQuestionSet $testObj; - - protected function setUp(): void - { - global $DIC; - parent::setUp(); - - $this->addGlobal_ilErr(); - $this->addGlobal_ilias(); - $this->addGlobal_resourceStorage(); - - $this->testObj = new ExportFixedQuestionSet( - $this->createMock(\ilLanguage::class), - $this->createMock(\ilDBInterface::class), - $this->createMock(\ilBenchmark::class), - $this->createMock(\ILIAS\Test\Logging\TestLogger::class), - $this->createMock(\ilTree::class), - $this->createMock(\ilComponentRepository::class), - $this->createMock(\ILIAS\TestQuestionPool\Questions\GeneralQuestionPropertiesRepository::class), - $this->createMock(\ILIAS\FileDelivery\Services::class), - $this->createMock(\ilObjTest::class), - $DIC['resource_storage'], - $DIC['ilUser'] - ); - } - - public function test_instantiateObject_shouldReturnInstance(): void - { - $this->assertInstanceOf(ExportFixedQuestionSet::class, $this->testObj); - } -} diff --git a/components/ILIAS/Test/tests/ExportImport/ExportRandomQuestionSetTest.php b/components/ILIAS/Test/tests/ExportImport/ExportRandomQuestionSetTest.php deleted file mode 100755 index acb3bbbaffd8..000000000000 --- a/components/ILIAS/Test/tests/ExportImport/ExportRandomQuestionSetTest.php +++ /dev/null @@ -1,59 +0,0 @@ - - */ -class ExportRandomQuestionSetTest extends \ilTestBaseTestCase -{ - private ExportRandomQuestionSet $testObj; - - protected function setUp(): void - { - global $DIC; - parent::setUp(); - - $this->addGlobal_ilErr(); - $this->addGlobal_resourceStorage(); - - $this->testObj = new ExportRandomQuestionSet( - $this->createMock(\ilLanguage::class), - $this->createMock(\ilDBInterface::class), - $this->createmOck(\ilBenchmark::class), - $this->createMock(\ILIAS\Test\Logging\TestLogger::class), - $this->createMock(\ilTree::class), - $DIC['component.repository'], - $this->createMock(\ILIAS\TestQuestionPool\Questions\GeneralQuestionPropertiesRepository::class), - $this->createMock(\ILIAS\FileDelivery\Services::class), - $this->getTestObjMock(), - $DIC['resource_storage'], - $DIC['ilUser'] - ); - } - - public function test_instantiateObject_shouldReturnInstance(): void - { - $this->assertInstanceOf(ExportRandomQuestionSet::class, $this->testObj); - } -} diff --git a/components/ILIAS/Test/tests/Results/Data/TestResultRepositoryTest.php b/components/ILIAS/Test/tests/Results/Data/TestResultRepositoryTest.php index 3f39deae32d4..71870d8050e4 100644 --- a/components/ILIAS/Test/tests/Results/Data/TestResultRepositoryTest.php +++ b/components/ILIAS/Test/tests/Results/Data/TestResultRepositoryTest.php @@ -174,7 +174,7 @@ public function testGetTestAttemptResult(array $query_result, array $expected): $this->mockGetTestPassResultQuery($query_result); $repository = $this->createInstance(); - $actual = $repository->getTestAttemptResult($query_result['active_fi']); + $actual = $repository->getTestAttemptResult($query_result['active_fi'], $query_result['pass']); $this->assertNotNull($actual); $this->assertInstanceOf(AttemptResult::class, $actual); @@ -188,7 +188,7 @@ public function testGetTestAttemptResultNotFound(): void $this->mockGetTestPassResultQuery(null); $repository = $this->createInstance(); - $actual = $repository->getTestAttemptResult(1000); + $actual = $repository->getTestAttemptResult(1000, 0); $this->assertNull($actual); } diff --git a/components/ILIAS/Test/tests/ilTestResultsToXMLTest.php b/components/ILIAS/Test/tests/ilTestResultsToXMLTest.php deleted file mode 100755 index 9cb7405aab4f..000000000000 --- a/components/ILIAS/Test/tests/ilTestResultsToXMLTest.php +++ /dev/null @@ -1,57 +0,0 @@ - - */ -class ilTestResultsToXMLTest extends ilTestBaseTestCase -{ - private ilTestResultsToXML $testObj; - - protected function setUp(): void - { - global $DIC; - parent::setUp(); - - $this->testObj = new ilTestResultsToXML( - $this->createMock(ilObjTest::class), - $DIC['ilDB'], - $DIC['resource_storage'], - $DIC['ilUser'], - $DIC['lng'], - '' - ); - } - - public function test_instantiateObject_shouldReturnInstance(): void - { - $this->assertInstanceOf(ilTestResultsToXML::class, $this->testObj); - } - - public function testIncludeRandomTestQuestionsEnabled(): void - { - $this->testObj->setIncludeRandomTestQuestionsEnabled(false); - $this->assertFalse($this->testObj->isIncludeRandomTestQuestionsEnabled()); - - $this->testObj->setIncludeRandomTestQuestionsEnabled(true); - $this->assertTrue($this->testObj->isIncludeRandomTestQuestionsEnabled()); - } -} diff --git a/components/ILIAS/TestQuestionPool/classes/Setup/class.ilTestQuestionPoolSetupAgent.php b/components/ILIAS/TestQuestionPool/classes/Setup/class.ilTestQuestionPoolSetupAgent.php index 7a18db41545c..d3e34d84d37b 100755 --- a/components/ILIAS/TestQuestionPool/classes/Setup/class.ilTestQuestionPoolSetupAgent.php +++ b/components/ILIAS/TestQuestionPool/classes/Setup/class.ilTestQuestionPoolSetupAgent.php @@ -20,6 +20,7 @@ use ILIAS\Setup\Objective; use ILIAS\Setup\ObjectiveCollection; use ILIAS\Setup\Metrics; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Setup\NormalizerArtifactObjective; class ilTestQuestionPoolSetupAgent extends NullAgent { @@ -60,4 +61,9 @@ public function getMigrations(): array ]; } + public function getBuildObjective(): Objective + { + return new NormalizerArtifactObjective(); + } + } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assAnswerBinaryState.php b/components/ILIAS/TestQuestionPool/classes/class.assAnswerBinaryState.php index dde732f7ecb9..1c44eb1ed9cf 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerBinaryState.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerBinaryState.php @@ -16,6 +16,9 @@ * *********************************************************************/ +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; + /** * Class for true/false or yes/no answers * @@ -180,4 +183,27 @@ public function setUnchecked(): void { $this->checked = false; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'checked' => $this->checked, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->setState($tt->bool($normalized['checked'])); + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assAnswerBinaryStateImage.php b/components/ILIAS/TestQuestionPool/classes/class.assAnswerBinaryStateImage.php index f2f19ca3afa4..999ba3865d36 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerBinaryStateImage.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerBinaryStateImage.php @@ -18,6 +18,10 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; +use ILIAS\TestQuestionPool\ExportImport\Envelopes\QuestionImage; + /** * Class for answers with a binary state indicator * @@ -43,11 +47,11 @@ class ASS_AnswerBinaryStateImage extends ASS_AnswerBinaryState * @param string $answertext A string defining the answer text * @param double $points The number of points given for the selected answer * @param integer $order A nonnegative value representing a possible display or sort order - * @param integer $state A integer value indicating the state of the answer + * @param bool $state A integer value indicating the state of the answer * @param string $a_image The image filename * @param integer $id The database id of the answer */ - public function __construct($answertext = "", $points = 0.0, $order = 0, $state = false, ?string $a_image = null, int $id = -1) + public function __construct(string $answertext = "", float $points = 0.0, int $order = 0, bool $state = false, ?string $a_image = null, int $id = -1) { parent::__construct($answertext, (float) $points, $order, $state, $id); $this->setImage($a_image); @@ -70,4 +74,29 @@ public function hasImage(): bool { return $this->image !== null; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(array $context): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'image' => $this->image ? $tt->normalize( + new QuestionImage($this->image, $context['question_id'] ?? null) + ) : null, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->setImage($tt->denormalize($normalized['image'], QuestionImage::class)?->getFilename()); + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assAnswerCloze.php b/components/ILIAS/TestQuestionPool/classes/class.assAnswerCloze.php index 1b6915fd607b..04f4bd4381d7 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerCloze.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerCloze.php @@ -16,6 +16,9 @@ * *********************************************************************/ +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; + /** * Class for cloze question numeric answers * @@ -150,4 +153,31 @@ public function getGapSize(): int { return $this->gap_size; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'lower_bound' => $this->lowerBound, + 'upper_bound' => $this->upperBound, + 'gap_size' => $this->gap_size, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->lowerBound = $tt->nullableString($normalized['lower_bound']); + $clone->upperBound = $tt->nullableString($normalized['upper_bound']); + $clone->gap_size = $tt->int($normalized['gap_size']); + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assAnswerErrorText.php b/components/ILIAS/TestQuestionPool/classes/class.assAnswerErrorText.php index 1736454b78ca..0567ce2925a4 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerErrorText.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerErrorText.php @@ -16,6 +16,10 @@ * *********************************************************************/ +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; + /** * Class for error text answers * @@ -24,7 +28,7 @@ * * @ingroup components\ILIASTestQuestionPool */ -class assAnswerErrorText +class assAnswerErrorText implements Normalizable { protected string $text_wrong; protected string $text_correct; @@ -94,4 +98,30 @@ public function getLength(): int { return $this->length; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + 'text_wrong' => $this->text_wrong, + 'text_correct' => $this->text_correct, + 'points' => $this->points, + 'position' => $this->position, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(array $normalized): self => new self( + $tt->string($normalized['text_wrong']), + $tt->string($normalized['text_correct']), + $tt->float($normalized['points']), + $tt->int($normalized['position']) + )); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assAnswerImagemap.php b/components/ILIAS/TestQuestionPool/classes/class.assAnswerImagemap.php index 7356acba7457..b3b69c42d554 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerImagemap.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerImagemap.php @@ -16,6 +16,9 @@ * *********************************************************************/ +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; + /** * Class for true/false or yes/no answers * @@ -142,4 +145,31 @@ public function setPointsUnchecked($points_unchecked): void $this->points_unchecked = 0.0; } } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'coords' => $this->coords, + 'area' => $this->area, + 'points_unchecked' => $this->points_unchecked, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->setCoords($tt->string($normalized['coords'])); + $clone->setArea($tt->string($normalized['area'])); + $clone->setPointsUnchecked($tt->float($normalized['points_unchecked'])); + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assAnswerMatching.php b/components/ILIAS/TestQuestionPool/classes/class.assAnswerMatching.php index ce7292c05eba..71137aac7763 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerMatching.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerMatching.php @@ -16,6 +16,10 @@ * *********************************************************************/ +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; + /** * Class for matching question answers * @@ -24,7 +28,7 @@ * @author Helmut Schottmüller * @ingroup components\ILIASTestQuestionPool */ -class ASS_AnswerMatching +class ASS_AnswerMatching implements Normalizable { public float $points; @@ -236,4 +240,32 @@ public function setPoints(float $points = 0.0): void { $this->points = $points; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + 'points' => $this->points, + 'picture_or_definition' => $this->picture_or_definition, + 'picture_or_definition_id' => $this->picture_or_definition_id, + 'term_id' => $this->term_id, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = clone $this; + $clone->setPoints($tt->float($normalized['points'])); + $clone->setPicture($tt->string($normalized['picture_or_definition'])); + $clone->setPictureId($tt->int($normalized['picture_or_definition_id'])); + $clone->setTermId($tt->int($normalized['term_id'])); + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assAnswerMatchingPair.php b/components/ILIAS/TestQuestionPool/classes/class.assAnswerMatchingPair.php index 4c628ee66d7e..9601aabf9026 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerMatchingPair.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerMatchingPair.php @@ -18,13 +18,17 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; + /** * Class for matching question pairs * * @author Helmut Schottmüller * @ingroup components\ILIASTestQuestionPool */ -class assAnswerMatchingPair +class assAnswerMatchingPair implements Normalizable { protected assAnswerMatchingTerm $term; protected assAnswerMatchingDefinition $definition; @@ -72,4 +76,28 @@ public function withPoints(float $points): self $clone->points = $points; return $clone; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(array $context): array => [ + 'points' => $this->points, + 'term' => $tt->normalize($this->term, $context), + 'definition' => $tt->normalize($this->definition, $context), + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + return $this->withPoints($tt->float($normalized['points'])) + ->withTerm($tt->denormalize($normalized['term'], $this->term)) + ->withDefinition($tt->denormalize($normalized['definition'], $this->definition)); + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assAnswerMatchingTerm.php b/components/ILIAS/TestQuestionPool/classes/class.assAnswerMatchingTerm.php index b2500c8e93d6..c2a986f71671 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerMatchingTerm.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerMatchingTerm.php @@ -18,13 +18,18 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; +use ILIAS\TestQuestionPool\ExportImport\Envelopes\QuestionImage; + /** * Class for matching question terms * * @author Helmut Schottmüller * @ingroup components\ILIASTestQuestionPool */ -class assAnswerMatchingTerm +class assAnswerMatchingTerm implements Normalizable { protected string $text; protected string $picture; @@ -76,4 +81,30 @@ public function withIdentifier(int $identifier): self $clone->identifier = $identifier; return $clone; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(array $context): array => [ + 'text' => $this->text, + 'picture' => $this->picture ? $tt->normalize( + new QuestionImage($this->picture, $context['question_id'] ?? null) + ) : null, + 'identifier' => $this->identifier, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + return $this->withText($tt->string($normalized['text'])) + ->withPicture($tt->denormalize($normalized['picture'], QuestionImage::class)?->getFilename() ?? '') + ->withIdentifier($tt->int($normalized['identifier'])); + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assAnswerMultipleResponse.php b/components/ILIAS/TestQuestionPool/classes/class.assAnswerMultipleResponse.php index ace7464d312e..331fd04594e2 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerMultipleResponse.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerMultipleResponse.php @@ -16,6 +16,9 @@ * *********************************************************************/ +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; + /** * Class for true/false or yes/no answers * @@ -97,4 +100,27 @@ public function getPointsChecked(): float { return $this->getPoints(); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'points_unchecked' => $this->points_unchecked, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->points_unchecked = $tt->nullableFloat($normalized['points_unchecked']); + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assAnswerMultipleResponseImage.php b/components/ILIAS/TestQuestionPool/classes/class.assAnswerMultipleResponseImage.php index 559c06ef0b49..c410a7e23463 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerMultipleResponseImage.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerMultipleResponseImage.php @@ -18,6 +18,10 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; +use ILIAS\TestQuestionPool\ExportImport\Envelopes\QuestionImage; + /** * ASS_AnswerBinaryStateImage is a class for answers with a binary state * indicator (checked/unchecked, set/unset) and an image file @@ -74,4 +78,29 @@ public function hasImage(): bool { return $this->image !== null; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(array $context): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'image' => $this->image ? $tt->normalize( + new QuestionImage($this->image, $context['question_id'] ?? null) + ) : null, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->setImage($tt->denormalize($normalized['image'], QuestionImage::class)?->getFilename()); + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assAnswerSimple.php b/components/ILIAS/TestQuestionPool/classes/class.assAnswerSimple.php index b6c6cff29fea..72b59cab0c11 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerSimple.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerSimple.php @@ -16,6 +16,11 @@ * *********************************************************************/ +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Envelopes\Id; +use ILIAS\Refinery\Transformation; + /** * Class for simple answers * @@ -26,7 +31,7 @@ * * @ingroup components\ILIASTestQuestionPool */ -class ASS_AnswerSimple +class ASS_AnswerSimple implements Normalizable { protected string $answertext; @@ -211,4 +216,30 @@ public function setPoints($points = 0.0): void $this->points = 0.0; } } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + 'id' => $tt->normalize(new Id($this->id, 'answer')), + 'answertext' => $this->answertext, + 'points' => $this->points, + 'order' => $this->order, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(array $normalized) => new static( + $tt->string($normalized['answertext']), + $tt->float($normalized['points']), + $tt->int($normalized['order']), + $tt->denormalize($normalized['id'], Id::class)->getId() + )); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assAnswerTrueFalse.php b/components/ILIAS/TestQuestionPool/classes/class.assAnswerTrueFalse.php index d366c5330d05..2ce73b3e9940 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerTrueFalse.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerTrueFalse.php @@ -16,6 +16,9 @@ * *********************************************************************/ +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; + /** * Class for true/false or yes/no answers * @@ -147,4 +150,27 @@ public function setFalse(): void { $this->correctness = "0"; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'correctness' => $this->correctness, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->setCorrectness($tt->string($normalized['correctness'])); + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assClozeGap.php b/components/ILIAS/TestQuestionPool/classes/class.assClozeGap.php index 89c5b8b22c80..1f12458585bb 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assClozeGap.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assClozeGap.php @@ -1,4 +1,5 @@ custom()->transformation(fn(): array => [ + 'type' => $this->type, + 'shuffle' => $this->shuffle, + 'gap_size' => $this->gap_size, + 'items' => $tt->normalize($this->items), + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = new self($normalized['type']); + $clone->setShuffle($normalized['shuffle']); + $clone->setGapSize($normalized['gap_size']); + $clone->items = array_map( + fn(array $item) => $tt->denormalize($item, new assAnswerCloze()), + $normalized['items'] + ); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assClozeTest.php b/components/ILIAS/TestQuestionPool/classes/class.assClozeTest.php index 34adaf3e51e6..d07959644629 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assClozeTest.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assClozeTest.php @@ -18,6 +18,9 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\TestQuestionPool\Questions\QuestionPartiallySaveable; @@ -35,7 +38,7 @@ * * @ingroup ModulesTestQuestionPool */ -class assClozeTest extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionPartiallySaveable, QuestionLMExportable, QuestionAutosaveable +class assClozeTest extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionPartiallySaveable, QuestionLMExportable, QuestionAutosaveable, Normalizable { /** * The gaps of the cloze question @@ -1727,4 +1730,46 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): arra } return $answers; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'feedback_mode' => $this->feedbackMode, + 'cloze_text' => $this->cloze_text, + 'textgap_rating' => $this->textgap_rating, + 'identical_scoring' => $this->identical_scoring, + 'fixed_text_length' => $this->fixed_text_length, + 'gaps' => $tt->normalize($this->gaps), + 'gap_combinations' => $this->gap_combinations, + 'gap_combinations_exist' => $this->gap_combinations_exist, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->feedbackMode = $tt->string($normalized['feedback_mode']); + $clone->cloze_text = $tt->string($normalized['cloze_text']); + $clone->textgap_rating = $tt->string($normalized['textgap_rating']); + $clone->identical_scoring = $tt->bool($normalized['identical_scoring']); + $clone->fixed_text_length = $tt->int($normalized['fixed_text_length']); + $clone->gap_combinations_exist = $tt->bool($normalized['gap_combinations_exist']); + $clone->gap_combinations = $normalized['gap_combinations']; + + foreach ($normalized['gaps'] as $gap) { + $type = $tt->int($gap['type']); + $clone->gaps[] = $tt->denormalize($gap, new assClozeGap($type)); + } + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assErrorText.php b/components/ILIAS/TestQuestionPool/classes/class.assErrorText.php index c3cc6ff3d1fc..b98d14cab935 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assErrorText.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assErrorText.php @@ -18,6 +18,9 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\Test\Logging\AdditionalInformationGenerator; @@ -34,7 +37,7 @@ * * @ingroup ModulesTestQuestionPool */ -class assErrorText extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionLMExportable, QuestionAutosaveable +class assErrorText extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionLMExportable, QuestionAutosaveable, Normalizable { protected const ERROR_TYPE_WORD = 1; protected const ERROR_TYPE_PASSAGE = 2; @@ -1000,4 +1003,37 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): stri { return $this->createErrorTextExport($this->getBestSelection()); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'errortext' => $this->errortext, + 'errortext_parsed' => $this->parsed_errortext, + 'errordata' => $tt->normalize($this->errordata), + 'points_wrong' => $this->points_wrong, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->errortext = $tt->string($normalized['errortext']); + $clone->parsed_errortext = $normalized['errortext_parsed']; + $clone->points_wrong = $tt->nullableFloat($normalized['points_wrong']); + $clone->errordata = array_map( + fn(array $error) => $tt->denormalize($error, new assAnswerErrorText()), + $normalized['errordata'] + ); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assFileUpload.php b/components/ILIAS/TestQuestionPool/classes/class.assFileUpload.php index 87fd4898d2ba..8bdffab786e8 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assFileUpload.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assFileUpload.php @@ -18,6 +18,9 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; use ILIAS\TestQuestionPool\QuestionPoolDIC; use ILIAS\Test\Participants\ParticipantRepository; use ILIAS\Test\Logging\AdditionalInformationGenerator; @@ -35,7 +38,7 @@ * * @ingroup ModulesTestQuestionPool */ -class assFileUpload extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjFileHandlingQuestionType +class assFileUpload extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjFileHandlingQuestionType, Normalizable { public const REUSE_FILES_TBL_POSTVAR = 'reusefiles'; public const DELETE_FILES_TBL_POSTVAR = 'deletefiles'; @@ -950,4 +953,32 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): stri { return ''; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'maxsize' => $this->maxsize, + 'allowedextensions' => $this->allowedextensions, + 'completion_by_submission' => $this->completion_by_submission, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->maxsize = $tt->nullableInt($normalized['maxsize']); + $clone->allowedextensions = $tt->string($normalized['allowedextensions']); + $clone->completion_by_submission = $tt->bool($normalized['completion_by_submission']); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestion.php b/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestion.php index 8f389d7462f5..6f86c8f55ca6 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestion.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestion.php @@ -18,7 +18,11 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; +use ILIAS\TestQuestionPool\QuestionPoolDIC; use ILIAS\Test\Logging\AdditionalInformationGenerator; /** @@ -28,7 +32,7 @@ * @version $Id: class.assFormulaQuestion.php 1236 2010-02-15 15:44:16Z hschottm $ * @ingroup components\ILIASTestQuestionPool */ -class assFormulaQuestion extends assQuestion implements iQuestionCondition, QuestionAutosaveable +class assFormulaQuestion extends assQuestion implements iQuestionCondition, QuestionAutosaveable, Normalizable { private array $variables; private array $results; @@ -1431,4 +1435,38 @@ function (string $v) use ($variables): string { array_keys($variables) ); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'variables' => $tt->normalize($this->variables), + 'results' => $tt->normalize($this->results) + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + + foreach ($normalized['variables'] as $key => $data) { + $dummy = new assFormulaQuestionVariable('', '', ''); + $clone->variables[$key] = $tt->denormalize($data, $dummy); + } + + foreach ($normalized['results'] as $key => $data) { + $dummy = new assFormulaQuestionResult('', '', '', 0, null, '', 0, 0); + $clone->results[$key] = $tt->denormalize($data, $dummy); + } + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionResult.php b/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionResult.php index d1bb686d78a1..2786e088e461 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionResult.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionResult.php @@ -16,7 +16,10 @@ * *********************************************************************/ +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\Refinery\Factory as Refinery; +use ILIAS\Refinery\Transformation; /** * Formula Question Result @@ -24,7 +27,7 @@ * @version $Id: class.assFormulaQuestionResult.php 944 2009-11-09 16:11:30Z hschottm $ * @ingroup components\ILIASTestQuestionPool * */ -class assFormulaQuestionResult +class assFormulaQuestionResult implements Normalizable { public const RESULT_NO_SELECTION = 0; public const RESULT_DEC = 1; @@ -846,4 +849,57 @@ public function getAvailableResultUnits($question_id): array return $this->available_units; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + 'available_units' => $tt->normalize($this->available_units), + 'range_min' => $this->range_min, + 'range_max' => $this->range_max, + 'range_min_txt' => $this->range_min_txt, + 'range_max_txt' => $this->range_max_txt, + 'result' => $this->result, + 'tolerance' => $this->tolerance, + 'unit' => $tt->normalize($this->unit), + 'formula' => $this->formula, + 'points' => $this->points, + 'precision' => $this->precision, + 'rating_simple' => $this->rating_simple, + 'rating_sign' => $this->rating_sign, + 'rating_value' => $this->rating_value, + 'rating_unit' => $this->rating_unit, + 'result_type' => $this->result_type, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = clone $this; + $clone->available_units = array_map(fn(array $unit) => $tt->denormalize($unit, new assFormulaQuestionUnit()), $normalized['available_units']); + $clone->range_min = $tt->float($normalized['range_min']); + $clone->range_max = $tt->float($normalized['range_max']); + $clone->range_min_txt = $tt->string($normalized['range_min_txt']); + $clone->range_max_txt = $tt->string($normalized['range_max_txt']); + $clone->result = $tt->string($normalized['result']); + $clone->tolerance = $tt->float($normalized['tolerance']); + $clone->unit = $tt->denormalize($normalized['unit'], new assFormulaQuestionUnit()); + $clone->formula = $tt->string($normalized['formula']); + $clone->points = $tt->float($normalized['points']); + $clone->precision = $tt->int($normalized['precision']); + $clone->rating_simple = $tt->bool($normalized['rating_simple']); + $clone->rating_sign = $tt->float($normalized['rating_sign']); + $clone->rating_value = $tt->float($normalized['rating_value']); + $clone->rating_unit = $tt->float($normalized['rating_unit']); + $clone->result_type = $tt->int($normalized['result_type']); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionUnit.php b/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionUnit.php index 959c8362192a..085204feb2fe 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionUnit.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionUnit.php @@ -18,12 +18,17 @@ declare(strict_types=1); +use ILIAS\Refinery\Transformation; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Envelopes\Id; + /** * Formula Question Unit * @author Helmut Schottmüller * @ingroup components\ILIASTestQuestionPool */ -class assFormulaQuestionUnit +class assFormulaQuestionUnit implements Normalizable { private int $id = 0; private string $unit = ''; @@ -159,4 +164,39 @@ private function sanitizeString(string $string): string { return htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE, 'utf-8'); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + 'id' => $tt->normalize(new Id($this->id, 'unit')), + 'unit' => $this->unit, + 'factor' => $this->factor, + 'category_id' => $tt->normalize(new Id($this->category, 'unit_category')), + 'sequence' => $this->sequence, + 'baseunit' => $tt->normalize(new Id($this->baseunit, 'unit')), + 'baseunit_title' => $this->baseunit_title, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = clone $this; + $clone->id = $tt->denormalize($normalized['id'], Id::class)->getId(); + $clone->unit = $tt->string($normalized['unit']); + $clone->factor = $tt->float($normalized['factor']); + $clone->category = $tt->denormalize($normalized['category_id'], Id::class)->getId(); + $clone->sequence = $tt->int($normalized['sequence']); + $clone->baseunit = $tt->denormalize($normalized['baseunit'], Id::class)->getId(); + $clone->baseunit_title = $tt->string($normalized['baseunit_title']); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionUnitCategory.php b/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionUnitCategory.php index 963c0d7637e5..4ce48ecb7caf 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionUnitCategory.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionUnitCategory.php @@ -18,12 +18,17 @@ declare(strict_types=1); +use ILIAS\Refinery\Transformation; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Envelopes\Id; + /** * Formula Question Unit Category * @author Helmut Schottmüller * @ingroup components\ILIASTestQuestionPool */ -class assFormulaQuestionUnitCategory +class assFormulaQuestionUnitCategory implements Normalizable { private int $id = 0; private string $category = ''; @@ -86,4 +91,31 @@ private function sanitizeString(string $string): string { return htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE, 'utf-8'); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + 'id' => $tt->normalize(new Id($this->id, 'unit_category')), + 'name' => $this->category, + 'question_id' => $tt->normalize(new Id($this->question_fi, 'question')), + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = clone $this; + $clone->id = $tt->denormalize($normalized['id'], Id::class)->getId(); + $clone->category = $tt->string($normalized['name']); + $clone->question_fi = $tt->denormalize($normalized['question_id'], Id::class)->getId(); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionVariable.php b/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionVariable.php index b568847e9442..3469ca82afcd 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionVariable.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionVariable.php @@ -18,13 +18,17 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; + /** * Formula Question Variable * @author Helmut Schottmüller * @version $Id: class.assFormulaQuestionVariable.php 465 2009-06-29 08:27:36Z hschottm $ * @ingroup components\ILIASTestQuestionPool * */ -class assFormulaQuestionVariable +class assFormulaQuestionVariable implements Normalizable { private $value = null; private float $range_min; @@ -197,4 +201,41 @@ public function getRangeMinTxt(): string { return $this->range_min_txt; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + 'variable' => $this->variable, + 'range_min' => $this->range_min, + 'range_max' => $this->range_max, + 'range_min_txt' => $this->range_min_txt, + 'range_max_txt' => $this->range_max_txt, + 'unit' => $tt->normalize($this->unit), + 'precision' => $this->precision, + 'intprecision' => $this->intprecision, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = clone $this; + $clone->variable = $tt->string($normalized['variable']); + $clone->range_min = $tt->float($normalized['range_min']); + $clone->range_max = $tt->float($normalized['range_max']); + $clone->range_min_txt = $tt->string($normalized['range_min_txt']); + $clone->range_max_txt = $tt->string($normalized['range_max_txt']); + $clone->unit = $tt->denormalize($normalized['unit'], new assFormulaQuestionUnit()); + $clone->precision = $tt->int($normalized['precision']); + $clone->intprecision = $tt->int($normalized['intprecision']); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assImagemapQuestion.php b/components/ILIAS/TestQuestionPool/classes/class.assImagemapQuestion.php index 67e0de5a6874..5e37ed712f7d 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assImagemapQuestion.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assImagemapQuestion.php @@ -18,10 +18,14 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\TestQuestionPool\ExportImport\Envelopes\QuestionImage; use ILIAS\TestQuestionPool\QuestionPoolDIC; use ILIAS\TestQuestionPool\RequestDataCollector; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\Test\Logging\AdditionalInformationGenerator; +use ILIAS\Refinery\Transformation; /** * Class for image map questions @@ -36,7 +40,7 @@ * * @ingroup ModulesTestQuestionPool */ -class assImagemapQuestion extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionLMExportable +class assImagemapQuestion extends assQuestion implements ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionLMExportable, Normalizable { private RequestDataCollector $request; // Hate it. @@ -915,4 +919,35 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): arra $this->getAnswers() ); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'image' => $tt->normalize(new QuestionImage($this->image_filename, $this->getId())), + 'multiple_choice' => $this->is_multiple_choice, + 'answers' => $tt->normalize($this->answers), + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->image_filename = $tt->denormalize($normalized['image'], QuestionImage::class)->getFilename(); + $clone->is_multiple_choice = $tt->bool($normalized['multiple_choice']); + $clone->answers = array_map( + fn(array $answer) => $tt->denormalize($answer, new ASS_AnswerImagemap()), + $normalized['answers'] + ); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assKprimChoice.php b/components/ILIAS/TestQuestionPool/classes/class.assKprimChoice.php index c8356c52e5b6..d25f05d1b540 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assKprimChoice.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assKprimChoice.php @@ -18,10 +18,13 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\TestQuestionPool\ManipulateImagesInChoiceQuestionsTrait; use ILIAS\Test\Logging\AdditionalInformationGenerator; +use ILIAS\Refinery\Transformation; /** * @author Björn Heyser @@ -29,7 +32,7 @@ * * @package components\ILIAS/TestQuestionPool */ -class assKprimChoice extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, ilAssSpecificFeedbackOptionLabelProvider, QuestionLMExportable, QuestionAutosaveable +class assKprimChoice extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, ilAssSpecificFeedbackOptionLabelProvider, QuestionLMExportable, QuestionAutosaveable, Normalizable { use ManipulateImagesInChoiceQuestionsTrait; @@ -920,4 +923,45 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): arra $this->getAnswers() ); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'shuffle_answers' => $this->shuffle_answers_enabled, + 'answer_type' => $this->answerType, + 'option_label' => $this->option_label, + 'custom_true_option_label' => $this->customTrueOptionLabel, + 'custom_false_option_label' => $this->customFalseOptionLabel, + 'score_partial_solution' => $this->scorePartialSolutionEnabled, + 'specific_feedback_setting' => $this->specific_feedback_setting, + 'answers' => $tt->normalize($this->answers, ['question_id' => $this->getId()]), + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->shuffle_answers_enabled = $tt->bool($normalized['shuffle_answers']); + $clone->answerType = $tt->string($normalized['answer_type']); + $clone->option_label = $tt->string($normalized['option_label']); + $clone->customTrueOptionLabel = $tt->string($normalized['custom_true_option_label']); + $clone->customFalseOptionLabel = $tt->string($normalized['custom_false_option_label']); + $clone->scorePartialSolutionEnabled = $tt->bool($normalized['score_partial_solution']); + $clone->specific_feedback_setting = $tt->int($normalized['specific_feedback_setting']); + $clone->answers = array_map( + fn(array $answer) => $tt->denormalize($answer, new ilAssKprimChoiceAnswer()), + $normalized['answers'] + ); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assLongMenu.php b/components/ILIAS/TestQuestionPool/classes/class.assLongMenu.php index 81dde64d6bad..addc75f602c3 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assLongMenu.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assLongMenu.php @@ -18,11 +18,14 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\Test\Logging\AdditionalInformationGenerator; +use ILIAS\Refinery\Transformation; -class assLongMenu extends assQuestion implements ilObjQuestionScoringAdjustable, QuestionLMExportable, QuestionAutosaveable +class assLongMenu extends assQuestion implements ilObjQuestionScoringAdjustable, QuestionLMExportable, QuestionAutosaveable, Normalizable { public const ANSWER_TYPE_SELECT_VAL = 0; public const ANSWER_TYPE_TEXT_VAL = 1; @@ -833,4 +836,38 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): arra } return $correct_answers; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'long_menu_text' => $this->long_menu_text, + 'json_structure' => $this->json_structure, + 'min_auto_complete' => $this->minAutoComplete, + 'identical_scoring' => $this->identical_scoring, + 'correct_answers' => $this->correct_answers, + 'answers' => $this->answers, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->long_menu_text = $tt->string($normalized['long_menu_text']); + $clone->json_structure = $tt->string($normalized['json_structure']); + $clone->minAutoComplete = $tt->int($normalized['min_auto_complete']); + $clone->identical_scoring = $tt->bool($normalized['identical_scoring']); + $clone->correct_answers = $normalized['correct_answers']; + $clone->answers = $normalized['answers']; + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assMatchingQuestion.php b/components/ILIAS/TestQuestionPool/classes/class.assMatchingQuestion.php index fa5c34243f11..3b09b41dabeb 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assMatchingQuestion.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assMatchingQuestion.php @@ -18,11 +18,14 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\Test\Logging\AdditionalInformationGenerator; use ILIAS\Refinery\Random\Group as RandomGroup; use ILIAS\Refinery\Random\Seed\RandomSeed; +use ILIAS\Refinery\Transformation; /** * Class for matching questions @@ -37,7 +40,7 @@ * * @ingroup ModulesTestQuestionPool */ -class assMatchingQuestion extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionLMExportable, QuestionAutosaveable +class assMatchingQuestion extends assQuestion implements ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionLMExportable, QuestionAutosaveable, Normalizable { public const MT_TERMS_PICTURES = 0; public const MT_TERMS_DEFINITIONS = 1; @@ -1423,4 +1426,44 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): arra $this->getMatchingPairs() ); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'shuffle_mode' => $this->shufflemode, + 'matching_mode' => $this->matching_mode, + 'matching_type' => $this->matching_type, + 'thumb_geometry' => $this->thumb_geometry, + 'matching_pairs' => $tt->normalize($this->matchingpairs, ['question_id' => $this->getId()]), + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->shufflemode = $tt->int($normalized['shuffle_mode']); + $clone->matching_mode = $tt->string($normalized['matching_mode']); + $clone->matching_type = $tt->int($normalized['matching_type']); + $clone->thumb_geometry = $tt->int($normalized['thumb_geometry']); + + foreach ($normalized['matching_pairs'] as $matching_pair) { + $term = $tt->denormalize($matching_pair['term'], new assAnswerMatchingTerm()); + $definition = $tt->denormalize($matching_pair['definition'], new assAnswerMatchingDefinition()); + + $clone->matchingpairs[] = new assAnswerMatchingPair($term, $definition, $tt->float($matching_pair['points'])); + $clone->terms[] = $term; + $clone->definitions[] = $definition; + } + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assMultipleChoice.php b/components/ILIAS/TestQuestionPool/classes/class.assMultipleChoice.php index 5de214063b0c..1b10f9168133 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assMultipleChoice.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assMultipleChoice.php @@ -18,11 +18,13 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\TestQuestionPool\ManipulateImagesInChoiceQuestionsTrait; use ILIAS\Test\Logging\AdditionalInformationGenerator; -use ILIAS\TestQuestionPool\RequestDataCollector; +use ILIAS\Refinery\Transformation; /** * Class for multiple choice tests. @@ -39,7 +41,7 @@ * * @ingroup ModulesTestQuestionPool */ -class assMultipleChoice extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, ilAssSpecificFeedbackOptionLabelProvider, QuestionLMExportable, QuestionAutosaveable +class assMultipleChoice extends assQuestion implements ilObjAnswerScoringAdjustable, iQuestionCondition, ilAssSpecificFeedbackOptionLabelProvider, QuestionLMExportable, QuestionAutosaveable, Normalizable { use ManipulateImagesInChoiceQuestionsTrait; @@ -956,4 +958,35 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): arra $this->getAnswers() ); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'selection_limit' => $this->selection_limit, + 'single_line' => $this->is_singleline, + 'answers' => $tt->normalize($this->answers, ['question_id' => $this->getId()]), + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->selection_limit = $tt->nullableInt($normalized['selection_limit']); + $clone->is_singleline = $tt->bool($normalized['single_line']); + $clone->answers = array_map( + fn(array $answer) => $tt->denormalize($answer, new ASS_AnswerMultipleResponseImage()), + $normalized['answers'] + ); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assNumeric.php b/components/ILIAS/TestQuestionPool/classes/class.assNumeric.php index e2d6e1ee9fe4..cb598474a73e 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assNumeric.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assNumeric.php @@ -18,8 +18,11 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\Test\Logging\AdditionalInformationGenerator; +use ILIAS\Refinery\Transformation; /** * Class for numeric questions @@ -36,7 +39,7 @@ * * @ingroup ModulesTestQuestionPool */ -class assNumeric extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionAutosaveable +class assNumeric extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionAutosaveable, Normalizable { protected $lower_limit; protected $upper_limit; @@ -463,4 +466,32 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): stri { return "{$this->getLowerLimit()}-{$this->getUpperLimit()}"; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'lower_limit' => $this->lower_limit, + 'upper_limit' => $this->upper_limit, + 'maxchars' => $this->maxchars, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->lower_limit = $tt->string($normalized['lower_limit']); + $clone->upper_limit = $tt->string($normalized['upper_limit']); + $clone->maxchars = $tt->int($normalized['maxchars']); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assOrderingHorizontal.php b/components/ILIAS/TestQuestionPool/classes/class.assOrderingHorizontal.php index d08e5d863841..d38540a1d712 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assOrderingHorizontal.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assOrderingHorizontal.php @@ -18,9 +18,12 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\Test\Logging\AdditionalInformationGenerator; +use ILIAS\Refinery\Transformation; /** * Class for horizontal ordering questions @@ -33,7 +36,7 @@ * * @ingroup ModulesTestQuestionPool */ -class assOrderingHorizontal extends assQuestion implements ilObjQuestionScoringAdjustable, iQuestionCondition, QuestionLMExportable, QuestionAutosaveable +class assOrderingHorizontal extends assQuestion implements ilObjQuestionScoringAdjustable, iQuestionCondition, QuestionLMExportable, QuestionAutosaveable, Normalizable { protected const HAS_SPECIFIC_FEEDBACK = false; protected const DEFAULT_TEXT_SIZE = 100; @@ -582,4 +585,34 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): stri { return $this->getOrderText(); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'ordertext' => $this->ordertext, + 'textsize' => $this->textsize, + 'separator' => $this->separator, + 'answer_separator' => $this->answer_separator, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->ordertext = $tt->string($normalized['ordertext']); + $clone->textsize = $tt->float($normalized['textsize']); + $clone->separator = $tt->string($normalized['separator']); + $clone->answer_separator = $tt->string($normalized['answer_separator']); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assOrderingQuestion.php b/components/ILIAS/TestQuestionPool/classes/class.assOrderingQuestion.php index 3b6f13861f80..df0280bdfa3e 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assOrderingQuestion.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assOrderingQuestion.php @@ -18,10 +18,13 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\TestQuestionPool\Questions\Ordering\OrderingQuestionDatabaseRepository as OQRepository; use ILIAS\Test\Logging\AdditionalInformationGenerator; +use ILIAS\Refinery\Transformation; /** * Class for ordering questions @@ -37,7 +40,7 @@ * * @ingroup components\ILIASTestQuestionPool */ -class assOrderingQuestion extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionLMExportable, QuestionAutosaveable +class assOrderingQuestion extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionLMExportable, QuestionAutosaveable, Normalizable { public const ORDERING_ELEMENT_FORM_FIELD_POSTVAR = 'order_elems'; @@ -1365,4 +1368,33 @@ function (ilAssOrderingElement $v): string { $elements ); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'ordering_type' => $this->ordering_type, + 'ordering_elements' => $tt->normalize($this->getOrderingElementList()->getElements(), ['question_id' => $this->getId()]), + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->ordering_type = $tt->int($normalized['ordering_type']); + $clone->getOrderingElementList()->setElements(array_map( + fn(array $element) => $tt->denormalize($element, new ilAssOrderingElement()), + $normalized['ordering_elements'] + )); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assQuestion.php b/components/ILIAS/TestQuestionPool/classes/class.assQuestion.php index 8a8cf44b2daf..64f4831830ae 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assQuestion.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assQuestion.php @@ -18,6 +18,8 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Envelopes\Id; use ILIAS\Test\Results\Data\Repository as TestResultRepository; use ILIAS\Test\TestDIC; use ILIAS\TestQuestionPool\Questions\QuestionPartiallySaveable; @@ -2947,4 +2949,64 @@ public function getVariablesAsTextArray( ): array { return []; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + 'id' => $tt->normalize(new Id($this->id, 'question')), + 'parent_id' => $tt->normalize(new Id($this->obj_id, 'object')), + 'original_id' => $this->original_id, + 'external_id' => $this->external_id, + 'type' => $this->getQuestionType(), + 'owner' => $this->owner, + 'title' => $this->title, + 'description' => $this->comment, + 'question_text' => $this->question, + 'available_points' => $this->points, + 'nr_of_tries' => $this->nr_of_tries, + 'lifecycle' => $tt->normalize($this->lifecycle), + 'author' => $this->author, + 'updated_timestamp' => $this->lastChange, + 'additional_content_editing_mode' => $this->additionalContentEditingMode, + 'thumb_size' => $this->thumb_size, + 'shuffle' => $this->shuffle, + 'suggested_solutions' => $tt->normalize($this->suggested_solutions), + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = clone $this; + $clone->id = $tt->denormalize($normalized['id'], Id::class)->getId(); + $clone->obj_id = $tt->denormalize($normalized['parent_id'], Id::class)->getId(); + $clone->original_id = $tt->nullableInt($normalized['original_id']); + $clone->external_id = $tt->nullableString($normalized['external_id']); + $clone->owner = $tt->int($normalized['owner']); + $clone->title = $tt->string($normalized['title']); + $clone->comment = $tt->string($normalized['description']); + $clone->question = $tt->string($normalized['question_text']); + $clone->points = $tt->float($normalized['available_points']); + $clone->nr_of_tries = $tt->int($normalized['nr_of_tries']); + $clone->lifecycle = $tt->denormalize($normalized['lifecycle'], $clone->lifecycle); + $clone->author = $tt->string($normalized['author']); + $clone->lastChange = $tt->nullableInt($normalized['updated_timestamp']); + $clone->additionalContentEditingMode = $tt->string($normalized['additional_content_editing_mode']); + $clone->thumb_size = $tt->int($normalized['thumb_size']); + $clone->shuffle = $tt->bool($normalized['shuffle']); + + $clone->suggested_solutions = array_map( + fn(array $suggested_solution) => $tt->denormalize($suggested_solution, SuggestedSolution::class), + $normalized['suggested_solutions'] + ); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assSingleChoice.php b/components/ILIAS/TestQuestionPool/classes/class.assSingleChoice.php index e90cb6c53ec0..8a15886c81a0 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assSingleChoice.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assSingleChoice.php @@ -18,10 +18,13 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\TestQuestionPool\ManipulateImagesInChoiceQuestionsTrait; use ILIAS\Test\Logging\AdditionalInformationGenerator; +use ILIAS\Refinery\Transformation; /** * Class for single choice questions @@ -36,7 +39,7 @@ * * @ingroup ModulesTestQuestionPool */ -class assSingleChoice extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, ilAssSpecificFeedbackOptionLabelProvider, QuestionLMExportable, QuestionAutosaveable +class assSingleChoice extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, ilAssSpecificFeedbackOptionLabelProvider, QuestionLMExportable, QuestionAutosaveable, Normalizable { use ManipulateImagesInChoiceQuestionsTrait; @@ -944,4 +947,35 @@ function (array $c, ASS_AnswerBinaryStateImage $v): array { [] ); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): array { + $normalized = $tt->normalize(parent::toNormalized($tt)); + $normalized['is_singleline'] = $this->is_singleline; + $normalized['feedback_setting'] = $this->feedback_setting; + $normalized['answers'] = $tt->normalize($this->answers, ['question_id' => $this->getId()]); + return $normalized; + }); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->is_singleline = $tt->bool($normalized['is_singleline']); + $clone->feedback_setting = $tt->int($normalized['feedback_setting']); + $clone->answers = array_map( + fn(array $answer) => $tt->denormalize($answer, new ASS_AnswerBinaryStateImage()), + $normalized['answers'] + ); + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assTextQuestion.php b/components/ILIAS/TestQuestionPool/classes/class.assTextQuestion.php index 7f7be1a73ffa..7228f5cb4f04 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assTextQuestion.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assTextQuestion.php @@ -18,9 +18,12 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\Test\Logging\AdditionalInformationGenerator; +use ILIAS\Refinery\Transformation; /** * Class for text questions @@ -35,7 +38,7 @@ * * @ingroup ModulesTestQuestionPool */ -class assTextQuestion extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, QuestionLMExportable, QuestionAutosaveable +class assTextQuestion extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, QuestionLMExportable, QuestionAutosaveable, Normalizable { protected const HAS_SPECIFIC_FEEDBACK = false; @@ -855,4 +858,40 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): arra ); } } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'word_counter_enabled' => $this->word_counter_enabled, + 'max_num_of_chars' => $this->max_num_of_chars, + 'text_rating' => $this->text_rating, + 'matchcondition' => $this->matchcondition, + 'keyword_relation' => $this->keyword_relation, + 'answers' => $tt->normalize($this->answers), + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->word_counter_enabled = $tt->bool($normalized['word_counter_enabled']); + $clone->max_num_of_chars = $tt->int($normalized['max_num_of_chars']); + $clone->text_rating = $tt->string($normalized['text_rating']); + $clone->matchcondition = $tt->int($normalized['matchcondition']); + $clone->keyword_relation = $tt->string($normalized['keyword_relation']); + $clone->answers = array_map( + fn(array $answer) => $tt->denormalize($answer, new ASS_AnswerMultipleResponseImage()), + $normalized['answers'] + ); + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assTextSubset.php b/components/ILIAS/TestQuestionPool/classes/class.assTextSubset.php index d85b05d91412..09bb06fe7f91 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assTextSubset.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assTextSubset.php @@ -18,9 +18,12 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\Test\Logging\AdditionalInformationGenerator; +use ILIAS\Refinery\Transformation; /** * Class for TextSubset questions @@ -37,7 +40,7 @@ * * @ingroup ModulesTestQuestionPool */ -class assTextSubset extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionLMExportable, QuestionAutosaveable +class assTextSubset extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionLMExportable, QuestionAutosaveable, Normalizable { public array $answers = []; public int $correctanswers = 0; @@ -755,4 +758,34 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): arra { return $this->getAvailableAnswers(); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'text_rating' => $this->text_rating, + 'correct_answers' => $this->correctanswers, + 'answers' => $tt->normalize($this->answers), + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->text_rating = $tt->string($normalized['text_rating']); + $clone->correctanswers = $tt->int($normalized['correct_answers']); + $clone->answers = array_map( + fn(array $answer) => $tt->denormalize($answer, new ASS_AnswerBinaryStateImage()), + $normalized['answers'] + ); + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilAssKprimChoiceAnswer.php b/components/ILIAS/TestQuestionPool/classes/class.ilAssKprimChoiceAnswer.php index bc833382d8a6..5f4fbda39bc0 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilAssKprimChoiceAnswer.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilAssKprimChoiceAnswer.php @@ -1,5 +1,10 @@ getImageWebDir() . $this->getThumbPrefix() . $this->getImageFile(); } + + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(array $context): array => [ + 'position' => $this->position, + 'answertext' => $this->answertext, + 'image' => $this->imageFile ? $tt->normalize( + new QuestionImage($this->imageFile, $context['question_id'] ?? null) + ) : null, + 'correctness' => $this->correctness, + ]); + } + + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = clone $this; + $clone->position = $tt->int($normalized['position']); + $clone->answertext = $tt->nullableString($normalized['answertext']); + $clone->imageFile = $tt->denormalize($normalized['image'], QuestionImage::class)?->getFilename(); + $clone->correctness = $tt->int($normalized['correctness']); + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPool.php b/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPool.php index 2aeefb63a399..2a666800da05 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPool.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPool.php @@ -35,8 +35,6 @@ class ilObjQuestionPool extends ilObject private ilComponentRepository $component_repository; private ilBenchmark $benchmark; - private array $mob_ids; - private array $file_ids; private bool $skill_service_enabled; private GeneralQuestionPropertiesRepository $questionrepository; @@ -305,246 +303,6 @@ public function getPrintviewQuestions(): array return $rows; } - /** - * @param ilXmlWriter $xmlWriter - */ - private function exportXMLSettings($xmlWriter): void - { - $xmlWriter->xmlStartTag('Settings'); - $xmlWriter->xmlElement('SkillService', null, (int) $this->isSkillServiceEnabled()); - $xmlWriter->xmlEndTag('Settings'); - } - - /** - * export pages of test to xml (see ilias_co.dtd) - * - * @param object $a_xml_writer ilXmlWriter object that receives the - * xml data - */ - public function objectToXmlWriter(ilXmlWriter &$a_xml_writer, $a_inst, $a_target_dir, &$expLog, $questions): void - { - $ilBench = $this->benchmark; - - $this->mob_ids = []; - $this->file_ids = []; - - $attrs = []; - $attrs['Type'] = 'Questionpool_Test'; - $a_xml_writer->xmlStartTag('ContentObject', $attrs); - - // MetaData - $this->exportTitleAndDescription($a_xml_writer); - - // Settings - $this->exportXMLSettings($a_xml_writer); - - // PageObjects - $expLog->write(date('[y-m-d H:i:s] ') . 'Start Export Page Objects'); - $ilBench->start('ContentObjectExport', 'exportPageObjects'); - $this->exportXMLPageObjects($a_xml_writer, $a_inst, $expLog, $questions); - $ilBench->stop('ContentObjectExport', 'exportPageObjects'); - $expLog->write(date('[y-m-d H:i:s] ') . 'Finished Export Page Objects'); - - // MediaObjects - $expLog->write(date('[y-m-d H:i:s] ') . 'Start Export Media Objects'); - $ilBench->start('ContentObjectExport', 'exportMediaObjects'); - $this->exportXMLMediaObjects($a_xml_writer, $a_inst, $a_target_dir, $expLog); - $ilBench->stop('ContentObjectExport', 'exportMediaObjects'); - $expLog->write(date('[y-m-d H:i:s] ') . 'Finished Export Media Objects'); - - // FileItems - $expLog->write(date('[y-m-d H:i:s] ') . 'Start Export File Items'); - $ilBench->start('ContentObjectExport', 'exportFileItems'); - $this->exportFileItems($a_target_dir, $expLog); - $ilBench->stop('ContentObjectExport', 'exportFileItems'); - $expLog->write(date('[y-m-d H:i:s] ') . 'Finished Export File Items'); - - // skill assignments - $this->populateQuestionSkillAssignmentsXml($a_xml_writer, $questions); - - $a_xml_writer->xmlEndTag('ContentObject'); - } - - /** - * @param ilXmlWriter $a_xml_writer - * @param $questions - */ - protected function populateQuestionSkillAssignmentsXml(ilXmlWriter &$a_xml_writer, $questions): void - { - $assignmentList = new ilAssQuestionSkillAssignmentList($this->db); - $assignmentList->setParentObjId($this->getId()); - $assignmentList->loadFromDb(); - $assignmentList->loadAdditionalSkillData(); - - $skillQuestionAssignmentExporter = new ilAssQuestionSkillAssignmentExporter(); - $skillQuestionAssignmentExporter->setXmlWriter($a_xml_writer); - $skillQuestionAssignmentExporter->setQuestionIds($questions); - $skillQuestionAssignmentExporter->setAssignmentList($assignmentList); - $skillQuestionAssignmentExporter->export(); - } - - public function exportTitleAndDescription(ilXmlWriter &$a_xml_writer): void - { - $a_xml_writer->xmlElement('Title', null, $this->getTitle()); - $a_xml_writer->xmlElement('Description', null, $this->getDescription()); - } - - public function modifyExportIdentifier($a_tag, $a_param, $a_value) - { - if ($a_tag == 'Identifier' && $a_param == 'Entry') { - $a_value = ilUtil::insertInstIntoID($a_value); - } - - return $a_value; - } - - /** - * export page objects to xml (see ilias_co.dtd) - * - * @param object $a_xml_writer ilXmlWriter object that receives the - * xml data - */ - public function exportXMLPageObjects(&$a_xml_writer, $a_inst, &$expLog, $questions): void - { - $ilBench = $this->benchmark; - - foreach ($questions as $question_id) { - $ilBench->start('ContentObjectExport', 'exportPageObject'); - $expLog->write(date('[y-m-d H:i:s] ') . 'Page Object ' . $question_id); - - $attrs = []; - $a_xml_writer->xmlStartTag('PageObject', $attrs); - - // export xml to writer object - $ilBench->start('ContentObjectExport', 'exportPageObject_XML'); - $page_object = new ilAssQuestionPage($question_id); - $page_object->buildDom(); - $page_object->insertInstIntoIDs($a_inst); - $mob_ids = $page_object->collectMediaObjects(false); - $file_ids = ilPCFileList::collectFileItems($page_object, $page_object->getDomDoc()); - $xml = $page_object->getXMLFromDom(false, false, false, '', true); - $xml = str_replace('&', '&', $xml); - $a_xml_writer->appendXML($xml); - $page_object->freeDom(); - unset($page_object); - $ilBench->stop('ContentObjectExport', 'exportPageObject_XML'); - - $ilBench->start("ContentObjectExport", "exportPageObject_CollectMedia"); - foreach ($mob_ids as $mob_id) { - $this->mob_ids[$mob_id] = $mob_id; - } - $ilBench->stop('ContentObjectExport', 'exportPageObject_CollectMedia'); - - // collect all file items - $ilBench->start('ContentObjectExport', 'exportPageObject_CollectFileItems'); - //$file_ids = $page_obj->getFileItemIds(); - foreach ($file_ids as $file_id) { - $this->file_ids[$file_id] = $file_id; - } - $ilBench->stop('ContentObjectExport', 'exportPageObject_CollectFileItems'); - - $a_xml_writer->xmlEndTag("PageObject"); - - $ilBench->stop('ContentObjectExport', 'exportPageObject'); - } - } - - public function exportXMLMediaObjects(&$a_xml_writer, $a_inst, $a_target_dir, &$expLog): void - { - foreach ($this->mob_ids as $mob_id) { - $expLog->write(date('[y-m-d H:i:s] ') . 'Media Object ' . $mob_id); - if (ilObjMediaObject::_exists((int) $mob_id)) { - $target_dir = $a_target_dir . DIRECTORY_SEPARATOR . 'objects' - . DIRECTORY_SEPARATOR . 'il_' . IL_INST_ID . '_mob_' . $mob_id; - ilFileUtils::createDirectory($target_dir); - $media_obj = new ilObjMediaObject((int) $mob_id); - $media_obj->exportXML($a_xml_writer, (int) $a_inst); - foreach ($media_obj->getMediaItems() as $item) { - $stream = $item->getLocationStream(); - file_put_contents($target_dir . DIRECTORY_SEPARATOR . $item->getLocation(), $stream); - $stream->close(); - } - unset($media_obj); - } - } - } - - /** - * export files of file itmes - * - */ - public function exportFileItems($target_dir, &$expLog): void - { - foreach ($this->file_ids as $file_id) { - $expLog->write(date("[y-m-d H:i:s] ") . "File Item " . $file_id); - $file_dir = $target_dir . '/objects/il_' . IL_INST_ID . '_file_' . $file_id; - ilFileUtils::makeDir($file_dir); - $file_obj = new ilObjFile((int) $file_id, false); - $source_file = $file_obj->getFile($file_obj->getVersion()); - if (!is_file($source_file)) { - $source_file = $file_obj->getFile(); - } - if (is_file($source_file)) { - copy($source_file, $file_dir . '/' . $file_obj->getFileName()); - } - unset($file_obj); - } - } - - /** - * creates data directory for export files - * (data_dir/qpl_data/qpl_/export, depending on data - * directory that is set in ILIAS setup/ini) - */ - public function createExportDirectory(): void - { - $qpl_data_dir = ilFileUtils::getDataDir() . '/qpl_data'; - ilFileUtils::makeDir($qpl_data_dir); - if (!is_writable($qpl_data_dir)) { - $this->error->raiseError( - 'Questionpool Data Directory (' . $qpl_data_dir - . ') not writeable.', - $this->error->FATAL - ); - } - - // create learning module directory (data_dir/lm_data/lm_) - $qpl_dir = $qpl_data_dir . '/qpl_' . $this->getId(); - ilFileUtils::makeDir($qpl_dir); - if (!@is_dir($qpl_dir)) { - $this->error->raiseError('Creation of Questionpool Directory failed.', $this->error->FATAL); - } - // create Export subdirectory (data_dir/lm_data/lm_/Export) - ilFileUtils::makeDir($this->getExportDirectory('xlsx')); - if (!@is_dir($this->getExportDirectory('xlsx'))) { - $this->error->raiseError('Creation of Export Directory failed.', $this->error->FATAL); - } - ilFileUtils::makeDir($this->getExportDirectory('zip')); - if (!@is_dir($this->getExportDirectory('zip'))) { - $this->error->raiseError('Creation of Export Directory failed.', $this->error->FATAL); - } - } - - /** - * get export directory of questionpool - */ - public function getExportDirectory($type = ''): string - { - switch ($type) { - case 'xml': - $export_dir = ilExport::_getExportDirectory($this->getId(), $type, $this->getType()); - break; - case 'xlsx': - case 'zip': - $export_dir = ilFileUtils::getDataDir() . "/qpl_data/qpl_{$this->getId()}/export_{$type}"; - break; - default: - $export_dir = ilFileUtils::getDataDir() . '/qpl_data' . '/qpl_' . $this->getId() . '/export'; - break; - } - return $export_dir; - } - /** * Retrieve an array containing all question ids of the questionpool * @@ -599,15 +357,6 @@ public function checkQuestionParent(int $question_id): bool return (bool) $row['cnt']; } - /** - * get array of (two) new created questions for - * import id - */ - public function getImportMapping(): array - { - return []; - } - /** * Returns a QTI xml representation of a list of questions * @@ -1242,10 +991,4 @@ public static function isSkillManagementGloballyActivated(): ?bool return self::$isSkillManagementGloballyActivated; } - - public function fromXML(?string $xml_file): void - { - $parser = new ilObjQuestionPoolXMLParser($this, $xml_file); - $parser->startParsing(); - } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php b/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php index 98195c86b9e0..a9667788bd03 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php @@ -19,7 +19,18 @@ declare(strict_types=1); use ILIAS\Skill\Service\SkillUsageService; +use ILIAS\TestQuestionPool\ExportImport\Import\CleanupStage; +use ILIAS\TestQuestionPool\ExportImport\Import\DetectLegacyImportStage; use ILIAS\TestQuestionPool\QuestionPoolDIC; +use ILIAS\TestQuestionPool\Import\TestQuestionsImportTrait; +use ILIAS\TestQuestionPool\ExportImport\Import\UploadValidationStage; +use ILIAS\TestQuestionPool\ExportImport\Import\QuestionSelectionStage; +use ILIAS\TestQuestionPool\ExportImport\Import\PersistStage; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportStageRunner; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportSessionRepository; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportContext; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\StageResult; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\StageResultType; use ILIAS\TestQuestionPool\RequestDataCollector; use ILIAS\TestQuestionPool\Questions\Presentation\QuestionTable; use ILIAS\TestQuestionPool\Questions\GeneralQuestionPropertiesRepository; @@ -32,12 +43,11 @@ use ILIAS\UI\URLBuilderToken; use ILIAS\Data\Factory as DataFactory; use ILIAS\GlobalScreen\Services as GlobalScreen; -use ILIAS\Filesystem\Stream\Streams; use ILIAS\Filesystem\Util\Archive\Archives; -use ILIAS\TestQuestionPool\Import\TestQuestionsImportTrait; use ILIAS\FileUpload\MimeType; use ILIAS\UI\Component\Modal\RoundTrip as RoundTripModal; use ILIAS\Style\Content\Service as ContentStyle; +use Psr\Log\LoggerInterface; /** * Class ilObjQuestionPoolGUI @@ -95,6 +105,8 @@ class ilObjQuestionPoolGUI extends ilObjectGUI implements ilCtrlBaseClassInterfa protected RequestDataCollector $request_data_collector; protected GeneralQuestionPropertiesRepository $questionrepository; protected GlobalTestSettings $global_test_settings; + protected ImportSessionRepository $import_session_repository; + protected LoggerInterface $import_logger; public function __construct() { @@ -121,6 +133,8 @@ public function __construct() $this->request_data_collector = $local_dic['request_data_collector']; $this->questionrepository = $local_dic['question.general_properties.repository']; $this->global_test_settings = $local_dic['global_test_settings']; + $this->import_session_repository = $local_dic['exportimport.session']; + $this->import_logger = $local_dic['exportimport.logging'](); parent::__construct('', $this->request_data_collector->getRefId(), true, false); @@ -731,201 +745,6 @@ public function download_paragraphObject(): void exit; } - public function importVerifiedFileObject(): void - { - if ($this->creation_mode - && !$this->checkPermissionBool('create', '', $this->request_data_collector->string('new_type')) - || !$this->creation_mode - && !$this->checkPermissionBool('read', '', $this->object->getType())) { - $this->redirectAfterMissingWrite(); - return; - } - - $file_to_import = ilSession::get('path_to_import_file'); - list($subdir, $importdir, $xmlfile, $qtifile) = $this->buildImportDirectoriesFromImportFile($file_to_import); - - $new_obj = new ilObjQuestionPool(0, true); - $new_obj->setType($this->request_data_collector->raw('new_type')); - $new_obj->setTitle('dummy'); - $new_obj->setDescription('questionpool import'); - $new_obj->create(true); - $new_obj->createReference(); - $new_obj->putInTree($this->request_data_collector->getRefId()); - $new_obj->setPermissions($this->request_data_collector->getRefId()); - - $selected_questions = $this->retrieveSelectedQuestionsFromImportQuestionsSelectionForm( - 'importVerifiedFile', - $importdir, - $qtifile, - $this->request - ); - - if (is_file($importdir . DIRECTORY_SEPARATOR . 'manifest.xml')) { - $this->importQuestionPoolWithValidManifest( - $new_obj, - $selected_questions, - $file_to_import - ); - } else { - $this->importQuestionsFromQtiFile( - $new_obj, - $selected_questions, - $qtifile, - $importdir, - $xmlfile - ); - - $new_obj->fromXML($xmlfile); - - $new_obj->update(); - $new_obj->saveToDb(); - } - $this->cleanupAfterImport($importdir); - - $this->tpl->setOnScreenMessage('success', $this->lng->txt('object_imported'), true); - $this->ctrl->setParameterByClass(self::class, 'ref_id', $new_obj->getRefId()); - $this->ctrl->redirectByClass(self::class); - } - - public function importVerifiedQuestionsFileObject(): void - { - $file_to_import = ilSession::get('path_to_import_file'); - - if (mb_substr($file_to_import, -3) === 'xml') { - $importdir = dirname($file_to_import); - $selected_questions = $this->retrieveSelectedQuestionsFromImportQuestionsSelectionForm( - 'importVerifiedQuestionsFile', - $importdir, - $file_to_import, - $this->request - ); - $this->importQuestionsFromQtiFile( - $this->getObject(), - $selected_questions, - $file_to_import, - $importdir - ); - } else { - list($subdir, $importdir, $xmlfile, $qtifile) = $this->buildImportDirectoriesFromImportFile($file_to_import); - $selected_questions = $this->retrieveSelectedQuestionsFromImportQuestionsSelectionForm( - 'importVerifiedQuestionsFile', - $importdir, - $qtifile, - $this->request - ); - if (is_file($importdir . DIRECTORY_SEPARATOR . 'manifest.xml')) { - $this->importQuestionPoolWithValidManifest( - $this->getObject(), - $selected_questions, - $file_to_import - ); - } else { - $this->importQuestionsFromQtiFile( - $this->getObject(), - $selected_questions, - $qtifile, - $importdir, - $xmlfile - ); - } - } - - $this->cleanupAfterImport($importdir); - - $this->tpl->setOnScreenMessage('success', $this->lng->txt('object_imported'), true); - $this->questionsObject(); - } - - public function uploadQuestionsImportObject(): void - { - $import_questions_modal = $this->buildImportQuestionsModal()->withRequest($this->request); - $data = $import_questions_modal->getData(); - if ($data === null) { - $this->questionsObject( - $import_questions_modal->withOnLoad( - $import_questions_modal->getShowSignal() - ) - ); - return; - } - $path_to_imported_file_in_temp_dir = $data['import_file'][0]; - $this->importQuestionsFile($path_to_imported_file_in_temp_dir); - } - - private function buildImportQuestionsModal(): RoundTripModal - { - $constraint = $this->refinery->custom()->constraint( - function ($vs): bool { - if ($vs === []) { - return false; - } - return true; - }, - $this->lng->txt('msg_no_files_selected') - ); - - $file_upload_input = $this->ui_factory->input()->field() - ->file(new \QuestionPoolImportUploadHandlerGUI(), $this->lng->txt('import_file')) - ->withAcceptedMimeTypes(self::SUPPORTED_IMPORT_MIME_TYPES) - ->withMaxFiles(1) - ->withAdditionalTransformation($constraint); - return $this->ui_factory->modal()->roundtrip( - $this->lng->txt('import'), - [], - ['import_file' => $file_upload_input], - $this->ctrl->getFormActionByClass(self::class, 'uploadQuestionsImport') - )->withSubmitLabel($this->lng->txt('import')); - } - - private function importQuestionPoolWithValidManifest( - ilObjQuestionPool $obj, - array $selected_questions, - string $file_to_import - ): void { - ilSession::set('qpl_import_selected_questions', $selected_questions); - $imp = new ilImport($this->request_data_collector->getRefId()); - $map = $imp->getMapping(); - $map->addMapping('components/ILIAS/TestQuestionPool', 'qpl', 'new_id', (string) $obj->getId()); - $imp->importObject($obj, $file_to_import, basename($file_to_import), 'qpl', 'components/ILIAS/TestQuestionPool', true); - } - - private function importQuestionsFromQtiFile( - ilObjQuestionPool $obj, - array $selected_questions, - string $qtifile, - string $importdir, - string $xmlfile = '' - ): void { - $qti_parser = new ilQTIParser( - $importdir, - $qtifile, - ilQTIParser::IL_MO_PARSE_QTI, - $obj->getId(), - $selected_questions - ); - $qti_parser->startParsing(); - - if ($xmlfile === '') { - return; - } - - $cont_parser = new ilQuestionPageParser( - $obj, - $xmlfile, - $importdir - ); - $cont_parser->setQuestionMapping($qti_parser->getImportMapping()); - $cont_parser->startParsing(); - } - - private function cleanupAfterImport(string $importdir): void - { - ilFileUtils::delDir($importdir); - $this->deleteUploadedImportFile(ilSession::get('path_to_uploaded_file_in_temp_dir')); - ilSession::clear('path_to_import_file'); - ilSession::clear('path_to_uploaded_file_in_temp_dir'); - } - public function createQuestionObject(): void { $form = $this->buildQuestionCreationForm()->withRequest($this->request); @@ -1056,24 +875,10 @@ public function exportQuestions(array $ids): void } } - protected function renoveImportFailsObject(): void - { - $qsaImportFails = new ilAssQuestionSkillAssignmentImportFails($this->object->getId()); - $qsaImportFails->deleteRegisteredImportFails(); - - $this->ctrl->redirectByClass( - [ - ilRepositoryGUI::class, - self::class, - ilInfoScreenGUI::class - ] - ); - } - /** * list questions of question pool */ - public function questionsObject(?RoundTripModal $import_questions_modal = null): void + public function questionsObject(): void { if (!$this->access->checkAccess("read", "", $this->request_data_collector->getRefId())) { $this->infoScreenForward(); @@ -1103,17 +908,6 @@ public function questionsObject(?RoundTripModal $import_questions_modal = null): ); $this->toolbar->addComponent($btn); - if ($import_questions_modal === null) { - $import_questions_modal = $this->buildImportQuestionsModal(); - } - - $btn_import = $this->ui_factory->button()->standard( - $this->lng->txt('import'), - $import_questions_modal->getShowSignal() - ); - $this->toolbar->addComponent($btn_import); - $out[] = $this->ui_renderer->render($import_questions_modal); - if (ilSession::get('qpl_clipboard') != null && count(ilSession::get('qpl_clipboard'))) { $btn_paste = $this->ui_factory->button()->standard( $this->lng->txt('paste'), @@ -1303,103 +1097,91 @@ public function editQuestionForTestObject(): void $this->ctrl->redirectByClass(ilAssQuestionPreviewGUI::class, 'show'); } - protected function importQuestionsFile(string $path_to_uploaded_file_in_temp_dir): void + + protected function importFile(string $file_to_import, string $path_to_uploaded_file_in_temp_dir): void { - if (!$this->temp_file_system->hasDir($path_to_uploaded_file_in_temp_dir) - || ($files = $this->temp_file_system->listContents($path_to_uploaded_file_in_temp_dir)) === [] - || mb_stripos($files[0]->getPath(), 'tst') !== false) { - $this->tpl->setOnScreenMessage('failure', $this->lng->txt('obj_import_file_error'), true); - $this->ctrl->redirectByClass(self::class, self::DEFAULT_CMD); - } + $this->import_session_repository->clear(); - $file_to_import = $this->import_temp_directory . DIRECTORY_SEPARATOR . $files[0]->getPath(); - $qtifile = $file_to_import; - $importdir = dirname($file_to_import); + $context = new ImportContext([UploadValidationStage::FILE_TO_IMPORT => $file_to_import]); + $this->import_session_repository->setContext($context); + $this->import_session_repository->setCurrentStageIndex(0); + $this->ctrl->redirectByClass(self::class, 'processImport'); + } - if ($this->temp_file_system->getMimeType($files[0]->getPath()) === MimeType::APPLICATION__ZIP) { - $options = (new ILIAS\Filesystem\Util\Archive\UnzipOptions()) - ->withZipOutputPath($this->getImportTempDirectory()); - $unzip = $this->archives->unzip($this->temp_file_system->readStream($files[0]->getPath()), $options); - $unzip->extract(); - list($subdir, $importdir, $xmlfile, $qtifile) = $this->buildImportDirectoriesFromImportFile($file_to_import); - } - if (!file_exists($qtifile)) { - ilFileUtils::delDir($importdir); - $this->tpl->setOnScreenMessage('failure', $this->lng->txt('cannot_find_xml'), true); - $this->questionsObject(); + public function processImportObject(): void + { + $permission = $this->creation_mode ? 'create' : 'read'; + if (!$this->checkPermissionBool($permission, '', $this->object->getType())) { + $this->redirectAfterMissingWrite(); return; } - ilSession::set('path_to_import_file', $file_to_import); - ilSession::set('path_to_uploaded_file_in_temp_dir', $path_to_uploaded_file_in_temp_dir); + $runner = $this->buildImportStageRunner(); + $result = $runner->run($this->request); - $form = $this->buildImportQuestionsSelectionForm( - 'importVerifiedQuestionsFile', - $importdir, - $qtifile, - $path_to_uploaded_file_in_temp_dir - ); + match ($result->type) { + StageResultType::INTERACT => $this->renderImportStage($runner, $result), + StageResultType::ADVANCE => $this->ctrl->redirectByClass(self::class, 'processImport'), + StageResultType::ERROR => $this->renderImportError($runner, $result), + StageResultType::COMPLETE => $this->renderImportSuccess($runner, $result), + }; + } - if ($form === null) { - return; - } + private function buildImportStageRunner(): ImportStageRunner + { + $form_action = $this->ctrl->getFormActionByClass(self::class, 'processImport'); - $panel = $this->ui_factory->panel()->standard( - $this->lng->txt('import_question'), + return new ImportStageRunner( [ - $this->ui_factory->legacy()->content($this->lng->txt('qpl_import_verify_found_questions')), - $form - ] + new UploadValidationStage( + $this->archives, + $this->lng, + $this->import_logger, + 'components/ILIAS/TestQuestionPool' + ), + new DetectLegacyImportStage($this->import_logger), + new QuestionSelectionStage( + $this->lng, + $this->import_logger, + $this->component_factory, + $this->ui_factory, + $this->request, + $form_action, + $this->lng->txt('import_qpl') + ), + new PersistStage( + $this->lng, + $this->request_data_collector, + $this->import_session_repository + ), + ], + $this->import_session_repository, + new CleanupStage($this->import_logger) ); - $this->tpl->setContent($this->ui_renderer->render($panel)); - $this->tpl->printToStdout(); - exit; } - protected function importFile(string $file_to_import, string $path_to_uploaded_file_in_temp_dir): void + private function renderImportStage(ImportStageRunner $runner, StageResult $result): void { - list($subdir, $importdir, $xmlfile, $qtifile) = $this->buildImportDirectoriesFromImportFile($file_to_import); - - $options = (new ILIAS\Filesystem\Util\Archive\UnzipOptions()) - ->withZipOutputPath($this->getImportTempDirectory()); - - $unzip = $this->archives->unzip(Streams::ofResource(fopen($file_to_import, 'r')), $options); - $unzip->extract(); - - if (!file_exists($qtifile)) { - ilFileUtils::delDir($importdir); - $this->tpl->setOnScreenMessage('failure', $this->lng->txt('cannot_find_xml'), true); - return; - } - - ilSession::set('path_to_import_file', $file_to_import); - ilSession::set('path_to_uploaded_file_in_temp_dir', $path_to_uploaded_file_in_temp_dir); - - $this->ctrl->setParameterByClass(self::class, 'new_type', $this->type); - $form = $this->buildImportQuestionsSelectionForm( - 'importVerifiedFile', - $importdir, - $qtifile, - $path_to_uploaded_file_in_temp_dir + $workflow = $runner->buildWorkflow($this->ui_factory, $this->lng->txt('import')); + $this->tpl->setContent( + $this->ui_renderer->render([$workflow, ...$result->components]) ); + } - if ($form === null) { - return; - } + private function renderImportError(ImportStageRunner $runner, StageResult $result): void + { + $this->tpl->setOnScreenMessage('failure', $result->error_message, true); + } - $panel = $this->ui_factory->panel()->standard( - $this->lng->txt('import_qpl'), - [ - $this->ui_factory->legacy()->content($this->lng->txt('qpl_import_verify_found_questions')), - $form - ] - ); - $this->tpl->setContent($this->ui_renderer->render($panel)); - $this->tpl->printToStdout(); - exit; + private function renderImportSuccess(ImportStageRunner $runner, StageResult $result): void + { + $this->tpl->setOnScreenMessage('success', $this->lng->txt('object_imported'), true); + $this->ctrl->setParameter($this, 'ref_id', $result->context->get('pool_obj_id')); + $this->ctrl->redirectByClass(self::class, self::DEFAULT_CMD); } + public function addLocatorItems(): void { $ilLocator = $this->locator; diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolXMLParser.php b/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolXMLParser.php index ee4ab378571e..bc2632ef42f5 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolXMLParser.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolXMLParser.php @@ -17,10 +17,7 @@ *********************************************************************/ /** - * @author Björn Heyser - * @version $Id$ - * - * @package components\ILIAS/Test + * @deprecated This class is only used for legacy imports and will be removed with further ILIAS versions. */ class ilObjQuestionPoolXMLParser extends ilSaxParser { diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilQuestionpoolExport.php b/components/ILIAS/TestQuestionPool/classes/class.ilQuestionpoolExport.php deleted file mode 100755 index acaf7943deae..000000000000 --- a/components/ILIAS/TestQuestionPool/classes/class.ilQuestionpoolExport.php +++ /dev/null @@ -1,186 +0,0 @@ - -* -* @version $Id$ -* -* @ingroup components\ILIASTestQuestionPool -*/ -class ilQuestionpoolExport -{ - public ilErrorHandling $err; // error object - public ilDBInterface $db; // database object - public ILIAS $ilias; // ilias object - public string $inst_id; // installation id - public ilLanguage $lng; - - private string $export_dir = ''; - private string $subdir = ''; - private string $filename = ''; - private string $zipfilename = ''; - private string $qti_filename = ''; - private ilXmlWriter $xml; - - /** - * Constructor - * @access public - */ - public function __construct( - protected ilObjQuestionPool $qpl_obj, - protected string $mode = "xml", - protected ?array $questions = null - ) { - global $DIC; - $this->err = $DIC['ilErr']; - $this->db = $DIC['ilDB']; - $this->ilias = $DIC['ilias']; - $this->lng = $DIC['lng']; - - if (!is_array($this->questions)) { - $this->questions = $this->qpl_obj->getAllQuestionIds(); - } - - $this->inst_id = IL_INST_ID; - $date = time(); - - $this->qpl_obj->createExportDirectory(); - switch ($this->mode) { - case "xml": - $this->export_dir = $this->qpl_obj->getExportDirectory('xml'); - $this->subdir = $date . "__" . $this->inst_id . "__" . - "qpl" . "_" . $this->qpl_obj->getId(); - $this->filename = $this->subdir . ".xml"; - $this->qti_filename = $date . "__" . $this->inst_id . "__" . - "qti" . "_" . $this->qpl_obj->getId() . ".xml"; - break; - case "xlsx": - $this->export_dir = $this->qpl_obj->getExportDirectory('xlsx'); - $this->filename = $date . "__" . $this->inst_id . "__" . - "qpl" . "_" . $this->qpl_obj->getId() . ".xlsx"; - $this->zipfilename = $date . "__" . $this->inst_id . "__" . - "qpl" . "_" . $this->qpl_obj->getId() . ".zip"; - break; - default: - $this->export_dir = $this->qpl_obj->getExportDirectory('zip'); - $this->subdir = $date . "__" . $this->inst_id . "__" . - "qpl" . "_" . $this->qpl_obj->getId(); - $this->filename = $this->subdir . ".xml"; - $this->qti_filename = $date . "__" . $this->inst_id . "__" . - "qti" . "_" . $this->qpl_obj->getId() . ".xml"; - break; - } - } - - public function getInstId() - { - return $this->inst_id; - } - - - /** - * build export file (complete zip file) - */ - public function buildExportFile(): string - { - switch ($this->mode) { - case "xlsx": - return $this->buildExportFileXLSX(); - case "xml": - default: - return $this->buildExportFileXML(); - } - } - - /** - * build xml export file - */ - public function buildExportFileXML(): string - { - global $DIC; - $ilBench = $DIC['ilBench']; - - $ilBench->start("QuestionpoolExport", "buildExportFile"); - - $this->xml = new ilXmlWriter(); - $this->xml->xmlSetDtdDef(""); - $this->xml->xmlSetGenCmt("Export of ILIAS Test Questionpool " . - $this->qpl_obj->getId() . " of installation " . $this->inst_id); - $this->xml->xmlHeader(); - - ilFileUtils::makeDir($this->export_dir . "/" . $this->subdir); - ilFileUtils::makeDir($this->export_dir . "/" . $this->subdir . "/objects"); - - $expDir = $this->qpl_obj->getExportDirectory(); - ilFileUtils::makeDirParents($expDir); - - $expLog = new ilLog($expDir, "export.log"); - $expLog->delete(); - $expLog->setLogFormat(""); - $expLog->write(date("[y-m-d H:i:s] ") . "Start Export"); - - $qti_file = fopen($this->export_dir . "/" . $this->subdir . "/" . $this->qti_filename, "w"); - fwrite($qti_file, $this->qpl_obj->questionsToXML($this->questions)); - fclose($qti_file); - - $ilBench->start("QuestionpoolExport", "buildExportFile_getXML"); - $this->qpl_obj->objectToXmlWriter( - $this->xml, - $this->inst_id, - $this->export_dir . "/" . $this->subdir, - $expLog, - $this->questions - ); - $ilBench->stop("QuestionpoolExport", "buildExportFile_getXML"); - - $ilBench->start("QuestionpoolExport", "buildExportFile_dumpToFile"); - $this->xml->xmlDumpFile($this->export_dir . "/" . $this->subdir . "/" . $this->filename, false); - $ilBench->stop("QuestionpoolExport", "buildExportFile_dumpToFile"); - - $ilBench->start("QuestionpoolExport", "buildExportFile_saveAdditionalMobs"); - $this->exportXHTMLMediaObjects($this->export_dir . "/" . $this->subdir); - $ilBench->stop("QuestionpoolExport", "buildExportFile_saveAdditionalMobs"); - - $ilBench->start("QuestionpoolExport", "buildExportFile_zipFile"); - ilFileUtils::zip($this->export_dir . "/" . $this->subdir, $this->export_dir . "/" . $this->subdir . ".zip"); - - $ilBench->stop("QuestionpoolExport", "buildExportFile_zipFile"); - - $expLog->write(date("[y-m-d H:i:s] ") . "Finished Export"); - $ilBench->stop("QuestionpoolExport", "buildExportFile"); - - return $this->export_dir . "/" . $this->subdir . ".zip"; - } - - public function exportXHTMLMediaObjects($a_export_dir): void - { - foreach ($this->questions as $question_id) { - $mobs = ilObjMediaObject::_getMobsOfObject("qpl:html", $question_id); - foreach ($mobs as $mob) { - if (ilObjMediaObject::_exists($mob)) { - $mob_obj = new ilObjMediaObject($mob); - $mob_obj->exportFiles($a_export_dir); - unset($mob_obj); - } - } - } - } -} diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolExporter.php b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolExporter.php index 9234ff485c35..54e4513695f7 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolExporter.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolExporter.php @@ -16,131 +16,78 @@ * *********************************************************************/ -/** - * Used for container export with tests - * - * @author Helmut Schottmüller - * @version $Id$ - * @ingroup components\ILIASTest - */ +use ILIAS\Export\ExportHandler\Factory as ExportHandler; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Bridge\XmlExporterBridge; +use ILIAS\TestQuestionPool\QuestionPoolDIC; + class ilTestQuestionPoolExporter extends ilXmlExporter { - private $ds; + use XmlExporterBridge; - /** - * Initialisation - */ public function init(): void { - } + $local_dic = QuestionPoolDIC::dic(); - /** - * Overwritten for qpl - * @param string $a_obj_type - * @param int $a_obj_id - * @param string $a_export_type - */ - public static function lookupExportDirectory(string $a_obj_type, int $a_obj_id, string $a_export_type = 'xml', string $a_entity = ""): string - { - if ($a_export_type == 'xml') { - return ilFileUtils::getDataDir() . "/qpl_data" . "/qpl_" . $a_obj_id . "/export_zip"; - } - return ilFileUtils::getDataDir() . "/qpl_data" . "/qpl_" . $a_obj_id . "/export_" . $a_export_type; + $this->export_handler = new ExportHandler(); + $this->state_holder = $local_dic['exportimport.state_holder']; + $this->exporter = $local_dic['exportimport.exporter']; + $this->logger = $local_dic['exportimport.logging'](); } - /** - * Get xml representation - * @param string entity - * @param string schema version - * @param string id - * @return string xml string + * Returns the final XML content for the question pool. + * + * This method is called after `getXmlExportTailDependencies()`. At this point the export writer and export + * directory are available, so the preprocessed export can be written to disk and returned as xml. */ public function getXmlRepresentation(string $a_entity, string $a_schema_version, string $a_id): string { - $qpl = new ilObjQuestionPool($a_id, false); - $qpl->loadFromDb(); - - $qpl_exp = new ilQuestionpoolExport($qpl, 'xml'); - $qpl_exp->buildExportFile(); + if ($a_entity !== 'qpl') { + throw new InvalidArgumentException("Invalid entity for question pool export: {$a_entity}"); + } - global $DIC; /* @var ILIAS\DI\Container $DIC */ - $DIC['ilLog']->write(__METHOD__ . ': Created zip file'); - return ''; // sagt mjansen + return $this->finalizeExport()->getContent(); } /** - * Get tail dependencies - * @param string entity - * @param string target release - * @param array ids - * @return array array of array with keys "component", entity", "ids" + * Collects export tail dependencies for the question pool. + * + * The export framework calls this method before `getXmlRepresentation()`. Therefore this method only prepares and + * processes the export in memory using the export state. The export state is created if it does not exist yet. */ public function getXmlExportTailDependencies(string $a_entity, string $a_target_release, array $a_ids): array { - if ($a_entity == 'qpl') { - $deps = []; - - $taxIds = $this->getDependingTaxonomyIds($a_ids); - - if (count($taxIds)) { - $deps[] = [ - 'component' => 'components/ILIAS/Taxonomy', - 'entity' => 'tax', - 'ids' => $taxIds - ]; - } - - $md_ids = []; - foreach ($a_ids as $id) { - $md_ids[] = $id . ':0:qpl'; - } - if ($md_ids !== []) { - $deps[] = [ - 'component' => 'components/ILIAS/MetaData', - 'entity' => 'md', - 'ids' => $md_ids - ]; - } - - return $deps; + if ($a_entity !== 'qpl') { + throw new InvalidArgumentException("Invalid entity for question pool export: {$a_entity}"); } - return parent::getXmlExportTailDependencies($a_entity, $a_target_release, $a_ids); - } - - /** - * @param array $testObjIds - * @return array $taxIds - */ - private function getDependingTaxonomyIds($poolObjIds): array - { - $taxIds = []; - - foreach ($poolObjIds as $poolObjId) { - foreach (ilObjTaxonomy::getUsageOfObject($poolObjId) as $taxId) { - $taxIds[$taxId] = $taxId; - } + // If the default export option was used, the state is not initialized yet. + if ($this->state_holder->exists() === false) { + $this->initExportState( + 'components/ILIAS/TestQuestionPool', + $a_target_release, + $a_entity, + $a_ids + ); } - return $taxIds; + return $this->processExport()->getDependencies(); } /** - * Returns schema versions that the component can export to. - * ILIAS chooses the first one, that has min/max constraints which - * fit to the target release. Please put the newest on top. - * @return array + * Returns schema versions that the component can export to. ILIAS chooses the first one, that has min/max + * constraints which fit to the target release. */ public function getValidSchemaVersions(string $a_entity): array { return [ - "4.1.0" => [ - "namespace" => "http://www.ilias.de/Modules/TestQuestionPool/htlm/4_1", - "xsd_file" => "ilias_qpl_4_1.xsd", - "uses_dataset" => false, - "min" => "4.1.0", - "max" => ""] + '4.1.0' => [ + 'namespace' => 'http://www.ilias.de/Modules/TestQuestionPool/htlm/4_1', + 'xsd_file' => 'ilias_qpl_4_1.xsd', + 'uses_dataset' => false, + 'min' => '4.1.0', + 'max' => '' + ] ]; } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php index f8d033a24518..1c6a2a528a4d 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php @@ -18,31 +18,34 @@ declare(strict_types=1); -use ILIAS\TestQuestionPool\Import\TestQuestionsImportTrait; +use ILIAS\Data\ReferenceId; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportSessionRepository; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Serializing\XMLMemoryDeserializer; +use ILIAS\TestQuestionPool\ExportImport\Import\DetectLegacyImportStage; +use ILIAS\TestQuestionPool\ExportImport\Import\QuestionPoolImporter; use ILIAS\TestQuestionPool\QuestionPoolDIC; -use ILIAS\TestQuestionPool\RequestDataCollector; - -/** - * Importer class for question pools - * - * @author Helmut Schottmüller - * @version $Id$ - * @ingroup components\ILIASLearningModule - */ class ilTestQuestionPoolImporter extends ilXmlImporter { - use TestQuestionsImportTrait; - - private ilObjQuestionPool $pool_obj; - protected readonly RequestDataCollector $request_data_collector; + protected readonly ImportSessionRepository $session; + protected readonly QuestionPoolImporter $importer; + protected readonly ilTestQuestionPoolLegacyImporter $legacy_importer; public function __construct() { parent::__construct(); + $this->legacy_importer = new ilTestQuestionPoolLegacyImporter(); $local_dic = QuestionPoolDIC::dic(); - $this->request_data_collector = $local_dic['request_data_collector']; + $this->session = $local_dic['exportimport.session']; + $this->importer = $local_dic['exportimport.importer']; + } + + public function init(): void + { + $this->legacy_importer->setImport($this->getImport()); + $this->legacy_importer->setImportDirectory($this->getImportDirectory()); + $this->legacy_importer->init(); } public function importXmlRepresentation( @@ -51,127 +54,51 @@ public function importXmlRepresentation( string $a_xml, ilImportMapping $a_mapping ): void { - global $DIC; - // Container import => pool object already created - if (($new_id = $a_mapping->getMapping('components/ILIAS/Container', 'objs', $a_id)) !== null) { - $new_obj = ilObjectFactory::getInstanceByObjId((int) $new_id, false); - $new_obj->getObjectProperties()->storePropertyIsOnline($new_obj->getObjectProperties()->getPropertyIsOnline()->withOffline()); // sets Question pools to always online - - $selected_questions = []; - [$importdir, $xmlfile, $qtifile] = $this->buildImportDirectoriesFromContainerImport( - $this->getImportDirectory() - ); - } elseif (($new_id = $a_mapping->getMapping('components/ILIAS/TestQuestionPool', 'qpl', 'new_id')) !== null) { - $new_obj = ilObjectFactory::getInstanceByObjId((int) $new_id, false); - - $selected_questions = ilSession::get('qpl_import_selected_questions'); - [$subdir, $importdir, $xmlfile, $qtifile] = $this->buildImportDirectoriesFromImportFile( - ilSession::get('path_to_import_file') - ); - ilSession::clear('qpl_import_selected_questions'); - } else { - // Shouldn't happen - $DIC['ilLog']->write(__METHOD__ . ': non container and no tax mapping, perhaps old qpl export'); - return; - } - - if (!file_exists($xmlfile)) { - $DIC['ilLog']->write(__METHOD__ . ': Cannot find xml definition: ' . $xmlfile); + // Check if forward to legacy importer is needed + $context = $this->session->getContext(); + if (DetectLegacyImportStage::isLegacyImport($context)) { + $this->legacy_importer->setInstallId($this->getInstallId()); + $this->legacy_importer->setInstallUrl($this->getInstallUrl()); + $this->legacy_importer->setSchemaVersion($this->getSchemaVersion()); + $this->legacy_importer->setSkipEntities($this->getSkipEntities()); + $this->legacy_importer->importXmlRepresentation($a_entity, $a_id, $a_xml, $a_mapping); return; } - if (!file_exists($qtifile)) { - $DIC['ilLog']->write(__METHOD__ . ': Cannot find qti definition: ' . $qtifile); - return; - } - - $this->pool_obj = $new_obj; - - $new_obj->fromXML($xmlfile); - - $qpl_new = $this->request_data_collector->string('qpl_new'); - - // set another question pool name (if possible) - if ($qpl_new !== '') { - $new_obj->setTitle($qpl_new); - } - - $new_obj->update(); - $new_obj->saveToDb(); - - // FIXME: Copied from ilObjQuestionPoolGUI::importVerifiedFileObject - // TODO: move all logic to ilObjQuestionPoolGUI::importVerifiedFile and call - // this method from ilObjQuestionPoolGUI and ilTestImporter - - $DIC['ilLog']->write(__METHOD__ . ': xml file: ' . $xmlfile . ', qti file:' . $qtifile); - - $qtiParser = new ilQTIParser( - $importdir, - $qtifile, - ilQTIParser::IL_MO_PARSE_QTI, - $new_obj->getId(), - $selected_questions - ); - $qtiParser->startParsing(); - $questionPageParser = new ilQuestionPageParser( - $new_obj, - $xmlfile, - $importdir + $result = $this->importer->import( + new XMLMemoryDeserializer()->open($a_xml), + $a_mapping, + new ReferenceId($a_mapping->getTargetId()), + $context, ); - $questionPageParser->setQuestionMapping($qtiParser->getImportMapping()); - $questionPageParser->startParsing(); - - foreach ($qtiParser->getImportMapping() as $k => $v) { - $old_question_id = substr($k, strpos($k, 'qst_') + strlen('qst_')); - $new_question_id = (string) $v['pool']; // yes, this is the new question id ^^ - - $a_mapping->addMapping( - 'components/ILIAS/Taxonomy', - 'tax_item', - "qpl:quest:{$old_question_id}", - $new_question_id - ); - - $a_mapping->addMapping( - 'components/ILIAS/Taxonomy', - 'tax_item_obj_id', - "qpl:quest:{$old_question_id}", - (string) $new_obj->getId() - ); + $this->session->setContext($result); + } - $a_mapping->addMapping( - 'components/ILIAS/TestQuestionPool', - 'quest', - $old_question_id, - $new_question_id - ); + public function finalProcessing(ilImportMapping $a_mapping): void + { + // Check if forward to legacy importer is needed + $context = $this->session->getContext(); + if (DetectLegacyImportStage::isLegacyImport($context)) { + $this->legacy_importer->finalProcessing($a_mapping); + return; } - $this->importQuestionSkillAssignments($xmlfile, $a_mapping, $new_obj->getId()); - - $a_mapping->addMapping('components/ILIAS/TestQuestionPool', 'qpl', $a_id, (string) $new_obj->getId()); - $a_mapping->addMapping( - 'components/ILIAS/MetaData', - 'md', - $a_id . ':0:qpl', - $new_obj->getId() . ':0:qpl' - ); - - - $new_obj->saveToDb(); + $this->importer->finalize($a_mapping); + $this->finalizeTaxonomyUsage($a_mapping); } - /** - * Final processing - * @param ilImportMapping $a_mapping - * @return void - */ - public function finalProcessing(ilImportMapping $a_mapping): void + private function finalizeTaxonomyUsage(ilImportMapping $a_mapping): void { - $maps = $a_mapping->getMappingsOfEntity('components/ILIAS/TestQuestionPool', 'qpl'); - foreach ($maps as $old => $new) { + $qpl_mappings = $a_mapping->getMappingsOfEntity('components/ILIAS/TestQuestionPool', 'qpl'); + + foreach ($qpl_mappings as $old => $new) { if ($old !== 'new_id' && (int) $old > 0) { - $new_tax_ids = $a_mapping->getMapping('components/ILIAS/Taxonomy', 'tax_usage_of_obj', (string) $old); + $new_tax_ids = $a_mapping->getMapping( + 'components/ILIAS/Taxonomy', + 'tax_usage_of_obj', + (string) $old + ); + if ($new_tax_ids !== null) { $tax_ids = explode(':', $new_tax_ids); foreach ($tax_ids as $tid) { @@ -181,26 +108,4 @@ public function finalProcessing(ilImportMapping $a_mapping): void } } } - - protected function importQuestionSkillAssignments($xmlFile, ilImportMapping $mappingRegistry, $targetParentObjId): void - { - $parser = new ilAssQuestionSkillAssignmentXmlParser($xmlFile); - $parser->startParsing(); - - $importer = new ilAssQuestionSkillAssignmentImporter(); - $importer->setTargetParentObjId($targetParentObjId); - $importer->setImportInstallationId($this->getInstallId()); - $importer->setImportMappingRegistry($mappingRegistry); - $importer->setImportMappingComponent('components/ILIAS/TestQuestionPool'); - $importer->setImportAssignmentList($parser->getAssignmentList()); - - $importer->import(); - - if ($importer->getFailedImportAssignmentList()->assignmentsExist()) { - $qsaImportFails = new ilAssQuestionSkillAssignmentImportFails($targetParentObjId); - $qsaImportFails->registerFailedImports($importer->getFailedImportAssignmentList()); - - $this->pool_obj->getObjectProperties()->storePropertyIsOnline($this->pool_obj->getObjectProperties()->getPropertyIsOnline()->withOffline()); - } - } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolLegacyImporter.php b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolLegacyImporter.php new file mode 100755 index 000000000000..5924475c1875 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolLegacyImporter.php @@ -0,0 +1,172 @@ +session = $local_dic['exportimport.session']; + $this->request_data_collector = $local_dic['request_data_collector']; + } + + public function importXmlRepresentation( + string $a_entity, + string $a_id, + string $a_xml, + ilImportMapping $a_mapping + ): void { + $this->pool_obj = new ilObjQuestionPool(0, true); + $this->pool_obj->setType('qpl'); + $this->pool_obj->setTitle('dummy'); + $this->pool_obj->setDescription('questionpool import'); + $this->pool_obj->create(true); + $this->pool_obj->createReference(); + $this->pool_obj->putInTree($this->request_data_collector->getRefId()); + $this->pool_obj->setPermissions($this->request_data_collector->getRefId()); + + $a_mapping->addMapping('components/ILIAS/TestQuestionPool', 'qpl', $a_id, (string) $this->pool_obj->getId()); + + $context = $this->session->getContext(); + $import_base_dir = $context->get(UploadValidationStage::IMPORT_BASE_DIR); + $xml_file = $context->get(DetectLegacyImportStage::LEGACY_XML_FILE); + $context = $context->with('pool_obj_id', $this->pool_obj->getId()); + $this->session->setContext($context); + + $qpl_parser = new ilObjQuestionPoolXMLParser( + $this->pool_obj, + $xml_file + ); + $qpl_parser->startParsing(); + + // set another question pool name (if possible) + $qpl_new = $this->request_data_collector->string('qpl_new'); + if ($qpl_new !== '') { + $this->pool_obj->setTitle($qpl_new); + } + + $this->pool_obj->update(); + $this->pool_obj->saveToDb(); + + $qti_parser = new ilQTIParser( + $import_base_dir, + $context->get(DetectLegacyImportStage::LEGACY_QTI_FILE), + ilQTIParser::IL_MO_PARSE_QTI, + $this->pool_obj->getId(), + $context->get('selected_questions') + ); + $qti_parser->startParsing(); + + $page_parser = new ilQuestionPageParser( + $this->pool_obj, + $xml_file, + $import_base_dir + ); + $page_parser->setQuestionMapping($qti_parser->getImportMapping()); + $page_parser->startParsing(); + + foreach ($qti_parser->getImportMapping() as $k => $v) { + $old_question_id = substr($k, strpos($k, 'qst_') + strlen('qst_')); + $new_question_id = (string) $v['pool']; // yes, this is the new question id ^^ + + $a_mapping->addMapping( + 'components/ILIAS/Taxonomy', + 'tax_item', + "qpl:quest:{$old_question_id}", + $new_question_id + ); + + $a_mapping->addMapping( + 'components/ILIAS/Taxonomy', + 'tax_item_obj_id', + "qpl:quest:{$old_question_id}", + (string) $this->pool_obj->getId() + ); + + $a_mapping->addMapping( + 'components/ILIAS/TestQuestionPool', + 'quest', + $old_question_id, + $new_question_id + ); + } + + $this->importQuestionSkillAssignments($xml_file, $a_mapping, $this->pool_obj->getId()); + + $a_mapping->addMapping( + 'components/ILIAS/MetaData', + 'md', + "{$a_id}:0:qpl", + "{$this->pool_obj->getId()}:0:qpl" + ); + + $this->pool_obj->saveToDb(); + } + + public function finalProcessing(ilImportMapping $a_mapping): void + { + $maps = $a_mapping->getMappingsOfEntity('components/ILIAS/TestQuestionPool', 'qpl'); + foreach ($maps as $old => $new) { + if ($old !== 'new_id' && (int) $old > 0) { + $new_tax_ids = $a_mapping->getMapping('components/ILIAS/Taxonomy', 'tax_usage_of_obj', (string) $old); + if ($new_tax_ids !== null) { + $tax_ids = explode(':', $new_tax_ids); + foreach ($tax_ids as $tid) { + ilObjTaxonomy::saveUsage((int) $tid, (int) $new); + } + } + } + } + } + + protected function importQuestionSkillAssignments($xmlFile, ilImportMapping $mappingRegistry, $targetParentObjId): void + { + $parser = new ilAssQuestionSkillAssignmentXmlParser($xmlFile); + $parser->startParsing(); + + $importer = new ilAssQuestionSkillAssignmentImporter(); + $importer->setTargetParentObjId($targetParentObjId); + $importer->setImportInstallationId($this->getInstallId()); + $importer->setImportMappingRegistry($mappingRegistry); + $importer->setImportMappingComponent('components/ILIAS/TestQuestionPool'); + $importer->setImportAssignmentList($parser->getAssignmentList()); + + $importer->import(); + + if ($importer->getFailedImportAssignmentList()->assignmentsExist()) { + $fails = new ilAssQuestionSkillAssignmentImportFails($targetParentObjId); + $fails->registerFailedImports($importer->getFailedImportAssignmentList()); + + $this->pool_obj->getObjectProperties()->storePropertyIsOnline($this->pool_obj->getObjectProperties()->getPropertyIsOnline()->withOffline()); + } + } +} diff --git a/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssOrderingElement.php b/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssOrderingElement.php index 0d8222ef76e3..2f65ab116890 100755 --- a/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssOrderingElement.php +++ b/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssOrderingElement.php @@ -17,6 +17,11 @@ *********************************************************************/ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Envelopes\Id; +use ILIAS\Refinery\Transformation; +use ILIAS\TestQuestionPool\ExportImport\Envelopes\QuestionImage; /** * Class represents an ordering element for assOrderingQuestion @@ -25,7 +30,7 @@ * @version $Id$ * @package Modules/TestQuestionPool */ -class ilAssOrderingElement +class ilAssOrderingElement implements Normalizable { public const EXPORT_IDENT_PROPERTY_SEPARATOR = '_'; @@ -440,4 +445,39 @@ public function withContent(string $content): self $clone->content = $content; return $clone; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(array $options): array => [ + 'id' => $tt->normalize(new Id($this->id, 'ordering')), + 'random_identifier' => $this->random_identifier, + 'solution_identifier' => $this->solution_identifier, + 'position' => $this->position, + 'indentation' => $this->indentation, + 'content' => $this->content ? $tt->normalize( + new QuestionImage($this->content, $options['question_id'] ?? null) + ) : null, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = $this->withRandomIdentifier($tt->int($normalized['random_identifier'])) + ->withSolutionIdentifier($tt->int($normalized['solution_identifier'])) + ->withPosition($tt->int($normalized['position'])) + ->withIndentation($tt->int($normalized['indentation'])); + + $clone->setContent($tt->denormalize($normalized['content'], QuestionImage::class)?->getFilename()); + $clone->setId($tt->denormalize($normalized['id'], Id::class)->getId()); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionLifecycle.php b/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionLifecycle.php index 5273eec999b3..7b43c435e033 100755 --- a/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionLifecycle.php +++ b/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionLifecycle.php @@ -16,12 +16,16 @@ * *********************************************************************/ +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; + /** * Class ilAssQuestionLifecycle * @author Björn Heyser * @package components\ILIAS/TestQuestionPool */ -class ilAssQuestionLifecycle +class ilAssQuestionLifecycle implements Normalizable { public const DRAFT = 'draft'; public const REVIEW = 'review'; @@ -153,4 +157,24 @@ public static function getDraftInstance(): self return $lifecycle; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + 'identifier' => $this->getIdentifier(), + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation( + fn(array $normalized) => self::getInstance($normalized['identifier']) + ); + } } diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Envelopes/Feedback.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Envelopes/Feedback.php new file mode 100644 index 000000000000..7450284c25d0 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Envelopes/Feedback.php @@ -0,0 +1,84 @@ + */ + private array $specific_feedback = [], + ) { + } + + public function getGenericUncompleted(): string + { + return $this->generic_uncompleted; + } + + public function getGenericCompleted(): string + { + return $this->generic_completed; + } + + /** + * @return list + */ + public function getSpecificFeedback(): array + { + return $this->specific_feedback; + } + + /** + * @inheritDoc + */ + public function toArray(Transformations $tt): array + { + return [ + 'question_id' => $tt->normalize($this->question_id), + 'generic_uncompleted' => $this->generic_uncompleted, + 'generic_completed' => $this->generic_completed, + 'specific' => $this->specific_feedback, + ]; + } + + /** + * @inheritDoc + */ + public static function fromArray(array $value, Transformations $tt): static + { + return new self( + $tt->denormalize($value['question_id'], Id::class), + $value['generic_uncompleted'], + $value['generic_completed'], + $value['specific'], + ); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Envelopes/QuestionImage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Envelopes/QuestionImage.php new file mode 100644 index 000000000000..a766438425eb --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Envelopes/QuestionImage.php @@ -0,0 +1,94 @@ +filename; + } + + public function getQuestionId(): ?int + { + return $this->question_id; + } + + public function getType(): int + { + return $this->type; + } + + public function getId(): string + { + return $this->id; + } + + public function setId(string $id): static + { + $this->id = $id; + return $this; + } + + /** + * @inheritDoc + */ + public function toArray(Transformations $tt): array + { + return [ + 'filename' => $this->filename, + 'question_id' => $this->question_id, + 'type' => $this->type, + 'id' => $this->id, + ]; + } + + /** + * @inheritDoc + */ + public static function fromArray(array $value, Transformations $tt): static + { + return new self( + $value['filename'], + $tt->int($value['question_id']), + $tt->int($value['type']), + $value['id'] + ); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Export/CollectsQuestions.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Export/CollectsQuestions.php new file mode 100644 index 000000000000..0cfb3dde61f8 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Export/CollectsQuestions.php @@ -0,0 +1,213 @@ + + */ + abstract public function getQuestionProperties(): array; + + /** + * Get the object ID of the object that contains the questions. + */ + abstract public function getObjectId(): ObjectId; + + /** + * Get the database interface. + */ + abstract private function database(): ilDBInterface; + + + private ?ilAssQuestionSkillAssignmentList $skill_assignments = null; + + /** + * Collect the question objects by instantiating the question objects. + * + * @return Generator + */ + public function getQuestionObjects(): Generator + { + foreach ($this->getQuestionProperties() as $question) { + yield assQuestion::instantiateQuestion($question->getQuestionId()); + } + } + + /* + Units + */ + + /** + * Get all unit categories and units for a formula question. + * + * @return array{categories: list, base_units: list, units: list} + */ + public function getUnitsAndCategories(int $question_id): array + { + $repository = new ilUnitConfigurationRepository($question_id); + $data = [ + 'categories' => [], + 'base_units' => [], + 'units' => [], + ]; + + foreach ($repository->getCategorizedUnits() as $item) { + if ($item instanceof assFormulaQuestionUnitCategory) { + $data['categories'][] = $item; + } + + if ($item instanceof assFormulaQuestionUnit) { + if ($item->getBaseUnit() === 0 || $item->getBaseUnit() === $item->getId()) { + $data['base_units'][] = $item; + } else { + $data['units'][] = $item; + } + } + } + + return $data; + } + + /* + Feedback + */ + + /** + * Collect the feedback content for a question and return it as a Feedback transfer object. + */ + public function getFeedback(assQuestion $question): Feedback + { + $feedback = new Feedback( + new Id($question->getId(), 'question'), + $question->feedbackOBJ->getGenericFeedbackExportPresentation($question->getId(), false), + $question->feedbackOBJ->getGenericFeedbackExportPresentation($question->getId(), true), + $this->loadSpecificFeedback($question), + ); + + return $feedback; + } + + private function loadSpecificFeedback(assQuestion $question): array + { + // Skip if specific feedback is not available or supported by the question type. + if ( + !$question->feedbackOBJ instanceof ilAssMultiOptionQuestionFeedback || + !$question->feedbackOBJ->isSpecificAnswerFeedbackAvailable($question->getId()) + ) { + return []; + } + + // Cloze question type specific feedback uses the identifier list to load the answer-specific feedback. + if ($question->feedbackOBJ instanceof ilAssClozeTestFeedback) { + $feedback_list = new ilAssSpecificFeedbackIdentifierList(); + $feedback_list->load($question->getId()); + + $feedback = []; + foreach ($feedback_list as $identifier) { + $feedback[$identifier->getAnswerIndex()] = [ + 'answer_index' => $identifier->getAnswerIndex(), + 'question_index' => $identifier->getQuestionIndex(), + 'feedback' => $question->feedbackOBJ->getSpecificAnswerFeedbackExportPresentation( + $question->getId(), + $identifier->getQuestionIndex(), + $identifier->getAnswerIndex() + ), + ]; + } + return $feedback; + } + + // Other question types with multiple answer options share the same approach + foreach (array_keys($question->feedbackOBJ->getAnswerOptionsByAnswerIndex()) as $answer_index) { + $feedback[$answer_index] = [ + 'answer_index' => $answer_index, + 'question_index' => 0, + 'feedback' => $question->feedbackOBJ->getSpecificAnswerFeedbackExportPresentation( + $question->getId(), + 0, + $answer_index + ), + ]; + } + return $feedback; + } + + /* + Skill Assignments + */ + + /** + * @return array<\ilAssQuestionSkillAssignment> + */ + public function getSkillAssignments(): array + { + if ($this->skill_assignments === null) { + $this->skill_assignments = new ilAssQuestionSkillAssignmentList($this->database()); + $this->skill_assignments->setParentObjId($this->getObjectId()->toInt()); + $this->skill_assignments->loadFromDb(); + $this->skill_assignments->loadAdditionalSkillData(); + } + + $assignments = []; + foreach ($this->getQuestionProperties() as $question) { + $assignments = array_merge( + $assignments, + $this->skill_assignments->getAssignmentsByQuestionId($question->getQuestionId()) + ); + } + + return $assignments; + } + + /* + CO Page & Media Objects + */ + + public function getQuestionPageIds(): array + { + $question_page_ids = []; + foreach ($this->getQuestionObjects() as $question) { + $question_page_ids[] = "qpl:{$question->getId()}"; + } + return $question_page_ids; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Export/QuestionPoolCollector.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Export/QuestionPoolCollector.php new file mode 100644 index 000000000000..28f91dfb8be1 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Export/QuestionPoolCollector.php @@ -0,0 +1,88 @@ + $questions */ + private ?array $questions = null; + private ?ilObjQuestionPool $pool_object = null; + + public function __construct( + private readonly GeneralQuestionPropertiesRepository $question_repository, + private readonly ilDBInterface $db, + private readonly ObjectId $pool_id + ) { + } + + /** + * Get the ID of the question pool. + * + * @return ObjectId + */ + public function getObjectId(): ObjectId + { + return $this->pool_id; + } + + /** + * Get the object of the question pool. It will be loaded from the database if not already loaded. + */ + public function getObject(): ilObjQuestionPool + { + if ($this->pool_object === null) { + $this->pool_object = new ilObjQuestionPool($this->pool_id->toInt(), false); + $this->pool_object->read(); + } + + return $this->pool_object; + } + + /** + * Collect the question properties for all questions in the question pool. + * + * @return array + */ + public function getQuestionProperties(): array + { + if ($this->questions === null) { + $this->questions = $this->question_repository->getForParentObjectId($this->pool_id->toInt()); + } + return $this->questions; + } + + private function database(): ilDBInterface + { + return $this->db; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Export/QuestionPoolExporter.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Export/QuestionPoolExporter.php new file mode 100644 index 000000000000..f472d0a43f81 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Export/QuestionPoolExporter.php @@ -0,0 +1,224 @@ +logger()->info('Preparing question pool export (1/3)...'); + $state->assertStep(ExportStep::INIT); + $state->setStep(ExportStep::PREPARE); + + $pool_id = $this->extractObjectId($state); + if ($pool_id === null) { + $state->logger()->warning('No question pool object ID found for export'); + return; + } + + $collector = new QuestionPoolCollector( + $this->question_repository, + $this->db, + $pool_id + ); + $state->setCollector($collector); + + $question_image_pipe = new CollectQuestionImages( + new UUIDFactory(), + $pool_id + ); + + $transformations = $this->builder + ->withAdditionalPipes([$question_image_pipe]) + ->create(); + $state->setTransformations($transformations); + + $state->logger()->info('...Finished preparing question pool export (1/3)'); + } + + /** + * Normalizes the question pool object and its questions and writes them to the serializer. It also collects the + * dependencies of the export. + */ + public function process(ExportState $state): void + { + $state->logger()->info('Processing question pool export (2/3)...'); + $state->assertStep(ExportStep::PREPARE); + $state->setStep(ExportStep::PROCESS); + + $state->serializer()->group( + 'general', + fn() => $this->exportObject( + $state->collector(), + $state->transformations(), + $state->serializer(), + $state + ) + ); + $state->serializer()->group( + 'questions', + fn() => $this->exportQuestions( + $state->collector(), + $state->transformations(), + $state->serializer(), + $state + ) + ); + $state->serializer()->group( + 'skill_assignments', + fn() => $this->exportSkillAssignments( + $state->collector(), + $state->transformations(), + $state->serializer(), + ) + ); + + $state->logger()->info('...Finished processing question pool export (2/3)'); + } + + /** + * Finalizes the export by copying the question images to the export directory and returning the export context. + */ + public function write(ExportState $state): void + { + $state->logger()->info('Writing question pool export (3/3)...'); + $state->assertStep(ExportStep::PROCESS); + $state->setStep(ExportStep::WRITE); + + $export_dir = $state->path()->getPathToComponentExpDirInContainer(); + $question_image_pipe = $state->transformations()->context(CollectQuestionImages::class); + + $state->logger()->debug("Copying question images to export directory {$export_dir}"); + foreach ($question_image_pipe->getFiles() as $file) { + $state->writer()->writeFileByFilePath( + $file['from'], + "{$export_dir}/" . $file['to'] + ); + $state->logger()->debug("Copied question image {$file['from']} to {$export_dir}/{$file['to']}"); + } + + $state->logger()->info('...Finished writing question pool export (3/3)'); + } + + + private function extractObjectId(ExportState $state): ?ObjectId + { + $target_ids = $state->target()->getObjectIds(); + + if (count($target_ids) === 0) { + $state->logger()->warning('No target object IDs found for question pool export'); + return null; + } + + if (count($target_ids) > 1) { + $state->logger()->warning( + 'Multiple target object IDs found for question pool export. Only the first one will be used.' + ); + } + + return $this->data_factory->objId(array_shift($target_ids)); + } + + private function exportObject( + QuestionPoolCollector $collector, + Transformations $transformations, + Serializer $serializer, + ExportState $state + ): void { + $serializer->append('object', $transformations->normalize($collector->getObject())); + + $obj_id = $collector->getObjectId()->toInt(); + + $state->addDependency('components/ILIAS/ILIASObject', 'common', [$obj_id]); + $state->addDependency('components/ILIAS/MetaData', 'qpl', ["{$obj_id}:0:qpl"]); + $state->addDependency( + 'components/ILIAS/Taxonomy', + 'tax', + $this->taxonomy->getUsageOfObject($obj_id) + ); + } + + private function exportQuestions( + QuestionPoolCollector $collector, + Transformations $transformations, + Serializer $serializer, + ExportState $state + ): void { + foreach ($collector->getQuestionObjects() as $question) { + $normalized = [ + ... $transformations->normalize($question), + 'feedback' => $transformations->normalize( + $collector->getFeedback($question) + ) + ]; + + if ($question instanceof assFormulaQuestion) { + $data = $collector->getUnitsAndCategories($question->getId()); + $normalized['formula_data'] = $transformations->normalize($data); + } + + $serializer->append('question', $normalized); + $state->addDependency('components/ILIAS/COPage', 'pg', ["qpl:{$question->getId()}"]); + } + } + + private function exportSkillAssignments( + QuestionPoolCollector $collector, + Transformations $transformations, + Serializer $serializer, + ): void { + foreach ($collector->getSkillAssignments() as $assignment) { + $serializer->append('skill_assignment', $transformations->normalize($assignment)); + } + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/ExportState.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/ExportState.php new file mode 100644 index 000000000000..5b005ca56e1d --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/ExportState.php @@ -0,0 +1,196 @@ +}> $dependencies + */ + private array $dependencies = []; + + public function __construct( + private ExportTarget $target, + private ExportConfig $config, + private string $option = '' + ) { + $this->step = ExportStep::INIT; + } + + public function target(): ExportTarget + { + return $this->target; + } + + public function config(): ExportConfig + { + return $this->config; + } + + public function getOption(): string + { + return $this->option; + } + + public function getStep(): ExportStep + { + return $this->step; + } + + public function setStep(ExportStep $step): void + { + $this->step = $step; + } + + public function assertStep(ExportStep $step): void + { + if ($this->step->value < $step->value) { + throw new RuntimeException("Expected step {$step->name}, but got {$this->step->name} instead"); + } + + $this->step = $step; + } + + public function logger(): Logger + { + $this->assertNotNull($this->logger, 'logger'); + return $this->logger; + } + + public function setLogger(Logger $logger): void + { + $this->logger = $logger; + } + + public function path(): ExportPath + { + $this->assertNotNull($this->path_info, 'path_info'); + return $this->path_info; + } + + public function setPathInfo(ExportPath $path_info): void + { + $this->path_info = $path_info; + } + + public function collector(): DataCollector + { + $this->assertNotNull($this->collector, 'collector'); + return $this->collector; + } + + public function setCollector(DataCollector $collector): void + { + $this->collector = $collector; + } + + public function transformations(): Transformations + { + $this->assertNotNull($this->transformations, 'transformations'); + return $this->transformations; + } + + public function setTransformations(Transformations $transformations): void + { + $this->transformations = $transformations; + } + + public function serializer(): Serializer + { + $this->assertNotNull($this->serializer, 'serializer'); + return $this->serializer; + } + + public function setSerializer(Serializer $serializer): void + { + $this->serializer = $serializer; + } + + public function writer(): ExportWriter + { + $this->assertNotNull($this->writer, 'writer'); + return $this->writer; + } + + public function setWriter(ExportWriter $writer): void + { + $this->writer = $writer; + } + + public function getDependencies(): array + { + return array_values($this->dependencies); + } + + public function addDependency(string $component, string $entity, array $ids): void + { + $key = "{$component}::{$entity}"; + + if (!isset($this->dependencies[$key])) { + $this->dependencies[$key] = [ + 'component' => $component, + 'entity' => $entity, + 'ids' => [], + ]; + } + + $this->dependencies[$key]['ids'] = array_values(array_unique(array_merge( + $this->dependencies[$key]['ids'], + $ids + ))); + } + + public function getContent(): string + { + return $this->serializer()->write(); + } + + private function assertNotNull(mixed $value, string $property): void + { + if ($value === null) { + throw new RuntimeException( + "{$property} not set. This may happen if the exporter steps are not executed in the correct order." + ); + } + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/ExportStep.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/ExportStep.php new file mode 100644 index 000000000000..bcbcf3f04b53 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/ExportStep.php @@ -0,0 +1,29 @@ +export_state = new ExportState($target, $config, $option); + return $this->export_state; + } + + public function exists(): bool + { + return $this->export_state !== null; + } + + public function get(): ExportState + { + if ($this->export_state === null) { + throw new RuntimeException('Export state not found. You need to create the state first.'); + } + return $this->export_state; + } + + public function set(ExportState $export_state): void + { + $this->export_state = $export_state; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/XmlExporterBridge.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/XmlExporterBridge.php new file mode 100644 index 000000000000..2ce4297e0741 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/XmlExporterBridge.php @@ -0,0 +1,134 @@ +state_holder->get(); + + if ($state->getStep()->value < ExportStep::PREPARE->value) { + $this->logger->debug("Preparing export for component {$state->target()->getComponent()}..."); + $state->setLogger($this->logger); + $this->exporter->prepare($state); + $this->logger->debug("...Finished preparing export"); + } + + if ($state->getStep()->value < ExportStep::PROCESS->value) { + $this->logger->debug("Processing export for component {$state->target()->getComponent()}..."); + $state->setSerializer(new SimpleXMLSerializer()->open('memory')); + $this->exporter->process($state); + $this->logger->debug("...Finished processing export"); + } + + $this->state_holder->set($state); + return $state; + } + + /** + * Finalizes the export by setting the path info and writer and calling the write step of the exporter. It performs + * the prepare and process steps if not already done. + */ + private function finalizeExport(): ExportState + { + $state = $this->processExport(); + + if ($state->getStep()->value < ExportStep::WRITE->value) { + $state->setPathInfo($this->createPathInfo()); + $state->setWriter($this->exp->getExportWriter()); + + $this->logger->debug("Writing export for component {$state->target()->getComponent()}..."); + $this->exporter->write($state); + $this->logger->debug("...Finished writing export"); + } + + $this->state_holder->set($state); + return $state; + } + + private function initExportState( + string $component, + string $target_release, + string $type, + array $object_ids, + string $option = '' + ): ExportState { + $target = $this->export_handler->target()->handler() + ->withType($type) + ->withTargetRelease($target_release) + ->withObjectIds($object_ids) + ->withClassname(static::class) + ->withComponent($component); + + $state = $this->state_holder->create( + $target, + $this->export_handler->consumer()->exportConfig()->collection(), + $option + ); + + $this->logger->debug(sprintf( + "Export state created for component %s with release %s, type %s, class %s, object ids %s, option %s", + $target->getComponent(), + $target->getTargetRelease(), + $target->getType(), + $target->getClassname(), + implode(', ', $object_ids), + $option + )); + + return $state; + } + + private function createPathInfo(): ExportPath + { + return $this->export_handler->info()->export()->path()->handler() + ->withPathToComponentDirInContainer($this->exp->getExportDirInContainer()) + ->withPathToComponentExpDirInContainer($this->exp->getPathToComponentExpDirInContainer()) + ->withSetNumber($this->exp->getSetNumber()) + ->withIsContainerExport($this->exp->isContainerExport()); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Builder.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Builder.php new file mode 100644 index 000000000000..07cf40ee86a4 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Builder.php @@ -0,0 +1,289 @@ + $prepend_pipes */ + private array $prepend_pipes = []; + + /** @var list $append_pipes */ + private array $append_pipes = []; + + /** @var list $containers */ + private array $containers = []; + + public function __construct( + private readonly ILIASContainer $dic, + Container ...$local_containers, + ) { + $this->containers = $local_containers; + } + + /* + Fluent interface methods + */ + + public function withDefaultNormalizers(bool $enable = true): self + { + $clone = clone $this; + $clone->default_normalizers = $enable; + return $clone; + } + + public function withLegacyNormalizers(string $version): self + { + $clone = clone $this; + $clone->legacy_version = $version; + return $clone; + } + + /** + * @param list $append + * @param list $prepend + */ + public function withAdditionalPipes(array $prepend = [], array $append = []): self + { + $clone = clone $this; + $clone->append_pipes += $append; + $clone->prepend_pipes += $prepend; + return $clone; + } + + /* + Object creation + */ + + /** + * Create a Transformations instance which was configured by the builder. + */ + + public function create(): TransformationsContract + { + $pipeline = new Pipeline(); + $object = new Transformations( + $this->dic->refinery(), + $pipeline + ); + + foreach ($this->prepend_pipes as $pipe) { + $pipeline->pipe($pipe); + } + + if ($this->legacy_version !== null) { + $registry = $this->buildRegistry($object, $this->legacy_version); + $pipeline->pipe(new NormalizingPipe($registry)); + $pipeline->pipe(new DenormalizingPipe($registry)); + } + + if ($this->default_normalizers) { + $registry = $this->buildRegistry($object); + $pipeline->pipe(new NormalizingPipe($registry)); + $pipeline->pipe(new DenormalizingPipe($registry)); + } + + foreach ($this->append_pipes as $pipe) { + $pipeline->pipe($pipe); + } + + $pipeline->pipe(new FinalizeNormalizing()); + + return $object; + } + + /** + * Register all normalizer classes from the type map artifact by checking for the given version and skipping if + * the normalizer is already registered. + */ + private function buildRegistry(Transformations $object, string $version = NormalizerArtifactObjective::DEFAULT_KEY): Registry + { + $type_map = require NormalizerArtifactObjective::PATH(); + $registry = new Registry(); + + foreach ($type_map as $type => $normalizer_classes) { + if ($registry->hasNormalizer($type)) { + continue; + } + + $resolved_key = $this->resolveVersionKey($version, $normalizer_classes); + if (!isset($normalizer_classes[$resolved_key])) { + continue; + } + + $registry->registerNormalizer( + $type, + fn() => $this->createInstance($normalizer_classes[$resolved_key], $object) + ); + } + + return $registry; + } + + /** + * Resolve the best matching version key from the available normalizer versions. + * + * Priority: exact match > nearest concrete version before requested > wildcard (major.*) > default. + * Uses version_compare for comparing ILIAS versions (major.minor, optional suffixes like alpha). + * + * @param array $available_versions + */ + private function resolveVersionKey(string $requested_version, array $available_versions): string + { + if (isset($available_versions[$requested_version])) { + return $requested_version; + } + + if ($requested_version === NormalizerArtifactObjective::DEFAULT_KEY) { + return $requested_version; + } + + $requested_major = strstr($requested_version, '.', true) ?: $requested_version; + + $best_concrete = null; + foreach (array_keys($available_versions) as $version) { + if ($version === NormalizerArtifactObjective::DEFAULT_KEY + || $this->isWildcardVersion($version)) { + continue; + } + + $version_major = strstr($version, '.', true) ?: $version; + if ($version_major !== $requested_major) { + continue; + } + + if (version_compare($version, $requested_version, '<=') + && ($best_concrete === null || version_compare($version, $best_concrete, '>'))) { + $best_concrete = $version; + } + } + + if ($best_concrete !== null) { + return $best_concrete; + } + + foreach (["{$requested_major}.*", $requested_major] as $wildcard) { + if (isset($available_versions[$wildcard])) { + return $wildcard; + } + } + + return NormalizerArtifactObjective::DEFAULT_KEY; + } + + private function isWildcardVersion(string $version): bool + { + return !str_contains($version, '.') || str_ends_with($version, '.*'); + } + + /* + Factory & Autowiring + */ + + /** + * Create an instance of a class by resolving the constructor arguments. + * + * @template T of object + * + * @param class-string $class_name + * @param Transformations $transformations + * @return T + */ + private function createInstance(string $class_name, Transformations $transformations): object + { + $reflection_class = new ReflectionClass($class_name); + $constructor = $reflection_class->getConstructor(); + + if ($constructor === null || $constructor->getNumberOfParameters() === 0) { + return $reflection_class->newInstance(); + } + + $arguments = []; + foreach ($constructor->getParameters() as $parameter) { + $arguments[] = $this->resolveConstructorArgument( + $parameter, + $transformations, + $class_name + ); + } + + return $reflection_class->newInstanceArgs($arguments); + } + + /** + * Resolve a constructor argument by trying to resolve it from the global ilias container, the local container or + * the default value. + * + * @throws RuntimeException if the argument cannot be resolved + */ + private function resolveConstructorArgument( + ReflectionParameter $parameter, + Transformations $transformations, + string $class_name + ) { + if ($parameter->isDefaultValueAvailable()) { + return $parameter->getDefaultValue(); + } + + $type = $parameter->getType(); + if ($type instanceof ReflectionNamedType && $type->allowsNull()) { + return null; + } + + + if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) { + $type_name = $type->getName(); + + if ($type_name === TransformationsContract::class || in_array(TransformationsContract::class, class_implements($type_name))) { + return $transformations; + } + + foreach ([$this->dic, ...$this->containers] as $container) { + if ($type_name === get_class($container)) { + return $container; + } + } + } + + $name = $parameter->getName(); + throw new RuntimeException("Unable to resolve constructor parameter \${$name} for class {$class_name}."); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/DataCollector.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/DataCollector.php new file mode 100644 index 000000000000..b23c8eb65133 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/DataCollector.php @@ -0,0 +1,35 @@ + Language-neutral normalized representation. + */ + public function toArray(Transformations $tt): array; + + /** + * Reconstruct an envelope instance from its normalized array representation. + * + * @param array $value Normalized envelope payload. + * @param Transformations $tt Transformation helpers used for value casting and mapping. + * @return static Reconstructed envelope instance. + */ + public static function fromArray(array $value, Transformations $tt): static; +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/Exporter.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/Exporter.php new file mode 100644 index 000000000000..0dcd56624270 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/Exporter.php @@ -0,0 +1,66 @@ + + * + * @template TValue + * @template TNormalized of null|scalar|NormalizedArray + */ +interface Normalizer +{ + /** + * Converts a value into a normalized form. + * + * @param TValue $value + * @return TNormalized + * + * @throws NormalizingException if the normalizer does not support the given type + */ + public function normalize($value): array|float|bool|int|string|null; + + /** + * Converts a normalized form back into a value. It uses the type hint to determine the expected type, which will be + * returned. + * + * @param TNormalized $normalized + * @param class-string $type + * @return TValue + * + * @throws NormalizingException if the normalizer does not support the given type + */ + public function denormalize(array|float|bool|int|string|null $normalized, string $type); +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/Pipe.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/Pipe.php new file mode 100644 index 000000000000..92b68b1a4e49 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/Pipe.php @@ -0,0 +1,39 @@ + $pipes + */ + public function through(array $pipes): self; + + /** + * Push additional pipe onto the pipeline. + * + * @param PipeParam $pipes + */ + public function pipe(\Closure|Pipe $pipe): self; + + /** + * Push additional pipe onto the pipeline which will be executed when the condition is met. + * + * @param \Closure(TPassable): bool $condition + * @param PipeParam $pipe + */ + public function pipeWhen(\Closure $condition, \Closure|Pipe $pipe): self; + + /** + * Push additional pipe onto the pipeline which will be executed when the condition is not met. + * + * @param \Closure(TPassable): bool $condition + * @param PipeParam $pipe + */ + public function pipeUnless(\Closure $condition, \Closure|Pipe $pipe): self; + + /** + * Run the pipeline with a final destination callback. + * + * @param \Closure(TPassable): TPassable $destination + * @return TPassable|mixed + */ + public function then(\Closure $destination): mixed; + + /** + * Run the pipeline and return the result. + * + * @return TPassable + */ + public function thenReturn(): mixed; + + /** + * Set a final callback to be executed after the pipeline ends regardless of the outcome. + * + * @param \Closure(TPassable): TPassable $callback + */ + public function finally(\Closure $callback): self; + + /** + * Get the array of pipes. + * + * @return list + */ + public function pipes(): array; +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/Serializer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/Serializer.php new file mode 100644 index 000000000000..71873e44e722 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/Serializer.php @@ -0,0 +1,78 @@ +|T $expected + * @return T|null + * + * @throws NormalizingException if the type is not supported + */ + public function denormalize(array|float|bool|int|string|null $normalized, string|object $expected): mixed; + + /* + Transformations + */ + + /** + * Returns the pipe of the given class from the pipeline. + * + * @template T of Pipe + * @param class-string $pipe_class + * @return T + * + * @throws \InvalidArgumentException if the pipe is not found + */ + public function context(string $pipe_class): Pipe; + + /** + * Returns a group of transformations that can be used to create custom transformations. + */ + public function custom(): Group; + + /** + * @throws \InvalidArgumentException if the value cannot be transformed into an integer + */ + public function int(mixed $value): int; + + /** + * @throws \InvalidArgumentException if the value cannot be transformed into a float + */ + public function float(mixed $value): float; + + /** + * @throws \InvalidArgumentException if the value cannot be transformed into a string + */ + public function string(mixed $value): string; + + /** + * @throws \InvalidArgumentException if the value cannot be transformed into a boolean + */ + public function bool(mixed $value): bool; + + public function nullableInt(mixed $value): ?int; + + public function nullableFloat(mixed $value): ?float; + + public function nullableString(mixed $value): ?string; + + public function nullableBool(mixed $value): ?bool; +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportContext.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportContext.php new file mode 100644 index 000000000000..01b35fbae0b0 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportContext.php @@ -0,0 +1,51 @@ + $data */ + public function __construct( + private array $data = [], + ) { + } + + public function with(string $key, mixed $value): self + { + $clone = clone $this; + $clone->data[$key] = $value; + return $clone; + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->data[$key] ?? $default; + } + + public function has(string $key): bool + { + return array_key_exists($key, $this->data); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportSessionRepository.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportSessionRepository.php new file mode 100644 index 000000000000..f7eda4318390 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportSessionRepository.php @@ -0,0 +1,84 @@ +key_index = "{$base}index"; + $this->key_context = "{$base}context"; + } + + public function getCurrentStageIndex(): int + { + if (ilSession::has($this->key_index)) { + return (int) ilSession::get($this->key_index); + } + + return 0; + } + + public function setCurrentStageIndex(int $index): void + { + ilSession::set($this->key_index, $index); + } + + public function getContext(): ImportContext + { + if (ilSession::has($this->key_context)) { + return unserialize( + ilSession::get($this->key_context), + ['allowed_classes' => [ImportContext::class]] + ); + } + + return new ImportContext([]); + } + + public function setContext(ImportContext $context): void + { + ilSession::set($this->key_context, serialize($context)); + } + + public function clear(): void + { + if (ilSession::has($this->key_index)) { + ilSession::clear($this->key_index); + } + if (ilSession::has($this->key_context)) { + ilSession::clear($this->key_context); + } + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportStageRunner.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportStageRunner.php new file mode 100644 index 000000000000..6aa50d897985 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportStageRunner.php @@ -0,0 +1,135 @@ + $stages */ + public function __construct( + private readonly array $stages, + private readonly ImportSessionRepository $session, + private readonly ?ImportStage $final_stage = null, + ) { + } + + /** + * Run the import stage runner. It manages the state of the import process and delegates to the current stage. + * It will return a StageResult that indicates the next action to take. + */ + public function run(): StageResult + { + $index = $this->session->getCurrentStageIndex(); + $context = $this->session->getContext(); + + if ($index >= count($this->stages)) { + return StageResult::complete($context); + } + + $stage = $this->stages[$index]; + $result = $stage->process($context); + + switch ($result->type) { + case StageResultType::ADVANCE: + $next_index = $index + 1; + $this->session->setContext($result->context); + $this->session->setCurrentStageIndex($next_index); + + if ($next_index >= count($this->stages)) { + return StageResult::complete($result->context); + } + + return $result; + + case StageResultType::ERROR: + $this->session->setContext($result->context); + $this->reset(); + return $result; + + case StageResultType::INTERACT: + $this->session->setContext($result->context); + return $result; + + case StageResultType::COMPLETE: + $this->reset(); + return $result; + } + + throw new \LogicException("Invalid stage result type: {$result->type->name}"); + } + + /** + * Build the workflow UI component for the import process. + */ + public function buildWorkflow(UIFactory $ui, string $title): Linear + { + $steps = []; + $active_index = $this->session->getCurrentStageIndex(); + + foreach ($this->stages as $i => $stage) { + if ($stage->getLabel() === null) { + continue; + } + + $step = $ui->listing()->workflow()->step( + $stage->getLabel(), + $stage->getDescription() + ); + + if ($i < $active_index) { + $step = $step->withStatus(Step::SUCCESSFULLY) + ->withAvailability(Step::NOT_ANYMORE); + } elseif ($i === $active_index) { + $step = $step->withStatus(Step::IN_PROGRESS) + ->withAvailability(Step::AVAILABLE); + } else { + $step = $step->withStatus(Step::NOT_STARTED) + ->withAvailability(Step::NOT_AVAILABLE); + } + + $steps[] = $step; + } + + return $ui->listing()->workflow()->linear($title, $steps) + ->withActive($active_index); + } + + /** + * Reset the import stage session. If a final stage is set, it will be processed. + */ + public function reset(): void + { + if ($this->final_stage) { + $this->final_stage->process($this->session->getContext()); + } + + $this->session->clear(); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/StageResult.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/StageResult.php new file mode 100644 index 000000000000..5e1dfa1d9a71 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/StageResult.php @@ -0,0 +1,76 @@ + $components + */ + private function __construct( + public readonly StageResultType $type, + public readonly ImportContext $context, + public readonly array $components = [], + public readonly ?string $error_message = null, + ) { + } + + /** + * Create a result that indicates the process should interrupt to display the given UI components. + * + * @param list $components + */ + public static function interact(ImportContext $context, array $components): self + { + return new self(StageResultType::INTERACT, $context, $components); + } + + /** + * Create a result that indicates the process should advance to the next stage. + */ + public static function advance(ImportContext $context): self + { + return new self(StageResultType::ADVANCE, $context); + } + + /** + * Create a result that indicates the process should fail with the given error message. + */ + public static function error(ImportContext $context, string $message): self + { + return new self(StageResultType::ERROR, $context, [], $message); + } + + /** + * Create a result that indicates the process should complete successfully. + */ + public static function complete(ImportContext $context): self + { + return new self(StageResultType::COMPLETE, $context); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/StageResultType.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/StageResultType.php new file mode 100644 index 000000000000..26d418ec7ed3 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/StageResultType.php @@ -0,0 +1,47 @@ +types = $types; + } + + /** @var list */ + public array $types; +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Attributes/NormalizesLegacy.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Attributes/NormalizesLegacy.php new file mode 100644 index 000000000000..f7343aa82a4e --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Attributes/NormalizesLegacy.php @@ -0,0 +1,48 @@ + */ + public array $versions; + + /** @var list */ + public array $types; + + /** + * @param class-string ...$types + */ + public function __construct( + string|array $version, + string ...$types + ) { + $this->versions = is_array($version) ? $version : [$version]; + $this->types = $types; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Envelopes/Id.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Envelopes/Id.php new file mode 100644 index 000000000000..1fb2a36186f0 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Envelopes/Id.php @@ -0,0 +1,99 @@ +id; + } + + public function getObject(): string + { + return $this->object; + } + + /** + * @inheritDoc + */ + public function toArray(Transformations $tt): array + { + if (is_object($this->id)) { + return [ + 'id' => $tt->normalize($this->id), + 'type' => get_class($this->id), + 'object' => $this->object, + ]; + } else { + return [ + 'id' => (string) $this->id, + 'type' => gettype($this->id), + 'object' => $this->object, + ]; + } + } + + /** + * @inheritDoc + */ + public static function fromArray(array $value, Transformations $tt): static + { + $raw_id = $value['id']; + $type = $value['type']; + + if (class_exists($type)) { + return new Id($tt->denormalize($raw_id, $type), $value['object']); + } else { + $id = match($type) { + 'integer' => (int) $raw_id, + 'string' => (string) $raw_id, + 'float' => (float) $raw_id, + 'bool' => (bool) $raw_id, + 'null' => null, + default => throw new NormalizingException("Invalid type for id: {$type}") + }; + return new Id($id, $value['object']); + } + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/DateTimeNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/DateTimeNormalizer.php new file mode 100644 index 000000000000..e2fb07c8d584 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/DateTimeNormalizer.php @@ -0,0 +1,58 @@ + + */ +#[Normalizes(DateTime::class, DateTimeImmutable::class)] +class DateTimeNormalizer implements Normalizer +{ + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if ($value instanceof DateTimeImmutable || $value instanceof DateTime) { + return $value->format(DATE_ATOM); + } + + throw new NormalizingException('Invalid datetime value', $value); + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): DateTime|DateTimeImmutable + { + return match($type) { + DateTimeImmutable::class => DateTimeImmutable::createFromFormat(DATE_ATOM, $value), + DateTime::class => DateTime::createFromFormat(DATE_ATOM, $value), + default => throw new NormalizingException("Invalid type for datetime: {$type}") + }; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/EnvelopeNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/EnvelopeNormalizer.php new file mode 100644 index 000000000000..697a6765077f --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/EnvelopeNormalizer.php @@ -0,0 +1,63 @@ + + */ +#[Normalizes(Envelope::class)] +class EnvelopeNormalizer implements Normalizer +{ + public function __construct( + private readonly Transformations $tt, + ) { + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if (!$value instanceof Envelope) { + throw new NormalizingException('Invalid envelope value', $value); + } + + return $value->toArray($this->tt); + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): Envelope + { + if (!in_array(Envelope::class, class_implements($type))) { + throw new NormalizingException('Invalid envelope type', $type); + } + + return $type::fromArray($value, $this->tt); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/IlObjectNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/IlObjectNormalizer.php new file mode 100644 index 000000000000..727963aa1378 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/IlObjectNormalizer.php @@ -0,0 +1,148 @@ + + */ +#[Normalizes(ilObject::class)] +class IlObjectNormalizer implements Normalizer +{ + public function __construct( + protected readonly Transformations $tt, + ) { + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if (!$value instanceof ilObject) { + throw new NormalizingException('Invalid value', $value); + } + + // Icon, tile, translations and container settings are exported by the ILIASObject component. + // See: ilObjectDataSet + return [ + 'obj_id' => $this->tt->normalize(new Id($value->getId(), 'object')), + 'title' => $value->getTitle(), + 'description' => $value->getLongDescription(), + 'type' => $value->getType(), + 'owner' => $value->getOwner(), + 'create_date' => $value->getCreateDate(), + 'last_update' => $value->getLastUpdateDate(), + 'import_id' => $value->getImportId(), + 'properties' => $this->normalizeProperties($value->getObjectProperties()), + ]; + } + + private function normalizeProperties(Properties $properties): array + { + return [ + 'owner' => $properties->getOwner(), + 'import_id' => $properties->getImportId(), + 'title_and_description' => $this->normalizeProperty($properties->getPropertyTitleAndDescription()), + 'title_and_icon_visibility' => $this->normalizeProperty($properties->getPropertyTitleAndIconVisibility()), + 'header_action_visibility' => $this->normalizeProperty($properties->getPropertyHeaderActionVisibility()), + 'info_tab_visibility' => $this->normalizeProperty($properties->getPropertyInfoTabVisibility()), + 'translations' => $this->normalizeTranslations($properties->getPropertyTranslations()), + ]; + } + + private function normalizeProperty(Property $property): array|bool|int|string|null + { + if ($property instanceof TitleAndDescription) { + return [ + 'title' => $property->getTitle(), + 'description' => $property->getDescription(), + 'long_description' => $property->getLongDescription(), + ]; + } + if ($property instanceof Online) { + return $property->getIsOnline(); + } + if ($property instanceof TitleAndIconVisibility || $property instanceof HeaderActionVisibility || $property instanceof InfoTabVisibility) { + return $property->getVisibility(); + } + + return null; + } + + private function normalizeTranslations(Translations $translations): array + { + return [ + 'default_language' => $translations->getDefaultLanguage(), + 'base_language' => $translations->getBaseLanguage(), + 'languages' => array_map(fn(Language $language): array => [ + 'language_code' => $language->getLanguageCode(), + 'title' => $language->getTitle(), + 'description' => $language->getDescription(), + ], $translations->getLanguages()), + ]; + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): ilObject + { + if ($type !== ilObject::class && !in_array(ilObject::class, class_parents($type))) { + throw new NormalizingException("Invalid type for ilObject: {$type}"); + } + + // Validate the class of the object by its type field + $object_type = $this->tt->string($value['type']); + $object_class = ilObjectFactory::getClassByType($object_type); + if ($object_class !== $type) { + throw new NormalizingException("Expected {$type}, got object of type {$object_type} ({$object_class})"); + } + + // Create new object instance without id to avoid reading the object from the database + $object = new $object_class(0, false); + + $object->setId($this->tt->denormalize($value['obj_id'], Id::class)->getId()); + $object->setTitle($this->tt->string($value['title'])); + $object->setDescription($this->tt->string($value['description'])); + $object->setType($this->tt->string($value['type'])); + $object->setOwner($this->tt->int($value['owner'])); + $object->setImportId($this->tt->string($value['import_id'])); + + return $object; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/Legacy11UUIDNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/Legacy11UUIDNormalizer.php new file mode 100644 index 000000000000..01c61026d6ac --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/Legacy11UUIDNormalizer.php @@ -0,0 +1,57 @@ + + */ + private array $type_map = []; + + /** + * Register a normalizer resolving callable for a type. The callable allows to defer the instantiation of the + * normalizer until it is actually needed. + * + * @param class-string $type + * @param callable():Normalizer $normalizer + * + * @throws NormalizingException if the type is already registered + */ + public function registerNormalizer(string $type, callable $normalizer): void + { + if (isset($this->type_map[$type])) { + throw new NormalizingException("Type {$type} is already registered"); + } + $this->type_map[$type] = $normalizer; + } + + /** + * Check if a normalizer is registered for a type. + * + * @param class-string $type + */ + public function hasNormalizer(string $type): bool + { + return isset($this->type_map[$type]); + } + + /** + * Return the normalizer that should handle the given type. Resolves to the most specific + * registered type (child classes / implementing classes before parents/interfaces). + * + * @param class-string $type + * @return Normalizer|null null if no normalizer supports this type + */ + public function getNormalizerFor(string $type): ?Normalizer + { + $candidates = $this->findCandidateTypes($type); + if ($candidates === []) { + return null; + } + $key = $this->selectMostSpecificType($type, $candidates); + + // Instantiate the normalizer on demand + if (is_callable($this->type_map[$key])) { + $this->type_map[$key] = $this->type_map[$key](); + } + + return $this->type_map[$key]; + } + + /** + * Types S from registry such that $type is assignable to S (same class or subclass/implementation). + * + * @param class-string $type + * @return list + */ + private function findCandidateTypes(string $type): array + { + $candidates = []; + foreach (array_keys($this->type_map) as $registeredType) { + if ($type === $registeredType || is_subclass_of($type, $registeredType)) { + $candidates[] = $registeredType; + } + } + + return $candidates; + } + + /** + * Among candidate types (all are assignable from $type), return the most specific one + * (the one closest to $type: child classes before parent classes/interfaces). + * + * @param class-string $type + * @param list $candidate_types + * @return class-string + */ + private function selectMostSpecificType(string $type, array $candidate_types): string + { + if (count($candidate_types) === 1) { + return $candidate_types[0]; + } + + usort($candidate_types, function (string $a, string $b): int { + if ($a === $b) { + return 0; + } + if (is_subclass_of($a, $b)) { + return -1; + } + if (is_subclass_of($b, $a)) { + return 1; + } + return 0; + }); + + return $candidate_types[0]; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/ResourceNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/ResourceNormalizer.php new file mode 100644 index 000000000000..2e8452a92dbb --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/ResourceNormalizer.php @@ -0,0 +1,147 @@ + + */ +#[Normalizes(ResourceIdentification::class, StorableResource::class)] +class ResourceNormalizer implements Normalizer +{ + private const string KEY_TYPE = 'type'; + private const string TYPE_RID = 'rid'; + private const string TYPE_RESOURCE = 'resource'; + + private readonly ResourceRepository $resource_repository; + + public function __construct( + private readonly Transformations $tt, + Container $dic + ) { + $this->resource_repository = $dic[InitResourceStorage::D_REPOSITORIES]->getResourceRepository(); + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if ($value instanceof ResourceIdentification) { + return $this->normalizeIdentification($value); + } + + if ($value instanceof StorableResource) { + return $this->normalizeResource($value); + } + + throw new NormalizingException('Invalid value', $value); + } + + private function normalizeIdentification(ResourceIdentification $rid): array + { + return [ + self::KEY_TYPE => self::TYPE_RID, + 'id' => $rid->serialize(), + ]; + } + + private function normalizeResource(StorableResource $resource): array + { + return [ + self::KEY_TYPE => self::TYPE_RESOURCE, + 'resource_type' => $resource->getType()->value, + 'id' => $resource->getIdentification()->serialize(), + 'revision' => $resource->getCurrentRevision()->getVersionNumber(), + 'title' => $resource->getCurrentRevision()->getTitle(), + 'mime_type' => $resource->getCurrentRevision()->getInformation()->getMimeType(), + 'suffix' => $resource->getCurrentRevision()->getInformation()->getSuffix(), + 'creation_date' => $this->tt->normalize($resource->getCurrentRevision()->getInformation()->getCreationDate()), + ]; + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): ResourceIdentification|StorableResource + { + if ($type === ResourceIdentification::class) { + return $this->denormalizeIdentification($value); + } + + if ($type === StorableResource::class) { + $this->denormalizeResource($value); + } + + throw new NormalizingException('Invalid type', $type); + } + + private function denormalizeIdentification(array $value): ResourceIdentification + { + if (!self::isResourceIdentification($value)) { + throw new NormalizingException('Invalid resource identification', $value); + } + + return new ResourceIdentification($value['id']); + } + + private function denormalizeResource(array $value): StorableResource + { + if (!self::isStorableResource($value)) { + throw new NormalizingException('Invalid storable resource', $value); + } + + $id = new ResourceIdentification($value['id']); + $type = ResourceType::from($value['resource_type']); + + return $this->resource_repository->blank($id, $type); + } + + /** + * Returns true if the value is a normalized resource identification. + */ + public static function isResourceIdentification(mixed $value): bool + { + return is_array($value) + && isset($value[self::KEY_TYPE]) + && $value[self::KEY_TYPE] === self::TYPE_RID; + } + + /** + * Returns true if the value is a normalized storable resource. + */ + public static function isStorableResource(mixed $value): bool + { + return is_array($value) + && isset($value[self::KEY_TYPE]) + && $value[self::KEY_TYPE] === self::TYPE_RESOURCE; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/TransformationNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/TransformationNormalizer.php new file mode 100644 index 000000000000..1f55670c6238 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/TransformationNormalizer.php @@ -0,0 +1,63 @@ + + */ +#[Normalizes(Transformation::class)] +class TransformationNormalizer implements Normalizer +{ + private readonly Refinery $refinery; + + public function __construct( + Container $dic + ) { + $this->refinery = $dic->refinery(); + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if ($value instanceof Transformation) { + return $value->transform([]); + } + + throw new NormalizingException('Invalid transformation value', $value); + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): Transformation + { + return $this->refinery->custom()->transformation(fn() => $value); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/UUIDNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/UUIDNormalizer.php new file mode 100644 index 000000000000..a0ef76fd1bfe --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/UUIDNormalizer.php @@ -0,0 +1,61 @@ + + */ +#[Normalizes(Uuid::class)] +class UUIDNormalizer implements Normalizer +{ + private readonly Factory $factory; + + public function __construct() + { + $this->factory = new Factory(); + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if ($value instanceof Uuid) { + return $value->toString(); + } + + throw new NormalizingException('Invalid UUID value', $value); + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): Uuid + { + return $this->factory->fromString((string) $value); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/NormalizingException.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/NormalizingException.php new file mode 100644 index 000000000000..ebf6c5344d12 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/NormalizingException.php @@ -0,0 +1,37 @@ + $resources + */ + private array $resources = []; + + /** + * @var array $import_mapping + */ + private array $import_mapping = []; + + public function __construct( + private readonly IRSS $irss, + private readonly LoggerInterface $log + ) { + } + + /** + * Get all resources collected during normalization. + * + * @return array + */ + public function getResources(): array + { + return $this->resources; + } + + /** + * Store a mapping of an old resource id to a new resource id. + * This is used to replace the old resource ids with the new resource ids during denormalization. + */ + public function storeMapping(string $old_id, ResourceIdentification $new_id): void + { + $this->import_mapping[$old_id] = $new_id; + } + + /** + * @inheritDoc + */ + public function handle(mixed $passable, \Closure $next): mixed + { + if ($passable instanceof NormalizeCarry && $passable->value instanceof ResourceIdentification) { + $this->handleNormalization($passable->value); + } + + if ($passable instanceof DenormalizeCarry && $passable->expected === ResourceIdentification::class) { + $passable->setResult( + $this->replaceRid($passable->result()) + ); + } + + return $next($passable); + } + + private function handleNormalization(ResourceIdentification $rid): void + { + $resource = $this->irss->manage()->getResource($rid); + + $this->resources[$rid->serialize()] = $resource; + } + + private function replaceRid(ResourceIdentification $rid): ResourceIdentification + { + $id = $rid->serialize(); + if (isset($this->import_mapping[$id])) { + $this->log->debug("Replaced resource id {$id} with {$this->import_mapping[$id]->serialize()}"); + return $this->import_mapping[$id]; + } else { + $this->log->warning("Unresolved resource id {$id}"); + return $rid; + } + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/DenormalizeCarry.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/DenormalizeCarry.php new file mode 100644 index 000000000000..42f1f9a1a2fb --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/DenormalizeCarry.php @@ -0,0 +1,47 @@ +result = $result; + return $this; + } + + /** + * Get the result of the denormalization carry. If no result is set, an exception will be thrown. + * + * @throws NormalizingException if the result is not set + */ + public function result(): mixed + { + if (!isset($this->result)) { + $expected_type = get_debug_type($this->expected); + $normalized_type = get_debug_type($this->normalized); + throw new NormalizingException("Unsupported value, expected: {$expected_type}, got: {$normalized_type}"); + } + return $this->result; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/DenormalizingPipe.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/DenormalizingPipe.php new file mode 100644 index 000000000000..233b8b825bf2 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/DenormalizingPipe.php @@ -0,0 +1,62 @@ + + */ +class DenormalizingPipe implements Pipe +{ + public function __construct( + private readonly Registry $registry + ) { + } + + public function handle(mixed $passable, \Closure $next): mixed + { + if (!$passable instanceof DenormalizeCarry) { + return $next($passable); + } + + // Check if normalizer for the expected type is registered. + if (is_string($passable->expected) && $normalizer = $this->registry->getNormalizerFor($passable->expected)) { + return $next( + $passable->setResult($normalizer->denormalize($passable->normalized, $passable->expected)) + ); + } + + // Use the fromNormalized method of the expected object to set the state of the object from the normalized form. + if ($passable->expected instanceof Normalizable) { + return $next( + $passable->setResult($passable->expected->fromNormalized($passable->transformations)->transform($passable->normalized)) + ); + } + + return $next($passable); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/FinalizeNormalizing.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/FinalizeNormalizing.php new file mode 100644 index 000000000000..4964f65e3bd1 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/FinalizeNormalizing.php @@ -0,0 +1,56 @@ +ensureNormalized($passable->result()); + return $next($passable); + } + + private function ensureNormalized(mixed $value): mixed + { + if (is_scalar($value) || $value === null) { + return $value; + } + + if (is_array($value)) { + return array_map($this->ensureNormalized(...), $value); + } + + throw new NormalizingException('Value is not normalized: ' . get_debug_type($value)); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/IdMappingPipe.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/IdMappingPipe.php new file mode 100644 index 000000000000..5645ed580eef --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/IdMappingPipe.php @@ -0,0 +1,87 @@ + + */ +class IdMappingPipe implements Pipe +{ + private array $unresolved = []; + + public function __construct( + private readonly ilImportMapping $mapping, + private readonly string $component, + private readonly LoggerInterface $log + ) { + } + + public function handle(mixed $passable, \Closure $next): mixed + { + if (!$passable instanceof DenormalizeCarry || $passable->expected !== Id::class) { + return $next($passable); + } + + $envelope = $passable->result(); + if (!$envelope instanceof Id) { + throw new NormalizingException('Expected id envelope, got ' . get_debug_type($envelope)); + } + + if ($new_id = $this->mapping->getMapping($this->component, $envelope->getObject(), (string) $envelope->getId())) { + // Replace the envelope with the mapped new id + if (is_int($envelope->getId())) { + $new_id = (int) $new_id; + } + + $passable->setResult(new Id($new_id, $envelope->getObject())); + $this->log->debug("Replaced id {$envelope->getObject()}:{$envelope->getId()} with {$new_id}"); + } else { + $this->unresolved[] = $envelope; + $this->log->warning("Unresolved id {$envelope->getObject()}:{$envelope->getId()}"); + } + + return $next($passable); + } + + /** + * @return list + */ + public function unresolved(): array + { + return $this->unresolved; + } + + public function mapping(): ilImportMapping + { + return $this->mapping; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/NormalizeCarry.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/NormalizeCarry.php new file mode 100644 index 000000000000..27cebb51fdad --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/NormalizeCarry.php @@ -0,0 +1,45 @@ +result = $result; + return $this; + } + + /** + * Get the result of the normalization carry. If no result is set, an exception will be thrown. + * + * @throws NormalizingException if the result is not set + */ + public function result(): array|float|bool|int|string|null + { + if ($this->result === null) { + throw new NormalizingException('Unsupported value', $this->value); + } + return $this->result; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/NormalizingPipe.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/NormalizingPipe.php new file mode 100644 index 000000000000..fa255289d8c9 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/NormalizingPipe.php @@ -0,0 +1,66 @@ + + */ +class NormalizingPipe implements Pipe +{ + public function __construct( + private readonly Registry $registry + ) { + } + + public function handle(mixed $passable, \Closure $next): mixed + { + if (!$passable instanceof NormalizeCarry) { + return $next($passable); + } + + if (is_scalar($passable->value)) { + return $next($passable->setResult($passable->value)); + } + + // Check if object is self-normalizable and use the toNormalized method + if ($passable->value instanceof Normalizable) { + $normalized = $passable->value->toNormalized($passable->transformations)->transform($passable->context); + + return $next($passable->setResult($normalized)); + } + + // Lookup normalizer for the object type and use the normalize method + if ($normalizer = $this->registry->getNormalizerFor(get_class($passable->value))) { + return $next( + $passable->setResult($normalizer->normalize($passable->value)) + ); + } + + return $next($passable); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Transformations.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Transformations.php new file mode 100644 index 000000000000..0cc194358ad5 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Transformations.php @@ -0,0 +1,151 @@ + $this->normalize($value, $context), $value); + } + + return $this->pipeline->send(new NormalizeCarry($this, $value, $context)) + ->then(fn(NormalizeCarry $carry) => $carry->result()); + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $normalized, string|object $expected): mixed + { + if ($normalized === null) { + return null; + } + + return $this->pipeline->send(new DenormalizeCarry($this, $normalized, $expected)) + ->then(fn(DenormalizeCarry $carry) => $carry->result()); + } + + /* + Transformations + */ + + /** + * @inheritDoc + */ + public function context(string $pipe_class): Pipe + { + foreach ($this->pipeline->pipes() as $pipe) { + if ($pipe instanceof $pipe_class) { + return $pipe; + } + } + throw new InvalidArgumentException("Pipe {$pipe_class} not found"); + } + + public function custom(): Group + { + return $this->refinery->custom(); + } + + /** + * @throws InvalidArgumentException if the value cannot be transformed into an integer + */ + public function int(mixed $value): int + { + return $this->refinery->kindlyTo()->int()->transform($value); + } + + /** + * @throws InvalidArgumentException if the value cannot be transformed into a float + */ + public function float(mixed $value): float + { + return $this->refinery->kindlyTo()->float()->transform($value); + } + + /** + * @throws InvalidArgumentException if the value cannot be transformed into a string + */ + public function string(mixed $value): string + { + return $this->refinery->kindlyTo()->string()->transform($value); + } + + /** + * @throws InvalidArgumentException if the value cannot be transformed into a boolean + */ + public function bool(mixed $value): bool + { + return $this->refinery->kindlyTo()->bool()->transform($value); + } + + public function nullableInt(mixed $value): ?int + { + return $this->refinery->byTrying([ + $this->refinery->kindlyTo()->int(), + $this->refinery->always(null) + ])->transform($value); + } + + public function nullableFloat(mixed $value): ?float + { + return $this->refinery->byTrying([ + $this->refinery->kindlyTo()->float(), + $this->refinery->always(null) + ])->transform($value); + } + + public function nullableString(mixed $value): ?string + { + return $this->refinery->byTrying([ + $this->refinery->kindlyTo()->string(), + $this->refinery->always(null) + ])->transform($value); + } + + public function nullableBool(mixed $value): ?bool + { + return $this->refinery->byTrying([ + $this->refinery->kindlyTo()->bool(), + $this->refinery->always(null) + ])->transform($value); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Pipeline.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Pipeline.php new file mode 100644 index 000000000000..fe32b10d38eb --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Pipeline.php @@ -0,0 +1,200 @@ + + * + * @phpstan-type PipeParam \Closure(TPassable, PipeParam)|Pipe: TPassable + */ +class Pipeline implements PipelineContract +{ + /** + * The object being passed through the pipeline. + * + * @var TPassable $passable + */ + protected mixed $passable; + + /** + * The array of class pipes. + * + * @var list $pipes + */ + protected array $pipes = []; + + /** + * The final callback to be executed after the pipeline ends regardless of the outcome. + */ + protected ?Closure $finally = null; + + + /** + * @inheritDoc + */ + public function send(mixed $passable): self + { + $this->passable = $passable; + return $this; + } + + /** + * @inheritDoc + */ + public function through(array $pipes): self + { + $this->pipes = $pipes; + return $this; + } + + /** + * @inheritDoc + */ + public function pipe(Closure|Pipe $pipe): self + { + $this->pipes[] = $pipe; + return $this; + } + + /** + * @inheritDoc + */ + public function pipeWhen(Closure $condition, Closure|Pipe $pipe): self + { + return $this->pipe(fn($passable, $next) => $condition($passable) + ? $this->executePipe($pipe, $passable, $next) + : $next($passable)); + } + + /** + * @inheritDoc + */ + public function pipeUnless(Closure $condition, Closure|Pipe $pipe): self + { + return $this->pipeWhen(fn($passable) => !$condition($passable), $pipe); + } + + /** + * @inheritDoc + */ + public function then(Closure $destination): mixed + { + $pipeline = array_reduce( + array_reverse($this->pipes), + $this->carry(), + $this->prepareDestination($destination) + ); + + try { + return $pipeline($this->passable); + } finally { + if ($this->finally) { + ($this->finally)($this->passable); + } + } + } + + /** + * @inheritDoc + */ + public function thenReturn(): mixed + { + return $this->then(fn($passable) => $passable); + } + + /** + * @inheritDoc + */ + public function finally(Closure $callback): self + { + $this->finally = $callback; + return $this; + } + + /** + * @inheritDoc + */ + public function pipes(): array + { + return $this->pipes; + } + + /** + * Get the final piece of the Closure onion. + */ + protected function prepareDestination(Closure $destination) + { + return function ($passable) use ($destination) { + try { + return $destination($passable); + } catch (Throwable $e) { + return $this->handleException($passable, $e); + } + }; + } + + /** + * Get a Closure that represents a slice of the application onion. + */ + protected function carry() + { + return fn($stack, $pipe) => function ($passable) use ($stack, $pipe) { + try { + return $this->executePipe($pipe, $passable, $stack); + } catch (Throwable $e) { + return $this->handleException($passable, $e); + } + }; + } + + /** + * Execute a single pipe, handling both Closure and Pipe instances. + * Pipe instances are skipped when their skip() method returns true. + */ + protected function executePipe(Closure|Pipe $pipe, mixed $passable, Closure $next): mixed + { + if ($pipe instanceof Pipe) { + return $pipe->handle($passable, $next); + } + + if (is_callable($pipe)) { + return $pipe($passable, $next); + } + + throw new \InvalidArgumentException('Invalid pipe'); + } + + /** + * Handle the given exception. + * + * @throws \Throwable + */ + protected function handleException(mixed $passable, Throwable $e): void + { + throw $e; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/JSONMemoryDeserializer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/JSONMemoryDeserializer.php new file mode 100644 index 000000000000..3ea8e3904b3b --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/JSONMemoryDeserializer.php @@ -0,0 +1,70 @@ + $handler */ + private array $handler = []; + + /** + * @inheritDoc + */ + public function open(string $json): static + { + $clone = clone $this; + $clone->decoded = json_decode($json, true); + return $clone; + } + + /** + * @inheritDoc + */ + public function addHandler(string $group, callable $handler): void + { + $this->handler[$group] = $handler; + } + + /** + * @inheritDoc + */ + public function process(): void + { + foreach ($this->decoded as $key => $value) { + if (is_array($value)) { + $head = $value[array_key_first($value)]; + $value = (!array_is_list($head)) ? [$head] : $head; + } + + if (isset($this->handler[$key])) { + $this->handler[$key]($value); + } + } + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/JSONMemorySerializer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/JSONMemorySerializer.php new file mode 100644 index 000000000000..dcf935c8dfd5 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/JSONMemorySerializer.php @@ -0,0 +1,113 @@ + Stack of for nested structure + */ + private array $stack = []; + + /** + * @inheritDoc + */ + public function open(string $path): static + { + $clone = clone $this; + $clone->stack = [ + ['name' => '', 'data' => []], + ]; + return $clone; + } + + /** + * @inheritDoc + */ + public function startGroup(string $name): void + { + $this->stack[] = ['name' => $name, 'data' => []]; + } + + /** + * @inheritDoc + */ + public function endGroup(string $name): void + { + $frame = array_pop($this->stack); + if ($frame['name'] !== $name) { + throw new \LogicException( + "Group name mismatch: expected end of '{$frame['name']}', got '{$name}'" + ); + } + $top = &$this->stack[array_key_last($this->stack)]; + $top['data'][$name] = $frame['data']; + } + + /** + * @inheritDoc + */ + public function group(string $name, callable $callback): void + { + $this->startGroup($name); + $callback(); + $this->endGroup($name); + } + + /** + * @inheritDoc + */ + public function append(string $name, array $data): void + { + $top = &$this->stack[array_key_last($this->stack)]; + if (array_key_exists($name, $top['data'])) { + $existing = $top['data'][$name]; + if (is_array($existing) && array_is_list($existing)) { + $top['data'][$name][] = $data; + } else { + $top['data'][$name] = [$existing, $data]; + } + } else { + $top['data'][$name] = $data; + } + } + + /** + * @inheritDoc + */ + public function write(): string + { + if ($this->stack === []) { + return '{}'; + } + $root = $this->stack[0]['data']; + $json = json_encode($root, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + $this->stack = [ + ['name' => '', 'data' => []], + ]; + return $json; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/SimpleXMLSerializer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/SimpleXMLSerializer.php new file mode 100644 index 000000000000..8df8f8e8b15c --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/SimpleXMLSerializer.php @@ -0,0 +1,190 @@ +writer = new \XMLWriter(); + } + + /** + * @inheritDoc + */ + public function open(string $path): static + { + $clone = clone $this; + $clone->writer->openMemory(); + $clone->writer->setIndent(true); + return $clone; + } + + /** + * Start a new xml document in the current writer. if a document has already been started, an exception will be + * thrown. + * + * @throws \LogicException if a document has already been started + */ + public function createDocument(string $comment): void + { + if ($this->has_document) { + throw new \LogicException('XML document already started'); + } + + $this->writer->startDocument('1.0', 'UTF-8'); + $this->writer->writeComment($comment); + $this->has_document = true; + } + + + /** + * @inheritDoc + */ + public function startGroup(string $name): void + { + $this->current_group = $name; + $this->writer->startElement($this->formatName($name)); + } + + /** + * @inheritDoc + */ + public function endGroup(string $name): void + { + if ($this->current_group !== $name) { + throw new \LogicException( + "Group name mismatch: expected end of '{$this->current_group}', got '{$name}'" + ); + } + + $this->current_group = ''; + $this->writer->endElement(); + } + + /** + * @inheritDoc + */ + public function group(string $name, callable $callback): void + { + $this->startGroup($name); + $callback(); + $this->endGroup($name); + } + + /** + * @inheritDoc + */ + public function append(string $name, array $data): void + { + $this->writer->startElement($this->formatName($name)); + if (count($data) === 0) { + $this->writer->writeAttribute('type', 'empty-array'); + } + + $this->appendRecursive($data); + $this->writer->endElement(); + } + + /** + * @inheritDoc + */ + public function write(): string + { + if ($this->has_document) { + $this->writer->endDocument(); + } + + return $this->writer->outputMemory(true); + } + + /** + * @param array $data + */ + private function appendRecursive(array $data): void + { + foreach ($data as $key => $value) { + $is_nested = is_array($value); + $formatted_key = $this->formatName($key); + + if ($this->shouldUseItemElement($key, $formatted_key)) { + $this->writer->startElement('item'); + + if (!array_is_list($data)) { + $this->writer->writeAttribute('key', (string) $key); + } + } else { + $this->writer->startElement($formatted_key); + } + + if (!$is_nested) { + $value = match (gettype($value)) { + 'NULL' => 'NULL', + 'integer' => (string) $value, + 'float' => (string) $value, + 'boolean' => $value ? '1' : '0', + default => htmlspecialchars((string) $value), + }; + + $this->writer->writeRaw($value); + } else { + if (count($value) === 0) { + $this->writer->writeAttribute('type', 'empty-array'); + } + $this->appendRecursive($value); + } + + $this->writer->endElement(); + } + } + + private function shouldUseItemElement(int|string $key, string $formatted_key): bool + { + if (is_numeric($key) || str_contains((string) $key, '-') || $key === '') { + return true; + } + + return !$this->isValidXmlElementName($formatted_key); + } + + private function isValidXmlElementName(string $name): bool + { + return $name !== '' && preg_match('/^[A-Za-z_][A-Za-z0-9._-]*$/', $name) === 1; + } + + private function formatName(int|string $name): string + { + // Transform key to kebab-case + $output = strtolower(preg_replace('/(? */ + private array $handler = []; + + public function addHandler(string $group, callable $handler): void + { + $this->handler[$group] = $handler; + } + + private function processReader(\XMLReader $reader): void + { + while ($reader->read()) { + $node_name = $this->kebabToSnake($reader->name); + + if ($reader->nodeType !== \XMLReader::ELEMENT || !isset($this->handler[$node_name])) { + continue; + } + + $group_data = $this->readGroup($reader); + $this->handler[$node_name]($group_data); + } + + $reader->close(); + } + + /** + * @return list + */ + private function readGroup(\XMLReader $reader): array + { + if ($reader->isEmptyElement) { + return []; + } + + $group_depth = $reader->depth; + $group_name = $reader->name; + $group_data = []; + + while ($reader->read()) { + if ( + $reader->nodeType === \XMLReader::END_ELEMENT + && $reader->depth === $group_depth + && $reader->name === $group_name + ) { + break; + } + + if ( + $reader->nodeType !== \XMLReader::ELEMENT + || $reader->depth !== $group_depth + 1 + ) { + continue; + } + + $group_data[] = $this->readElementValue($reader); + } + + return $group_data; + } + + private function readElementValue(\XMLReader $reader): mixed + { + $is_marked_empty_array = $this->isMarkedEmptyArray($reader); + + if ($reader->isEmptyElement) { + if ($is_marked_empty_array) { + return []; + } + return ''; + } + + $element_depth = $reader->depth; + $element_name = $reader->name; + $children = []; + $text_content = ''; + + while ($reader->read()) { + if ( + $reader->nodeType === \XMLReader::END_ELEMENT + && $reader->depth === $element_depth + && $reader->name === $element_name + ) { + break; + } + + if ( + $reader->nodeType === \XMLReader::ELEMENT + && $reader->depth === $element_depth + 1 + ) { + $child_key = $this->resolveElementKey($reader); + $child_value = $this->readElementValue($reader); + + if ($child_key === null) { + $children[] = $child_value; + continue; + } + + $this->appendValue($children, $child_key, $child_value); + continue; + } + + if ( + $reader->depth === $element_depth + 1 + && in_array( + $reader->nodeType, + [ + \XMLReader::TEXT, + \XMLReader::CDATA, + \XMLReader::SIGNIFICANT_WHITESPACE + ], + true + ) + ) { + $text_content .= $reader->value; + } + } + + if ($children !== []) { + return $children; + } + + if ($is_marked_empty_array && trim($text_content) === '') { + return []; + } + + return $this->decodeScalarValue($text_content); + } + + private function isMarkedEmptyArray(\XMLReader $reader): bool + { + return $reader->getAttribute('type') === 'empty-array'; + } + + private function resolveElementKey(\XMLReader $reader): ?string + { + if ($reader->name !== 'item') { + return $this->kebabToSnake($reader->name); + } + + $raw_key = $reader->getAttribute('key'); + if ($raw_key === null || $raw_key === '') { + return null; + } + + return $this->kebabToSnake($raw_key); + } + + /** + * @param array $target + */ + private function appendValue(array &$target, string $key, mixed $value): void + { + if (!array_key_exists($key, $target)) { + $target[$key] = $value; + return; + } + + if (!is_array($target[$key]) || !array_is_list($target[$key])) { + $target[$key] = [$target[$key]]; + } + + $target[$key][] = $value; + } + + private function decodeScalarValue(string $value): mixed + { + $decoded = htmlspecialchars_decode($value, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5); + return $decoded === 'NULL' ? null : $decoded; + } + + private function kebabToSnake(string $name): string + { + return str_replace('-', '_', $name); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/XMLFileDeserializer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/XMLFileDeserializer.php new file mode 100644 index 000000000000..90e7ad6e52e2 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/XMLFileDeserializer.php @@ -0,0 +1,72 @@ +file_path = $path; + return $clone; + } + + /** + * @inheritDoc + */ + public function process(): void + { + if ($this->file_path === '') { + throw new \RuntimeException( + 'No file has been opened. Call open() before process().' + ); + } + + $reader = new \XMLReader(); + + if (!$reader->open($this->file_path, null, LIBXML_NONET)) { + throw new \RuntimeException( + "Unable to open XML file '{$this->file_path}'." + ); + } + + $this->processReader($reader); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/XMLMemoryDeserializer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/XMLMemoryDeserializer.php new file mode 100644 index 000000000000..395d0d2a5b87 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/XMLMemoryDeserializer.php @@ -0,0 +1,64 @@ +xml = $path; + return $clone; + } + + /** + * @inheritDoc + */ + public function process(): void + { + $reader = new \XMLReader(); + $xml = $this->prepareXmlInput($this->xml); + + if (!$reader->XML($xml, null, LIBXML_NONET)) { + throw new \RuntimeException('Unable to read XML input.'); + } + + $this->processReader($reader); + } + + private function prepareXmlInput(string $xml): string + { + $xml = preg_replace('/^\s*<\?xml[^>]*\?>\s*/i', '', trim($xml)) ?? trim($xml); + return "{$xml}"; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Setup/NormalizerArtifactObjective.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Setup/NormalizerArtifactObjective.php new file mode 100644 index 000000000000..22c220a0ca28 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Setup/NormalizerArtifactObjective.php @@ -0,0 +1,82 @@ +getMatchingClassNames(Normalizer::class) as $class_name) { + $ref = new ReflectionClass($class_name); + + $attrs = $ref->getAttributes(Normalizes::class); + foreach ($attrs as $attr) { + $instance = $attr->newInstance(); + foreach ($instance->types as $type) { + $type_map[$type][self::DEFAULT_KEY] = $class_name; + } + } + + $attrs = $ref->getAttributes(NormalizesLegacy::class); + foreach ($attrs as $attr) { + $instance = $attr->newInstance(); + foreach ($instance->types as $type) { + foreach ($instance->versions as $version) { + $type_map[$type][$version] = $class_name; + } + } + } + } + + return new ArrayArtifact($type_map); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/CleanupStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/CleanupStage.php new file mode 100644 index 000000000000..f13f3548247c --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/CleanupStage.php @@ -0,0 +1,91 @@ +get(UploadValidationStage::FILE_TO_IMPORT); + if ($file_to_import !== null) { + $temp_dir = dirname($file_to_import); + if ($temp_dir && file_exists($temp_dir) && is_dir($temp_dir)) { + $this->removeDirectory($temp_dir); + $this->log->info("Removed temporary import directory: {$temp_dir}"); + } else { + $this->log->warning("Temporary import directory does not exist: {$temp_dir}"); + } + } + + $import_base_dir = $context->get(UploadValidationStage::IMPORT_BASE_DIR); + if ($import_base_dir && file_exists($import_base_dir) && is_dir($import_base_dir)) { + $this->removeDirectory($import_base_dir); + $this->log->info("Removed import target base directory: {$import_base_dir}"); + } else { + $this->log->warning("Import target base directory does not exist: {$import_base_dir}"); + } + + return StageResult::complete($context); + } + + private function removeDirectory(string $path): void + { + $files = array_diff(scandir($path), ['.', '..']); + foreach ($files as $file) { + if (is_dir("$path/$file")) { + $this->removeDirectory("$path/$file"); + } else { + unlink("$path/$file"); + } + } + + rmdir($path); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/DetectLegacyImportStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/DetectLegacyImportStage.php new file mode 100644 index 000000000000..cb331f69b8d6 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/DetectLegacyImportStage.php @@ -0,0 +1,80 @@ +get(UploadValidationStage::IMPORT_BASE_DIR); + $import_name = basename($import_base_dir); + + $xml_file = $import_base_dir . DIRECTORY_SEPARATOR . $import_name . '.xml'; + $qti_file = $import_base_dir . DIRECTORY_SEPARATOR . str_replace(['_qpl_', '_tst_'], '_qti_', $import_name) . '.xml'; + + if (!file_exists($qti_file) || !file_exists($xml_file)) { + $this->log->debug("No legacy import files found for {$import_name}"); + return StageResult::advance($context); + } + + $this->log->info("Detected legacy import files for {$import_name}"); + return StageResult::advance( + $context->with(self::LEGACY_QTI_FILE, $qti_file) + ->with(self::LEGACY_XML_FILE, $xml_file) + ); + } + + public static function isLegacyImport(ImportContext $context): bool + { + return $context->has(self::LEGACY_QTI_FILE) && $context->has(self::LEGACY_XML_FILE); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/PersistStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/PersistStage.php new file mode 100644 index 000000000000..f3173b323d0f --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/PersistStage.php @@ -0,0 +1,74 @@ +lng->txt('qpl_import_step_persist'); + } + + public function getDescription(): ?string + { + return ''; + } + + public function process(ImportContext $context): StageResult + { + $importer = new ilImport($this->request_data_collector->getRefId()); + $importer->importObject( + null, + $context->get(UploadValidationStage::FILE_TO_IMPORT), + basename($context->get(UploadValidationStage::FILE_TO_IMPORT)), + 'qpl', + 'components/ILIAS/TestQuestionPool', + true, + ); + + // Context is updated by the QuestionPoolImporter so we need to reload it + return StageResult::complete($this->session->getContext()); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionPoolImporter.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionPoolImporter.php new file mode 100644 index 000000000000..dbcfa852510b --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionPoolImporter.php @@ -0,0 +1,169 @@ +log); + $images_pipe = new CollectQuestionImages(new Factory(), $this->data_factory->objId(0)); + $tt = $this->builder->withAdditionalPipes(append: [$id_mapping_pipe, $images_pipe])->create(); + + $selected_questions = QuestionSelectionStage::getSelectedQuestions($context); + + $deserializer->addHandler( + 'general', + function (array $objects) use ($tt, $mapping, $parent_id, &$context): void { + $new_pool_id = $this->importQuestionPool( + array_pop($objects), + $tt, + $mapping, + $parent_id + ); + $context = $context->with('pool_obj_id', $new_pool_id); + } + ); + + $deserializer->addHandler( + 'questions', + function (array $questions) use ($tt, $mapping, $selected_questions): void { + foreach ($questions as $question) { + $this->questions_importer->importQuestion( + $question, + $tt, + $mapping, + $selected_questions + ); + } + } + ); + + $deserializer->addHandler( + 'skill_assignments', + function (array $assignments) use ($tt, $mapping, &$context): void { + $result = $this->skill_importer->import( + $assignments, + UploadValidationStage::getInstallId($context), + $tt, + $mapping, + ); + $context = $context->with('skill_assignments', $result); + } + ); + + $this->log->info('Importing question pool export file...'); + $deserializer->process(); + $this->log->info('...Finished importing question pool export file'); + + $this->log->info('Importing question images...'); + $this->questions_importer->importQuestionImages( + $context->get('pool_obj_id'), + $mapping, + $context, + $images_pipe + ); + $this->log->info('...Finished importing question images'); + + $this->log->info("Finished importing question pool {$context->get('pool_obj_id')} (Object ID)"); + return $context; + } + + /** + * Finalize the import after all dependencies have been imported. + * It will replace the old question ids with the new question ids in the question pages. + */ + public function finalize(ilImportMapping $mapping): void + { + $this->log->info('Finalizing question pool import...'); + $this->questions_importer->finalizeQuestionPages($mapping); + $this->log->info('...Finished finalizing question pool'); + } + + protected function importQuestionPool( + array $normalized, + Transformations $transformations, + ilImportMapping $mapping, + ReferenceId $parent_id + ): int { + $pool_object = $transformations->denormalize($normalized, ilObjQuestionPool::class); + $old_pool_id = $pool_object->getId(); + + $pool_object->setTitle('Imported'); //TODO: Remove after testing + $new_pool_id = $pool_object->create(true); + $pool_object->getObjectProperties()->storePropertyIsOnline( + $pool_object->getObjectProperties()->getPropertyIsOnline()->withOffline() + ); + $pool_object->saveToDb(); + $this->log->debug("Created new pool object: {$old_pool_id} -> {$new_pool_id}"); + + $pool_object->createReference(); + $pool_object->putInTree($parent_id->toInt()); + $pool_object->setPermissions($parent_id->toInt()); + $this->log->debug("Stored pool object in tree: {$parent_id->toInt()} (Parent Ref) -> {$pool_object->getRefId()} (Pool Ref)"); + + $mapping->addMapping('components/ILIAS/TestQuestionPool', 'qpl', (string) $old_pool_id, (string) $new_pool_id); + $mapping->addMapping('components/ILIAS/TestQuestionPool', 'object', (string) $old_pool_id, (string) $new_pool_id); + $mapping->addMapping('components/ILIAS/MetaData', 'md', "{$old_pool_id}:0:qpl", "{$new_pool_id}:0:qpl"); + + return $new_pool_id; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionSelectionStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionSelectionStage.php new file mode 100644 index 000000000000..7bffa9f3a7b1 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionSelectionStage.php @@ -0,0 +1,228 @@ + \ilQTIItem::QT_ORDERING, + 'KPRIM CHOICE QUESTION' => \ilQTIItem::QT_KPRIM_CHOICE, + 'LONG MENU QUESTION' => \ilQTIItem::QT_LONG_MENU, + 'SINGLE CHOICE QUESTION' => \ilQTIItem::QT_MULTIPLE_CHOICE_SR, + 'MULTIPLE CHOICE QUESTION' => \ilQTIItem::QT_MULTIPLE_CHOICE_MR, + 'MATCHING QUESTION' => \ilQTIItem::QT_MATCHING, + 'CLOZE QUESTION' => \ilQTIItem::QT_CLOZE, + 'IMAGE MAP QUESTION' => \ilQTIItem::QT_IMAGEMAP, + 'TEXT QUESTION' => \ilQTIItem::QT_TEXT, + 'NUMERIC QUESTION' => \ilQTIItem::QT_NUMERIC, + 'TEXTSUBSET QUESTION' => \ilQTIItem::QT_TEXTSUBSET + ]; + + public function __construct( + private readonly Language $lng, + private readonly LoggerInterface $log, + private readonly ilComponentFactory $component_factory, + private readonly UIFactory $ui_factory, + private readonly ServerRequestInterface $request, + private readonly string $form_action, + private readonly string $title, + ) { + } + + public function getIdentifier(): string + { + return 'question_selection'; + } + + public function getLabel(): ?string + { + return $this->lng->txt('qpl_import_step_select'); + } + + public function getDescription(): ?string + { + return ''; + } + + public function process(ImportContext $context): StageResult + { + if ($context->has('selectable_questions')) { + $options = []; + foreach ($context->get('selectable_questions') as $question) { + $options[$question] = $question; + } + + $data = $this->buildSelectQuestionsForm($options) + ->withRequest($this->request) + ->getData(); + + if (isset($data['selected_questions'])) { + return StageResult::advance($context->with(self::SELECTED_QUESTIONS, $data['selected_questions'])); + } + } + + if (!$context->has(UploadValidationStage::COMPONENT_IMPORT_FILE)) { + $this->log->error("No component import file found in context"); + return StageResult::error($context, $this->lng->txt('qpl_import_file_not_found')); + } + + $options = DetectLegacyImportStage::isLegacyImport($context) + ? $this->readQuestionsFromQTI($context) + : $this->readQuestions($context); + + if ($options === []) { + $this->log->error("No questions found in import file"); + return StageResult::error($context, $this->lng->txt('qpl_import_no_items')); + } + + $panel = $this->ui_factory->panel()->standard( + $this->title, + [ + $this->ui_factory->legacy()->content($this->lng->txt('qpl_import_verify_found_questions')), + $this->buildSelectQuestionsForm($options) + ] + ); + + return StageResult::interact( + $context->with(self::SELECTABLE_QUESTIONS, array_keys($options)), + [$panel] + ); + } + + /** + * @return list + */ + public static function getSelectedQuestions(ImportContext $context): array + { + return array_map('intval', $context->get(self::SELECTED_QUESTIONS, [])); + } + + private function readQuestions(ImportContext $context): array + { + $options = []; + + $deserializer = new XMLFileDeserializer()->open( + $context->get(UploadValidationStage::COMPONENT_IMPORT_FILE) + ); + + $deserializer->addHandler('questions', function (array $questions) use (&$options): void { + foreach ($questions as $question) { + if (!isset($question['title']) || !isset($question['type'])) { + continue; + } + + $raw_id = $question['id']; + $id = is_array($raw_id) ? (string) ($raw_id['id'] ?? '') : (string) $raw_id; + $options[$id] = "{$question['title']} ({$this->getLabelForQuestionType($question['type'])})"; + } + }); + $deserializer->process(); + + $count = count($options); + $this->log->info("Found {$count} questions in import file"); + + return $options; + } + + /** + * @deprecated This method is only used for legacy imports and will be removed with further ILIAS versions. + */ + private function readQuestionsFromQTI(ImportContext $context): array + { + $parser = new \ilQTIParser( + $context->get(UploadValidationStage::IMPORT_BASE_DIR), + $context->get(DetectLegacyImportStage::LEGACY_QTI_FILE), + \ilQTIParser::IL_MO_VERIFY_QTI, + 0 + ); + $parser->startParsing(); + + $options = []; + foreach ($parser->getFoundItems() as $item) { + $options[$item['ident']] = "{$item['title']} ({$this->getLabelForQuestionType($item['type'])})"; + } + + $count = count($options); + $this->log->info("Found {$count} questions in legacy import file"); + + return $options; + } + + private function buildSelectQuestionsForm(array $options): Form + { + $input = $this->ui_factory->input()->field()->multiSelect( + $this->lng->txt('questions'), + $options + )->withValue(array_keys($options)); + + $form = $this->ui_factory->input()->container()->form()->standard( + $this->form_action, + ['selected_questions' => $input] + )->withSubmitLabel($this->lng->txt('import')); + + return $form; + } + + private function getLabelForQuestionType(string $type): string + { + if ($this->lng->exists($type)) { + return $this->lng->txt($type); + } + + /** + * @todo Remove with ILIAS 12: This is here for backward compatibility. + * As we support the import of a previous version this should go with + * ILIAS 11, but being generous: ILIAS 12 it is. + */ + if (array_key_exists($type, $this->old_export_question_types)) { + return $this->lng->txt($this->old_export_question_types[$type]); + } + return $this->getLabelForPluginQuestionTypes($type); + } + + private function getLabelForPluginQuestionTypes(string $type): string + { + foreach ($this->component_factory->getActivePluginsInSlot('qst') as $pl) { + if ($pl->getQuestionType() === $type) { + return $pl->getQuestionTypeTranslation(); + } + } + return $type; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionsImporter.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionsImporter.php new file mode 100644 index 000000000000..238daf17c287 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionsImporter.php @@ -0,0 +1,324 @@ +filesystem = $filesystems->web(); + } + + public function importQuestion( + array $normalized, + Transformations $transformations, + ilImportMapping $mapping, + array $selected_questions + ): ?assQuestion { + $question_class = $normalized['type']; + if (!class_exists($question_class)) { + throw new \InvalidArgumentException("Question class {$question_class} does not exist"); + } + + /** @var assQuestion $question */ + $question = $transformations->denormalize($normalized, new $question_class()); + $old_question_id = $question->getId(); + if (!in_array($old_question_id, $selected_questions)) { + $this->log->debug("Skipping question import for ID {$old_question_id} (not selected)"); + return null; + } + + // Initialize feedback object to prevent error when saving the question + $feedback_class = $question::getFeedbackClassNameByQuestionType($question->getQuestionType()); + $question->feedbackOBJ = new $feedback_class($question, $this->ctrl, $this->database, $this->language); + + // Create new question and store basic question properties + $new_question_id = $question->createNewQuestion(false); + $this->log->debug("Created new question: {$old_question_id} -> {$new_question_id}"); + $this->storeQuestionMappings($mapping, $old_question_id, $new_question_id, $question->getObjId()); + + if ($question instanceof assFormulaQuestion) { + $this->importFormulaQuestion($normalized, $question, $transformations, $mapping, ); + } + + // Save question-specific properties + $question->saveToDb(); + $this->log->debug("Imported question {$new_question_id} (type: {$question->getQuestionType()})"); + + $feedback = $transformations->denormalize($normalized['feedback'], Feedback::class); + $this->importFeedback($feedback, $question); + + return $question; + } + + + public function importQuestionImages( + int $parent_obj_id, + ilImportMapping $mapping, + ImportContext $context, + CollectQuestionImages $pipe, + ): void { + $import_dir = dirname($context->get(UploadValidationStage::COMPONENT_IMPORT_FILE)) . '/expDir_1'; + + foreach ($pipe->getEnvelopes() as $filename => $envelope) { + $source_path = $import_dir . DIRECTORY_SEPARATOR . $filename; + if (!file_exists($source_path)) { + $this->log->error("Imported image path does not exist: {$source_path}"); + continue; + } + + $image_base_path = $this->buildImageBasePath($parent_obj_id, $envelope, $mapping); + $image_path = "{$image_base_path}/{$envelope->getFilename()}"; + if ($this->filesystem->has($image_path)) { + $this->log->warning("Question image already exists: {$image_path}, skipping"); + continue; + } + + $input_stream = Streams::ofReattachableResource(fopen($source_path, 'rb')); + $this->filesystem->writeStream($image_path, $input_stream); + $this->log->debug("Imported question image: {$source_path} -> {$image_path}"); + + $thumbnail = $this->generateThumbnail($input_stream); + if (!$thumbnail) { + continue; + } + + $thumbnail_path = "{$image_base_path}/thumb.{$envelope->getFilename()}"; + $this->filesystem->writeStream($thumbnail_path, $thumbnail); + $this->log->debug("Generated question image thumbnail: {$thumbnail_path}"); + + $thumbnail->close(); + $input_stream->close(); + } + } + + private function buildImageBasePath(int $parent_obj_id, QuestionImage $envelope, ilImportMapping $mapping): ?string + { + $question_id = $mapping->getMapping($this->component, 'question', (string) $envelope->getQuestionId()); + if (!$question_id) { + $this->log->error("Question ID mapping not found for {$envelope->getQuestionId()}"); + return null; + } + + $subdir = $envelope->getType() === QuestionImage::TYPE_SOLUTION ? 'solution' : 'images'; + return "assessment/{$parent_obj_id}/{$question_id}/{$subdir}"; + } + + + private function generateThumbnail(FileStream $image_stream): ?FileStream + { + $converter = $this->image_converter->thumbnail( + $image_stream, + 100, + new ImageOutputOptions()->withFormat(ImageOutputOptions::FORMAT_KEEP), + ); + + if (!$converter->isOK()) { + $this->log->error("Could not generate thumbnail: {$converter->getThrowableIfAny()?->getMessage()}"); + return null; + } + + return $converter->getStream(); + } + + /** + * Finalize the imported question pages by replacing the old question ids with the new question ids. + */ + public function finalizeQuestionPages(ilImportMapping $mapping): void + { + $page_mappings = $mapping->getMappingsOfEntity('components/ILIAS/COPage', 'pg'); + + foreach ($page_mappings as $old => $new) { + if (!preg_match('/^qpl:(\d+)$/', $old, $old_matches)) { + continue; + } + $old_question_id = $old_matches[1]; + + if (!preg_match('/^qpl:(\d+)$/', $new, $new_matches)) { + continue; + } + $new_question_id = $new_matches[1]; + $this->log->debug("Finalizing question page: {$old_question_id} -> {$new_question_id}"); + + $page = new ilAssQuestionPage((int) $new_question_id); + $xml = preg_replace( + '/il_\d+_qst_' . preg_quote($old_question_id, '/') . '\b/', + "il__qst_{$new_question_id}", + $page->getXMLContent() + ); + if ($xml === null) { + continue; + } + $page->setXMLContent($xml); + + $parent_obj_id = $mapping->getMapping( + $this->component, + 'question_assignment', + $new_question_id + ); + if ($parent_obj_id !== null) { + $page->setParentId((int) $parent_obj_id); + } + + $page->updateFromXML(); + $this->log->debug("Updated question page: {$page->getId()}"); + unset($page); + } + } + + private function storeQuestionMappings( + ilImportMapping $mapping, + int $old_question_id, + int $new_question_id, + int $parent_obj_id, + ): void { + $mapping->addMapping( + $this->component, + 'question', + (string) $old_question_id, + (string) $new_question_id + ); + $mapping->addMapping( + $this->component, + 'question_assignment', + (string) $new_question_id, + (string) $parent_obj_id + ); + $mapping->addMapping( + 'components/ILIAS/Taxonomy', + 'tax_item', + "{$this->parent_type}:quest:{$old_question_id}", + (string) $new_question_id + ); + $mapping->addMapping( + 'components/ILIAS/Taxonomy', + 'tax_item_obj_id', + "{$this->parent_type}:quest:{$old_question_id}", + (string) $parent_obj_id + ); + $mapping->addMapping( + 'components/ILIAS/COPage', + 'pg', + "qpl:{$old_question_id}", + "qpl:{$new_question_id}" + ); + } + + private function importFeedback(Feedback $feedback, assQuestion $question): void + { + $question_id = $question->getId(); + $question->feedbackOBJ->importGenericFeedback($question_id, false, $feedback->getGenericUncompleted()); + $question->feedbackOBJ->importGenericFeedback($question_id, true, $feedback->getGenericCompleted()); + + foreach ($feedback->getSpecificFeedback() as $specific_feedback) { + $question->feedbackOBJ->importSpecificAnswerFeedback( + $question_id, + (int) $specific_feedback['question_index'], + (int) $specific_feedback['answer_index'], + $specific_feedback['feedback'] + ); + } + + $this->log->debug("Imported feedback for question: {$question_id}"); + } + + private function importFormulaQuestion( + array $normalized, + assFormulaQuestion $question, + Transformations $transformations, + ilImportMapping $mapping, + ): void { + $formula = $normalized['formula_data']; + $repository = new ilUnitConfigurationRepository($question->getId()); + + // First, import the unit categories which are referenced by the units + foreach ($formula['categories'] as $normalized_category) { + $category = $transformations->denormalize($normalized_category, new assFormulaQuestionUnitCategory()); + $old_category_id = $category->getId(); + + $repository->saveNewUnitCategory($category); + $this->log->debug("Imported formula question unit category: {$old_category_id} -> {$category->getId()}"); + $mapping->addMapping($this->component, 'unit_category', (string) $old_category_id, (string) $category->getId()); + } + + // Ensure base units are imported first so they can be referenced by the units. The mapping pipe will ensure + // that the category id, question id and base unit id are mapped to the new ids. + $normalized_units = array_merge($formula['base_units'], $formula['units']); + foreach ($normalized_units as $normalized_unit) { + $old_unit_id = $transformations->denormalize($normalized_unit['id'], Id::class)->getId(); + + $unit = new assFormulaQuestionUnit(); + $repository->createNewUnit($unit); + $mapping->addMapping($this->component, 'unit', (string) $old_unit_id, (string) $unit->getId()); + + $unit = $transformations->denormalize($normalized_unit, $unit); + $repository->saveUnit($unit); + $this->log->debug("Imported formula question unit: {$old_unit_id} -> {$unit->getId()}"); + } + + // The question object is denormalized again to ensure the new unit ids are set in the variables and results. + $new_question = $transformations->denormalize($normalized, $question); + + $question->clearVariables(); + foreach ($new_question->getVariables() as $variable) { + $question->addVariable($variable); + } + + $question->clearResults(); + foreach ($new_question->getResults() as $result) { + $question->addResult($result); + } + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/SkillAssignmentsImporter.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/SkillAssignmentsImporter.php new file mode 100644 index 000000000000..47ccd6d6831e --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/SkillAssignmentsImporter.php @@ -0,0 +1,150 @@ +> $normalized_assignments + * @return array{failed: list, success: list} + */ + public function import( + array $normalized_assignments, + int $import_install_id, + Transformations $transformations, + ilImportMapping $mapping, + ): array { + $result = ['failed' => [], 'success' => []]; + + foreach ($normalized_assignments as $item) { + // ParentObjID and QuestionID will be replaced by the mapping pipe + $assignment = $transformations->denormalize($item, ilAssQuestionSkillAssignment::class); + + $skill_data = $this->getSkillIdMapping( + $assignment->getSkillBaseId(), + $assignment->getSkillTrefId(), + $import_install_id + ); + if ($skill_data === null) { + $this->log->warning("Failed to find skill id mapping for assignment: {$assignment->getSkillBaseId()}/{$assignment->getSkillTrefId()}"); + $result['failed'][] = $this->buildResultData($assignment); + continue; + } + + $mapping->addMapping( + $this->component, + 'skill_base', + (string) $assignment->getSkillBaseId(), + (string) $skill_data['skill_id'] + ); + $mapping->addMapping( + $this->component, + 'skill_tref', + (string) $assignment->getSkillTrefId(), + (string) $skill_data['tref_id'] + ); + $assignment->setSkillBaseId($skill_data['skill_id']); + $assignment->setSkillTrefId($skill_data['tref_id']); + $this->log->debug("Found skill assignment: {$assignment->getSkillBaseId()}/{$assignment->getSkillTrefId()} -> {$skill_data['skill_id']}/{$skill_data['tref_id']}"); + + $assignment->initSolutionComparisonExpressionList(); + foreach ($assignment->getSolutionComparisonExpressionList()->get() as $expression) { + $expression->setSkillBaseId($assignment->getSkillBaseId()); + $expression->setSkillTrefId($assignment->getSkillTrefId()); + } + + $assignment->saveToDb(); + $assignment->saveComparisonExpressions(); + $this->log->debug("Saved skill assignment: {$assignment->getSkillBaseId()}/{$assignment->getSkillTrefId()}"); + + $this->skill_usage_service->addUsage( + $assignment->getParentObjId(), + $assignment->getSkillBaseId(), + $assignment->getSkillTrefId() + ); + + $result['success'][] = $this->buildResultData($assignment); + } + + return $result; + } + + protected function getSkillIdMapping(int $skill_base_id, int $skill_tref_id, int $import_install_id): ?array + { + if ($import_install_id === $this->local_install_id) { + return [ + 'skill_id' => $skill_base_id, + 'tref_id' => $skill_tref_id, + ]; + } + + $found_skill_data = $this->skill_repo->getCommonSkillIdForImportId( + $import_install_id, + $skill_base_id, + $skill_tref_id + ); + + $skill_data = current($found_skill_data); + if (!is_array($skill_data) || !isset($skill_data['skill_id']) || !isset($skill_data['tref_id'])) { + return null; + } + + return $skill_data; + } + + /** + * @return ImportResultData + */ + protected function buildResultData(ilAssQuestionSkillAssignment $assignment): array + { + return [ + 'skill_id' => $assignment->getSkillBaseId(), + 'tref_id' => $assignment->getSkillTrefId(), + 'title' => $assignment->getSkillTitle(), + 'path' => $assignment->getSkillPath(), + ]; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/UploadValidationStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/UploadValidationStage.php new file mode 100644 index 000000000000..4a26199c8a5c --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/UploadValidationStage.php @@ -0,0 +1,115 @@ +lng->txt('upload'); + } + + public function getDescription(): ?string + { + return ''; + } + + public function process(ImportContext $context): StageResult + { + $file_to_import = $context->get(self::FILE_TO_IMPORT); + if ( + $file_to_import === null + || !is_file($file_to_import) + || !str_ends_with(strtolower($file_to_import), '.zip') + ) { + $this->log->error("Invalid import file: {$file_to_import}"); + return StageResult::error($context, $this->lng->txt('obj_import_file_error')); + } + + $subdir = basename($file_to_import, '.zip'); + $import_base_dir = self::IMPORT_TEMP_DIR . DIRECTORY_SEPARATOR . $subdir; + + $options = (new UnzipOptions())->withZipOutputPath(self::IMPORT_TEMP_DIR); + $unzip = $this->archives->unzip(Streams::ofResource(fopen($file_to_import, 'r')), $options); + $unzip->extract(); + $this->log->info("Extracted import file: {$file_to_import} -> {$import_base_dir}"); + + $manifest = new ilManifestParser($import_base_dir . DIRECTORY_SEPARATOR . 'manifest.xml'); + $export_file = array_find( + $manifest->getExportFiles(), + fn($file): bool => $file['component'] === $this->component + ); + + if ($export_file === null) { + $this->log->error("No export file found for component: {$this->component}"); + return StageResult::error($context, $this->lng->txt('obj_import_file_error')); + } + + $component_import_file = $import_base_dir . DIRECTORY_SEPARATOR . $export_file['path']; + $this->log->info("Found export file for {$this->component}: -> {$component_import_file}"); + $this->log->info("Found valid export file from installation: {$manifest->getInstallId()}"); + + return StageResult::advance( + $context->with(self::COMPONENT_IMPORT_FILE, $component_import_file) + ->with(self::IMPORT_BASE_DIR, $import_base_dir) + ->with(self::INSTALL_ID, $manifest->getInstallId()) + ); + } + + public static function getInstallId(ImportContext $context): int + { + return intval($context->get(self::INSTALL_ID)); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/LoggingProvider.php b/components/ILIAS/TestQuestionPool/src/ExportImport/LoggingProvider.php new file mode 100644 index 000000000000..e0b39c6bd0dc --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/LoggingProvider.php @@ -0,0 +1,43 @@ +getLogger(); + } + + public function __invoke() + { + return $this->getLogger(); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/SuggestedSolutionNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/SuggestedSolutionNormalizer.php new file mode 100644 index 000000000000..3f962e121309 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/SuggestedSolutionNormalizer.php @@ -0,0 +1,139 @@ + + */ +#[Normalizes(SuggestedSolution::class)] +class SuggestedSolutionNormalizer implements Normalizer +{ + public function __construct( + private readonly Transformations $tt, + ) { + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if (!$value instanceof SuggestedSolution) { + throw new NormalizingException('Invalid value', $value); + } + + $normalized = [ + 'id' => $this->tt->normalize(new Id($value->getId(), 'suggested_solution')), + 'question_id' => $this->tt->normalize(new Id($value->getQuestionId(), 'question')), + 'subquestion_index' => $value->getSubquestionIndex(), + 'import_id' => $value->getImportId(), + 'last_update' => $this->tt->normalize($value->getLastUpdate()), + ]; + + if ($value instanceof SuggestedSolutionLink) { + $normalized['type'] = $value->getType(); + $normalized['internal_link'] = $value->getInternalLink(); + } + + if ($value instanceof SuggestedSolutionFile) { + $normalized['type'] = $value->getType(); + $normalized['title'] = $value->getTitle(); + $normalized['mime'] = $value->getMime(); + $normalized['size'] = $value->getSize(); + + $normalized['file'] = $this->tt->normalize( + new QuestionImage($value->getFilename(), $value->getQuestionId(), QuestionImage::TYPE_SOLUTION) + ); + + } + + return $normalized; + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): SuggestedSolution + { + if ($type !== SuggestedSolution::class && !in_array(SuggestedSolution::class, class_parents($type))) { + throw new NormalizingException("Invalid type: {$type}"); + } + + // If abstract class expected we need to lookup the concrete type from the normalized value + $denormalized_type = $this->tt->string($value['type']); + if ($type === SuggestedSolution::class) { + $type = match($denormalized_type) { + SuggestedSolution::TYPE_FILE => SuggestedSolutionFile::class, + SuggestedSolution::TYPE_LM => SuggestedSolutionLink::class, + SuggestedSolution::TYPE_LM_CHAPTER => SuggestedSolutionLink::class, + SuggestedSolution::TYPE_LM_PAGE => SuggestedSolutionLink::class, + SuggestedSolution::TYPE_GLOSARY_TERM => SuggestedSolutionLink::class, + default => throw new NormalizingException("Invalid denormalized type: {$denormalized_type}"), + }; + } + + $id = $this->tt->denormalize($value['id'], Id::class)->getId(); + $question_id = $this->tt->denormalize($value['question_id'], Id::class)->getId(); + $subquestion_index = $this->tt->int($value['subquestion_index']); + $import_id = $this->tt->string($value['import_id']); + $last_update = $this->tt->denormalize($value['last_update'], DateTimeImmutable::class); + + switch ($type) { + case SuggestedSolutionFile::class: + return new SuggestedSolutionFile( + $id, + $question_id, + $subquestion_index, + $import_id, + $last_update, + $denormalized_type, + '' + ) + ->withTitle($this->tt->string($value['title'])) + ->withFilename($this->tt->denormalize($value['file'], QuestionImage::class)->getFilename()) + ->withMime($this->tt->string($value['mime'])) + ->withSize($this->tt->int($value['size'])); + case SuggestedSolutionLink::class: + return new SuggestedSolutionLink( + $id, + $question_id, + $subquestion_index, + $import_id, + $last_update, + $denormalized_type, + $this->tt->string($value['internal_link']) + ); + default: + throw new NormalizingException("Invalid type: {$type}"); + } + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/ilAssQuestionSkillAssignmentNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/ilAssQuestionSkillAssignmentNormalizer.php new file mode 100644 index 000000000000..f8ea08567123 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/ilAssQuestionSkillAssignmentNormalizer.php @@ -0,0 +1,145 @@ + + */ +#[Normalizes(ilAssQuestionSkillAssignment::class)] +class ilAssQuestionSkillAssignmentNormalizer implements Normalizer +{ + private readonly ilDBInterface $db; + + public function __construct( + private readonly Transformations $tt, + Container $dic + ) { + $this->db = $dic->database(); + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if (!$value instanceof ilAssQuestionSkillAssignment) { + throw new NormalizingException('Invalid value', $value); + } + + $normalized = [ + 'parent_id' => $this->tt->normalize(new Id($value->getParentObjId(), 'object')), + 'question_id' => $this->tt->normalize(new Id($value->getQuestionId(), 'question')), + 'base_id' => $this->tt->normalize(new Id($value->getSkillBaseId(), 'skill_base')), + 'tref_id' => $this->tt->normalize(new Id($value->getSkillTrefId(), 'skill_tref')), + 'original_title' => $value->getSkillTitle(), + 'original_path' => $value->getSkillPath(), + 'eval_mode' => $value->getEvalMode(), + ]; + + switch ($value->getEvalMode()) { + case ilAssQuestionSkillAssignment::EVAL_MODE_BY_QUESTION_RESULT: + $normalized['points'] = $value->getSkillPoints(); + break; + + case ilAssQuestionSkillAssignment::EVAL_MODE_BY_QUESTION_SOLUTION: + $normalized['solution_comparison_expressions'] = $this->normalizeExpressionList($value); + break; + } + return $normalized; + } + + private function normalizeExpressionList(ilAssQuestionSkillAssignment $value): array + { + $value->initSolutionComparisonExpressionList(); + + $list = []; + foreach ($value->getSolutionComparisonExpressionList()->get() as $expression) { + $list[] = [ + 'points' => $expression->getPoints(), + 'expression' => $expression->getExpression(), + 'order_index' => $expression->getOrderIndex(), + ]; + } + + return $list; + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): ilAssQuestionSkillAssignment + { + if ($type !== ilAssQuestionSkillAssignment::class) { + throw new NormalizingException("Invalid type for ilAssQuestionSkillAssignment: {$type}"); + } + + $assignment = new ilAssQuestionSkillAssignment($this->db); + $assignment->setParentObjId($this->tt->denormalize($value['parent_id'], Id::class)->getId()); + $assignment->setQuestionId($this->tt->denormalize($value['question_id'], Id::class)->getId()); + $assignment->setSkillBaseId($this->tt->denormalize($value['base_id'], Id::class)->getId()); + $assignment->setSkillTrefId($this->tt->denormalize($value['tref_id'], Id::class)->getId()); + $assignment->setSkillTitle($this->tt->string($value['original_title'])); + $assignment->setSkillPath($this->tt->string($value['original_path'])); + $assignment->setEvalMode($this->tt->string($value['eval_mode'])); + $assignment->initSolutionComparisonExpressionList(); + + switch ($assignment->getEvalMode()) { + case ilAssQuestionSkillAssignment::EVAL_MODE_BY_QUESTION_RESULT: + $assignment->setSkillPoints($this->tt->int($value['points'])); + break; + + case ilAssQuestionSkillAssignment::EVAL_MODE_BY_QUESTION_SOLUTION: + $list = $assignment->getSolutionComparisonExpressionList(); + foreach ($value['solution_comparison_expressions'] as $normalized) { + $list->add($this->denormalizeExpression($normalized, $assignment)); + } + break; + } + + return $assignment; + } + + private function denormalizeExpression( + array $normalized, + ilAssQuestionSkillAssignment $assignment + ): ilAssQuestionSolutionComparisonExpression { + $expression = new ilAssQuestionSolutionComparisonExpression(); + $expression->setQuestionId($assignment->getQuestionId()); + $expression->setSkillBaseId($assignment->getSkillBaseId()); + $expression->setSkillTrefId($assignment->getSkillTrefId()); + + $expression->setOrderIndex($this->tt->int($normalized['order_index'])); + $expression->setExpression($this->tt->string($normalized['expression'])); + $expression->setPoints($this->tt->int($normalized['points'])); + + return $expression; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/ilObjQuestionPoolNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/ilObjQuestionPoolNormalizer.php new file mode 100644 index 000000000000..d4cfb429eadd --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/ilObjQuestionPoolNormalizer.php @@ -0,0 +1,65 @@ + + */ +#[Normalizes(ilObjQuestionPool::class)] +class ilObjQuestionPoolNormalizer extends IlObjectNormalizer implements Normalizer +{ + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if (!$value instanceof ilObjQuestionPool) { + throw new NormalizingException('Invalid value', $value); + } + + $normalized = parent::normalize($value); + $normalized['skill_service_enabled'] = $value->isSkillServiceEnabled(); + + return $normalized; + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): ilObjQuestionPool + { + if ($type !== ilObjQuestionPool::class) { + throw new NormalizingException("Invalid type for ilObjQuestionPool: {$type}"); + } + + /** @var ilObjQuestionPool $object */ + $object = parent::denormalize($value, ilObjQuestionPool::class); + $object->setSkillServiceEnabled($this->tt->bool($value['skill_service_enabled'])); + + return $object; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Pipes/CollectQuestionImages.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Pipes/CollectQuestionImages.php new file mode 100644 index 000000000000..b32beb3e1d88 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Pipes/CollectQuestionImages.php @@ -0,0 +1,121 @@ + $files + */ + private array $files = []; + + /** + * @var array $envelopes + */ + private array $envelopes = []; + + public function __construct( + private readonly Factory $uuid_factory, + private readonly ObjectId $pool_id, + ) { + $this->question_files = new QuestionFiles(); + } + + /** + * @inheritDoc + */ + public function handle(mixed $passable, \Closure $next): mixed + { + if ($passable instanceof NormalizeCarry && $passable->value instanceof QuestionImage) { + $this->handleNormalization($passable->value); + } + + if ($passable instanceof DenormalizeCarry && $passable->expected === QuestionImage::class) { + $this->handleDenormalization($passable); + } + + return $next($passable); + } + + private function handleNormalization(QuestionImage $envelope): void + { + $pool_id = $this->pool_id->toInt(); + + $base_dir = $envelope->getType() === QuestionImage::TYPE_ANSWER + ? $this->question_files->buildImagePath($envelope->getQuestionId(), $pool_id) + : $this->question_files->buildSolutionPath($envelope->getQuestionId(), $pool_id); + + $source_path = $base_dir . $envelope->getFilename(); + + // Generate a unique ID for the image and set it on the envelope and the relative target path + $id = $this->uuid_factory->uuid4(); + $envelope->setId($id->toString()); + + $extension = pathinfo($envelope->getFilename(), PATHINFO_EXTENSION); + $target_path = $id->toString() . '.' . $extension; + + $this->files[] = ['from' => $source_path, 'to' => $target_path]; + } + + private function handleDenormalization(DenormalizeCarry $passable): void + { + $envelope = $passable->result(); + if (!$envelope instanceof QuestionImage) { + throw new NormalizingException('Expected question image envelope, got ' . get_debug_type($envelope)); + } + + $extension = pathinfo($envelope->getFilename(), PATHINFO_EXTENSION); + $path = $envelope->getId() . '.' . $extension; + + $this->envelopes[$path] = $envelope; + } + + /** + * @return list + */ + public function getFiles(): array + { + return $this->files; + } + + /** + * @return array + */ + public function getEnvelopes(): array + { + return $this->envelopes; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php b/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php index f2cce4c0a366..18dcd71d7c64 100755 --- a/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php +++ b/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php @@ -20,6 +20,15 @@ namespace ILIAS\TestQuestionPool; +use ILIAS\Data\Factory as DataFactory; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Bridge\StateHolder; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Builder; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportSessionRepository; +use ILIAS\TestQuestionPool\ExportImport\Export\QuestionPoolExporter; +use ILIAS\TestQuestionPool\ExportImport\Import\QuestionPoolImporter; +use ILIAS\TestQuestionPool\ExportImport\Import\QuestionsImporter; +use ILIAS\TestQuestionPool\ExportImport\Import\SkillAssignmentsImporter; +use ILIAS\TestQuestionPool\ExportImport\LoggingProvider; use Pimple\Container as PimpleContainer; use ILIAS\DI\Container as ILIASContainer; use ILIAS\TestQuestionPool\Questions\SuggestedSolution\SuggestedSolutionsDatabaseRepository; @@ -67,6 +76,54 @@ protected static function buildDIC(ILIASContainer $DIC): self $dic['global_test_settings'] = static fn($c): GlobalTestSettings => (new GlobalTestSettingsRepository($DIC['ilSetting'], new \ilSetting('assessment')))->getGlobalSettings(); + $dic['exportimport.logging'] = static fn($c): LoggingProvider => + new LoggingProvider(); + $dic['exportimport.builder'] = static fn($c): Builder => + new Builder( + $DIC, + $c + ); + $dic['exportimport.state_holder'] = static fn($c): StateHolder => + new StateHolder(); + $dic['exportimport.exporter'] = static fn($c): QuestionPoolExporter => + new QuestionPoolExporter( + $c['exportimport.builder'], + new DataFactory(), + $c['question.general_properties.repository'], + $DIC->database(), + $DIC->taxonomy()->domain() + ); + + $dic['exportimport.session'] = static fn($c): ImportSessionRepository => + new ImportSessionRepository('qpl'); + $dic['exportimport.skill_assignments_importer'] = static fn($c): SkillAssignmentsImporter => + new SkillAssignmentsImporter( + $c['exportimport.logging'](), + $DIC->skills()->internal()->repo()->getTreeRepo(), + $DIC->skills()->usage(), + 'components/ILIAS/TestQuestionPool', + (int) $DIC->settings()->get('inst_id', '0') + ); + $dic['exportimport.questions_importer'] = static fn($c): QuestionsImporter => + new QuestionsImporter( + 'components/ILIAS/TestQuestionPool', + 'qpl', + $DIC->ctrl(), + $DIC->database(), + $DIC->language(), + $c['exportimport.logging'](), + $DIC->fileConverters()->images(), + $DIC->filesystem() + ); + $dic['exportimport.importer'] = static fn($c): QuestionPoolImporter => + new QuestionPoolImporter( + $c['exportimport.builder'], + $c['exportimport.logging'](), + new DataFactory(), + $c['exportimport.questions_importer'], + $c['exportimport.skill_assignments_importer'] + ); + return $dic; } } diff --git a/components/ILIAS/TestQuestionPool/src/Questions/Files/QuestionFiles.php b/components/ILIAS/TestQuestionPool/src/Questions/Files/QuestionFiles.php index 9c515f468ae3..0740c6e5f5b8 100755 --- a/components/ILIAS/TestQuestionPool/src/Questions/Files/QuestionFiles.php +++ b/components/ILIAS/TestQuestionPool/src/Questions/Files/QuestionFiles.php @@ -90,4 +90,9 @@ public function buildImagePath($questionId, $parentObjectId): string { return CLIENT_WEB_DIR . '/assessment/' . $parentObjectId . '/' . $questionId . '/images/'; } + + public function buildSolutionPath($questionId, $parentObjectId): string + { + return CLIENT_WEB_DIR . '/assessment/' . $parentObjectId . '/' . $questionId . '/solution/'; + } } diff --git a/components/ILIAS/TestQuestionPool/tests/ilQuestionpoolExportTest.php b/components/ILIAS/TestQuestionPool/tests/ilQuestionpoolExportTest.php deleted file mode 100644 index 76eb801d3b85..000000000000 --- a/components/ILIAS/TestQuestionPool/tests/ilQuestionpoolExportTest.php +++ /dev/null @@ -1,48 +0,0 @@ - -* -* @ingroup components\ILIASTestQuestionPool -* -* This test was automatically generated. -*/ -class ilQuestionpoolExportTest extends assBaseTestCase -{ - protected $backupGlobals = false; - - private ilQuestionpoolExport $object; - - protected function setUp(): void - { - parent::setUp(); - - $this->addGlobal_ilErr(); - $this->addGlobal_ilias(); - - $this->object = new ilQuestionpoolExport($this->createMock(ilObjQuestionPool::class), 'xml', null); - } - - public function testConstruct(): void - { - $this->assertInstanceOf(ilQuestionpoolExport::class, $this->object); - } -} diff --git a/lang/ilias_de.lang b/lang/ilias_de.lang index ec6d7552ec0e..d4d012738ebe 100644 --- a/lang/ilias_de.lang +++ b/lang/ilias_de.lang @@ -1066,6 +1066,8 @@ assessment#:#qpl_imagemap_preview_missing#:#ILIAS konnte keine Vorschaudatei mit assessment#:#qpl_import_create_new_qpl#:#In einen neuen Fragenpool importieren assessment#:#qpl_import_no_items#:#Fehler: die Importdatei enthält keine Fragen! assessment#:#qpl_import_non_ilias_files#:#Fehler: Die Importdatei enthält QTI-Dateien, die nicht von einem ILIAS-System erstellt wurden. Bitte kontaktieren Sie das ILIAS-Team, um einen Importfilter für Ihr QTI-Dateiformat zu bekommen. +assessment#:#qpl_import_step_persist#:#Fragenpool in ILIAS importieren +assessment#:#qpl_import_step_select#:#Fragen auswählen assessment#:#qpl_import_verify_found_questions#:#ILIAS hat die folgenden Fragen in der Importdatei gefunden. Bitte wählen Sie die Fragen aus, die Sie importieren wollen. assessment#:#qpl_lac_desc_brackets#:#Klammerung assessment#:#qpl_lac_desc_compare_answer_exist#:#Vergleiche ob für eine Frage/Lücke einer Frage keine Antwort gegeben wurde diff --git a/lang/ilias_en.lang b/lang/ilias_en.lang index 404b0f4aa53b..42467261257a 100644 --- a/lang/ilias_en.lang +++ b/lang/ilias_en.lang @@ -1066,6 +1066,8 @@ assessment#:#qpl_imagemap_preview_missing#:#ILIAS could not create the temporary assessment#:#qpl_import_create_new_qpl#:#Import the questions in a new question pool assessment#:#qpl_import_no_items#:#Error: The import file contains no questions! assessment#:#qpl_import_non_ilias_files#:#Error: The import file contains QTI files which are not created by an ILIAS system. Please contact the ILIAS team to get in import filter for your QTI file format. +assessment#:#qpl_import_step_persist#:#Import question pool into ILIAS +assessment#:#qpl_import_step_select#:#Select questions to import assessment#:#qpl_import_verify_found_questions#:#ILIAS found the following questions in the import file. Please select the questions you want to import. assessment#:#qpl_lac_desc_brackets#:#Brackets assessment#:#qpl_lac_desc_compare_answer_exist#:#Compare if there is an Answer for a Question/Gap