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/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.ilObjTest.php b/components/ILIAS/Test/classes/class.ilObjTest.php index 46b85830e5a1..76960257d062 100755 --- a/components/ILIAS/Test/classes/class.ilObjTest.php +++ b/components/ILIAS/Test/classes/class.ilObjTest.php @@ -407,36 +407,6 @@ public function getExportFiles(string $dir = ''): array return $files; } - /** - * creates data directory for import files - * (data_dir/tst_data/tst_/import, depending on data - * directory that is set in ILIAS setup/ini) - */ - public static function _createImportDirectory(): string - { - global $DIC; - $ilias = $DIC['ilias']; - $tst_data_dir = ilFileUtils::getDataDir() . "/tst_data"; - ilFileUtils::makeDir($tst_data_dir); - - if (!is_writable($tst_data_dir)) { - $ilias->raiseError("Test Data Directory (" . $tst_data_dir - . ") not writeable.", $ilias->error_obj->FATAL); - } - - // create test directory (data_dir/tst_data/tst_import) - $tst_dir = $tst_data_dir . "/tst_import"; - ilFileUtils::makeDir($tst_dir); - if (!@is_dir($tst_dir)) { - $ilias->raiseError("Creation of test import directory failed.", $ilias->error_obj->FATAL); - } - - // assert that this is empty and does not contain old data - ilFileUtils::delDir($tst_dir, true); - - return $tst_dir; - } - final public function isComplete(ilTestQuestionSetConfig $test_question_set_config): bool { if ($this->getMarkSchema() === null @@ -2798,8 +2768,7 @@ public function getAvailableQuestions($arr_filter, $completeonly = 0): array } /** - * Receives parameters from a QTI parser and creates a valid ILIAS test object - * @param ilQTIAssessment $assessment + * @deprecated 12: Use ILIAS\Test\ExportImport\TestImporter instead */ public function fromXML(ilQTIAssessment $assessment, array $mappings): void { diff --git a/components/ILIAS/Test/classes/class.ilObjTestGUI.php b/components/ILIAS/Test/classes/class.ilObjTestGUI.php index ceebd6d9f0d7..d79cab256267 100755 --- a/components/ILIAS/Test/classes/class.ilObjTestGUI.php +++ b/components/ILIAS/Test/classes/class.ilObjTestGUI.php @@ -18,13 +18,44 @@ declare(strict_types=1); -use ILIAS\Test\Logging\AdditionalInformationGenerator; +use ILIAS\Data\Factory as DataFactory; +use ILIAS\Filesystem\Util\Archive\Archives; +use ILIAS\GlobalScreen\Services as GlobalScreen; +use ILIAS\ResourceStorage\Services as IRSS; +use ILIAS\Skill\Service\SkillService; use ILIAS\Skill\Service\SkillUsageService; +use ILIAS\Style\Content\Service as ContentStyle; +use ILIAS\Taxonomy\DomainService as TaxonomyService; +use ILIAS\Test\ExportImport\DBRepository as ExportRepository; +use ILIAS\Test\ExportImport\Factory as ExportImportFactory; +use ILIAS\Test\ExportImport\Import\PersistStage; +use ILIAS\Test\GUIFactory; +use ILIAS\Test\Logging\AdditionalInformationGenerator; +use ILIAS\Test\Logging\LogTable; +use ILIAS\Test\Logging\TestAdministrationInteractionTypes; +use ILIAS\Test\Logging\TestQuestionAdministrationInteractionTypes; +use ILIAS\Test\Participants\ParticipantRepository; +use ILIAS\Test\Presentation\TabsManager; +use ILIAS\Test\Presentation\TestScreenGUI; +use ILIAS\Test\Questions\Presentation\Printer as QuestionPrinter; +use ILIAS\Test\Questions\Presentation\QuestionsTable; +use ILIAS\Test\Questions\Presentation\QuestionsTableActions; +use ILIAS\Test\Questions\Presentation\QuestionsTableQuery; +use ILIAS\Test\Questions\Properties\Repository as TestQuestionsRepository; +use ILIAS\Test\RequestDataCollector; +use ILIAS\Test\ResponseHandler; +use ILIAS\Test\Results\Data\Factory as ResultsDataFactory; use ILIAS\Test\Results\Data\Repository as TestResultRepository; +use ILIAS\Test\Results\Presentation\Factory as ResultsPresentationFactory; +use ILIAS\Test\Results\Toplist\TestTopListRepository; +use ILIAS\Test\Scoring\Manual\ConsecutiveScoringGUI; use ILIAS\Test\Scoring\Marks\MarkSchemaFactory; +use ILIAS\Test\Scoring\Marks\MarkSchemaGUI; use ILIAS\Test\Scoring\Marks\MarksRepository; use ILIAS\Test\Settings\MainSettings\MainSettingsRepository; +use ILIAS\Test\Settings\MainSettings\SettingsMainGUI; use ILIAS\Test\Settings\ScoreReporting\ScoreSettingsRepository; +use ILIAS\Test\Settings\ScoreReporting\SettingsScoringGUI; use ILIAS\Test\Settings\SettingsFactory; use ILIAS\Test\Settings\SettingsNotFoundException; use ILIAS\Test\Settings\Templates\PersonalSettingsCreateAction; @@ -38,49 +69,28 @@ use ILIAS\Test\Settings\Templates\PersonalSettingsTableExportAction; use ILIAS\Test\Settings\Templates\PersonalSettingsTableShowAction; use ILIAS\Test\TestDIC; -use ILIAS\Test\RequestDataCollector; -use ILIAS\Test\ResponseHandler; use ILIAS\Test\Utilities\TitleColumnsBuilder; -use ILIAS\Test\Questions\Presentation\QuestionsTable; -use ILIAS\Test\Questions\Presentation\QuestionsTableQuery; -use ILIAS\Test\Questions\Presentation\QuestionsTableActions; -use ILIAS\Test\Questions\Presentation\Printer as QuestionPrinter; -use ILIAS\Test\Questions\Properties\Repository as TestQuestionsRepository; -use ILIAS\Test\Participants\ParticipantRepository; -use ILIAS\Test\Settings\MainSettings\SettingsMainGUI; -use ILIAS\Test\Settings\ScoreReporting\SettingsScoringGUI; -use ILIAS\Test\Scoring\Marks\MarkSchemaGUI; -use ILIAS\Test\Scoring\Manual\ConsecutiveScoringGUI; -use ILIAS\Test\Logging\LogTable; -use ILIAS\Test\Logging\TestQuestionAdministrationInteractionTypes; -use ILIAS\Test\Logging\TestAdministrationInteractionTypes; -use ILIAS\Test\Presentation\TestScreenGUI; -use ILIAS\Test\Presentation\TabsManager; -use ILIAS\Test\Results\Data\Factory as ResultsDataFactory; -use ILIAS\Test\Results\Presentation\Factory as ResultsPresentationFactory; -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\Import\TestQuestionsImportTrait; use ILIAS\TestQuestionPool\Questions\GeneralQuestionPropertiesRepository; use ILIAS\TestQuestionPool\RequestDataCollector as QPLRequestDataCollector; -use ILIAS\TestQuestionPool\Import\TestQuestionsImportTrait; -use ILIAS\Data\Factory as DataFactory; -use ILIAS\UI\Component\Modal\Modal; -use ILIAS\UI\URLBuilder; use ILIAS\UI\Component\Input\Container\Form\Form; -use ILIAS\UI\Component\Input\Input; -use ILIAS\UI\Component\Input\Field\Select; use ILIAS\UI\Component\Input\Field\Radio; +use ILIAS\UI\Component\Input\Field\Select; use ILIAS\UI\Component\Input\Field\SwitchableGroup; -use ILIAS\GlobalScreen\Services as GlobalScreen; -use ILIAS\Filesystem\Stream\Streams; -use ILIAS\Filesystem\Util\Archive\Archives; -use ILIAS\Skill\Service\SkillService; -use ILIAS\ResourceStorage\Services as IRSS; -use ILIAS\Taxonomy\DomainService as TaxonomyService; -use ILIAS\Style\Content\Service as ContentStyle; +use ILIAS\UI\Component\Input\Input; +use ILIAS\UI\Component\Modal\Modal; +use ILIAS\UI\URLBuilder; +use ILIAS\UICore\GlobalTemplate; 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())) { @@ -363,8 +377,6 @@ public function executeCommand(): void $this->ui_renderer, $this->irss, $this->request, - $this->export_repository, - $this->temp_file_system, $this->participant_access_filter_factory, $this->test_pass_result_repository, new ilTestHTMLGenerator() @@ -1366,81 +1378,121 @@ 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); + $result = $this->buildImportStageRunner()->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( + GlobalTemplate::MESSAGE_TYPE_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, + [] + ) + ); + } + + $question_skill_assignments_import_fails = new ilAssQuestionSkillAssignmentImportFails($new_obj->getId()); + if ($question_skill_assignments_import_fails->failedImportsRegistered()) { + $this->tpl->setOnScreenMessage( + GlobalTemplate::MESSAGE_TYPE_INFO, + $question_skill_assignments_import_fails->getFailedImportsMessage($this->lng), + true + ); } - $panel = $this->ui_factory->panel()->standard( - $this->lng->txt('import_tst'), + $this->tpl->setOnScreenMessage( + GlobalTemplate::MESSAGE_TYPE_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 + { + 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, + $this->ctrl->getFormActionByClass(self::class, 'processImport'), + $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 @@ -1482,108 +1534,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 - */ - public function downloadFileObject() - { - $file = explode("_", $this->testrequest->raw("file_id")); - $fileObj = new ilObjFile((int) $file[count($file) - 1], false); - $fileObj->sendFile(); - exit; - } - /** * show fullscreen view */ @@ -2090,22 +2040,6 @@ public function createTemplateObject(): void $this->ctrl->redirectByClass(self::class, 'showTemplates'); } - public function importTemplateObject(): void - { - $this->protectByWritePermission(); - - try { - $this->buildPersonalSettingsImportAction() - ->perform($this->request); - - $this->tpl->setOnScreenMessage('success', $this->lng->txt('personal_settings_import_success'), true); - } catch (\InvalidArgumentException $e) { - $this->tpl->setOnScreenMessage('failure', $this->lng->txt($e->getMessage()), true); - } - - $this->ctrl->redirectByClass(self::class, 'showTemplates'); - } - public function executeTemplatesActionObject(): void { $this->protectByWritePermission(); @@ -2307,16 +2241,6 @@ private function forwardToInfoScreen(): void $this->ctrl->forwardCommand($info); } - protected function removeImportFailsObject() - { - $qsaImportFails = new ilAssQuestionSkillAssignmentImportFails($this->getTestObject()->getId()); - $qsaImportFails->deleteRegisteredImportFails(); - $sltImportFails = new ilTestSkillLevelThresholdImportFails($this->getTestObject()->getId()); - $sltImportFails->deleteRegisteredImportFails(); - - $this->ctrl->redirectByClass([ilRepositoryGUI::class, self::class, ilInfoScreenGUI::class]); - } - public function addLocatorItems(): void { switch ($this->ctrl->getCmd()) { @@ -2366,6 +2290,7 @@ public function addLocatorItems(): void ); break; case "importFile": + case "processImport": case "cloneAll": case "importVerifiedFile": case "cancelImport": diff --git a/components/ILIAS/Test/classes/class.ilObjTestXMLParser.php b/components/ILIAS/Test/classes/class.ilObjTestXMLParser.php index 040d72b5e6af..bf86e7f42f4d 100755 --- a/components/ILIAS/Test/classes/class.ilObjTestXMLParser.php +++ b/components/ILIAS/Test/classes/class.ilObjTestXMLParser.php @@ -23,10 +23,7 @@ use ILIAS\Test\Logging\TestLogger; /** - * @author Björn Heyser - * @version $Id$ - * - * @package components\ILIAS/Test + * @deprecated 12: Use ILIAS\Test\ExportImport\TestImporter instead */ class ilObjTestXMLParser extends ilSaxParser { diff --git a/components/ILIAS/Test/classes/class.ilTestExportGUI.php b/components/ILIAS/Test/classes/class.ilTestExportGUI.php index 88e324fde351..41b88220a6a3 100755 --- a/components/ILIAS/Test/classes/class.ilTestExportGUI.php +++ b/components/ILIAS/Test/classes/class.ilTestExportGUI.php @@ -19,14 +19,10 @@ declare(strict_types=1); 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; /** @@ -47,8 +43,6 @@ public function __construct( private readonly UIRenderer $ui_renderer, private readonly IRSS $irss, private readonly ServerRequestInterface $request, - private readonly DBRepository $export_repository, - private readonly Filesystem $temp_file_system, private readonly ilTestParticipantAccessFilterFactory $participant_access_filter_factory, private readonly TestResultsRepository $test_results_repository, private readonly ilTestHTMLGenerator $html_generator @@ -56,62 +50,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..9dbcb826d5ec 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()) { + $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..e4ffba4da970 100755 --- a/components/ILIAS/Test/classes/class.ilTestImporter.php +++ b/components/ILIAS/Test/classes/class.ilTestImporter.php @@ -18,38 +18,39 @@ 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\Test\TestDIC; -use ILIAS\Test\Logging\TestLogger; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportSessionRepository; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Serializing\XMLMemoryDeserializer; +use ILIAS\TestQuestionPool\ExportImport\Import\DetectLegacyImportStage; -/** - * 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(); + + $local_dic = TestDIC::dic(); + $this->session = $local_dic['exportimport.session']; + $this->importer = $local_dic['exportimport.importer']; + + $this->legacy_importer = new ilTestLegacyImporter(); + } + + 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 +59,60 @@ 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); + // 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)) { - $this->logger->error(__METHOD__ . ': Cannot find xml definition: ' . $qtifile); - 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 - ) - ); + $this->importer->finalize($a_mapping); + $this->finalizeTaxonomyUsage($a_mapping); + } - $src_pool_def_list->loadDefinitions(); + private function finalizeTaxonomyUsage(ilImportMapping $a_mapping): void + { + $tst_mappings = $a_mapping->getMappingsOfEntity('components/ILIAS/Test', 'tst'); - foreach ($src_pool_def_list as $definition) { - $mapped_taxonomy_filter = $definition->getMappedTaxonomyFilter(); - if ($mapped_taxonomy_filter === []) { + foreach ($tst_mappings as $old => $new) { + if ($old === 'new_id' || (int) $old <= 0) { 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( + $new_tax_ids = $a_mapping->getMapping( 'components/ILIAS/Taxonomy', - 'tax', - (string) $tax_id + 'tax_usage_of_obj', + (string) $old ); - 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; + 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..5a7f54645879 --- /dev/null +++ b/components/ILIAS/Test/classes/class.ilTestLegacyImporter.php @@ -0,0 +1,339 @@ +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(); + $new_obj->createReference(); + + $ref_id = $this->request_data_collector->getRefId(); + $new_obj->putInTree($ref_id); + $new_obj->setPermissions($ref_id); + $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::$finally_processed_tests_registry[$new])) { + continue; + } + + /** @var ?ilObjTest $test_obj */ + $test_obj = ilObjectFactory::getInstanceByObjId((int) $new, false); + if ($test_obj->isRandomTest()) { + $this->finalRandomTestTaxonomyProcessing($a_mapping, (string) $old, $new, $test_obj); + } + + self::$finally_processed_tests_registry[$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()) { + $qsa_import_fails = new ilAssQuestionSkillAssignmentImportFails($test_obj->getId()); + $qsa_import_fails->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()) { + $slt_import_fails = new ilTestSkillLevelThresholdImportFails($test_obj->getId()); + $slt_import_fails->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..30daab2bedc0 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( + static fn($node_id): mixed => $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( + static fn($node_id): mixed => $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( + static fn($node_id): mixed => $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( + static fn($node_id): mixed => $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.ilTestResultsImportParser.php b/components/ILIAS/Test/classes/class.ilTestResultsImportParser.php index ce95d671e3cc..664f11aa3a3e 100755 --- a/components/ILIAS/Test/classes/class.ilTestResultsImportParser.php +++ b/components/ILIAS/Test/classes/class.ilTestResultsImportParser.php @@ -22,6 +22,9 @@ use ILIAS\ResourceStorage\Services as ResourceStorage; use ILIAS\Test\Logging\TestLogger; +/** + * @deprecated 12: Use ILIAS\Test\ExportImport\TestImporter instead + */ class ilTestResultsImportParser extends ilSaxParser { private $table; diff --git a/components/ILIAS/Test/classes/class.ilTestResultsToXML.php b/components/ILIAS/Test/classes/class.ilTestResultsToXML.php old mode 100755 new mode 100644 index bc3a6d67888d..56492edf0670 --- a/components/ILIAS/Test/classes/class.ilTestResultsToXML.php +++ b/components/ILIAS/Test/classes/class.ilTestResultsToXML.php @@ -20,6 +20,9 @@ use ILIAS\ResourceStorage\Services as ResourceStorage; +/** + * @deprecated 12: Use ILIAS\Test\ExportImport\TestExporter instead + */ class ilTestResultsToXML extends ilXmlWriter { private array $active_ids = []; diff --git a/components/ILIAS/Test/classes/class.ilTestSkillLevelThresholdExporter.php b/components/ILIAS/Test/classes/class.ilTestSkillLevelThresholdExporter.php index 7c8c3a6b6553..e638617684e5 100755 --- a/components/ILIAS/Test/classes/class.ilTestSkillLevelThresholdExporter.php +++ b/components/ILIAS/Test/classes/class.ilTestSkillLevelThresholdExporter.php @@ -19,10 +19,7 @@ declare(strict_types=1); /** - * @author Björn Heyser - * @version $Id$ - * - * @package components\ILIAS/Test(QuestionPool) + * @deprecated 12: Use ILIAS\Test\ExportImport\TestExporter instead */ class ilTestSkillLevelThresholdExporter { diff --git a/components/ILIAS/Test/classes/class.ilTestSkillLevelThresholdImport.php b/components/ILIAS/Test/classes/class.ilTestSkillLevelThresholdImport.php index 39d86e9d0e0c..c96c69ba7023 100755 --- a/components/ILIAS/Test/classes/class.ilTestSkillLevelThresholdImport.php +++ b/components/ILIAS/Test/classes/class.ilTestSkillLevelThresholdImport.php @@ -19,10 +19,7 @@ declare(strict_types=1); /** - * @author Björn Heyser - * @version $Id$ - * - * @package components\ILIAS/Test(QuestionPool) + * @deprecated 12: Use ILIAS\Test\ExportImport\Import\SkillLevelThresholdImporter instead */ class ilTestSkillLevelThresholdImport { diff --git a/components/ILIAS/Test/classes/class.ilTestSkillLevelThresholdImportFails.php b/components/ILIAS/Test/classes/class.ilTestSkillLevelThresholdImportFails.php index a81bafdf12d1..ec746488d441 100755 --- a/components/ILIAS/Test/classes/class.ilTestSkillLevelThresholdImportFails.php +++ b/components/ILIAS/Test/classes/class.ilTestSkillLevelThresholdImportFails.php @@ -19,10 +19,7 @@ declare(strict_types=1); /** - * @author Björn Heyser - * @version $Id$ - * - * @package components\ILIAS/Test(QuestionPool) + * @deprecated 12: Use ILIAS\Test\ExportImport\Import\SkillLevelThresholdImporter instead */ class ilTestSkillLevelThresholdImportFails { diff --git a/components/ILIAS/Test/classes/class.ilTestSkillLevelThresholdImportList.php b/components/ILIAS/Test/classes/class.ilTestSkillLevelThresholdImportList.php index ee780c7e4c58..c67bac11a301 100755 --- a/components/ILIAS/Test/classes/class.ilTestSkillLevelThresholdImportList.php +++ b/components/ILIAS/Test/classes/class.ilTestSkillLevelThresholdImportList.php @@ -19,10 +19,7 @@ declare(strict_types=1); /** - * @author Björn Heyser - * @version $Id$ - * - * @package components\ILIAS/Test(QuestionPool) + * @deprecated 12: Use ILIAS\Test\ExportImport\Import\SkillLevelThresholdImporter instead */ class ilTestSkillLevelThresholdImportList implements Iterator { diff --git a/components/ILIAS/Test/classes/class.ilTestSkillLevelThresholdImporter.php b/components/ILIAS/Test/classes/class.ilTestSkillLevelThresholdImporter.php index a10f4136d347..3620da697425 100755 --- a/components/ILIAS/Test/classes/class.ilTestSkillLevelThresholdImporter.php +++ b/components/ILIAS/Test/classes/class.ilTestSkillLevelThresholdImporter.php @@ -19,10 +19,7 @@ declare(strict_types=1); /** - * @author Björn Heyser - * @version $Id$ - * - * @package components\ILIAS/Test + * @deprecated 12: Use ILIAS\Test\ExportImport\Import\SkillLevelThresholdImporter instead */ class ilTestSkillLevelThresholdImporter { diff --git a/components/ILIAS/Test/classes/class.ilTestSkillLevelThresholdList.php b/components/ILIAS/Test/classes/class.ilTestSkillLevelThresholdList.php index 1fde8f370a0a..766478011e71 100755 --- a/components/ILIAS/Test/classes/class.ilTestSkillLevelThresholdList.php +++ b/components/ILIAS/Test/classes/class.ilTestSkillLevelThresholdList.php @@ -19,10 +19,7 @@ declare(strict_types=1); /** - * @author Björn Heyser - * @version $Id$ - * - * @package components\ILIAS/Test + * @deprecated 12: Use ILIAS\Test\ExportImport\Import\SkillLevelThresholdImporter instead */ class ilTestSkillLevelThresholdList { diff --git a/components/ILIAS/Test/classes/class.ilTestSkillLevelThresholdXmlParser.php b/components/ILIAS/Test/classes/class.ilTestSkillLevelThresholdXmlParser.php index 2d7f1ab684a8..9ba875ca76cd 100755 --- a/components/ILIAS/Test/classes/class.ilTestSkillLevelThresholdXmlParser.php +++ b/components/ILIAS/Test/classes/class.ilTestSkillLevelThresholdXmlParser.php @@ -19,10 +19,7 @@ declare(strict_types=1); /** - * @author Björn Heyser - * @version $Id$ - * - * @package components\ILIAS/Test + * @deprecated 12: Use ILIAS\Test\ExportImport\Import\SkillLevelThresholdImporter instead */ class ilTestSkillLevelThresholdXmlParser extends ilSaxParser { 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/ExportFixedQuestionSet.php b/components/ILIAS/Test/src/ExportImport/ExportFixedQuestionSet.php index 6602c4040e6a..26336e194c22 100755 --- a/components/ILIAS/Test/src/ExportImport/ExportFixedQuestionSet.php +++ b/components/ILIAS/Test/src/ExportImport/ExportFixedQuestionSet.php @@ -21,10 +21,7 @@ namespace ILIAS\Test\ExportImport; /** - * @author Björn Heyser - * @version $Id$ - * - * @package components\ILIAS/Test + * @deprecated 12: Use ILIAS\Test\ExportImport\TestExporter instead */ class ExportFixedQuestionSet extends Export { 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..1ce13929c0a5 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/ExportOptions/XMLWithResultsOption.php @@ -0,0 +1,93 @@ +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/ExportRandomQuestionSet.php b/components/ILIAS/Test/src/ExportImport/ExportRandomQuestionSet.php index 3785bc712fee..89a245670df7 100755 --- a/components/ILIAS/Test/src/ExportImport/ExportRandomQuestionSet.php +++ b/components/ILIAS/Test/src/ExportImport/ExportRandomQuestionSet.php @@ -21,10 +21,7 @@ namespace ILIAS\Test\ExportImport; /** - * @author Björn Heyser - * @version $Id$ - * - * @package components\ILIAS/Test + * @deprecated 12: Use ILIAS\Test\ExportImport\TestExporter instead */ class ExportRandomQuestionSet extends Export { diff --git a/components/ILIAS/Test/src/ExportImport/Exportable.php b/components/ILIAS/Test/src/ExportImport/Exportable.php index 2ba1e1cbdd70..8849c6f9dfa3 100644 --- a/components/ILIAS/Test/src/ExportImport/Exportable.php +++ b/components/ILIAS/Test/src/ExportImport/Exportable.php @@ -35,6 +35,8 @@ * The methods should operate solely on the object's internal state and the provided * data array, without relying on external services, database connections, or any other context. * --- + * + * @deprecated 12: Use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable instead */ interface Exportable { 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..79b289c5c32e --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Import/PersistStage.php @@ -0,0 +1,105 @@ +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; + } + } + + (new ilImport($this->requested_ref_id))->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 (!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..2b31f75c63db --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Import/RandomTestConfigImporter.php @@ -0,0 +1,185 @@ +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 ($ref_ids !== []) { + $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; + } + + $definition->setMappedTaxonomyFilter($this->remapTaxonomyFilter($mapping, $mapped_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..b3009a6d9711 --- /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) { + // The mapping pipe replaces TestID and Skill BaseID/TRefID + $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..9bc51891b6d5 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Import/TestImporter.php @@ -0,0 +1,452 @@ +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()); + $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 instanceof \assQuestion || $normalized['sequence'] === null) { + continue; + } + + $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}"); + + // The mapping pipe replaces TestID, UserID and ActiveID + $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 + { + // The mapping pipe replaces TestID and UserID + $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..b8011e106ba3 --- /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) { + // The mapping pipe replaces ActiveID and QuestionIDs + $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) { + // The mapping pipe replaces ActiveID and QuestionID + $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) { + // The mapping pipe replaces ActiveID and QuestionID + $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) { + // The mapping pipe replaces ActiveID + $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; + } + + // The mapping pipe replaces ActiveID + $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) { + // The mapping pipe replaces ActiveID + $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) { + // The mapping pipe replaces ActiveID, QuestionID and UserID + $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 + { + // The mapping pipe replaces ActiveID, QuestionID and UserID + $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) { + // The mapping pipe replaces UserID and TestID + $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) { + // The mapping pipe replaces ActiveID, QuestionID and SourcePoolDefinitionID + $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..26730e45317b --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Import/UserImportResolver.php @@ -0,0 +1,83 @@ + $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 = array_column($this->db->fetchAll($query), 'usr_id', 'identifier'); + + $mapping = []; + foreach ($users as $original_id => $identifier) { + if (!isset($db_mapping[$identifier])) { + $this->log->warning("User identifier {$identifier} not found for user {$original_id}, using anonymous user ID"); + $mapping[$original_id] = ANONYMOUS_USER_ID; + } + + $this->log->debug("User identifier {$identifier} found, mapping user {$original_id} to {$db_mapping[$identifier]}"); + $mapping[$original_id] = $db_mapping[$identifier]; + } + + 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..59e54fcafafa --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalizer/QuestionSetConfigNormalizer.php @@ -0,0 +1,222 @@ + + */ +#[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'); + $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 + { + if($normalized['type'] === ilObjTest::QUESTION_SET_TYPE_FIXED) { + return new ilTestFixedQuestionSetConfig( + $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'] + ); + } + + $config = new ilTestRandomQuestionSetConfig( + $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'] + ); + + $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): mixed => $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..44cfb4556513 --- /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): mixed => $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): mixed => $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..b099476a60e7 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Pipes/CollectUserIds.php @@ -0,0 +1,63 @@ + $ids + */ + private array $ids = []; + + /** + * @inheritDoc + */ + public function handle(mixed $passable, \Closure $next): mixed + { + if ( + $passable instanceof NormalizeCarry + && $passable->value instanceof Id + && $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..65f2ec8b0c66 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/TestCollector.php @@ -0,0 +1,439 @@ + $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 + { + return $this->test ??= new ilObjTest($this->object_id->toInt(), false); + } + + 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) { + $mapping = array_combine($user_ids, $user_ids); + } 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 + { + return $this->questions ??= $this->questions_repository->getQuestionPropertiesForTest($this->getObject()); + } + + /** + * @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) { + return $this->participants; + } + + $this->participants = []; + foreach ($this->getParticipants() as $participant) { + if ($participant->getActiveId() === null) { + continue; + } + + $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..cb3b3497e4c6 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/TestExporter.php @@ -0,0 +1,443 @@ +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(ExportDependencies $state): ?ObjectId + { + $target_ids = $state->target()->getObjectIds(); + + if ($target_ids === []) { + $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(ExportDependencies $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(ExportDependencies $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(ExportDependencies $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->logger()->warning('Question image file not found: ' . $file['from']); + continue; + } + + $state->writer()->writeFileByFilePath( + $file['from'], + "{$export_dir}/" . $file['to'] + ); + $state->logger()->debug("Copied question image {$file['from']} to {$export_dir}/{$file['to']}"); + } + + 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, + ExportDependencies $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, + ExportDependencies $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, + ExportDependencies $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, + ExportDependencies $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 d51eaf4f4b93..e6acb287f462 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..71b3d65b1c9a 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", + '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..457e4f00c37a 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,96 @@ 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 ee733ac0754a..79e01a7588e5 100644 --- a/components/ILIAS/Test/tests/Results/Data/TestResultRepositoryTest.php +++ b/components/ILIAS/Test/tests/Results/Data/TestResultRepositoryTest.php @@ -163,7 +163,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); @@ -177,7 +177,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/QTI/class.ilQTIParser.php b/components/ILIAS/TestQuestionPool/classes/QTI/class.ilQTIParser.php index a76740b964d0..9b2fdbd5cb48 100755 --- a/components/ILIAS/TestQuestionPool/classes/QTI/class.ilQTIParser.php +++ b/components/ILIAS/TestQuestionPool/classes/QTI/class.ilQTIParser.php @@ -21,13 +21,7 @@ use ILIAS\TestQuestionPool\Questions\Files\QuestionFiles; /** - * QTI Parser - * - * @author Helmut Schottmüller - * @version $Id$ - * - * @extends ilSaxParser - * @package assessment + * @deprecated 12: QTI export is no longer supported */ class ilQTIParser extends ilSaxParser { 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..347c508aa7f0 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\Refinery\Transformation; +use ILIAS\TestQuestionPool\ExportImport\Envelopes\QuestionImage; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; + /** * Class for answers with a binary state indicator * @@ -43,12 +47,18 @@ 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 string $a_image The image filename + * @param bool $state A boolen 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 +80,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..d8a978e28193 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..d1945f7f6146 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..9a16c41b0b2a 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): static => 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 98ad47494b5b..99c9138e23e7 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assClozeGap.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assClozeGap.php @@ -16,8 +16,9 @@ * *********************************************************************/ +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\Refinery\Transformation; -use ILIAS\Refinery\Random\Transformation\ShuffleTransformation; /** * Class for cloze question gaps @@ -31,7 +32,7 @@ * * @ingroup components\ILIASTestQuestionPool */ -class assClozeGap +class assClozeGap implements Normalizable { public const TYPE_TEXT = 0; public const TYPE_SELECT = 1; @@ -454,4 +455,35 @@ public function numericRangeExists(): bool return false; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->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 a7934c1a21c2..00a80a5af323 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 @@ -1767,4 +1770,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->nullableInt($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 44f9b1980f56..8e8f57f9986c 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestion.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestion.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\QuestionAutosaveable; use ILIAS\Test\Logging\AdditionalInformationGenerator; @@ -28,7 +31,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; @@ -1483,4 +1486,38 @@ function (array $c, array $v): array { ] ); } + + /** + * @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 c7ed5c55d085..31a2835e2056 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionResult.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionResult.php @@ -17,6 +17,9 @@ *********************************************************************/ use ILIAS\Refinery\Factory as Refinery; +use ILIAS\Refinery\Transformation; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; /** * 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; @@ -838,4 +841,60 @@ 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( + static fn(array $unit): assFormulaQuestionUnit => $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 c01d92697d56..6fd2aac00248 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. @@ -53,19 +57,19 @@ class assImagemapQuestion extends assQuestion implements ilObjQuestionScoringAdj 'POLY' => 'poly']; /** @var $answers array The possible answers of the imagemap question. */ - public $answers; + public array $answers; /** @var $image_filename string The image file containing the name of image file. */ - public $image_filename; + public string $image_filename; /** @var $imagemap_contents string The variable containing contents of an imagemap file. */ - public $imagemap_contents; + public string $imagemap_contents; /** @var $coords array */ - public $coords; + public array $coords; /** @var $is_multiple_choice bool Defines weather the Question is a Single or a Multiplechoice question. */ - protected $is_multiple_choice = false; + protected bool $is_multiple_choice = false; /** * assImagemapQuestion constructor @@ -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( + static fn(array $answer): ASS_AnswerImagemap => $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..475be6e0d14d 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( + static fn(array $answer): ilAssKprimChoiceAnswer => $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 cdfe104f27ee..65b38fe28f72 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..c62f18e58ee0 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( + static fn(array $answer): ASS_AnswerMultipleResponseImage => $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 0f332c7e678d..8f7107785a5b 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\Questions\QuestionLMExportable; -use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; -use ILIAS\TestQuestionPool\Questions\Ordering\OrderingQuestionDatabaseRepository as OQRepository; +use ILIAS\Refinery\Transformation; use ILIAS\Test\Logging\AdditionalInformationGenerator; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\TestQuestionPool\Questions\Ordering\OrderingQuestionDatabaseRepository as OQRepository; +use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; +use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; /** * 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,37 @@ 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']); + $denormalized_elements = array_map( + static fn(array $element): ilAssOrderingElement => $tt->denormalize($element, new ilAssOrderingElement()), + $normalized['ordering_elements'] + ); + $clone->element_list_for_deferred_saving = new ilAssOrderingElementList(null, $denormalized_elements); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assQuestion.php b/components/ILIAS/TestQuestionPool/classes/class.assQuestion.php index 5395d9924ff2..ca0a2bfdd85f 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assQuestion.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assQuestion.php @@ -18,32 +18,34 @@ declare(strict_types=1); +use ILIAS\DI\Container; +use ILIAS\DI\LoggingServices; +use ILIAS\HTTP\Services as HTTPServices; +use ILIAS\Notes\InternalDataService as NotesInternalDataService; +use ILIAS\Notes\Note; +use ILIAS\Notes\NoteDBRepository as NotesRepo; +use ILIAS\Notes\NotesManager; +use ILIAS\Notes\Service as NotesService; +use ILIAS\Refinery\Factory as Refinery; +use ILIAS\Refinery\Transformation; +use ILIAS\Skill\Service\SkillUsageService; +use ILIAS\Test\Logging\AdditionalInformationGenerator; +use ILIAS\Test\Logging\TestParticipantInteraction; +use ILIAS\Test\Logging\TestParticipantInteractionTypes; +use ILIAS\Test\Logging\TestQuestionAdministrationInteraction; +use ILIAS\Test\Logging\TestQuestionAdministrationInteractionTypes; use ILIAS\Test\Results\Data\Repository as TestResultRepository; use ILIAS\Test\TestDIC; -use ILIAS\TestQuestionPool\Questions\QuestionPartiallySaveable; -use ILIAS\TestQuestionPool\Questions\Question; -use ILIAS\TestQuestionPool\Questions\SuggestedSolution\SuggestedSolution; -use ILIAS\TestQuestionPool\Questions\SuggestedSolution\SuggestedSolutionsDatabaseRepository; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Envelopes\Id; use ILIAS\TestQuestionPool\QuestionPoolDIC; use ILIAS\TestQuestionPool\Questions\Files\QuestionFiles; use ILIAS\TestQuestionPool\Questions\GeneralQuestionPropertiesRepository; +use ILIAS\TestQuestionPool\Questions\Question; +use ILIAS\TestQuestionPool\Questions\QuestionPartiallySaveable; +use ILIAS\TestQuestionPool\Questions\SuggestedSolution\SuggestedSolution; +use ILIAS\TestQuestionPool\Questions\SuggestedSolution\SuggestedSolutionsDatabaseRepository; use ILIAS\TestQuestionPool\RequestDataCollector; -use ILIAS\Test\Logging\TestParticipantInteraction; -use ILIAS\Test\Logging\TestQuestionAdministrationInteraction; -use ILIAS\Test\Logging\TestParticipantInteractionTypes; -use ILIAS\Test\Logging\TestQuestionAdministrationInteractionTypes; -use ILIAS\Test\Logging\AdditionalInformationGenerator; -use ILIAS\Refinery\Transformation; -use ILIAS\DI\Container; -use ILIAS\Skill\Service\SkillUsageService; -use ILIAS\Notes\Service as NotesService; -use ILIAS\Notes\InternalDataService as NotesInternalDataService; -use ILIAS\Notes\NoteDBRepository as NotesRepo; -use ILIAS\Notes\NotesManager; -use ILIAS\Notes\Note; -use ILIAS\DI\LoggingServices; -use ILIAS\Refinery\Factory as Refinery; -use ILIAS\HTTP\Services as HTTPServices; /** * @author Helmut Schottmüller @@ -259,6 +261,9 @@ public function getProcessLocker(): ilAssQuestionProcessLocker return $this->processLocker; } + /** + * @deprecated 12: Use ILIAS\TestQuestionPool\ExportImport\Import\QuestionsImporter instead + */ final public function fromXML( string $importdirectory, int $user_id, @@ -285,10 +290,8 @@ final public function fromXML( } /** - * Returns a QTI xml representation of the question - * - * @return string The QTI xml representation of the question - */ + * @deprecated 12: use question normalizing and serialization instead + */ final public function toXML( bool $a_include_header = true, bool $a_include_binary = true, @@ -2960,4 +2963,67 @@ 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( + static fn(array $suggested_solution): SuggestedSolution => $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..150c10d4dc16 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( + static fn(array $answer): ASS_AnswerBinaryStateImage => $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..19cee4cf0744 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\Questions\QuestionLMExportable; -use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; +use ILIAS\Refinery\Transformation; use ILIAS\Test\Logging\AdditionalInformationGenerator; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; +use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; /** * 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,44 @@ 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( + static fn(array $answer): ASS_AnswerMultipleResponseImage => $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..41017b4d8873 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\Questions\QuestionLMExportable; -use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; +use ILIAS\Refinery\Transformation; use ILIAS\Test\Logging\AdditionalInformationGenerator; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; +use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; /** * 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,37 @@ 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( + static fn(array $answer): ASS_AnswerBinaryStateImage => $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..aedde7705f2e 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..1fe3c72815f3 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPool.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPool.php @@ -33,10 +33,7 @@ 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; @@ -44,7 +41,6 @@ public function __construct(int $a_id = 0, bool $a_call_by_reference = true) { global $DIC; $this->component_repository = $DIC['component.repository']; - $this->benchmark = $DIC['ilBench']; $local_dic = QuestionPoolDIC::dic(); $this->questionrepository = $local_dic['question.general_properties.repository']; @@ -305,246 +301,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,21 +355,14 @@ 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 * * @param array $questions An array containing the question ids of the questions * @return string The QTI xml representation of the questions * @access public + * + * @deprecated 12: Use ILIAS\Test\ExportImport\TestExporter instead */ public function questionsToXML($questions): string { @@ -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 c1e066ff7ed8..31d164995891 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php @@ -18,26 +18,36 @@ declare(strict_types=1); +use ILIAS\Data\Factory as DataFactory; +use ILIAS\Filesystem\Util\Archive\Archives; +use ILIAS\FileUpload\MimeType; +use ILIAS\GlobalScreen\Services as GlobalScreen; use ILIAS\Skill\Service\SkillUsageService; +use ILIAS\Style\Content\Service as ContentStyle; +use ILIAS\Taxonomy\Service; +use ILIAS\Test\Settings\GlobalSettings\GlobalTestSettings; +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\StageResult; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\StageResultType; +use ILIAS\TestQuestionPool\ExportImport\Import\CleanupStage; +use ILIAS\TestQuestionPool\ExportImport\Import\DetectLegacyImportStage; +use ILIAS\TestQuestionPool\ExportImport\Import\PersistStage; +use ILIAS\TestQuestionPool\ExportImport\Import\QuestionSelectionStage; +use ILIAS\TestQuestionPool\ExportImport\Import\UploadValidationStage; +use ILIAS\TestQuestionPool\Import\TestQuestionsImportTrait; use ILIAS\TestQuestionPool\QuestionPoolDIC; -use ILIAS\TestQuestionPool\RequestDataCollector; -use ILIAS\TestQuestionPool\Questions\Presentation\QuestionTable; use ILIAS\TestQuestionPool\Questions\GeneralQuestionPropertiesRepository; -use ILIAS\Test\Settings\GlobalSettings\GlobalTestSettings; -use ILIAS\Taxonomy\Service; +use ILIAS\TestQuestionPool\Questions\Presentation\QuestionTable; +use ILIAS\TestQuestionPool\RequestDataCollector; use ILIAS\UI\Component\Input\Container\Form\Form; use ILIAS\UI\Component\Input\Field\Select; use ILIAS\UI\Component\Input\Input; use ILIAS\UI\URLBuilder; 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 ILIAS\UICore\GlobalTemplate; +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); @@ -733,201 +747,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); @@ -1039,6 +858,9 @@ public function cancelDeleteQuestionsObject(): void $this->ctrl->redirect($this, self::DEFAULT_CMD); } + /** + * @deprecated 12: Use ILIAS\Test\ExportImport\TestExporter instead + */ public function exportQuestions(array $ids): void { if ($ids !== []) { @@ -1058,24 +880,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(); @@ -1105,17 +913,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'), @@ -1289,6 +1086,9 @@ public function moveQuestions(array $ids): void $this->tpl->setOnScreenMessage('info', $this->lng->txt('qpl_move_insert_clipboard'), true); } + /** + * @deprecated 12: excel export is not longer supported + */ public function createExportExcel(): void { $rbacsystem = $this->rbac_system; @@ -1305,103 +1105,102 @@ 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(); - $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($result), + StageResultType::COMPLETE => $this->renderImportSuccess($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); + $workflow = $runner->buildWorkflow($this->ui_factory, $this->lng->txt('import')); + $this->tpl->setContent( + $this->ui_renderer->render([$workflow, ...$result->components]) + ); + } - $this->ctrl->setParameterByClass(self::class, 'new_type', $this->type); - $form = $this->buildImportQuestionsSelectionForm( - 'importVerifiedFile', - $importdir, - $qtifile, - $path_to_uploaded_file_in_temp_dir + private function renderImportError(StageResult $result): void + { + $this->tpl->setOnScreenMessage( + GlobalTemplate::MESSAGE_TYPE_FAILURE, + $result->error_message, + true ); + } - if ($form === null) { - return; - } + private function renderImportSuccess(StageResult $result): void + { + $pool_obj_id = $this->data_factory->objId($result->context->get('pool_obj_id')); + $pool_ref_id = $pool_obj_id->toReferenceIds()[0]->toInt(); - $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->setOnScreenMessage( + GlobalTemplate::MESSAGE_TYPE_SUCCESS, + $this->lng->txt('object_imported'), + true ); - $this->tpl->setContent($this->ui_renderer->render($panel)); - $this->tpl->printToStdout(); - exit; + $this->ctrl->setParameter($this, 'ref_id', $pool_ref_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..005a6255745e 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 used only for legacy imports and will be removed in a future ILIAS release */ class ilObjQuestionPoolXMLParser extends ilSaxParser { diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilQuestionpoolExport.php b/components/ILIAS/TestQuestionPool/classes/class.ilQuestionpoolExport.php old mode 100755 new mode 100644 index acaf7943deae..7425310d8a8d --- a/components/ILIAS/TestQuestionPool/classes/class.ilQuestionpoolExport.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilQuestionpoolExport.php @@ -17,14 +17,16 @@ *********************************************************************/ /** -* Export class for questionpools -* -* @author Helmut Schottmüller -* -* @version $Id$ -* -* @ingroup components\ILIASTestQuestionPool -*/ + * Export class for questionpools + * + * @author Helmut Schottmüller + * + * @version $Id$ + * + * @ingroup components\ILIASTestQuestionPool + * + * @deprecated 12: Use ILIAS\TestQuestionPool\ExportImport\QuestionpoolExporter instead + */ class ilQuestionpoolExport { public ilErrorHandling $err; // error object @@ -41,9 +43,9 @@ class ilQuestionpoolExport private ilXmlWriter $xml; /** - * Constructor - * @access public - */ + * Constructor + * @access public + */ public function __construct( protected ilObjQuestionPool $qpl_obj, protected string $mode = "xml", @@ -97,8 +99,8 @@ public function getInstId() /** - * build export file (complete zip file) - */ + * build export file (complete zip file) + */ public function buildExportFile(): string { switch ($this->mode) { @@ -111,8 +113,8 @@ public function buildExportFile(): string } /** - * build xml export file - */ + * build xml export file + */ public function buildExportFileXML(): string { global $DIC; @@ -183,4 +185,4 @@ public function exportXHTMLMediaObjects($a_export_dir): void } } } -} +} \ No newline at end of file diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolExporter.php b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolExporter.php index 9ad04c2ce93b..b50fad4eeb33 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolExporter.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolExporter.php @@ -16,131 +16,80 @@ * *********************************************************************/ -/** - * 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']->info(': 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()) { + $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 78ae5f10e618..7c3d3f6b6752 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,156 +54,62 @@ 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']->info('non container and no tax mapping, perhaps old qpl export'); - return; - } - - if (!file_exists($xmlfile)) { - $DIC['ilLog']->info('Cannot find xml definition: ' . $xmlfile); - return; - } - if (!file_exists($qtifile)) { - $DIC['ilLog']->info('Cannot find qti 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; } - $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']->info('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 - ); - $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() - ); - - $a_mapping->addMapping( - 'components/ILIAS/TestQuestionPool', - 'quest', - $old_question_id, - $new_question_id - ); - } - - $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' + $result = $this->importer->import( + new XMLMemoryDeserializer()->open($a_xml), + $a_mapping, + new ReferenceId($a_mapping->getTargetId()), + $context, ); - - - $new_obj->saveToDb(); + $this->session->setContext($result); } - /** - * Final processing - * @param ilImportMapping $a_mapping - * @return void - */ 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); - } - } - } + // Check if forward to legacy importer is needed + $context = $this->session->getContext(); + if (DetectLegacyImportStage::isLegacyImport($context)) { + $this->legacy_importer->finalProcessing($a_mapping); + return; } + + $this->importer->finalize($a_mapping); + $this->finalizeTaxonomyUsage($a_mapping); } - protected function importQuestionSkillAssignments($xmlFile, ilImportMapping $mappingRegistry, $targetParentObjId): void + private function finalizeTaxonomyUsage(ilImportMapping $a_mapping): void { - $parser = new ilAssQuestionSkillAssignmentXmlParser($xmlFile); - $parser->startParsing(); + $qpl_mappings = $a_mapping->getMappingsOfEntity('components/ILIAS/TestQuestionPool', 'qpl'); - $importer = new ilAssQuestionSkillAssignmentImporter(); - $importer->setTargetParentObjId($targetParentObjId); - $importer->setImportInstallationId($this->getInstallId()); - $importer->setImportMappingRegistry($mappingRegistry); - $importer->setImportMappingComponent('components/ILIAS/TestQuestionPool'); - $importer->setImportAssignmentList($parser->getAssignmentList()); + foreach ($qpl_mappings as $old => $new) { + if ($old === 'new_id' || (int) $old <= 0) { + continue; + } - $importer->import(); + $new_tax_ids = $a_mapping->getMapping( + 'components/ILIAS/Taxonomy', + 'tax_usage_of_obj', + (string) $old + ); - if ($importer->getFailedImportAssignmentList()->assignmentsExist()) { - $qsaImportFails = new ilAssQuestionSkillAssignmentImportFails($targetParentObjId); - $qsaImportFails->registerFailedImports($importer->getFailedImportAssignmentList()); + if ($new_tax_ids === null) { + continue; + } - $this->pool_obj->getObjectProperties()->storePropertyIsOnline($this->pool_obj->getObjectProperties()->getPropertyIsOnline()->withOffline()); + $tax_ids = explode(':', $new_tax_ids); + foreach ($tax_ids as $tid) { + ilObjTaxonomy::saveUsage((int) $tid, (int) $new); + } } } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolLegacyImporter.php b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolLegacyImporter.php new file mode 100755 index 000000000000..f4f09b48a77d --- /dev/null +++ b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolLegacyImporter.php @@ -0,0 +1,173 @@ +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) { + continue; + } + + $new_tax_ids = $a_mapping->getMapping('components/ILIAS/Taxonomy', 'tax_usage_of_obj', (string) $old); + if ($new_tax_ids === null) { + continue; + } + + $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()) { + return; + } + + $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/export/qti12/class.assClozeTestExport.php b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assClozeTestExport.php index 38df4fc4c97e..f17da91214e2 100755 --- a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assClozeTestExport.php +++ b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assClozeTestExport.php @@ -27,6 +27,8 @@ * @author Helmut Schottmüller * @version $Id$ * @ingroup components\ILIASTestQuestionPool + * + * @deprecated Use normalized question export instead */ class assClozeTestExport extends assQuestionExport { diff --git a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assErrorTextExport.php b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assErrorTextExport.php index 7e10367f03a8..ebc698d0fb27 100755 --- a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assErrorTextExport.php +++ b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assErrorTextExport.php @@ -22,6 +22,8 @@ * @author Helmut Schottmüller * @version $Id$ * @ingroup components\ILIASTestQuestionPool + * + * @deprecated Use normalized question export instead */ class assErrorTextExport extends assQuestionExport { diff --git a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assFileUploadExport.php b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assFileUploadExport.php index 729ca485edea..9be379594bc2 100755 --- a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assFileUploadExport.php +++ b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assFileUploadExport.php @@ -22,6 +22,8 @@ * @author Helmut Schottmüller * @version $Id$ * @ingroup components\ILIASTestQuestionPool + * + * @deprecated Use normalized question export instead */ class assFileUploadExport extends assQuestionExport { diff --git a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assFormulaQuestionExport.php b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assFormulaQuestionExport.php index 75746c4f8761..8d7bb7b138ea 100755 --- a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assFormulaQuestionExport.php +++ b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assFormulaQuestionExport.php @@ -24,6 +24,8 @@ * @author Helmut Schottmüller * @version $Id: class.assFormulaQuestionExport.php 1185 2010-02-02 08:36:26Z hschottm $ * @ingroup components\ILIASTestQuestionPool + * + * @deprecated Use normalized question export instead */ class assFormulaQuestionExport extends assQuestionExport { diff --git a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assImagemapQuestionExport.php b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assImagemapQuestionExport.php index 0820fc8f17a2..5b491c36704b 100755 --- a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assImagemapQuestionExport.php +++ b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assImagemapQuestionExport.php @@ -24,6 +24,8 @@ * @author Helmut Schottmüller * @version $Id$ * @ingroup components\ILIASTestQuestionPool + * + * @deprecated Use normalized question export instead */ class assImagemapQuestionExport extends assQuestionExport { diff --git a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assKprimChoiceExport.php b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assKprimChoiceExport.php index cc1ba7f8c060..0714429deb63 100755 --- a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assKprimChoiceExport.php +++ b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assKprimChoiceExport.php @@ -21,6 +21,8 @@ * @version $Id$ * * @package components\ILIAS/Test + * + * @deprecated Use normalized question export instead */ class assKprimChoiceExport extends assQuestionExport { diff --git a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assLongMenuExport.php b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assLongMenuExport.php index 74f602d0676f..3ab5b06433e3 100755 --- a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assLongMenuExport.php +++ b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assLongMenuExport.php @@ -16,6 +16,9 @@ * *********************************************************************/ +/** + * @deprecated Use normalized question export instead + */ class assLongMenuExport extends assQuestionExport { /** diff --git a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assMatchingQuestionExport.php b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assMatchingQuestionExport.php index 778bfd88a31a..392765a828c5 100755 --- a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assMatchingQuestionExport.php +++ b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assMatchingQuestionExport.php @@ -26,6 +26,8 @@ * @author Helmut Schottmüller * @version $Id$ * @ingroup components\ILIASTestQuestionPool + * + * @deprecated Use normalized question export instead */ class assMatchingQuestionExport extends assQuestionExport { diff --git a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assMultipleChoiceExport.php b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assMultipleChoiceExport.php index 472c8b9dd5b7..eda1f43901dc 100755 --- a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assMultipleChoiceExport.php +++ b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assMultipleChoiceExport.php @@ -24,6 +24,8 @@ * @author Helmut Schottmüller * @version $Id$ * @ingroup components\ILIASTestQuestionPool + * + * @deprecated Use normalized question export instead */ class assMultipleChoiceExport extends assQuestionExport { diff --git a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assNumericExport.php b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assNumericExport.php index 9a2c6c24b5d6..aa3f33a2ecd7 100755 --- a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assNumericExport.php +++ b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assNumericExport.php @@ -24,6 +24,8 @@ * @author Helmut Schottmüller * @version $Id$ * @ingroup components\ILIASTestQuestionPool + * + * @deprecated Use normalized question export instead */ class assNumericExport extends assQuestionExport { diff --git a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assOrderingHorizontalExport.php b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assOrderingHorizontalExport.php index aee266f640af..0741b2176f06 100755 --- a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assOrderingHorizontalExport.php +++ b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assOrderingHorizontalExport.php @@ -24,6 +24,8 @@ * @author Helmut Schottmüller * @version $Id$ * @ingroup components\ILIASTestQuestionPool + * + * @deprecated Use normalized question export instead */ class assOrderingHorizontalExport extends assQuestionExport { diff --git a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assOrderingQuestionExport.php b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assOrderingQuestionExport.php index de04e8117f9d..fbe29f69d106 100755 --- a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assOrderingQuestionExport.php +++ b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assOrderingQuestionExport.php @@ -24,6 +24,8 @@ * @author Helmut Schottmüller * @version $Id$ * @ingroup components\ILIASTestQuestionPool + * + * @deprecated Use normalized question export instead */ class assOrderingQuestionExport extends assQuestionExport { diff --git a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assQuestionExport.php b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assQuestionExport.php index a6c0a2755b23..4afa6e755e5a 100755 --- a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assQuestionExport.php +++ b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assQuestionExport.php @@ -26,6 +26,8 @@ * @author Helmut Schottmüller * @version $Id$ * @ingroup components\ILIASTestQuestionPool + * + * @deprecated Use normalized question export instead */ class assQuestionExport { diff --git a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assSingleChoiceExport.php b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assSingleChoiceExport.php index d9aedc377e7a..299e459abced 100755 --- a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assSingleChoiceExport.php +++ b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assSingleChoiceExport.php @@ -24,6 +24,8 @@ * @author Helmut Schottmüller * @version $Id$ * @ingroup components\ILIASTestQuestionPool + * + * @deprecated Use normalized question export instead */ class assSingleChoiceExport extends assQuestionExport { diff --git a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assTextQuestionExport.php b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assTextQuestionExport.php index 009fb070ac68..7115f427d2af 100755 --- a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assTextQuestionExport.php +++ b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assTextQuestionExport.php @@ -24,6 +24,8 @@ * @author Helmut Schottmüller * @version $Id$ * @ingroup components\ILIASTestQuestionPool + * + * @deprecated Use normalized question export instead */ class assTextQuestionExport extends assQuestionExport { diff --git a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assTextSubsetExport.php b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assTextSubsetExport.php index 8fbbf6a12d04..da12b9fc3a56 100755 --- a/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assTextSubsetExport.php +++ b/components/ILIAS/TestQuestionPool/classes/export/qti12/class.assTextSubsetExport.php @@ -24,6 +24,8 @@ * @author Helmut Schottmüller * @version $Id$ * @ingroup components\ILIASTestQuestionPool + * + * @deprecated Use normalized question export instead */ class assTextSubsetExport extends assQuestionExport { diff --git a/components/ILIAS/TestQuestionPool/classes/export/qti21/class.assLongMenuExportQti21.php b/components/ILIAS/TestQuestionPool/classes/export/qti21/class.assLongMenuExportQti21.php index 2160425f2087..91a664b0f780 100755 --- a/components/ILIAS/TestQuestionPool/classes/export/qti21/class.assLongMenuExportQti21.php +++ b/components/ILIAS/TestQuestionPool/classes/export/qti21/class.assLongMenuExportQti21.php @@ -16,6 +16,9 @@ * *********************************************************************/ +/** + * @deprecated Use normalized question export instead + */ class assLongMenuExportQti21 extends assQuestionExport { /** diff --git a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assClozeTestImport.php b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assClozeTestImport.php index 1e95b8d5c56d..26c9fbfa93e6 100755 --- a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assClozeTestImport.php +++ b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assClozeTestImport.php @@ -24,6 +24,8 @@ * @author Helmut Schottmüller * @version $Id$ * @ingroup components\ILIASTestQuestionPool + * + * @deprecated Use normalized question import instead */ class assClozeTestImport extends assQuestionImport { diff --git a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assErrorTextImport.php b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assErrorTextImport.php index aaa7db0a95a1..30aaba38460b 100755 --- a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assErrorTextImport.php +++ b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assErrorTextImport.php @@ -22,6 +22,8 @@ * @author Helmut Schottmüller * @version $Id$ * @ingroup components\ILIASTestQuestionPool + * + * @deprecated Use normalized question import instead */ class assErrorTextImport extends assQuestionImport { diff --git a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assFileUploadImport.php b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assFileUploadImport.php index 3f08c77b5e4c..86a38521584d 100755 --- a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assFileUploadImport.php +++ b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assFileUploadImport.php @@ -22,6 +22,8 @@ * @author Helmut Schottmüller * @version $Id$ * @ingroup components\ILIASTestQuestionPool + * + * @deprecated Use normalized question import instead */ class assFileUploadImport extends assQuestionImport { diff --git a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assFormulaQuestionImport.php b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assFormulaQuestionImport.php index 5fb30a0a6003..860f3df10f6d 100755 --- a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assFormulaQuestionImport.php +++ b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assFormulaQuestionImport.php @@ -24,6 +24,8 @@ * @author Helmut Schottmüller * @version $Id: class.assFormulaQuestionImport.php 1185 2010-02-02 08:36:26Z hschottm $ * @ingroup components\ILIASTestQuestionPool + * + * @deprecated Use normalized question import instead */ class assFormulaQuestionImport extends assQuestionImport { diff --git a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assImagemapQuestionImport.php b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assImagemapQuestionImport.php index 3e19507abbdc..b8cab0d96fa8 100755 --- a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assImagemapQuestionImport.php +++ b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assImagemapQuestionImport.php @@ -24,6 +24,8 @@ * @author Helmut Schottmüller * @version $Id$ * @ingroup components\ILIASTestQuestionPool + * + * @deprecated Use normalized question import instead */ class assImagemapQuestionImport extends assQuestionImport { diff --git a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assKprimChoiceImport.php b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assKprimChoiceImport.php index 734b1917d099..e3c68406308f 100755 --- a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assKprimChoiceImport.php +++ b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assKprimChoiceImport.php @@ -21,6 +21,8 @@ * @version $Id$ * * @package components\ILIAS/Test + * + * @deprecated Use normalized question import instead */ class assKprimChoiceImport extends assQuestionImport { diff --git a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assLongMenuImport.php b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assLongMenuImport.php index f98757706f54..e8433eae21f1 100755 --- a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assLongMenuImport.php +++ b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assLongMenuImport.php @@ -16,6 +16,9 @@ * *********************************************************************/ +/** + * @deprecated Use normalized question import instead + */ class assLongMenuImport extends assQuestionImport { public $object; diff --git a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assMatchingQuestionImport.php b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assMatchingQuestionImport.php index 3e7a5946d040..7d992a428b8d 100755 --- a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assMatchingQuestionImport.php +++ b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assMatchingQuestionImport.php @@ -24,6 +24,8 @@ * @author Helmut Schottmüller * @version $Id$ * @ingroup components\ILIASTestQuestionPool + * + * @deprecated Use normalized question import instead */ class assMatchingQuestionImport extends assQuestionImport { diff --git a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assMultipleChoiceImport.php b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assMultipleChoiceImport.php index 7b8cb9841dc8..584bb6fdce36 100755 --- a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assMultipleChoiceImport.php +++ b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assMultipleChoiceImport.php @@ -24,6 +24,8 @@ * @author Helmut Schottmüller * @version $Id$ * @ingroup components\ILIASTestQuestionPool + * + * @deprecated Use normalized question import instead */ class assMultipleChoiceImport extends assQuestionImport { diff --git a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assNumericImport.php b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assNumericImport.php index ce1750add09a..c73529bdc747 100755 --- a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assNumericImport.php +++ b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assNumericImport.php @@ -24,6 +24,8 @@ * @author Helmut Schottmüller * @version $Id$ * @ingroup components\ILIASTestQuestionPool + * + * @deprecated Use normalized question import instead */ class assNumericImport extends assQuestionImport { diff --git a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assOrderingHorizontalImport.php b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assOrderingHorizontalImport.php index 94550a37f495..9969ef698257 100755 --- a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assOrderingHorizontalImport.php +++ b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assOrderingHorizontalImport.php @@ -24,6 +24,8 @@ * @author Helmut Schottmüller * @version $Id$ * @ingroup components\ILIASTestQuestionPool + * + * @deprecated Use normalized question import instead */ class assOrderingHorizontalImport extends assQuestionImport { diff --git a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assOrderingQuestionImport.php b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assOrderingQuestionImport.php index 5c1ef72b620b..74853cda461d 100755 --- a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assOrderingQuestionImport.php +++ b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assOrderingQuestionImport.php @@ -24,6 +24,8 @@ * @author Helmut Schottmüller * @version $Id$ * @ingroup components\ILIASTestQuestionPool + * + * @deprecated Use normalized question import instead */ class assOrderingQuestionImport extends assQuestionImport { diff --git a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assQuestionImport.php b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assQuestionImport.php index 1892242f8f5e..168e2b69573b 100755 --- a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assQuestionImport.php +++ b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assQuestionImport.php @@ -30,6 +30,8 @@ * @author Helmut Schottmüller * @version $Id$ * @ingroup components\ILIASTestQuestionPool + * + * @deprecated Use normalized question import instead */ class assQuestionImport { diff --git a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assSingleChoiceImport.php b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assSingleChoiceImport.php index af1fcdd4ce61..3d70e52b39e6 100755 --- a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assSingleChoiceImport.php +++ b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assSingleChoiceImport.php @@ -24,6 +24,8 @@ * @author Helmut Schottmüller * @version $Id$ * @ingroup components\ILIASTestQuestionPool + * + * @deprecated Use normalized question import instead */ class assSingleChoiceImport extends assQuestionImport { diff --git a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assTextQuestionImport.php b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assTextQuestionImport.php index 3ab40e8be8bc..2039f5cea6c6 100755 --- a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assTextQuestionImport.php +++ b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assTextQuestionImport.php @@ -24,6 +24,8 @@ * @author Helmut Schottmüller * @version $Id$ * @ingroup components\ILIASTestQuestionPool + * + * @deprecated Use normalized question import instead */ class assTextQuestionImport extends assQuestionImport { diff --git a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assTextSubsetImport.php b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assTextSubsetImport.php index cd21132fb74f..46241f5df10f 100755 --- a/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assTextSubsetImport.php +++ b/components/ILIAS/TestQuestionPool/classes/import/qti12/class.assTextSubsetImport.php @@ -24,6 +24,8 @@ * @author Helmut Schottmüller * @version $Id$ * @ingroup components\ILIASTestQuestionPool + * + * @deprecated Use normalized question import instead */ class assTextSubsetImport extends assQuestionImport { diff --git a/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssOrderingElement.php b/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssOrderingElement.php index 0d8222ef76e3..8b7c174f35b4 100755 --- a/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssOrderingElement.php +++ b/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssOrderingElement.php @@ -18,6 +18,12 @@ declare(strict_types=1); +use ILIAS\Refinery\Transformation; +use ILIAS\TestQuestionPool\ExportImport\Envelopes\QuestionImage; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Envelopes\Id; + /** * Class represents an ordering element for assOrderingQuestion * @@ -25,7 +31,7 @@ * @version $Id$ * @package Modules/TestQuestionPool */ -class ilAssOrderingElement +class ilAssOrderingElement implements Normalizable { public const EXPORT_IDENT_PROPERTY_SEPARATOR = '_'; @@ -440,4 +446,44 @@ 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'])); + + if (is_array($normalized['content'])) { + $clone->setContent($tt->denormalize($normalized['content'], QuestionImage::class)?->getFilename()); + } else { + $clone->setContent((string) $normalized['content']); + } + + $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..49ba023e22c2 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( + static fn(array $normalized): ilAssQuestionLifecycle => self::getInstance($normalized['identifier']) + ); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionSkillAssignmentExporter.php b/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionSkillAssignmentExporter.php index 3dad3c8d4346..ce2863de95a8 100755 --- a/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionSkillAssignmentExporter.php +++ b/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionSkillAssignmentExporter.php @@ -19,10 +19,7 @@ declare(strict_types=1); /** - * @author Björn Heyser - * @version $Id$ - * - * @package components\ILIAS/TestQuestionPool + * @deprecated 12: Use ILIAS\TestQuestionPool\ExportImport\Export\QuestionPoolExporter instead */ class ilAssQuestionSkillAssignmentExporter { diff --git a/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionSkillAssignmentImport.php b/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionSkillAssignmentImport.php index 4c744ef0bfe2..b19aecac3437 100755 --- a/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionSkillAssignmentImport.php +++ b/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionSkillAssignmentImport.php @@ -17,10 +17,7 @@ *********************************************************************/ /** - * @author Björn Heyser - * @version $Id$ - * - * @package components\ILIAS/TestQuestionPool + * @deprecated 12: Use ILIAS\TestQuestionPool\ExportImport\Import\SkillAssignmentsImporter instead */ class ilAssQuestionSkillAssignmentImport { diff --git a/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionSkillAssignmentImportFails.php b/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionSkillAssignmentImportFails.php index 5ebe88a7d3f8..a6d32523c1f2 100755 --- a/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionSkillAssignmentImportFails.php +++ b/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionSkillAssignmentImportFails.php @@ -17,10 +17,7 @@ *********************************************************************/ /** - * @author Björn Heyser - * @version $Id$ - * - * @package components\ILIAS/TestQuestionPool + * @deprecated 12: Use ILIAS\TestQuestionPool\ExportImport\Import\SkillAssignmentsImporter instead */ class ilAssQuestionSkillAssignmentImportFails { diff --git a/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionSkillAssignmentImportList.php b/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionSkillAssignmentImportList.php index aefe73068ac1..5fed2d9f0571 100755 --- a/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionSkillAssignmentImportList.php +++ b/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionSkillAssignmentImportList.php @@ -19,11 +19,7 @@ declare(strict_types=1); /** - * @author Björn Heyser - * @version $Id$ - * - * @package components\ILIAS/TestQuestionPool - * @implements Iterator + * @deprecated 12: Use ILIAS\TestQuestionPool\ExportImport\Import\SkillAssignmentsImporter instead */ class ilAssQuestionSkillAssignmentImportList implements Iterator { diff --git a/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionSkillAssignmentImporter.php b/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionSkillAssignmentImporter.php index 049f48ebbc48..2942b61082db 100755 --- a/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionSkillAssignmentImporter.php +++ b/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionSkillAssignmentImporter.php @@ -19,10 +19,7 @@ use ILIAS\Skill\Service\SkillUsageService; /** - * @author Björn Heyser - * @version $Id$ - * - * @package components\ILIAS/TestQuestionPool + * @deprecated 12: Use ILIAS\TestQuestionPool\ExportImport\Import\SkillAssignmentsImporter instead */ class ilAssQuestionSkillAssignmentImporter { diff --git a/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionSkillAssignmentRegistry.php b/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionSkillAssignmentRegistry.php index 07af315d4ab0..49b409c38c10 100755 --- a/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionSkillAssignmentRegistry.php +++ b/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionSkillAssignmentRegistry.php @@ -17,7 +17,7 @@ *********************************************************************/ /** - * Class ilAssQuestionSkillAssignmentRegistry + * @deprecated 12: Use ILIAS\TestQuestionPool\ExportImport\Import\SkillAssignmentsImporter instead */ class ilAssQuestionSkillAssignmentRegistry { diff --git a/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionSkillAssignmentXmlParser.php b/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionSkillAssignmentXmlParser.php index 58c0b7c85db8..9b48877ef0af 100755 --- a/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionSkillAssignmentXmlParser.php +++ b/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionSkillAssignmentXmlParser.php @@ -19,10 +19,7 @@ declare(strict_types=1); /** - * @author Björn Heyser - * @version $Id$ - * - * @package components\ILIAS/TestQuestionPool + * @deprecated 12: Use ILIAS\TestQuestionPool\ExportImport\Import\SkillAssignmentsImporter instead */ class ilAssQuestionSkillAssignmentXmlParser extends ilSaxParser { diff --git a/components/ILIAS/TestQuestionPool/interfaces/QTI/interface.ilQTIFlowMatAware.php b/components/ILIAS/TestQuestionPool/interfaces/QTI/interface.ilQTIFlowMatAware.php index d6a63ebe6a2e..d123e088c1cc 100755 --- a/components/ILIAS/TestQuestionPool/interfaces/QTI/interface.ilQTIFlowMatAware.php +++ b/components/ILIAS/TestQuestionPool/interfaces/QTI/interface.ilQTIFlowMatAware.php @@ -19,8 +19,7 @@ declare(strict_types=1); /** - * Interface ilQTIMaterialAware - * @author Michael Jansen + * @deprecated 12: QTI export is no longer supported */ interface ilQTIFlowMatAware { diff --git a/components/ILIAS/TestQuestionPool/interfaces/QTI/interface.ilQTIMaterialAware.php b/components/ILIAS/TestQuestionPool/interfaces/QTI/interface.ilQTIMaterialAware.php index 3aa61a17ca2d..0217a864d1db 100755 --- a/components/ILIAS/TestQuestionPool/interfaces/QTI/interface.ilQTIMaterialAware.php +++ b/components/ILIAS/TestQuestionPool/interfaces/QTI/interface.ilQTIMaterialAware.php @@ -19,8 +19,7 @@ declare(strict_types=1); /** - * Interface ilQTIMaterialAware - * @author Michael Jansen + * @deprecated 12: QTI export is no longer supported */ interface ilQTIMaterialAware { diff --git a/components/ILIAS/TestQuestionPool/interfaces/QTI/interface.ilQTIPresentationMaterialAware.php b/components/ILIAS/TestQuestionPool/interfaces/QTI/interface.ilQTIPresentationMaterialAware.php index f0a445a25990..cb7bf714e096 100755 --- a/components/ILIAS/TestQuestionPool/interfaces/QTI/interface.ilQTIPresentationMaterialAware.php +++ b/components/ILIAS/TestQuestionPool/interfaces/QTI/interface.ilQTIPresentationMaterialAware.php @@ -19,8 +19,7 @@ declare(strict_types=1); /** - * Interface ilQTIPresentationMaterialAware - * @author Michael Jansen + * @deprecated 12: QTI export is no longer supported */ interface ilQTIPresentationMaterialAware { 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..bc3f6621e67b --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Envelopes/QuestionImage.php @@ -0,0 +1,93 @@ +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): void + { + $this->id = $id; + } + + /** + * @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..27102ad8b283 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Export/CollectsQuestions.php @@ -0,0 +1,215 @@ + + */ + 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) { + continue; + } + + 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 + { + return new Feedback( + new Id($question->getId(), 'question'), + $question->feedbackOBJ->getGenericFeedbackExportPresentation($question->getId(), false), + $question->feedbackOBJ->getGenericFeedbackExportPresentation($question->getId(), true), + $this->loadSpecificFeedback($question), + ); + } + + 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 []; + } + + $feedback = []; + + // 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()); + + 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..6117764f053d --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Export/QuestionPoolCollector.php @@ -0,0 +1,85 @@ + $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 + { + return $this->questions ??= $this->question_repository->getForParentObjectId($this->pool_id->toInt()); + } + + 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..5aa1af67461c --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Export/QuestionPoolExporter.php @@ -0,0 +1,220 @@ +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(ExportDependencies $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(ExportDependencies $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(ExportDependencies $state): ?ObjectId + { + $target_ids = $state->target()->getObjectIds(); + + if ($target_ids === []) { + $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, + ExportDependencies $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, + ExportDependencies $state + ): void { + foreach ($collector->getQuestionObjects() as $question) { + $normalized = [ + ... $transformations->normalize($question), + 'feedback' => $transformations->normalize($collector->getFeedback($question)) + ]; + + if ($question instanceof assFormulaQuestion) { + $normalized['formula_data'] = $transformations->normalize( + $collector->getUnitsAndCategories($question->getId()) + ); + } + + $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..2bae15d3b356 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/ExportState.php @@ -0,0 +1,192 @@ +}> $dependencies + */ + private array $dependencies = []; + + public function __construct( + private ExportTarget $target, + private ExportConfig $config, + private string $option = '' + ) { + } + + 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); + } + + 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..512df474c02b --- /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..724f5cd01b88 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Builder.php @@ -0,0 +1,292 @@ + $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; + } + } + } + + throw new RuntimeException("Unable to resolve constructor parameter \${$parameter->getName()} 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/ExportDependencies.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/ExportDependencies.php new file mode 100644 index 000000000000..6736fca44114 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/ExportDependencies.php @@ -0,0 +1,81 @@ +}> + */ + public function getDependencies(): array; + + /** + * @param array $ids + */ + public function addDependency(string $component, string $entity, array $ids): void; + + public function getContent(): string; +} 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..5cfd1f10966c --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/Exporter.php @@ -0,0 +1,64 @@ + + * + * @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..b609f75e2679 --- /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..ed18fdc70859 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportSessionRepository.php @@ -0,0 +1,74 @@ +key_index = "{$base}index"; + $this->key_context = "{$base}context"; + } + + public function getCurrentStageIndex(): int + { + return \ilSession::has($this->key_index) ? (int) \ilSession::get($this->key_index) : 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 + { + \ilSession::clear($this->key_index); + \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..d17f3d0e5c11 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportStageRunner.php @@ -0,0 +1,130 @@ + $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); + } + + $result = $this->stages[$index]->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 @@ + */ + public array $types; + + /** + * @param class-string ...$types + */ + public function __construct(string ...$types) { + $this->types = $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..190a54a83da2 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Envelopes/Id.php @@ -0,0 +1,100 @@ +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, + ]; + } + + 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']); + } + + $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..ffec74f76c48 --- /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..497b3d022ceb --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/IlObjectNormalizer.php @@ -0,0 +1,154 @@ + + */ +#[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..2d6374b7328a --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/Legacy11UUIDNormalizer.php @@ -0,0 +1,55 @@ + + */ + 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 ($this->hasNormalizer($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..d0a131617c05 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/ResourceNormalizer.php @@ -0,0 +1,143 @@ + + */ +#[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); + } + + return $this->resource_repository->blank( + new ResourceIdentification($value['id']), + ResourceType::from($value['resource_type']) + ); + } + + /** + * Returns true if the value is a normalized resource identification. + */ + public static function isResourceIdentification(mixed $value): bool + { + return ($value[self::KEY_TYPE] ?? false) === self::TYPE_RID; + } + + /** + * Returns true if the value is a normalized storable resource. + */ + public static function isStorableResource(mixed $value): bool + { + return ($value[self::KEY_TYPE] ?? false) === 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..3d79aaff8653 --- /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(static 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 + { + $this->resources[$rid->serialize()] = $this->irss->manage()->getResource($rid); + } + + private function replaceRid(ResourceIdentification $rid): ResourceIdentification + { + $id = $rid->serialize(); + if (!isset($this->import_mapping[$id])) { + $this->log->warning("Unresolved resource id {$id}"); + return $rid; + } + + $this->log->debug("Replaced resource id {$id} with {$this->import_mapping[$id]->serialize()}"); + return $this->import_mapping[$id]; + } +} 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..b3987aa12cca --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/DenormalizeCarry.php @@ -0,0 +1,48 @@ +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..3c49d26c6d61 --- /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(static fn(NormalizeCarry $carry): mixed => $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(static fn(DenormalizeCarry $carry): mixed => $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..ed79290c6917 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Pipeline.php @@ -0,0 +1,202 @@ + + * + * @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(mixed $passable): mixed => $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): Closure + { + return function (mixed $passable) use ($destination): mixed { + try { + return $destination($passable); + } catch (Throwable $e) { + $this->handleException($passable, $e); + } + }; + } + + /** + * Get a Closure that represents a slice of the application onion. + */ + protected function carry(): Closure + { + return fn($stack, $pipe) => function ($passable) use ($stack, $pipe): mixed { + try { + return $this->executePipe($pipe, $passable, $stack); + } catch (Throwable $e) { + $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 + { + // Maybe we want to handle specific exceptions differently in the future. + // For now, we'll just re-throw the exception.' + 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..9313ab12a262 --- /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..92911b306b1f --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/JSONMemorySerializer.php @@ -0,0 +1,117 @@ + 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 === null) { + throw new \LogicException("Called endGroup() without matching startGroup()"); + } + + if ($frame['name'] !== $name) { + throw new \LogicException("Group name mismatch: expected end of '{$frame['name']}', got '{$name}'"); + } + $this->stack[array_key_last($this->stack)]['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)]; + + // First entry for this name: store it directly. + if (!array_key_exists($name, $top['data'])) { + $top['data'][$name] = $data; + return; + } + + $existing = $top['data'][$name]; + + // Multiple entries already exist: append to the list. + if (is_array($existing) && array_is_list($existing)) { + $top['data'][$name][] = $data; + return; + } + + // Second entry: convert the existing single entry into a list. + $top['data'][$name] = [$existing, $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..e331a7232ac9 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/SimpleXMLSerializer.php @@ -0,0 +1,188 @@ +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 ($data === []) { + $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 ($value === []) { + $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; + } + + $this->handler[$node_name]($this->readGroup($reader)); + } + + $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) { + return $is_marked_empty_array ? [] : ''; + } + + $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..c91cb66c19f0 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/XMLFileDeserializer.php @@ -0,0 +1,66 @@ +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..ab40dea5474e --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/XMLMemoryDeserializer.php @@ -0,0 +1,65 @@ +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 = trim($xml); + $xml = preg_replace('/^\s*<\?xml[^>]*\?>\s*/i', '', $xml) ?? $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..8260d1cebbaa --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Setup/NormalizerArtifactObjective.php @@ -0,0 +1,90 @@ +getMatchingClassNames(Normalizer::class) as $class_name) { + foreach ($this->getDeclarations(new ReflectionClass($class_name)) as [$types, $versions]) { + $entry = array_fill_keys($versions, $class_name); + foreach ($types as $type) { + // union instead of array_merge: numeric version keys would be reindexed + $type_map[$type] = $entry + ($type_map[$type] ?? []); + } + } + } + + return new ArrayArtifact($type_map); + } + + /** + * Both attributes declare the same thing: every combination of type and version is served by the given + * class. Normalize is the special case where the only version is the default one. + * + * @param ReflectionClass $ref + * @return Generator, list}> + */ + private function getDeclarations(ReflectionClass $ref): Generator + { + if (($attribute = $ref->getAttributes(Normalizes::class)[0] ?? null) !== null) { + yield [$attribute->newInstance()->types, [self::DEFAULT_KEY]]; + } + + if (($attribute = $ref->getAttributes(NormalizesLegacy::class)[0] ?? null) !== null) { + $legacy = $attribute->newInstance(); + yield [$legacy->types, $legacy->versions]; + } + } +} 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..0f9869c21868 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/CleanupStage.php @@ -0,0 +1,90 @@ +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 + { + foreach (array_diff(scandir($path), ['.', '..']) 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..ff163bf3c26a --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/DetectLegacyImportStage.php @@ -0,0 +1,79 @@ +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..e03ec66fe422 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionPoolImporter.php @@ -0,0 +1,164 @@ +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($pool_object->getTitle()); + $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..fbad7b3a7ca8 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionSelectionStage.php @@ -0,0 +1,229 @@ + \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)); + + return $this->ui_factory->input()->container()->form()->standard( + $this->form_action, + ['selected_questions' => $input] + )->withSubmitLabel($this->lng->txt('import')); + } + + 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 $plugin) { + if ($plugin->getQuestionType() === $type) { + return $plugin->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..bc942f5a3edd --- /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 instanceof FileStream) { + 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..6aed319e2474 --- /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) { + // The mapping pipe replaces ParentObjID and QuestionID + $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..585263caf527 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/UploadValidationStage.php @@ -0,0 +1,116 @@ +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(array $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..524a8e0f023d --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/LoggingProvider.php @@ -0,0 +1,44 @@ +loggerFactory() + ->getLazy($component_id ?? self::COMPONENT_ID); + } + + 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..41777693b1c0 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Pipes/CollectQuestionImages.php @@ -0,0 +1,120 @@ + $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)); + } + + $path = "{$envelope->getId()}." . pathinfo($envelope->getFilename(), PATHINFO_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/Import/TestQuestionsImportTrait.php b/components/ILIAS/TestQuestionPool/src/Import/TestQuestionsImportTrait.php index 07e896a3a420..152496760d60 100644 --- a/components/ILIAS/TestQuestionPool/src/Import/TestQuestionsImportTrait.php +++ b/components/ILIAS/TestQuestionPool/src/Import/TestQuestionsImportTrait.php @@ -23,6 +23,9 @@ use Psr\Http\Message\ServerRequestInterface; use ILIAS\UI\Component\Input\Container\Form\Standard as StandardForm; +/** + * @deprecated 12: Use ILIAS\Test\ExportImport\TestImporter instead + */ trait TestQuestionsImportTrait { private string $import_temp_directory = CLIENT_DATA_DIR . DIRECTORY_SEPARATOR . 'temp'; 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 6087ae3c50eb..bbdf2076161b 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 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 f6f408f91592..5b459f6cad4b 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 +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