diff --git a/components/ILIAS/Test/PRIVACY.md b/components/ILIAS/Test/PRIVACY.md index e4c1616016f5..0370c389c217 100644 --- a/components/ILIAS/Test/PRIVACY.md +++ b/components/ILIAS/Test/PRIVACY.md @@ -40,6 +40,8 @@ At the Administration node for Test and Assessment accounts having 'Edit Setting At Administration > Repository and Objects > Test and Assessment > Settings it is possible to select which 'Unique user criteria' is used in test imports/exports. The selected type of personal data will be included in export type 'XML incl. Participants Results' of a test for each account. Options are: usr_id, login, email, matriculation, ext_account. This personal data is required to match accounts results when importing an export file at the same or another platform. +When importing such an export file, the accounts are looked up by the 'Unique user criteria' configured at the importing platform. Test attempts of accounts which cannot be matched are not discarded but assigned to the anonymous account. The personal data contained in the export file for those accounts is therefore imported without being linked to an account of the importing platform. + At Administration > Repository and Objects > Test and Assessment > Log Data accounts with 'Edit Settings' permission can activate the History-tab via the checkbox 'Activate Test and Assessment Logging'. Additionally the setting 'Log IP' allows for logging the IP-adress of participants along with the interactions as well as specific settings of said test during the interaction. The purpose of both options is to store important events and information for configuring and performing tests. This gives the possibility to check those information in case of issues or concerns after performing tests. @@ -230,9 +232,23 @@ There are three export files at the tab Test > Export which contain personal dat The ‘Archive file’ contains all personal data which is being stored and presented in the test (see above). Its purpose is to have this data easily accessible outside of ILIAS, e.g. for long-term archiving of the data. -The 'XML' export contains the personal data 'Author' of the questions and of the test itself within the metadata (see above). Its purpose is to be imported into ILIAS again, although the contained personal data is easily accessible. +The 'XML' export contains the personal data 'Author' of the questions and of the test itself within the metadata (see above). In addition it contains the 'User ID' of the owner of each question. Its purpose is to be imported into ILIAS again, although the contained personal data is easily accessible. + +The ‘XML export incl. Participant Results’ contains all personal data which is being stored and presented in the test (see above). The data in the ‘History’ tab is an exception; it is not included. Its purpose is to be imported into ILIAS again, although the contained personal data is easily accessible. This export file can be used to, e.g., provide the test results to the participants at another ILIAS installation. In addition to the data of the 'XML' export, it contains the following personal data for each participant: -The ‘XML export incl. Participant Results’ contains all personal data which is being stored and presented in the test (see above). The data in the ‘History’ tab is an exception; it is not included. Its purpose is to be imported into ILIAS again, although the contained personal data is easily accessible. This export file can be used to, e.g., provide the test results to the participants at another ILIAS installation. +- User ID (exported as the 'Unique user criteria' configured at Administration > Repository and Objects > Test and Assessment > Settings, see above) +- Anonymous ID, if the test is set to anonymous +- Name (first name and last name) +- Login +- Matriculation Number +- Import name, if the account was imported +- Client IP Range +- Invitation date +- Additional working time granted to the participant +- Timestamp of the first and the last access +- All test attempts with their timestamps, answers, scoring and status + +For questions which have been scored manually, the 'User ID' of the account which finalized the scoring is contained as well. ### Administration > Repository and Objects > Test and Assessment > Log Data > Log Data Output diff --git a/components/ILIAS/Test/ROADMAP.md b/components/ILIAS/Test/ROADMAP.md index da4b23f58c30..193e98d0322e 100755 --- a/components/ILIAS/Test/ROADMAP.md +++ b/components/ILIAS/Test/ROADMAP.md @@ -23,7 +23,7 @@ Among other challenges, we are planning to use the given developing budget from * Use of IRSS in T&A * Further reduction of access and use of request variables (GPCR) 5. Work on [(Project) Revision of Test Question Creation](https://docu.ilias.de/go/wiki/wpage_8111_1357) -6. Work on Project Revision of Import/Export of the T&A +6. Work on Project Revision of Import/Export of the T&A. Export and import of Test and Question Pool have been rebuilt upon a normalized XML format in ILIAS 12. The QTI based export and the Excel export of questions are deprecated, import of files created with previous ILIAS versions is covered by dedicated legacy importers. Still open are the revision of the Archive file and the removal of the deprecated legacy code. 7. Feasibility study Test in Learning sequence - Could the LS uses the actual Exam View (since ILIAS9 - General Kiosk Mode) 8. Authorized Saving of Participant Input - Problems e.g. in the question list, see [Mantis 44211](https://mantis.ilias.de/view.php?id=44211). In order to address the problem, we need a concept for the entire test run at the technical level and an analysis of the current situation. From this, we want to derive an operating concept for controlling the test run. By redesigning the test run, we know where we want to go and can at least mitigate the problems in existing ILIAS versions with usability fixes. 9. KS Improvements / Exchange of single elements' trough existing KS elements independently of the Legacy-UI-Project @@ -46,7 +46,6 @@ Remarks on the individual items are marked with "@PHP8-CR" * \ilTestPlayerAbstractGUI::autosaveCmd / This looks like another issue in the autosaving. Left for review/analysis by TechSquad * \ilAssLacCompositeValidator::validateSubTree / Incompatible type. Left for review/analysis by TechSquad ### TestQuestionPool -* \ilObjQuestionPoolGUI::exportQuestionObject / Void result used. Left for review/analysis by TechSquad * \assMatchingQuestionGUI::writeAnswerSpecificPostData / Incompatible type. Left for review/analysis by TechSquad * \assMatchingQuestionGUI::populateAnswerSpecificFormPart / Incompatible type. Left for review/analysis by TechSquad * \assMatchingQuestionImport::fromXML / Incompatible type. Left for review/analysis by TechSquad diff --git a/components/ILIAS/Test/classes/Forms/class.ilTestRandomQuestionSetPoolDefinitionFormGUI.php b/components/ILIAS/Test/classes/Forms/class.ilTestRandomQuestionSetPoolDefinitionFormGUI.php index 3ef3cd1a7035..d29881d84d16 100755 --- a/components/ILIAS/Test/classes/Forms/class.ilTestRandomQuestionSetPoolDefinitionFormGUI.php +++ b/components/ILIAS/Test/classes/Forms/class.ilTestRandomQuestionSetPoolDefinitionFormGUI.php @@ -154,7 +154,7 @@ public function build(ilTestRandomQuestionSetSourcePoolDefinition $sourcePool, $ $lifecycleFilter = new ilSelectInputGUI($this->lng->txt('qst_lifecycle'), 'filter_lifecycle'); $lifecycleFilter->setRequired(true); $lifecycleFilter->setMulti(true); - $lifecycleFilter->setOptions(ilAssQuestionLifecycle::getDraftInstance()->getSelectOptions($this->lng)); + $lifecycleFilter->setOptions(new ilAssQuestionLifecycle()->getSelectOptions($this->lng)); $lifecycleFilter->setValue($lifecycleFilterValues); $lifecycleCheckbox->addSubItem($lifecycleFilter); $this->addItem($lifecycleCheckbox); diff --git a/components/ILIAS/Test/classes/class.ilObjTest.php b/components/ILIAS/Test/classes/class.ilObjTest.php index de177f886df7..9e2b633664da 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 dfe4933928e0..3aab97229f3d 100755 --- a/components/ILIAS/Test/classes/class.ilObjTestGUI.php +++ b/components/ILIAS/Test/classes/class.ilObjTestGUI.php @@ -18,13 +18,43 @@ 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\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,48 +68,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\ExportImport\Factory as ExportImportFactory; -use ILIAS\Test\ExportImport\DBRepository as ExportRepository; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Import\ImportSession; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Import\ImportSessionRepository; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Import\ImportWizard; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Import\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 @@ -195,6 +205,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; @@ -248,6 +260,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())) { @@ -383,8 +397,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() @@ -1377,81 +1389,116 @@ public function runObject() $this->ctrl->redirectByClass([ilRepositoryGUI::class, self::class, ilInfoScreenGUI::class]); } + protected function importFile(string $file_to_import, string $path_to_uploaded_file_in_temp_dir): void { - list($subdir, $importdir, $xmlfile, $qtifile) = $this->buildImportDirectoriesFromImportFile($file_to_import); - - $options = (new ILIAS\Filesystem\Util\Archive\UnzipOptions()) - ->withZipOutputPath($this->getImportTempDirectory()); + $this->import_session_repository->clear(); - $unzip = $this->archives->unzip(Streams::ofResource(fopen($file_to_import, 'r')), $options); - $unzip->extract(); + $session = (new ImportSession())->withFileToImport($file_to_import); + $this->import_session_repository->setSession($session); + $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 + { + if (!$this->checkPermissionBool('write', '', $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->buildImportWizard()->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::RequiresUserInteraction: + $this->tpl->setContent( + $this->ui_renderer->render($result->components) + ); + break; + + case StageResultType::Error: + $this->tpl->setOnScreenMessage( + GlobalTemplate::MESSAGE_TYPE_FAILURE, + $result->error_message, + true + ); + break; + + case StageResultType::CompletedSuccessfully: + $this->afterImportCompleted($result->session); + break; } + } - $form = $this->buildImportQuestionsSelectionForm( - 'importVerifiedFile', - $importdir, - $qtifile, - $file_to_import, - $path_to_uploaded_file_in_temp_dir - ); + private function afterImportCompleted(ImportSession $session): void + { + $new_obj = new ilObjTest(0, false); + $new_obj->setId($session->testObjId() ?? 0); + $new_obj->setRefId($session->testRefId() ?? 0); - 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 buildImportWizard(): ImportWizard + { + return new ImportWizard( [ - $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 @@ -1493,108 +1540,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 */ @@ -2104,22 +2049,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(); @@ -2321,16 +2250,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 { $next_class = $this->ctrl->getNextClass(); @@ -2406,6 +2325,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 f01433516c0d..ae932ca0d0ad 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 9244605526e9..4d04bdb2816c 100755 --- a/components/ILIAS/Test/classes/class.ilTestExporter.php +++ b/components/ILIAS/Test/classes/class.ilTestExporter.php @@ -18,182 +18,74 @@ 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\Export\ExportPhase; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Export\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->context_holder = $local_dic['exportimport.export_context_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 context. The export context 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 - ]; - } - - $deps[] = [ - "component" => "components/ILIAS/Tracking", - "entity" => "lpsettings", - "ids" => $a_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 context is not initialized yet. + if ( + !$this->context_holder->exists() + || $this->context_holder->get()->getPhase() !== ExportPhase::Init + ) { + $this->initExportContext( + '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 6e78e48129a6..523af1a7ed87 100755 --- a/components/ILIAS/Test/classes/class.ilTestImporter.php +++ b/components/ILIAS/Test/classes/class.ilTestImporter.php @@ -18,38 +18,38 @@ declare(strict_types=1); -use ILIAS\ResourceStorage\Services as ResourceStorage; -use ILIAS\TestQuestionPool\Import\TestQuestionsImportTrait; +use ILIAS\Data\ReferenceId; +use ILIAS\Test\ExportImport\Import\TestImporter; use ILIAS\Test\TestDIC; -use ILIAS\Test\Logging\TestLogger; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Import\ImportSessionRepository; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Serialize\XmlDeserializer; -/** - * 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,298 +58,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); - return; - } - if (!file_exists($qtifile)) { - $this->logger->error(__METHOD__ . ': Cannot find xml definition: ' . $qtifile); + // Check if forward to legacy importer is needed + $session = $this->session->getSession(); + if ($session->isLegacyImport()) { + $this->legacy_importer->setInstallId($this->getInstallId()); + $this->legacy_importer->setInstallUrl($this->getInstallUrl()); + $this->legacy_importer->setSchemaVersion($this->getSchemaVersion()); + $this->legacy_importer->setSkipEntities($this->getSkipEntities()); + $this->legacy_importer->importXmlRepresentation($a_entity, $a_id, $a_xml, $a_mapping); return; } - // start parsing of QTI files - $qti_parser = new ilQTIParser( - $importdir, - $qtifile, - ilQTIParser::IL_MO_PARSE_QTI, - $new_obj->getId(), - $selected_questions, - $a_mapping->getAllMappings() - ); - $qti_parser->setTestObject($new_obj); - $qti_parser->startParsing(); - $new_obj = $qti_parser->getTestObject(); - - // import page data - $question_page_parser = new ilQuestionPageParser( - $new_obj, - $xmlfile, - $importdir - ); - $question_page_parser->setQuestionMapping($qti_parser->getImportMapping()); - $question_page_parser->startParsing(); - - $a_mapping = $this->addTaxonomyAndQuestionsMapping($qti_parser->getQuestionIdMapping(), $new_obj->getId(), $a_mapping); - - if ($new_obj->isRandomTest()) { - $this->importRandomQuestionSetConfig($new_obj, $xmlfile, $a_mapping); - } - - if ($results_file_path !== null && file_exists($results_file_path)) { - $results = new ilTestResultsImportParser($results_file_path, $new_obj, $this->db, $this->logger, $this->irss); - $results->setQuestionIdMapping($a_mapping->getMappingsOfEntity('components/ILIAS/Test', 'quest')); - $results->setSrcPoolDefIdMapping($a_mapping->getMappingsOfEntity('components/ILIAS/Test', 'rnd_src_pool_def')); - $results->startParsing(); - } - - $new_obj->saveToDb(); // this creates test_fi - $new_obj->update(); // this saves ilObject data - - $this->importSkillLevelThresholds( + $result = $this->importer->import( + XmlDeserializer::fromString($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" - ); - $a_mapping->addMapping( - "components/ILIAS/Tracking", - "obj", - $a_id, - (string) $new_obj->getId() + new ReferenceId($a_mapping->getTargetId()), + $session, ); - } - - 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->setSession($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 + $session = $this->session->getSession(); + if ($session->isLegacyImport()) { + $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..120a9c22b7fa --- /dev/null +++ b/components/ILIAS/Test/classes/class.ilTestLegacyImporter.php @@ -0,0 +1,335 @@ +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()); + + $session = $this->session->getSession(); + + // start parsing of QTI files + $qti_parser = new ilQTIParser( + $session->importBaseDir(), + $session->legacyQtiFile(), + ilQTIParser::IL_MO_PARSE_QTI, + $new_obj->getId(), + $session->selectedQuestionIds(), + $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, + $session->legacyXmlFile(), + $session->importBaseDir() + ); + $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, $session->legacyXmlFile(), $a_mapping); + } + + $results_file_path = str_replace('__tst', '__results', $session->legacyXmlFile()); + 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, $session->legacyXmlFile()), + $new_obj, + $session->legacyXmlFile() + ); + + $a_mapping->addMapping( + 'components/ILIAS/MetaData', + 'md', + "{$a_id}:0:tst", + "{$new_obj->getId()}:0:tst" + ); + + $session = $session->withTestObjId($new_obj->getId())->withTestRefId($new_obj->getRefId()); + $this->session->setSession($session); + } + + 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.ilTestQuestionFilterLabelTranslator.php b/components/ILIAS/Test/classes/class.ilTestQuestionFilterLabelTranslator.php index a164996e7271..de2ba588a036 100755 --- a/components/ILIAS/Test/classes/class.ilTestQuestionFilterLabelTranslator.php +++ b/components/ILIAS/Test/classes/class.ilTestQuestionFilterLabelTranslator.php @@ -149,7 +149,7 @@ public function getTaxonomyFilterLabel( public function getLifecycleFilterLabel(array $filter = []): string { - $lifecycle_translations = ilAssQuestionLifecycle::getDraftInstance()->getSelectOptions($this->lng); + $lifecycle_translations = new ilAssQuestionLifecycle()->getSelectOptions($this->lng); $lifecycles = []; foreach ($filter as $lifecycle) { diff --git a/components/ILIAS/Test/classes/class.ilTestQuestionSetConfig.php b/components/ILIAS/Test/classes/class.ilTestQuestionSetConfig.php index 320f896ee042..d714a3082561 100755 --- a/components/ILIAS/Test/classes/class.ilTestQuestionSetConfig.php +++ b/components/ILIAS/Test/classes/class.ilTestQuestionSetConfig.php @@ -49,6 +49,11 @@ abstract public function doesQuestionSetRelatedDataExist(): bool; abstract public function removeQuestionSetRelatedData(): void; abstract public function cloneQuestionSetRelatedData(ilObjTest $clone_test_obj): void; + public function getTestObject(): ilObjTest + { + return $this->test_obj; + } + public function getQuestionPoolPathString(int $pool_id): string { $ref_id = current(ilObject::_getAllReferences($pool_id)); diff --git a/components/ILIAS/Test/classes/class.ilTestRandomQuestionSetSourcePoolDefinition.php b/components/ILIAS/Test/classes/class.ilTestRandomQuestionSetSourcePoolDefinition.php index d04074bbcfbf..34544c4238e9 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\TestQuestionPool\ExportImport\Foundation\Normalize\FromNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\ToNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Envelopes\Id; + /** * @author Björn Heyser * @version $Id$ * * @package Modules/Test */ -class ilTestRandomQuestionSetSourcePoolDefinition +class ilTestRandomQuestionSetSourcePoolDefinition implements ToNormalized, FromNormalized { private ?int $id = null; private ?int $pool_id = null; @@ -469,4 +474,85 @@ public function getPoolInfoLabel(ilLanguage $lng): string } // ----------------------------------------------------------------------------------------------------------------- + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null { + $normalized = [ + 'id' => $transformations->normalize(new Id($this->getId(), 'rnd_src_pool_def')), + 'pool_id' => $transformations->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' => $transformations->normalize(new Id($tax_id, 'tax')), + 'node_ids' => array_map( + static fn($node_id): mixed => $transformations->normalize(new Id($node_id, 'tax_node')), + $node_ids + ), + ]; + } + + foreach ($this->getMappedTaxonomyFilter() as $tax_id => $node_ids) { + $normalized['mapped_taxonomy_filter'][] = [ + 'tax_id' => $transformations->normalize(new Id($tax_id, 'mapped_tax')), + 'node_ids' => array_map( + static fn($node_id): mixed => $transformations->normalize(new Id($node_id, 'mapped_tax_node')), + $node_ids + ), + ]; + } + + return $normalized; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static { + $clone = clone $this; + $clone->setId($transformations->denormalize($normalized['id'], Id::class)->getId()); + $clone->setPoolId($transformations->denormalize($normalized['pool_id'], Id::class)->getId()); + $clone->setPoolTitle($transformations->string($normalized['pool_title'])); + $clone->setPoolPath($transformations->string($normalized['pool_path'])); + $clone->setQuestionAmount($transformations->nullableInt($normalized['quest_amount'])); + $clone->setPoolQuestionCount($transformations->nullableInt($normalized['pool_quest_count'])); + $clone->setSequencePosition($transformations->int($normalized['position'])); + $clone->setTypeFilterFromTypeTags($normalized['type_filter']); + $clone->setLifecycleFilter($normalized['lifecycle_filter']); + + $taxonomy_filter = []; + foreach ($normalized['taxonomy_filter'] as $item) { + $tax_id = $transformations->denormalize($item['tax_id'], Id::class)->getId(); + $taxonomy_filter[$tax_id] = array_map( + static fn($node_id): mixed => $transformations->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 = $transformations->denormalize($item['tax_id'], Id::class)->getId(); + $mapped_taxonomy_filter[$tax_id] = array_map( + static fn($node_id): mixed => $transformations->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 01430e9c6403..4ec1272ba213 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/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..2261c29f4820 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/ExportOptions/XMLWithResultsOption.php @@ -0,0 +1,92 @@ +lng = $DIC->language(); + $this->context_holder = TestDIC::dic()['exportimport.export_context_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 context to bridge between export option and the xml exporter + $this->context_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..5218a045715a 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 the direction-specific normalization contracts 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..47451ebc3699 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Import/PersistStage.php @@ -0,0 +1,110 @@ +lng->txt('qpl_import_step_persist'); + } + + public function getDescription(): ?string + { + return ''; + } + + public function process(ImportSession $session): StageResult + { + if (!$session->isLegacyImport()) { + $result = $this->importMappingsFile($session); + if ($result !== null) { + return $result; + } + } + + (new \ilImport($this->requested_ref_id))->importObject( + null, + $session->fileToImport(), + basename($session->fileToImport()), + 'tst', + 'components/ILIAS/Test', + true, + ); + + // Context is updated by the TestImporter so we need to reload it + return StageResult::completedSuccessfully($this->session->getSession()); + } + + private function importMappingsFile(ImportSession $session): ?StageResult + { + $component_import_dir = dirname($session->componentImportFile()); + $mappings_file = "{$component_import_dir}/mappings.xml"; + if (!is_file($mappings_file)) { + $this->log->error("Mappings file not found: {$mappings_file}"); + return StageResult::error($session, $this->lng->txt('obj_import_file_error')); + } + + $deserializer = XmlDeserializer::fromFile($mappings_file); + $deserializer->addHandler('mappings', function (array $mappings) use (&$session): void { + $user_mappings = $mappings[0] ?? null; + $resource_mappings = $mappings[1] ?? []; + if (!is_array($user_mappings) || !is_array($resource_mappings)) { + return; + } + + $session = $session + ->withUserMappings($user_mappings) + ->withResourceMappings($resource_mappings); + }); + + $deserializer->process(); + + $this->session->setSession($session); + 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..7d799e160b90 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Import/RandomTestConfigImporter.php @@ -0,0 +1,170 @@ +isRandom()) { + throw new \InvalidArgumentException('Expected random question set config'); + } + + $config->getConfig()->saveToDb(); + + 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 + ); + + // 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(); + } + } + + 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()); + } + } + + $old_definition_id = $definition->getId(); + $definition->setId(0); + $definition->saveToDb(); + + $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(); + } + } + } + + 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..a45382aa5406 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Import/SkillLevelThresholdsImporter.php @@ -0,0 +1,117 @@ +> $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 processor 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; + } + + $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(); + + return $result; + } + + private 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..91d7a9c1ba80 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Import/TestImporter.php @@ -0,0 +1,422 @@ +irss, $this->log); + $question_images_collector = new CollectQuestionImages(new UUIDFactory(), $this->data_factory->objId(0)); + + $transformations = $this->builder->forImport( + null, + new IdMappingProcessor($mapping, 'components/ILIAS/Test', $this->log), + $question_images_collector, + $resource_collector + ); + + /** @var \ilObjTest|null $test_object */ + $test_object = null; + + $deserializer->addHandler( + 'general', + function (array $objects) use ($transformations, $mapping, $parent_id, &$test_object): void { + $test_object = $this->importTest( + array_pop($objects), + $transformations, + $mapping, + $parent_id + ); + } + ); + + $deserializer->addHandler( + 'settings', + function (array $settings) use ($transformations, $mapping, &$test_object): void { + $this->importSettings( + $settings, + $transformations, + $mapping, + $test_object + ); + } + ); + + $deserializer->addHandler( + 'questions', + function (array $normalized) use ($transformations, $mapping, $session, &$test_object): void { + $this->importQuestions( + $normalized, + $transformations, + $mapping, + $session, + $test_object + ); + } + ); + + $deserializer->addHandler( + 'question_set_config', + function (array $normalized) use ($transformations, $mapping, $session, &$test_object): void { + $this->importQuestionSetConfig( + reset($normalized), + $transformations, + $mapping, + $test_object + ); + } + ); + + $deserializer->addHandler( + 'skill_assignments', + function (array $assignments) use ($transformations, $mapping, &$session): void { + $result = $this->skill_importer->import( + $assignments, + $session->installId(), + $transformations, + $mapping, + ); + $session = $session->withSkillAssignments($result); + } + ); + + $deserializer->addHandler( + 'skill_thresholds', + function (array $thresholds) use ($transformations, $mapping, &$session): void { + $result = $this->skill_thresholds_importer->import( + $thresholds, + $session->installId(), + $transformations, + $mapping, + ); + $session = $session->withSkillThresholds($result); + } + ); + + $deserializer->addHandler( + 'participants', + function (array $participants) use ($transformations, $mapping): void { + $this->importParticipants( + $participants, + $transformations, + $mapping, + ); + } + ); + + $deserializer->addHandler( + 'results', + function (array $results) use ($transformations): void { + $this->test_results_importer->import( + $results, + $transformations, + ); + } + ); + + $deserializer->addHandler( + 'additional_working_times', + function (array $times) use ($transformations): void { + $this->test_results_importer->importAdditionalWorkingTimes( + $times, + $transformations, + ); + } + ); + + $this->importMappings($mapping, $resource_collector, $session); + + $deserializer->process(); + + $this->questions_importer->importQuestionImages( + $test_object->getId(), + $mapping, + $session, + $question_images_collector + ); + + return $session->withTestObjId($test_object->getId())->withTestRefId($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->questions_importer->finalizeQuestionPages($mapping); + $this->random_test_config_importer->finalizeTaxonomyFilters($mapping); + } + + + private function importMappings( + \ilImportMapping $mapping, + CollectResources $resource_collector, + ImportSession $session + ): void { + $user_mapping = $session->userMappings(); + + $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); + + $import_dir = dirname($session->componentImportFile()) . '/expDir_1'; + foreach ($session->resourceMappings() 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_collector->storeMapping($resource['id'], $new_id); + } + } + + private function importTest( + array $normalized, + Transformations $transformations, + \ilImportMapping $mapping, + ReferenceId $parent_id + ): \ilObjTest { + $test_object = $transformations->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); + + $test_object->createReference(); + $test_object->putInTree($parent_id->toInt()); + $test_object->setPermissions($parent_id->toInt()); + + $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"); + $mapping->addMapping('components/ILIAS/Tracking', 'lpsettings', (string) $old_obj_id, (string) $new_obj_id); + + return $test_object; + } + + private function importSettings( + array $list, + Transformations $transformations, + \ilImportMapping $mapping, + \ilObjTest $test_object + ): void { + $settings_id = $test_object->getMainSettings()->getId(); + + $main_settings = $transformations->denormalize($list[0], MainSettings::class)->withId($settings_id); + $scoring_settings = $transformations->denormalize($list[1], ScoreSettings::class)->withId($settings_id); + $mark_schema = $transformations->denormalize($list[2], MarkSchema::class)->withTestId($test_object->getTestId()); + + $intro_page_id = $main_settings->getIntroductionSettings()->getIntroductionPageId(); + if ($intro_page_id !== null && $intro_page_id !== 0) { + $new_page_id = $this->createPage($intro_page_id, $test_object->getId(), $mapping); + $main_settings = $main_settings->withIntroductionSettings( + $main_settings->getIntroductionSettings()->withIntroductionPageId($new_page_id) + ); + } + + $concluding_page_id = $main_settings->getFinishingSettings()->getConcludingRemarksPageId(); + if ($concluding_page_id !== null && $concluding_page_id !== 0) { + $new_page_id = $this->createPage($concluding_page_id, $test_object->getId(), $mapping); + $main_settings = $main_settings->withFinishingSettings( + $main_settings->getFinishingSettings()->withConcludingRemarksPageId($new_page_id) + ); + } + + $test_object->getMainSettingsRepository()->store($main_settings); + $test_object->getScoreSettingsRepository()->store($scoring_settings); + $this->marks_repository->storeMarkSchema($mark_schema); + } + + 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 $transformations, + \ilImportMapping $mapping, + ImportSession $session, + \ilObjTest $test_object + ): void { + foreach ($list as $normalized) { + $question = $this->questions_importer->importQuestion( + $normalized, + $transformations, + $mapping, + $session->selectedQuestionIds() + ); + if (!($question instanceof \assQuestion) || $normalized['sequence'] === null) { + continue; + } + + $sequence = $transformations->int($normalized['sequence']); + $test_object->questions[$sequence] = $question->getId(); + } + + $test_object->saveQuestionsToDb(); + } + + private function importQuestionSetConfig( + array $normalized, + Transformations $transformations, + \ilImportMapping $mapping, + \ilObjTest $test_object + ): void { + $config = $transformations->denormalize($normalized, QuestionSetConfig::class); + + if ($config->isRandom()) { + $this->random_test_config_importer->import($config, $mapping, $test_object); + } + } + + private function importParticipants(array $list, Transformations $transformations, \ilImportMapping $mapping): void + { + foreach ($list as $normalized) { + if ($normalized['active_id'] === null) { + $this->importInvitedParticipant($normalized, $transformations); + continue; + } + + $old_active_id = $transformations->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); + + // The mapping processor replaces TestID, UserID and ActiveID + $participant = $transformations->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() === true ? 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, $transformations->nullableString($normalized['submittimestamp'])], + 'lastindex' => [\ilDBConstants::T_INTEGER, $transformations->nullableInt($normalized['lastindex'])], + 'objective_container' => [\ilDBConstants::T_INTEGER, $transformations->nullableInt($normalized['objective_container'])], + 'start_lock' => [\ilDBConstants::T_TEXT, $transformations->nullableString($normalized['start_lock'])], + ] + ); + } + } + + private function importInvitedParticipant(array $normalized, Transformations $transformations): void + { + // The mapping processor replaces TestID and UserID + $participant = $transformations->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()], + ] + ); + } +} 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..6dcff8011a07 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Import/TestResultsImporter.php @@ -0,0 +1,270 @@ + $data) { + match($name) { + 'sequences' => $this->importTestSequences($data, $tt), + 'question_attempts' => $this->importQuestionAttempts($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}"), + }; + } + } + } + + private function importTestSequences(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // The mapping processor replaces ActiveID and QuestionIDs + $sequence = $tt->denormalize($normalized, \ilTestSequence::class); + $sequence->saveToDb(); + } + } + + private function importQuestionAttempts(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // The mapping processor replaces ActiveID and QuestionID + $attempt = $tt->denormalize($normalized, Attempt::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, $attempt->active_id->getId()], + 'question_fi' => [\ilDBConstants::T_INTEGER, $attempt->question_id->getId()], + 'pass' => [\ilDBConstants::T_INTEGER, $attempt->attempt], + 'value1' => [\ilDBConstants::T_TEXT, $attempt->value1 !== null ? (string) $attempt->value1 : null], + 'value2' => [\ilDBConstants::T_TEXT, $attempt->value2], + 'points' => [\ilDBConstants::T_FLOAT, $attempt->points], + 'step' => [\ilDBConstants::T_INTEGER, $attempt->step], + 'authorized' => [\ilDBConstants::T_INTEGER, $attempt->authorized === true ? 1 : 0], + 'tstamp' => [\ilDBConstants::T_INTEGER, time()], + ] + ); + } + } + + private function importQuestionResults(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // The mapping processor 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 === true ? 1 : 0], + 'tstamp' => [\ilDBConstants::T_INTEGER, time()], + 'answered' => [\ilDBConstants::T_INTEGER, $result->answered === true ? 1 : 0], + 'step' => [\ilDBConstants::T_INTEGER, $result->step], + ] + ); + } + } + private function importAttemptResults(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // The mapping processor 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()], + ] + ); + } + } + + private function importTestResult(?array $normalized, Transformations $tt): void + { + if ($normalized === null) { + $this->log->warning("Missing test result, skipping"); + return; + } + + // The mapping processor 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() === true ? 1 : 0], + 'failed' => [\ilDBConstants::T_INTEGER, $result->isFailed() === true ? 1 : 0], + 'tstamp' => [\ilDBConstants::T_INTEGER, time()], + ] + ); + } + + private function importWorkingTimes(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // The mapping processor 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()], + ] + ); + } + } + + private function importManualFeedback(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // The mapping processor 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 === true ? 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()], + ] + ); + } + } + + private function importManualScoring(array $normalized, Transformations $tt): void + { + // The mapping processor replaces ActiveID, QuestionID and UserID + $active_id = $tt->denormalize($normalized['active_id'], Id::class)->getId(); + + (new TestManScoringDoneHelper())->setDone($active_id, $tt->bool($normalized['done'])); + } + + public function importAdditionalWorkingTimes(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // The mapping processor 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], + ] + ); + } + } + + private function importRandomTestQuestions(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // The mapping processor 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()], + ] + ); + } + } +} 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..bf2d8bee8af7 --- /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 + { + 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; + } + + $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/Normalize/Envelopes/AdditionalWorkingTime.php b/components/ILIAS/Test/src/ExportImport/Normalize/Envelopes/AdditionalWorkingTime.php new file mode 100644 index 000000000000..33f59c7326cc --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalize/Envelopes/AdditionalWorkingTime.php @@ -0,0 +1,68 @@ + $tt->normalize($this->user_id), + 'test_id' => $tt->normalize($this->test_id), + 'time' => $this->time, + 'timestamp' => $this->timestamp, + ]; + } + + #[\Override] + public static function pack(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/Normalize/Envelopes/Attempt.php b/components/ILIAS/Test/src/ExportImport/Normalize/Envelopes/Attempt.php new file mode 100644 index 000000000000..dbe07b57516e --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalize/Envelopes/Attempt.php @@ -0,0 +1,97 @@ + $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 + ]; + } + + #[\Override] + public static function pack(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/Normalize/Envelopes/ManualFeedback.php b/components/ILIAS/Test/src/ExportImport/Normalize/Envelopes/ManualFeedback.php new file mode 100644 index 000000000000..a0e6292a2e7c --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalize/Envelopes/ManualFeedback.php @@ -0,0 +1,80 @@ + $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), + ]; + } + + #[\Override] + public static function pack(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/Normalize/Envelopes/QuestionResult.php b/components/ILIAS/Test/src/ExportImport/Normalize/Envelopes/QuestionResult.php new file mode 100644 index 000000000000..bceec6f6cd46 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalize/Envelopes/QuestionResult.php @@ -0,0 +1,84 @@ + $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, + ]; + } + + #[\Override] + public static function pack(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/Normalize/Envelopes/QuestionSetConfig.php b/components/ILIAS/Test/src/ExportImport/Normalize/Envelopes/QuestionSetConfig.php new file mode 100644 index 000000000000..4be75fa345e3 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalize/Envelopes/QuestionSetConfig.php @@ -0,0 +1,76 @@ + */ + 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<\ilTestRandomQuestionSetSourcePoolDefinition> + */ + public function getDefinitions(): array + { + return $this->definitions; + } + + /** + * @param list<\ilTestRandomQuestionSetSourcePoolDefinition> $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/Normalize/Envelopes/RandomTestQuestion.php b/components/ILIAS/Test/src/ExportImport/Normalize/Envelopes/RandomTestQuestion.php new file mode 100644 index 000000000000..08f816574784 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalize/Envelopes/RandomTestQuestion.php @@ -0,0 +1,76 @@ + $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), + ]; + } + + #[\Override] + public static function pack(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/Normalize/Envelopes/WorkingTime.php b/components/ILIAS/Test/src/ExportImport/Normalize/Envelopes/WorkingTime.php new file mode 100644 index 000000000000..11dbd0e27537 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalize/Envelopes/WorkingTime.php @@ -0,0 +1,68 @@ + $tt->normalize($this->active_id), + 'attempt' => $this->attempt, + 'started' => $this->started, + 'finished' => $this->finished, + ]; + } + + #[\Override] + public static function pack(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/Normalize/Normalizer/AttemptResultNormalizer.php b/components/ILIAS/Test/src/ExportImport/Normalize/Normalizer/AttemptResultNormalizer.php new file mode 100644 index 000000000000..1d1599113647 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalize/Normalizer/AttemptResultNormalizer.php @@ -0,0 +1,78 @@ + + */ +class AttemptResultNormalizer implements Normalizer +{ + public function __construct(private readonly Transformations $transformations) { + } + + #[\Override] + public function normalize($value): array|float|bool|int|string|null + { + if (!($value instanceof AttemptResult)) { + throw new NormalizingException('Invalid value', $value); + } + + return [ + 'active_id' => $this->transformations->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(), + ]; + } + + #[\Override] + 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->transformations->denormalize($value['active_id'], Id::class)->getId(), + $this->transformations->int($value['attempt']), + $this->transformations->float($value['max_points']), + $this->transformations->float($value['reached_points']), + $this->transformations->int($value['question_count']), + $this->transformations->int($value['answered_questions']), + $this->transformations->int($value['working_time']), + $this->transformations->int($value['timestamp']), + $this->transformations->string($value['exam_id']), + $this->transformations->string($value['finalized_by']), + ); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Normalize/Normalizer/ExportableNormalizer.php b/components/ILIAS/Test/src/ExportImport/Normalize/Normalizer/ExportableNormalizer.php new file mode 100644 index 000000000000..f94f76aed8db --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalize/Normalizer/ExportableNormalizer.php @@ -0,0 +1,51 @@ + + */ +class ExportableNormalizer implements Normalizer +{ + #[\Override] + public function normalize($value): array|float|bool|int|string|null + { + if (!($value instanceof Exportable)) { + throw new NormalizingException('Invalid exportable value', $value); + } + + return $value->toExport(); + } + + #[\Override] + 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/Normalize/Normalizer/ParticipantNormalizer.php b/components/ILIAS/Test/src/ExportImport/Normalize/Normalizer/ParticipantNormalizer.php new file mode 100644 index 000000000000..d7ea0e8b1a7f --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalize/Normalizer/ParticipantNormalizer.php @@ -0,0 +1,106 @@ + + */ +class ParticipantNormalizer implements Normalizer +{ + public function __construct( + private readonly Transformations $tt, + ) { + } + + #[\Override] + 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(), + ]; + } + + #[\Override] + 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/Normalize/Normalizer/ParticipantResultNormalizer.php b/components/ILIAS/Test/src/ExportImport/Normalize/Normalizer/ParticipantResultNormalizer.php new file mode 100644 index 000000000000..f1b675a6a018 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalize/Normalizer/ParticipantResultNormalizer.php @@ -0,0 +1,75 @@ + + */ +class ParticipantResultNormalizer implements Normalizer +{ + public function __construct( + private readonly Transformations $tt, + ) { + } + + #[\Override] + 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(), + ]; + } + + #[\Override] + 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/Normalize/Normalizer/QuestionSetConfigNormalizer.php b/components/ILIAS/Test/src/ExportImport/Normalize/Normalizer/QuestionSetConfigNormalizer.php new file mode 100644 index 000000000000..e2f67f3ee6c0 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalize/Normalizer/QuestionSetConfigNormalizer.php @@ -0,0 +1,207 @@ + + */ +class QuestionSetConfigNormalizer implements Normalizer +{ + public function __construct( + private readonly Transformations $tt, + private readonly \ilDBInterface $db, + private readonly \ilLanguage $lng, + private readonly \ilTree $tree, + private readonly \ilComponentRepository $component_repository, + private readonly TestLogger $logger, + private readonly GeneralQuestionPropertiesRepository $repository, + ) { + } + + #[\Override] + 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 + { + $test_obj = $config->getTestObject(); + + return [ + 'obj_id' => $this->tt->normalize(new Id($test_obj->getId(), 'object')), + 'test_id' => $this->tt->normalize(new Id($test_obj->getTestId(), 'tst')), + ]; + } + + #[\Override] + 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->tree, + $this->db, + $this->lng, + $this->logger, + $this->component_repository, + $test_obj, + $this->repository + ); + } + + $config = new \ilTestRandomQuestionSetConfig( + $this->tree, + $this->db, + $this->lng, + $this->logger, + $this->component_repository, + $test_obj, + $this->repository + ); + + $amount_mode = $this->tt->string($normalized['amount_mode']); + if ($config->isValidQuestionAmountConfigurationMode($amount_mode) === false) { + 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->db, + $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/Normalize/Normalizer/ilObjTestNormalizer.php b/components/ILIAS/Test/src/ExportImport/Normalize/Normalizer/ilObjTestNormalizer.php new file mode 100644 index 000000000000..03d9080774d6 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalize/Normalizer/ilObjTestNormalizer.php @@ -0,0 +1,61 @@ + + */ +class ilObjTestNormalizer extends IlObjectNormalizer implements Normalizer +{ + #[\Override] + 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; + } + + #[\Override] + 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/Normalize/Normalizer/ilTestSequenceNormalizer.php b/components/ILIAS/Test/src/ExportImport/Normalize/Normalizer/ilTestSequenceNormalizer.php new file mode 100644 index 000000000000..dc72b80b0e5d --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalize/Normalizer/ilTestSequenceNormalizer.php @@ -0,0 +1,108 @@ + + */ +class ilTestSequenceNormalizer implements Normalizer +{ + public function __construct( + private readonly Transformations $tt, + private readonly \ilDBInterface $db, + private readonly GeneralQuestionPropertiesRepository $repository, + ) { + } + + #[\Override] + 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 + ); + } + + #[\Override] + 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/Normalize/Normalizer/ilTestSkillLevelThresholdNormalizer.php b/components/ILIAS/Test/src/ExportImport/Normalize/Normalizer/ilTestSkillLevelThresholdNormalizer.php new file mode 100644 index 000000000000..1f574d317028 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalize/Normalizer/ilTestSkillLevelThresholdNormalizer.php @@ -0,0 +1,71 @@ + + */ +class ilTestSkillLevelThresholdNormalizer implements Normalizer +{ + public function __construct( + private readonly Transformations $tt, + private readonly \ilDBInterface $db, + ) { + } + + #[\Override] + 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(), + ]; + } + + #[\Override] + 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/Normalize/Processors/CollectUserIds.php b/components/ILIAS/Test/src/ExportImport/Normalize/Processors/CollectUserIds.php new file mode 100644 index 000000000000..ebaf141045e6 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalize/Processors/CollectUserIds.php @@ -0,0 +1,59 @@ + $ids + */ + private array $ids = []; + + #[\Override] + public function process(object $carry): void + { + if ( + $carry instanceof NormalizeCarry + && $carry->value() instanceof Id + && $carry->value()->getObject() === 'user' + ) { + $this->ids[$carry->value()->getId()] = true; + } + } + + /** + * 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..6de54744003f --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/TestCollector.php @@ -0,0 +1,422 @@ + $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 + */ + + 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<\ilTestSkillLevelThreshold> + */ + 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() === false) { + 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)), + 'question_attempts' => $this->getQuestionAttempts($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 + */ + private function getQuestionAttempts(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): Attempt => Attempt::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 + */ + private 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<\ilTestSequence> + */ + private 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 + */ + private 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..4847f343351d --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/TestExporter.php @@ -0,0 +1,454 @@ +logger()->info('Preparing test export (1/3)...'); + $context->assertPhase(ExportPhase::Init); + $context->setPhase(ExportPhase::Prepare); + + $object_id = $this->extractObjectId($context); + 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 + ); + $context->setCollector($collector); + + $transformations = $this->builder->forExport( + new CollectUserIds(), + new CollectQuestionImages( + new UUIDFactory(), + $object_id + ), + new CollectResources( + $this->irss, + $this->logger + ) + ); + + $context->setTransformations($transformations); + $context->logger()->info('...Finished preparing test export (1/3)'); + } + + private function extractObjectId(ExportContext $context): ?ObjectId + { + $target_ids = $context->target()->getObjectIds(); + + if ($target_ids === []) { + $context->logger()->warning('No target object IDs found for test export'); + return null; + } + + if (count($target_ids) > 1) { + $context->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)); + } + + private function collector(ExportContext $context): TestCollector + { + $collector = $context->collector(); + if (!($collector instanceof TestCollector)) { + throw new \LogicException('Unexpected test collector'); + } + + return $collector; + } + + public function process(ExportContext $context): void + { + $context->logger()->info('Processing test export (2/3)...'); + $context->assertPhase(ExportPhase::Prepare); + $context->setPhase(ExportPhase::Process); + + $context->serializer()->group( + 'general', + fn() => $this->exportObject( + $this->collector($context), + $context->transformations(), + $context->serializer(), + $context + ) + ); + $context->serializer()->group( + 'settings', + fn() => $this->exportSettings( + $this->collector($context), + $context->transformations(), + $context->serializer(), + $context + ) + ); + $context->serializer()->group( + 'questions', + fn() => $this->exportQuestions( + $this->collector($context), + $context->transformations(), + $context->serializer(), + $context + ) + ); + $context->serializer()->group( + 'question_set_config', + fn() => $this->exportQuestionSetConfig( + $this->collector($context), + $context->transformations(), + $context->serializer(), + ) + ); + $context->serializer()->group( + 'additional_working_times', + fn() => $this->exportAdditionalWorkingTimes( + $this->collector($context), + $context->transformations(), + $context->serializer(), + ) + ); + $context->serializer()->group( + 'skill_assignments', + fn() => $this->exportSkillAssignments( + $this->collector($context), + $context->transformations(), + $context->serializer(), + ) + ); + $context->serializer()->group( + 'skill_thresholds', + fn() => $this->exportSkillLevelThresholds( + $this->collector($context), + $context->transformations(), + $context->serializer(), + ) + ); + + if ($context->getOption() === Types::XML_WITH_RESULTS->value) { + $context->logger()->info('Processing test results export ...'); + $this->processResults($context); + $context->logger()->info('...Finished processing test results export'); + } + + $context->logger()->info('...Finished processing test export (2/3)'); + } + + private function processResults(ExportContext $context): void + { + $context->serializer()->group( + 'participants', + fn() => $this->exportParticipants( + $this->collector($context), + $context->transformations(), + $context->serializer() + ) + ); + $context->serializer()->group( + 'results', + fn() => $this->exportResults( + $this->collector($context), + $context->transformations(), + $context->serializer() + ) + ); + } + + public function write(ExportContext $context): void + { + $context->logger()->info('Writing test export (3/3)...'); + $context->assertPhase(ExportPhase::Process); + $context->setPhase(ExportPhase::Write); + + $export_dir = $context->path()->getPathToComponentExpDirInContainer(); + $question_image_collector = $context->transformations() + ->normalizationProcessor(CollectQuestionImages::class); + if (!($question_image_collector instanceof CollectQuestionImages)) { + throw new \LogicException('Unexpected question image processor'); + } + + $resource_collector = $context->transformations() + ->normalizationProcessor(CollectResources::class); + if (!($resource_collector instanceof CollectResources)) { + throw new \LogicException('Unexpected resource processor'); + } + + foreach ($question_image_collector->getFiles() as $file) { + if (!file_exists($file['from'])) { + $context->logger()->warning('Question image file not found: ' . $file['from']); + continue; + } + + $context->writer()->writeFileByFilePath( + $file['from'], + "{$export_dir}/" . $file['to'] + ); + } + + foreach ($resource_collector->getResources() as $id => $resource) { + $clean_id = str_replace(['-', '_'], '', $id); + $file = "{$clean_id}.{$resource->getCurrentRevision()->getInformation()->getSuffix()}"; + + $context->writer()->writeFilesByResourceId( + $id, + "{$export_dir}/resources/{$file}" + ); + } + + $this->writeMappings( + $this->collector($context), + $context->transformations(), + $context + ); + + $context->logger()->info('...Finished writing test export (3/3)'); + } + + + private function exportObject( + TestCollector $collector, + Transformations $transformations, + Serializer $serializer, + ExportContext $context + ): void { + $serializer->append('object', $transformations->normalize($collector->getObject())); + + $obj_id = $collector->getObjectId()->toInt(); + $context->addDependency('components/ILIAS/ILIASObject', 'common', [$obj_id]); + $context->addDependency('components/ILIAS/MetaData', 'tst', ["{$obj_id}:0:tst"]); + $context->addDependency( + 'components/ILIAS/Taxonomy', + 'tax', + $this->taxonomy->getUsageOfObject($obj_id) + ); + $context->addDependency('components/ILIAS/Tracking', 'lpsettings', [$obj_id]); + } + + private function exportSettings( + TestCollector $collector, + Transformations $transformations, + Serializer $serializer, + ExportContext $context + ): 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())); + + $intro_page_id = $main_settings->getIntroductionSettings()->getIntroductionPageId(); + if ($intro_page_id !== null && $intro_page_id !== 0) { + $context->addDependency('components/ILIAS/COPage', 'pg', ["tst:{$intro_page_id}"]); + } + $concluding_page_id = $main_settings->getFinishingSettings()->getConcludingRemarksPageId(); + if ($concluding_page_id !== null && $concluding_page_id !== 0) { + $context->addDependency('components/ILIAS/COPage', 'pg', ["tst:{$concluding_page_id}"]); + } + } + + private function exportQuestions( + TestCollector $collector, + Transformations $transformations, + Serializer $serializer, + ExportContext $context + ): 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); + $context->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, + ExportContext $context + ): void { + $serializer = XmlSerializer::inMemory(); + $serializer->createDocument('Test Export Mappings'); + $serializer->startGroup('mappings'); + + $user_id_processor = $transformations->normalizationProcessor(CollectUserIds::class); + if (!($user_id_processor instanceof CollectUserIds)) { + throw new \LogicException('Unexpected user ID processor'); + } + $user_ids = $user_id_processor->getIds(); + $serializer->append('users', $collector->getUserMapping($user_ids)); + + $resource_processor = $transformations->normalizationProcessor(CollectResources::class); + if (!($resource_processor instanceof CollectResources)) { + throw new \LogicException('Unexpected resource processor'); + } + $resources = $resource_processor->getResources(); + $serializer->append( + 'resources', + array_map($transformations->normalize(...), $resources) + ); + + $serializer->endGroup('mappings'); + + $context->writer()->writeFileByStream( + Streams::ofString($serializer->write()), + "{$context->path()->getPathToComponentDirInContainer()}/mappings.xml" + ); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/TestNormalizerRegistration.php b/components/ILIAS/Test/src/ExportImport/TestNormalizerRegistration.php new file mode 100644 index 000000000000..a6ce9bc18722 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/TestNormalizerRegistration.php @@ -0,0 +1,83 @@ +register(\ilObjTest::class, new ilObjTestNormalizer($transformations)); + $registry->register( + \ilTestSequence::class, + new ilTestSequenceNormalizer( + $transformations, + $this->dic->database(), + $this->test_dic['question.general_properties.repository'] + ) + ); + $registry->register(AttemptResult::class, new AttemptResultNormalizer($transformations)); + $registry->register( + ParticipantResult::class, + new ParticipantResultNormalizer($transformations) + ); + $registry->register(Participant::class, new ParticipantNormalizer($transformations)); + $registry->register(Exportable::class, new ExportableNormalizer()); + $registry->register( + QuestionSetConfig::class, + new QuestionSetConfigNormalizer( + $transformations, + $this->dic->database(), + $this->dic->language(), + $this->dic->repositoryTree(), + $this->dic['component.repository'], + $this->test_dic['logging.logger'], + $this->test_dic['question.general_properties.repository'] + ) + ); + $registry->register( + \ilTestSkillLevelThreshold::class, + new ilTestSkillLevelThresholdNormalizer($transformations, $this->dic->database()) + ); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/TransformationsBuilder.php b/components/ILIAS/Test/src/ExportImport/TransformationsBuilder.php new file mode 100644 index 000000000000..62725e71278b --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/TransformationsBuilder.php @@ -0,0 +1,87 @@ +create(null, $runtime_processors, []); + } + + public function forImport( + ?string $legacy_version, + Processor ...$runtime_processors + ): Transformations { + return $this->create($legacy_version, [], $runtime_processors); + } + + /** + * @param list $normalization_processors + * @param list $denormalization_processors + */ + private function create( + ?string $legacy_version, + array $normalization_processors, + array $denormalization_processors + ): Transformations { + $registry = new Registry(); + $transformations = new Transformations( + $this->global_dic->refinery(), + new Queue(...[ + ...$normalization_processors, + new NormalizingProcessor($registry), + new FinalizeNormalizing() + ]), + new Queue(...[ + new DenormalizingProcessor($registry, $legacy_version), + ...$denormalization_processors + ]) + ); + + (new NormalizerRegistration($this->global_dic)) + ->register($registry, $transformations); + (new QuestionPoolNormalizerRegistration($this->global_dic)) + ->register($registry, $transformations); + (new TestNormalizerRegistration($this->global_dic, $this->test_dic)) + ->register($registry, $transformations); + + return $transformations; + } +} diff --git a/components/ILIAS/Test/src/Participants/Participant.php b/components/ILIAS/Test/src/Participants/Participant.php index 1f464c83a92f..cce4de3f3ec2 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/Questions/Presentation/QuestionsBrowserFilter.php b/components/ILIAS/Test/src/Questions/Presentation/QuestionsBrowserFilter.php index 8a79803341cf..b0cc810ae01b 100644 --- a/components/ILIAS/Test/src/Questions/Presentation/QuestionsBrowserFilter.php +++ b/components/ILIAS/Test/src/Questions/Presentation/QuestionsBrowserFilter.php @@ -71,7 +71,7 @@ private function getFields(FieldFactory $input): array { $lifecycle_options = array_merge( ['' => $this->lng->txt('qst_lifecycle_filter_all')], - ilAssQuestionLifecycle::getDraftInstance()->getSelectOptions($this->lng) + new ilAssQuestionLifecycle()->getSelectOptions($this->lng) ); $yes_no_all_options = [ '' => $this->lng->txt('resulttable_all'), diff --git a/components/ILIAS/Test/src/Questions/Presentation/QuestionsBrowserTable.php b/components/ILIAS/Test/src/Questions/Presentation/QuestionsBrowserTable.php index e2ab7e31744d..4bfb81613037 100644 --- a/components/ILIAS/Test/src/Questions/Presentation/QuestionsBrowserTable.php +++ b/components/ILIAS/Test/src/Questions/Presentation/QuestionsBrowserTable.php @@ -155,7 +155,7 @@ public function getRows( $record['question_type'] = $record['question_type']; $record['complete'] = (bool) $record['complete']; - $record['lifecycle'] = \ilAssQuestionLifecycle::getInstance($record['lifecycle'])->getTranslation($this->lng) ?? ''; + $record['lifecycle'] = new \ilAssQuestionLifecycle($record['lifecycle'])->getTranslation($this->lng) ?? ''; $record['created'] = (new \DateTimeImmutable("@{$record['created']}"))->setTimezone($timezone); $record['tstamp'] = (new \DateTimeImmutable("@{$record['tstamp']}"))->setTimezone($timezone); diff --git a/components/ILIAS/Test/src/Questions/Properties/Properties.php b/components/ILIAS/Test/src/Questions/Properties/Properties.php index 21aa07136104..c2b22cd723e2 100644 --- a/components/ILIAS/Test/src/Questions/Properties/Properties.php +++ b/components/ILIAS/Test/src/Questions/Properties/Properties.php @@ -107,7 +107,7 @@ public function getAsQuestionsTableRow( 'points' => $this->question_properties->getAvailablePoints(), 'author' => $this->question_properties->getAuthor(), 'complete' => $this->question_properties->isRequiredInformationComplete(), - 'lifecycle' => \ilAssQuestionLifecycle::getInstance($this->question_properties->getLifecycle())->getTranslation($lng) ?? '', + 'lifecycle' => new \ilAssQuestionLifecycle($this->question_properties->getLifecycle())->getTranslation($lng) ?? '', 'qpl' => $title_builder->buildAccessCheckedQuestionpoolTitleAsLink($this->question_properties->getOriginObjectId()), 'nr_of_answers' => $this->getAggregatedResults()->getNumberOfAnswers(), 'average_points' => $this->getAggregatedResults()->getAveragePoints(), 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/ScoreSettings.php b/components/ILIAS/Test/src/Settings/ScoreReporting/ScoreSettings.php index ae83f277ec75..afbbafa8e82a 100755 --- a/components/ILIAS/Test/src/Settings/ScoreReporting/ScoreSettings.php +++ b/components/ILIAS/Test/src/Settings/ScoreReporting/ScoreSettings.php @@ -88,8 +88,7 @@ public function getArrayForLog(AdditionalInformationGenerator $additional_info): { return $this->settings_scoring->toLog($additional_info) + $this->settings_result_summary->toLog($additional_info) - + $this->settings_result_details->toLog($additional_info) - + $this->settings_gamification->toLog($additional_info); + + $this->settings_result_details->toLog($additional_info); } public function toExport(): array @@ -97,8 +96,7 @@ public function toExport(): array return [ 'settings_scoring' => $this->settings_scoring->toExport(), 'settings_result_summary' => $this->settings_result_summary->toExport(), - 'settings_result_details' => $this->settings_result_details->toExport(), - 'settings_gamification' => $this->settings_gamification->toExport() + 'settings_result_details' => $this->settings_result_details->toExport() ]; } @@ -108,8 +106,7 @@ public static function fromExport(array $data): static $data['id'] ?? -1, SettingsScoring::fromExport($data['settings_scoring']), SettingsResultSummary::fromExport($data['settings_result_summary']), - SettingsResultDetails::fromExport($data['settings_result_details']), - SettingsGamification::fromExport($data['settings_gamification']) + SettingsResultDetails::fromExport($data['settings_result_details']) ); } } 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 134b3bb69325..9ba14bd1a28b 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; @@ -45,14 +48,23 @@ use ILIAS\Test\Logging\Factory as InteractionFactory; use ILIAS\Test\ExportImport\Factory as ExportImportFactory; use ILIAS\Test\ExportImport\DBRepository as ExportImportRepository; +use ILIAS\Test\ExportImport\TransformationsBuilder; use ILIAS\Test\Questions\Properties\Repository as TestQuestionsRepository; use ILIAS\Test\Questions\Properties\DatabaseRepository as TestQuestionsDatabaseRepository; use ILIAS\Test\Results\Data\Factory as ResultsDataFactory; use ILIAS\Test\Results\Presentation\Factory as ResultsPresentationFactory; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Export\ExportContextHolder; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Import\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 @@ -229,6 +241,92 @@ protected static function buildDIC(ILIASContainer $DIC): self $DIC['ilDB'] ); + $dic['exportimport.logging'] = static fn($c): LoggingProvider => + new LoggingProvider(); + + $dic['exportimport.export_context_holder'] = static fn($c): ExportContextHolder => + new ExportContextHolder(); + + $dic['exportimport.session'] = static fn($c): ImportSessionRepository => + new ImportSessionRepository('tst'); + + $dic['exportimport.transformations_builder'] = static fn($c): TransformationsBuilder => + new TransformationsBuilder($DIC, $c); + + $dic['exportimport.exporter'] = static fn($c): TestExporter => + new TestExporter( + $c['exportimport.transformations_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(), + new DataFactory() + ); + + $dic['exportimport.importer'] = static fn($c): TestImporter => + new TestImporter( + $c['exportimport.transformations_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/Scoring/Settings/ScoreSettingsTest.php b/components/ILIAS/Test/tests/Scoring/Settings/ScoreSettingsTest.php index cd87d24ea795..d4253cbbd81c 100755 --- a/components/ILIAS/Test/tests/Scoring/Settings/ScoreSettingsTest.php +++ b/components/ILIAS/Test/tests/Scoring/Settings/ScoreSettingsTest.php @@ -49,6 +49,31 @@ public function testScoreSettingsBuild(): void $this->assertInstanceOf(SettingsResultDetails::class, $s->getResultDetailsSettings()); } + public function testScoreSettingsExportRoundtripWithoutGamification(): void + { + $s = new ScoreSettings( + 42, + (new SettingsScoring())->withCountSystem(1)->withScoreCutting(0)->withPassScoring(0), + (new SettingsResultSummary())->withShowGradingStatusEnabled(true), + (new SettingsResultDetails())->withResultsPresentation(1)->withExportSettings(2) + ); + + $export = $s->toExport(); + + $this->assertSame( + [ + 'settings_scoring', + 'settings_result_summary', + 'settings_result_details', + ], + array_keys($export) + ); + + $restored = ScoreSettings::fromExport(['id' => 42] + $export); + $this->assertSame(42, $restored->getId()); + $this->assertEquals($export, $restored->toExport()); + } + public function testScoreSettingsScoring(): void { $s = new SettingsScoring(); 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/PRIVACY.md b/components/ILIAS/TestQuestionPool/PRIVACY.md index afcf3ed4c589..a39834feb174 100755 --- a/components/ILIAS/TestQuestionPool/PRIVACY.md +++ b/components/ILIAS/TestQuestionPool/PRIVACY.md @@ -43,4 +43,5 @@ It is possible to delete questions. Within this the personal data at the field ' ## Data being exported - The XML export of the question pool contains the personal data 'Author' of the questions and of the question pool itself within the metadata. +- In addition the XML export contains the 'User ID' of the owner of each question (see 'Ownership of Questions' above). - It's purpose is to being imported in ILIAS again, although the contained personal data is easily accesible. Including this information ensures that the authorship of the question is not lost after import. In addition it is possible to contact the author, e.g. if there are problems with the question or the configuration of it. 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..06c48ffe8f48 100755 --- a/components/ILIAS/TestQuestionPool/classes/Setup/class.ilTestQuestionPoolSetupAgent.php +++ b/components/ILIAS/TestQuestionPool/classes/Setup/class.ilTestQuestionPoolSetupAgent.php @@ -19,6 +19,7 @@ use ILIAS\Setup\Agent\NullAgent; use ILIAS\Setup\Objective; use ILIAS\Setup\ObjectiveCollection; +use ILIAS\Setup\Objective\NullObjective; use ILIAS\Setup\Metrics; class ilTestQuestionPoolSetupAgent extends NullAgent @@ -60,4 +61,9 @@ public function getMigrations(): array ]; } + public function getBuildObjective(): Objective + { + return new NullObjective(); + } + } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assAnswerBinaryState.php b/components/ILIAS/TestQuestionPool/classes/class.assAnswerBinaryState.php index dde732f7ecb9..89e2cf3495c3 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerBinaryState.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerBinaryState.php @@ -16,6 +16,8 @@ * *********************************************************************/ +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; + /** * Class for true/false or yes/no answers * @@ -180,4 +182,27 @@ public function setUnchecked(): void { $this->checked = false; } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + ...$transformations->normalize(parent::toNormalized($transformations, $context)), + 'checked' => $this->checked, + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + $clone = parent::fromNormalized($normalized, $transformations); + $clone->setState($transformations->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..1f61d09d4a2f 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerBinaryStateImage.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerBinaryStateImage.php @@ -18,6 +18,9 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Normalize\Envelopes\QuestionImage; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; + /** * Class for answers with a binary state indicator * @@ -43,12 +46,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 +79,29 @@ public function hasImage(): bool { return $this->image !== null; } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + ...$transformations->normalize(parent::toNormalized($transformations, $context)), + 'image' => $this->image ? + $transformations->normalize(new QuestionImage($this->image, $context['question_id'] ?? null)) + : null, + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + $clone = parent::fromNormalized($normalized, $transformations); + $clone->setImage($transformations->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..6f79f373c262 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerCloze.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerCloze.php @@ -16,6 +16,8 @@ * *********************************************************************/ +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; + /** * Class for cloze question numeric answers * @@ -150,4 +152,31 @@ public function getGapSize(): int { return $this->gap_size; } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + ...$transformations->normalize(parent::toNormalized($transformations, $context)), + 'lower_bound' => $this->lowerBound, + 'upper_bound' => $this->upperBound, + 'gap_size' => $this->gap_size, + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + $clone = parent::fromNormalized($normalized, $transformations); + $clone->lowerBound = $transformations->nullableString($normalized['lower_bound']); + $clone->upperBound = $transformations->nullableString($normalized['upper_bound']); + $clone->gap_size = $transformations->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..7a2acb8f3ea8 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\Normalize\FromNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\ToNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; + /** * Class for error text answers * @@ -24,7 +28,7 @@ * * @ingroup components\ILIASTestQuestionPool */ -class assAnswerErrorText +class assAnswerErrorText implements ToNormalized, FromNormalized { protected string $text_wrong; protected string $text_correct; @@ -94,4 +98,32 @@ public function getLength(): int { return $this->length; } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + 'text_wrong' => $this->text_wrong, + 'text_correct' => $this->text_correct, + 'points' => $this->points, + 'position' => $this->position, + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): self + { + return new self( + $transformations->string($normalized['text_wrong']), + $transformations->string($normalized['text_correct']), + $transformations->float($normalized['points']), + $transformations->int($normalized['position']) + ); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assAnswerImagemap.php b/components/ILIAS/TestQuestionPool/classes/class.assAnswerImagemap.php index 7356acba7457..a0b548d1a0f1 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerImagemap.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerImagemap.php @@ -16,6 +16,8 @@ * *********************************************************************/ +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; + /** * Class for true/false or yes/no answers * @@ -142,4 +144,31 @@ public function setPointsUnchecked($points_unchecked): void $this->points_unchecked = 0.0; } } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + ...$transformations->normalize(parent::toNormalized($transformations, $context)), + 'coords' => $this->coords, + 'area' => $this->area, + 'points_unchecked' => $this->points_unchecked, + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + $clone = parent::fromNormalized($normalized, $transformations); + $clone->setCoords($transformations->string($normalized['coords'])); + $clone->setArea($transformations->string($normalized['area'])); + $clone->setPointsUnchecked($transformations->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..b1909cd5bbdf 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\Normalize\FromNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\ToNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; + /** * Class for matching question answers * @@ -24,7 +28,7 @@ * @author Helmut Schottmüller * @ingroup components\ILIASTestQuestionPool */ -class ASS_AnswerMatching +class ASS_AnswerMatching implements ToNormalized, FromNormalized { public float $points; @@ -236,4 +240,32 @@ public function setPoints(float $points = 0.0): void { $this->points = $points; } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + 'points' => $this->points, + 'picture_or_definition' => $this->picture_or_definition, + 'picture_or_definition_id' => $this->picture_or_definition_id, + 'term_id' => $this->term_id, + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + $clone = clone $this; + $clone->setPoints($transformations->float($normalized['points'])); + $clone->setPicture($transformations->string($normalized['picture_or_definition'])); + $clone->setPictureId($transformations->int($normalized['picture_or_definition_id'])); + $clone->setTermId($transformations->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..214170dcdf69 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\Normalize\FromNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\ToNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; + /** * Class for matching question pairs * * @author Helmut Schottmüller * @ingroup components\ILIASTestQuestionPool */ -class assAnswerMatchingPair +class assAnswerMatchingPair implements ToNormalized, FromNormalized { protected assAnswerMatchingTerm $term; protected assAnswerMatchingDefinition $definition; @@ -72,4 +76,28 @@ public function withPoints(float $points): self $clone->points = $points; return $clone; } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + 'points' => $this->points, + 'term' => $transformations->normalize($this->term, $context), + 'definition' => $transformations->normalize($this->definition, $context), + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): self + { + return $this->withPoints($transformations->float($normalized['points'])) + ->withTerm($transformations->denormalize($normalized['term'], $this->term)) + ->withDefinition($transformations->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..cf0daa2072a6 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\Normalize\FromNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\ToNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; +use ILIAS\TestQuestionPool\ExportImport\Normalize\Envelopes\QuestionImage; + /** * Class for matching question terms * * @author Helmut Schottmüller * @ingroup components\ILIASTestQuestionPool */ -class assAnswerMatchingTerm +class assAnswerMatchingTerm implements ToNormalized, FromNormalized { protected string $text; protected string $picture; @@ -76,4 +81,30 @@ public function withIdentifier(int $identifier): self $clone->identifier = $identifier; return $clone; } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + 'text' => $this->text, + 'picture' => $this->picture + ? $transformations->normalize(new QuestionImage($this->picture, $context['question_id'] ?? null)) + : null, + 'identifier' => $this->identifier, + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): self + { + return $this->withText($transformations->string($normalized['text'])) + ->withPicture($transformations->denormalize($normalized['picture'], QuestionImage::class)?->getFilename() ?? '') + ->withIdentifier($transformations->int($normalized['identifier'])); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assAnswerMultipleResponse.php b/components/ILIAS/TestQuestionPool/classes/class.assAnswerMultipleResponse.php index ace7464d312e..0ebdba2a48e7 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerMultipleResponse.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerMultipleResponse.php @@ -16,6 +16,8 @@ * *********************************************************************/ +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; + /** * Class for true/false or yes/no answers * @@ -97,4 +99,27 @@ public function getPointsChecked(): float { return $this->getPoints(); } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + ...$transformations->normalize(parent::toNormalized($transformations, $context)), + 'points_unchecked' => $this->points_unchecked, + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + $clone = parent::fromNormalized($normalized, $transformations); + $clone->points_unchecked = $transformations->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..0ad319ff3147 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerMultipleResponseImage.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerMultipleResponseImage.php @@ -18,6 +18,9 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; +use ILIAS\TestQuestionPool\ExportImport\Normalize\Envelopes\QuestionImage; + /** * ASS_AnswerBinaryStateImage is a class for answers with a binary state * indicator (checked/unchecked, set/unset) and an image file @@ -74,4 +77,29 @@ public function hasImage(): bool { return $this->image !== null; } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + ...$transformations->normalize(parent::toNormalized($transformations, $context)), + 'image' => $this->image + ? $transformations->normalize(new QuestionImage($this->image, $context['question_id'] ?? null)) + : null, + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + $clone = parent::fromNormalized($normalized, $transformations); + $clone->setImage($transformations->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..55611a44010e 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\Normalize\FromNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\ToNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Envelopes\Id; + /** * Class for simple answers * @@ -26,7 +31,7 @@ * * @ingroup components\ILIASTestQuestionPool */ -class ASS_AnswerSimple +class ASS_AnswerSimple implements ToNormalized, FromNormalized { protected string $answertext; @@ -211,4 +216,32 @@ public function setPoints($points = 0.0): void $this->points = 0.0; } } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + 'id' => $transformations->normalize(new Id($this->id, 'answer')), + 'answertext' => $this->answertext, + 'points' => $this->points, + 'order' => $this->order, + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + return new static( + $transformations->string($normalized['answertext']), + $transformations->float($normalized['points']), + $transformations->int($normalized['order']), + $transformations->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..5539b04da677 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerTrueFalse.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerTrueFalse.php @@ -16,6 +16,8 @@ * *********************************************************************/ +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; + /** * Class for true/false or yes/no answers * @@ -147,4 +149,27 @@ public function setFalse(): void { $this->correctness = "0"; } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + ...$transformations->normalize(parent::toNormalized($transformations, $context)), + 'correctness' => $this->correctness, + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + $clone = parent::fromNormalized($normalized, $transformations); + $clone->setCorrectness($transformations->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..33c78f129c13 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assClozeGap.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assClozeGap.php @@ -16,8 +16,10 @@ * *********************************************************************/ +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\FromNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\ToNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; use ILIAS\Refinery\Transformation; -use ILIAS\Refinery\Random\Transformation\ShuffleTransformation; /** * Class for cloze question gaps @@ -31,7 +33,7 @@ * * @ingroup components\ILIASTestQuestionPool */ -class assClozeGap +class assClozeGap implements ToNormalized, FromNormalized { public const TYPE_TEXT = 0; public const TYPE_SELECT = 1; @@ -454,4 +456,35 @@ public function numericRangeExists(): bool return false; } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + 'type' => $this->type, + 'shuffle' => $this->shuffle, + 'gap_size' => $this->gap_size, + 'items' => $transformations->normalize($this->items), + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): self + { + $clone = new self($normalized['type']); + $clone->setShuffle($normalized['shuffle']); + $clone->setGapSize($normalized['gap_size']); + $clone->items = array_map( + fn(array $item) => $transformations->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..276871acbc50 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\Normalize\FromNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\ToNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; 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, ToNormalized, FromNormalized { /** * The gaps of the cloze question @@ -169,9 +172,9 @@ public function loadFromDb(int $question_id): void $this->setFeedbackMode($data['feedback_mode'] === null ? ilAssClozeTestFeedback::FB_MODE_GAP_QUESTION : $data['feedback_mode']); try { - $this->setLifecycle(ilAssQuestionLifecycle::getInstance($data['lifecycle'])); + $this->setLifecycle(new ilAssQuestionLifecycle($data['lifecycle'])); } catch (ilTestQuestionPoolInvalidArgumentException $e) { - $this->setLifecycle(ilAssQuestionLifecycle::getDraftInstance()); + $this->setLifecycle(new ilAssQuestionLifecycle()); } $this->question = ilRTE::_replaceMediaObjectImageSrc($this->question, 1); @@ -1767,4 +1770,46 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): arra } return $answers; } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + ...$transformations->normalize(parent::toNormalized($transformations, $context)), + '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' => $transformations->normalize($this->gaps), + 'gap_combinations' => $this->gap_combinations, + 'gap_combinations_exist' => $this->gap_combinations_exist, + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + $clone = parent::fromNormalized($normalized, $transformations); + $clone->feedbackMode = $transformations->string($normalized['feedback_mode']); + $clone->cloze_text = $transformations->string($normalized['cloze_text']); + $clone->textgap_rating = $transformations->string($normalized['textgap_rating']); + $clone->identical_scoring = $transformations->bool($normalized['identical_scoring']); + $clone->fixed_text_length = $transformations->nullableInt($normalized['fixed_text_length']); + $clone->gap_combinations_exist = $transformations->bool($normalized['gap_combinations_exist']); + $clone->gap_combinations = $normalized['gap_combinations']; + + foreach ($normalized['gaps'] as $gap) { + $type = $transformations->int($gap['type']); + $clone->gaps[] = $transformations->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..7b47bb3f7e92 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\Normalize\FromNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\ToNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; 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, ToNormalized, FromNormalized { protected const ERROR_TYPE_WORD = 1; protected const ERROR_TYPE_PASSAGE = 2; @@ -172,9 +175,9 @@ public function loadFromDb($question_id): void $this->setPointsWrong($data["points_wrong"]); try { - $this->setLifecycle(ilAssQuestionLifecycle::getInstance($data['lifecycle'])); + $this->setLifecycle(new ilAssQuestionLifecycle($data['lifecycle'])); } catch (ilTestQuestionPoolInvalidArgumentException $e) { - $this->setLifecycle(ilAssQuestionLifecycle::getDraftInstance()); + $this->setLifecycle(new ilAssQuestionLifecycle()); } try { @@ -1000,4 +1003,37 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): stri { return $this->createErrorTextExport($this->getBestSelection()); } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + ...$transformations->normalize(parent::toNormalized($transformations, $context)), + 'errortext' => $this->errortext, + 'errortext_parsed' => $this->parsed_errortext, + 'errordata' => $transformations->normalize($this->errordata), + 'points_wrong' => $this->points_wrong, + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + $clone = parent::fromNormalized($normalized, $transformations); + $clone->errortext = $transformations->string($normalized['errortext']); + $clone->parsed_errortext = $normalized['errortext_parsed']; + $clone->points_wrong = $transformations->nullableFloat($normalized['points_wrong']); + $clone->errordata = array_map( + fn(array $error) => $transformations->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..a72b8835a50b 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\Normalize\FromNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\ToNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; 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, ToNormalized, FromNormalized { public const REUSE_FILES_TBL_POSTVAR = 'reusefiles'; public const DELETE_FILES_TBL_POSTVAR = 'deletefiles'; @@ -164,9 +167,9 @@ public function loadFromDb(int $question_id): void $this->setCompletionBySubmission($data['compl_by_submission'] == 1 ? true : false); try { - $this->setLifecycle(ilAssQuestionLifecycle::getInstance($data['lifecycle'])); + $this->setLifecycle(new ilAssQuestionLifecycle($data['lifecycle'])); } catch (ilTestQuestionPoolInvalidArgumentException $e) { - $this->setLifecycle(ilAssQuestionLifecycle::getDraftInstance()); + $this->setLifecycle(new ilAssQuestionLifecycle()); } try { @@ -950,4 +953,32 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): stri { return ''; } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + ...$transformations->normalize(parent::toNormalized($transformations, $context)), + 'maxsize' => $this->maxsize, + 'allowedextensions' => $this->allowedextensions, + 'completion_by_submission' => $this->completion_by_submission, + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + $clone = parent::fromNormalized($normalized, $transformations); + $clone->maxsize = $transformations->nullableInt($normalized['maxsize']); + $clone->allowedextensions = $transformations->string($normalized['allowedextensions']); + $clone->completion_by_submission = $transformations->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 b5389fc27efe..d5beaab1e4ed 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\Normalize\FromNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\ToNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; 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, ToNormalized, FromNormalized { private array $variables; private array $results; @@ -711,9 +714,9 @@ public function loadFromDb(int $question_id): void $this->setOwner($data["owner"]); try { - $this->setLifecycle(ilAssQuestionLifecycle::getInstance($data['lifecycle'])); + $this->setLifecycle(new ilAssQuestionLifecycle($data['lifecycle'])); } catch (ilTestQuestionPoolInvalidArgumentException $e) { - $this->setLifecycle(ilAssQuestionLifecycle::getDraftInstance()); + $this->setLifecycle(new ilAssQuestionLifecycle()); } try { @@ -1486,4 +1489,38 @@ function (array $c, array $v): array { ] ); } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + ...$transformations->normalize(parent::toNormalized($transformations, $context)), + 'variables' => $transformations->normalize($this->variables), + 'results' => $transformations->normalize($this->results) + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + $clone = parent::fromNormalized($normalized, $transformations); + + foreach ($normalized['variables'] as $key => $data) { + $dummy = new assFormulaQuestionVariable('', '', ''); + $clone->variables[$key] = $transformations->denormalize($data, $dummy); + } + + foreach ($normalized['results'] as $key => $data) { + $dummy = new assFormulaQuestionResult('', '', '', 0, null, '', 0, 0); + $clone->results[$key] = $transformations->denormalize($data, $dummy); + } + + return $clone; + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionGUI.php b/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionGUI.php index 3af4a7ed05f0..83b2fd4c3687 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionGUI.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionGUI.php @@ -98,7 +98,7 @@ protected function writePostData(bool $always = false): int } try { - $lifecycle = ilAssQuestionLifecycle::getInstance($this->request_data_collector->string('lifecycle')); + $lifecycle = new ilAssQuestionLifecycle($this->request_data_collector->string('lifecycle')); $this->object->setLifecycle($lifecycle); } catch (ilTestQuestionPoolInvalidArgumentException $e) { } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionResult.php b/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionResult.php index 715ce465d734..5367f1fec155 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\TestQuestionPool\ExportImport\Foundation\Normalize\FromNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\ToNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\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 ToNormalized, FromNormalized { public const RESULT_NO_SELECTION = 0; public const RESULT_DEC = 1; @@ -862,4 +865,60 @@ public function getAvailableResultUnits($question_id): array return $this->available_units; } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + 'available_units' => $transformations->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' => $transformations->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, + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + $clone = clone $this; + $clone->available_units = array_map( + static fn(array $unit): assFormulaQuestionUnit => $transformations->denormalize($unit, new assFormulaQuestionUnit()), + $normalized['available_units'] + ); + $clone->range_min = $transformations->float($normalized['range_min']); + $clone->range_max = $transformations->float($normalized['range_max']); + $clone->range_min_txt = $transformations->string($normalized['range_min_txt']); + $clone->range_max_txt = $transformations->string($normalized['range_max_txt']); + $clone->result = $transformations->string($normalized['result']); + $clone->tolerance = $transformations->float($normalized['tolerance']); + $clone->unit = $transformations->denormalize($normalized['unit'], new assFormulaQuestionUnit()); + $clone->formula = $transformations->string($normalized['formula']); + $clone->points = $transformations->float($normalized['points']); + $clone->precision = $transformations->int($normalized['precision']); + $clone->rating_simple = $transformations->bool($normalized['rating_simple']); + $clone->rating_sign = $transformations->float($normalized['rating_sign']); + $clone->rating_value = $transformations->float($normalized['rating_value']); + $clone->rating_unit = $transformations->float($normalized['rating_unit']); + $clone->result_type = $transformations->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..1fe121022463 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\TestQuestionPool\ExportImport\Foundation\Normalize\FromNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\ToNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Envelopes\Id; + /** * Formula Question Unit * @author Helmut Schottmüller * @ingroup components\ILIASTestQuestionPool */ -class assFormulaQuestionUnit +class assFormulaQuestionUnit implements ToNormalized, FromNormalized { 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'); } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + 'id' => $transformations->normalize(new Id($this->id, 'unit')), + 'unit' => $this->unit, + 'factor' => $this->factor, + 'category_id' => $transformations->normalize(new Id($this->category, 'unit_category')), + 'sequence' => $this->sequence, + 'baseunit' => $transformations->normalize(new Id($this->baseunit, 'unit')), + 'baseunit_title' => $this->baseunit_title, + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + $clone = clone $this; + $clone->id = $transformations->denormalize($normalized['id'], Id::class)->getId(); + $clone->unit = $transformations->string($normalized['unit']); + $clone->factor = $transformations->float($normalized['factor']); + $clone->category = $transformations->denormalize($normalized['category_id'], Id::class)->getId(); + $clone->sequence = $transformations->int($normalized['sequence']); + $clone->baseunit = $transformations->denormalize($normalized['baseunit'], Id::class)->getId(); + $clone->baseunit_title = $transformations->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..bc58fa2f99eb 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\TestQuestionPool\ExportImport\Foundation\Normalize\FromNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\ToNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Envelopes\Id; + /** * Formula Question Unit Category * @author Helmut Schottmüller * @ingroup components\ILIASTestQuestionPool */ -class assFormulaQuestionUnitCategory +class assFormulaQuestionUnitCategory implements ToNormalized, FromNormalized { 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'); } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + 'id' => $transformations->normalize(new Id($this->id, 'unit_category')), + 'name' => $this->category, + 'question_id' => $transformations->normalize(new Id($this->question_fi, 'question')), + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + $clone = clone $this; + $clone->id = $transformations->denormalize($normalized['id'], Id::class)->getId(); + $clone->category = $transformations->string($normalized['name']); + $clone->question_fi = $transformations->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..8322037bd12b 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\Normalize\FromNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\ToNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; + /** * 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 ToNormalized, FromNormalized { private $value = null; private float $range_min; @@ -197,4 +201,41 @@ public function getRangeMinTxt(): string { return $this->range_min_txt; } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + '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' => $transformations->normalize($this->unit), + 'precision' => $this->precision, + 'intprecision' => $this->intprecision, + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + $clone = clone $this; + $clone->variable = $transformations->string($normalized['variable']); + $clone->range_min = $transformations->float($normalized['range_min']); + $clone->range_max = $transformations->float($normalized['range_max']); + $clone->range_min_txt = $transformations->string($normalized['range_min_txt']); + $clone->range_max_txt = $transformations->string($normalized['range_max_txt']); + $clone->unit = $transformations->denormalize($normalized['unit'], new assFormulaQuestionUnit()); + $clone->precision = $transformations->int($normalized['precision']); + $clone->intprecision = $transformations->int($normalized['intprecision']); + + return $clone; + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assImagemapQuestion.php b/components/ILIAS/TestQuestionPool/classes/class.assImagemapQuestion.php index 5408fda96360..4e55cb7645b1 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assImagemapQuestion.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assImagemapQuestion.php @@ -18,6 +18,10 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\FromNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\ToNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; +use ILIAS\TestQuestionPool\ExportImport\Normalize\Envelopes\QuestionImage; use ILIAS\TestQuestionPool\QuestionPoolDIC; use ILIAS\TestQuestionPool\RequestDataCollector; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; @@ -36,7 +40,7 @@ * * @ingroup ModulesTestQuestionPool */ -class assImagemapQuestion extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionLMExportable +class assImagemapQuestion extends assQuestion implements ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionLMExportable, ToNormalized, FromNormalized { 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 @@ -255,9 +259,9 @@ public function loadFromDb(int $question_id): void $this->setImageFilename($data['image_file'] ?? ''); try { - $this->setLifecycle(ilAssQuestionLifecycle::getInstance($data['lifecycle'])); + $this->setLifecycle(new ilAssQuestionLifecycle($data['lifecycle'])); } catch (ilTestQuestionPoolInvalidArgumentException $e) { - $this->setLifecycle(ilAssQuestionLifecycle::getDraftInstance()); + $this->setLifecycle(new ilAssQuestionLifecycle()); } try { @@ -927,4 +931,35 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): arra $this->getAnswers() ); } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + ...$transformations->normalize(parent::toNormalized($transformations, $context)), + 'image' => $transformations->normalize(new QuestionImage($this->image_filename, $this->getId())), + 'multiple_choice' => $this->is_multiple_choice, + 'answers' => $transformations->normalize($this->answers), + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + $clone = parent::fromNormalized($normalized, $transformations); + $clone->image_filename = $transformations->denormalize($normalized['image'], QuestionImage::class)->getFilename(); + $clone->is_multiple_choice = $transformations->bool($normalized['multiple_choice']); + $clone->answers = array_map( + static fn(array $answer): ASS_AnswerImagemap => $transformations->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..bd781b632666 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assKprimChoice.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assKprimChoice.php @@ -18,6 +18,9 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\FromNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\ToNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\TestQuestionPool\ManipulateImagesInChoiceQuestionsTrait; @@ -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, ToNormalized, FromNormalized { use ManipulateImagesInChoiceQuestionsTrait; @@ -258,9 +261,9 @@ public function loadFromDb($questionId): void } try { - $this->setLifecycle(ilAssQuestionLifecycle::getInstance($data['lifecycle'])); + $this->setLifecycle(new ilAssQuestionLifecycle($data['lifecycle'])); } catch (ilTestQuestionPoolInvalidArgumentException $e) { - $this->setLifecycle(ilAssQuestionLifecycle::getDraftInstance()); + $this->setLifecycle(new ilAssQuestionLifecycle()); } try { @@ -920,4 +923,45 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): arra $this->getAnswers() ); } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + ...$transformations->normalize(parent::toNormalized($transformations, $context)), + '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' => $transformations->normalize($this->answers, ['question_id' => $this->getId()]), + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + $clone = parent::fromNormalized($normalized, $transformations); + $clone->shuffle_answers_enabled = $transformations->bool($normalized['shuffle_answers']); + $clone->answerType = $transformations->string($normalized['answer_type']); + $clone->option_label = $transformations->string($normalized['option_label']); + $clone->customTrueOptionLabel = $transformations->string($normalized['custom_true_option_label']); + $clone->customFalseOptionLabel = $transformations->string($normalized['custom_false_option_label']); + $clone->scorePartialSolutionEnabled = $transformations->bool($normalized['score_partial_solution']); + $clone->specific_feedback_setting = $transformations->int($normalized['specific_feedback_setting']); + $clone->answers = array_map( + static fn(array $answer): ilAssKprimChoiceAnswer => $transformations->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..b3c299090cb6 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\Normalize\FromNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\ToNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\Test\Logging\AdditionalInformationGenerator; -class assLongMenu extends assQuestion implements ilObjQuestionScoringAdjustable, QuestionLMExportable, QuestionAutosaveable +class assLongMenu extends assQuestion implements ilObjQuestionScoringAdjustable, QuestionLMExportable, QuestionAutosaveable, ToNormalized, FromNormalized { public const ANSWER_TYPE_SELECT_VAL = 0; public const ANSWER_TYPE_TEXT_VAL = 1; @@ -384,9 +387,9 @@ public function loadFromDb($question_id): void } try { - $this->setLifecycle(ilAssQuestionLifecycle::getInstance($data['lifecycle'])); + $this->setLifecycle(new ilAssQuestionLifecycle($data['lifecycle'])); } catch (ilTestQuestionPoolInvalidArgumentException $e) { - $this->setLifecycle(ilAssQuestionLifecycle::getDraftInstance()); + $this->setLifecycle(new ilAssQuestionLifecycle()); } try { @@ -833,4 +836,38 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): arra } return $correct_answers; } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + ...$transformations->normalize(parent::toNormalized($transformations, $context)), + '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, + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + $clone = parent::fromNormalized($normalized, $transformations); + $clone->long_menu_text = $transformations->string($normalized['long_menu_text']); + $clone->json_structure = $transformations->string($normalized['json_structure']); + $clone->minAutoComplete = $transformations->int($normalized['min_auto_complete']); + $clone->identical_scoring = $transformations->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 c5484858e9ef..56203be65cd4 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assMatchingQuestion.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assMatchingQuestion.php @@ -18,6 +18,9 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\FromNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\ToNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\Test\Logging\AdditionalInformationGenerator; @@ -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, ToNormalized, FromNormalized { public const MT_TERMS_PICTURES = 0; public const MT_TERMS_DEFINITIONS = 1; @@ -259,9 +262,9 @@ public function loadFromDb($question_id): void $this->setMatchingMode($data['matching_mode'] === null ? self::MATCHING_MODE_1_ON_1 : $data['matching_mode']); try { - $this->setLifecycle(ilAssQuestionLifecycle::getInstance($data['lifecycle'])); + $this->setLifecycle(new ilAssQuestionLifecycle($data['lifecycle'])); } catch (ilTestQuestionPoolInvalidArgumentException $e) { - $this->setLifecycle(ilAssQuestionLifecycle::getDraftInstance()); + $this->setLifecycle(new ilAssQuestionLifecycle()); } try { @@ -1423,4 +1426,44 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): arra $this->getMatchingPairs() ); } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + ...$transformations->normalize(parent::toNormalized($transformations, $context)), + 'shuffle_mode' => $this->shufflemode, + 'matching_mode' => $this->matching_mode, + 'matching_type' => $this->matching_type, + 'thumb_geometry' => $this->thumb_geometry, + 'matching_pairs' => $transformations->normalize($this->matchingpairs, ['question_id' => $this->getId()]), + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + $clone = parent::fromNormalized($normalized, $transformations); + $clone->shufflemode = $transformations->int($normalized['shuffle_mode']); + $clone->matching_mode = $transformations->string($normalized['matching_mode']); + $clone->matching_type = $transformations->int($normalized['matching_type']); + $clone->thumb_geometry = $transformations->int($normalized['thumb_geometry']); + + foreach ($normalized['matching_pairs'] as $matching_pair) { + $term = $transformations->denormalize($matching_pair['term'], new assAnswerMatchingTerm()); + $definition = $transformations->denormalize($matching_pair['definition'], new assAnswerMatchingDefinition()); + + $clone->matchingpairs[] = new assAnswerMatchingPair($term, $definition, $transformations->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 2d2594587ee1..45f230b17846 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\Normalize\FromNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\ToNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\TestQuestionPool\ManipulateImagesInChoiceQuestionsTrait; use ILIAS\Test\Logging\AdditionalInformationGenerator; -use ILIAS\TestQuestionPool\RequestDataCollector; /** * 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, ToNormalized, FromNormalized { use ManipulateImagesInChoiceQuestionsTrait; @@ -142,9 +144,9 @@ public function loadFromDb(int $question_id): void } try { - $this->setLifecycle(ilAssQuestionLifecycle::getInstance($data['lifecycle'])); + $this->setLifecycle(new ilAssQuestionLifecycle($data['lifecycle'])); } catch (ilTestQuestionPoolInvalidArgumentException $e) { - $this->setLifecycle(ilAssQuestionLifecycle::getDraftInstance()); + $this->setLifecycle(new ilAssQuestionLifecycle()); } try { @@ -957,4 +959,35 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): arra $this->getAnswers() ); } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + ...$transformations->normalize(parent::toNormalized($transformations, $context)), + 'selection_limit' => $this->selection_limit, + 'single_line' => $this->is_singleline, + 'answers' => $transformations->normalize($this->answers, ['question_id' => $this->getId()]), + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + $clone = parent::fromNormalized($normalized, $transformations); + $clone->selection_limit = $transformations->nullableInt($normalized['selection_limit']); + $clone->is_singleline = $transformations->bool($normalized['single_line']); + $clone->answers = array_map( + static fn(array $answer): ASS_AnswerMultipleResponseImage => $transformations->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..b6ae2aa43eb5 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assNumeric.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assNumeric.php @@ -18,6 +18,9 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\FromNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\ToNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\Test\Logging\AdditionalInformationGenerator; @@ -36,7 +39,7 @@ * * @ingroup ModulesTestQuestionPool */ -class assNumeric extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionAutosaveable +class assNumeric extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionAutosaveable, ToNormalized, FromNormalized { protected $lower_limit; protected $upper_limit; @@ -85,9 +88,9 @@ public function loadFromDb(int $question_id): void $this->setMaxChars($data["maxnumofchars"]); try { - $this->setLifecycle(ilAssQuestionLifecycle::getInstance($data['lifecycle'])); + $this->setLifecycle(new ilAssQuestionLifecycle($data['lifecycle'])); } catch (ilTestQuestionPoolInvalidArgumentException $e) { - $this->setLifecycle(ilAssQuestionLifecycle::getDraftInstance()); + $this->setLifecycle(new ilAssQuestionLifecycle()); } try { @@ -463,4 +466,32 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): stri { return "{$this->getLowerLimit()}-{$this->getUpperLimit()}"; } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + ...$transformations->normalize(parent::toNormalized($transformations, $context)), + 'lower_limit' => $this->lower_limit, + 'upper_limit' => $this->upper_limit, + 'maxchars' => $this->maxchars, + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + $clone = parent::fromNormalized($normalized, $transformations); + $clone->lower_limit = $transformations->string($normalized['lower_limit']); + $clone->upper_limit = $transformations->string($normalized['upper_limit']); + $clone->maxchars = $transformations->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..6e9a40fc75a6 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assOrderingHorizontal.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assOrderingHorizontal.php @@ -18,6 +18,9 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\FromNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\ToNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\Test\Logging\AdditionalInformationGenerator; @@ -33,7 +36,7 @@ * * @ingroup ModulesTestQuestionPool */ -class assOrderingHorizontal extends assQuestion implements ilObjQuestionScoringAdjustable, iQuestionCondition, QuestionLMExportable, QuestionAutosaveable +class assOrderingHorizontal extends assQuestion implements ilObjQuestionScoringAdjustable, iQuestionCondition, QuestionLMExportable, QuestionAutosaveable, ToNormalized, FromNormalized { protected const HAS_SPECIFIC_FEEDBACK = false; protected const DEFAULT_TEXT_SIZE = 100; @@ -129,9 +132,9 @@ public function loadFromDb($question_id): void $this->setTextSize($data["textsize"]); try { - $this->setLifecycle(ilAssQuestionLifecycle::getInstance($data['lifecycle'])); + $this->setLifecycle(new ilAssQuestionLifecycle($data['lifecycle'])); } catch (ilTestQuestionPoolInvalidArgumentException $e) { - $this->setLifecycle(ilAssQuestionLifecycle::getDraftInstance()); + $this->setLifecycle(new ilAssQuestionLifecycle()); } try { @@ -582,4 +585,34 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): stri { return $this->getOrderText(); } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + ...$transformations->normalize(parent::toNormalized($transformations, $context)), + 'ordertext' => $this->ordertext, + 'textsize' => $this->textsize, + 'separator' => $this->separator, + 'answer_separator' => $this->answer_separator, + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + $clone = parent::fromNormalized($normalized, $transformations); + $clone->ordertext = $transformations->string($normalized['ordertext']); + $clone->textsize = $transformations->float($normalized['textsize']); + $clone->separator = $transformations->string($normalized['separator']); + $clone->answer_separator = $transformations->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 2fcf3643960d..11982ea0d8b7 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\Test\Logging\AdditionalInformationGenerator; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\FromNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\ToNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\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, ToNormalized, FromNormalized { public const ORDERING_ELEMENT_FORM_FIELD_POSTVAR = 'order_elems'; @@ -146,9 +149,9 @@ public function loadFromDb($question_id): void $this->element_height = $data["element_height"] ? (int) $data['element_height'] : null; try { - $this->setLifecycle(ilAssQuestionLifecycle::getInstance($data['lifecycle'])); + $this->setLifecycle(new ilAssQuestionLifecycle($data['lifecycle'])); } catch (ilTestQuestionPoolInvalidArgumentException $e) { - $this->setLifecycle(ilAssQuestionLifecycle::getDraftInstance()); + $this->setLifecycle(new ilAssQuestionLifecycle()); } try { @@ -1366,4 +1369,37 @@ function (ilAssOrderingElement $v): string { $elements ); } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + ...$transformations->normalize(parent::toNormalized($transformations, $context)), + 'ordering_type' => $this->ordering_type, + 'ordering_elements' => $transformations->normalize( + $this->getOrderingElementList()->getElements(), + ['question_id' => $this->getId()] + ), + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + $clone = parent::fromNormalized($normalized, $transformations); + $clone->ordering_type = $transformations->int($normalized['ordering_type']); + $denormalized_elements = array_map( + static fn(array $element): ilAssOrderingElement => $transformations->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..d60ba080c425 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\Normalize\Transformations; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\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 @@ -162,7 +164,7 @@ public function __construct( $this->questionActionCmd = 'handleQuestionAction'; $this->export_image_path = ''; $this->shuffler = $DIC->refinery()->random()->dontShuffle(); - $this->lifecycle = ilAssQuestionLifecycle::getDraftInstance(); + $this->lifecycle = new ilAssQuestionLifecycle(); $this->skillUsageService = $DIC->skills()->usage(); $this->test_result_repository = TestDIC::dic()['results.data.repository']; @@ -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,70 @@ public function getVariablesAsTextArray( ): array { return []; } + + /** + * @param array $context + * @return array|float|bool|int|string|null + */ + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + 'id' => $transformations->normalize(new Id($this->id, 'question')), + 'parent_id' => $transformations->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' => $transformations->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' => $transformations->normalize($this->suggested_solutions), + ]; + } + + /** @param array $normalized */ + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + $clone = clone $this; + $clone->id = $transformations->denormalize($normalized['id'], Id::class)->getId(); + $clone->obj_id = $transformations->denormalize($normalized['parent_id'], Id::class)->getId(); + $clone->original_id = $transformations->nullableInt($normalized['original_id']); + $clone->external_id = $transformations->nullableString($normalized['external_id']); + $clone->owner = $transformations->int($normalized['owner']); + $clone->title = $transformations->string($normalized['title']); + $clone->comment = $transformations->string($normalized['description']); + $clone->question = $transformations->string($normalized['question_text']); + $clone->points = $transformations->float($normalized['available_points']); + $clone->nr_of_tries = $transformations->int($normalized['nr_of_tries']); + $clone->lifecycle = $transformations->denormalize($normalized['lifecycle'], $clone->lifecycle); + $clone->author = $transformations->string($normalized['author']); + $clone->lastChange = $transformations->nullableInt($normalized['updated_timestamp']); + $clone->additionalContentEditingMode = $transformations->string($normalized['additional_content_editing_mode']); + $clone->thumb_size = $transformations->int($normalized['thumb_size']); + $clone->shuffle = $transformations->bool($normalized['shuffle']); + + $clone->suggested_solutions = array_map( + static fn(array $suggested_solution): SuggestedSolution => $transformations->denormalize( + $suggested_solution, + SuggestedSolution::class + ), + $normalized['suggested_solutions'] + ); + + return $clone; + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assQuestionGUI.php b/components/ILIAS/TestQuestionPool/classes/class.assQuestionGUI.php index f6645a6b19bb..b07f212af67a 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assQuestionGUI.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assQuestionGUI.php @@ -1669,7 +1669,7 @@ protected function writeQuestionGenericPostData(): void } try { - $lifecycle = ilAssQuestionLifecycle::getInstance( + $lifecycle = new ilAssQuestionLifecycle( $this->request_data_collector->string('lifecycle') ); $this->object->setLifecycle($lifecycle); diff --git a/components/ILIAS/TestQuestionPool/classes/class.assSingleChoice.php b/components/ILIAS/TestQuestionPool/classes/class.assSingleChoice.php index e90cb6c53ec0..798603bbb875 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assSingleChoice.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assSingleChoice.php @@ -18,6 +18,9 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\FromNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\ToNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\TestQuestionPool\ManipulateImagesInChoiceQuestionsTrait; @@ -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, ToNormalized, FromNormalized { use ManipulateImagesInChoiceQuestionsTrait; @@ -124,9 +127,9 @@ public function loadFromDb(int $question_id): void $this->feedback_setting = $data['feedback_setting'] ?? self::FEEDBACK_MODE_SELECTED_ANSWERS; try { - $this->setLifecycle(ilAssQuestionLifecycle::getInstance($data['lifecycle'])); + $this->setLifecycle(new ilAssQuestionLifecycle($data['lifecycle'])); } catch (ilTestQuestionPoolInvalidArgumentException $e) { - $this->setLifecycle(ilAssQuestionLifecycle::getDraftInstance()); + $this->setLifecycle(new ilAssQuestionLifecycle()); } try { @@ -944,4 +947,33 @@ function (array $c, ASS_AnswerBinaryStateImage $v): array { [] ); } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + $normalized = $transformations->normalize(parent::toNormalized($transformations, $context)); + $normalized['is_singleline'] = $this->is_singleline; + $normalized['feedback_setting'] = $this->feedback_setting; + $normalized['answers'] = $transformations->normalize($this->answers, ['question_id' => $this->getId()]); + return $normalized; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + $clone = parent::fromNormalized($normalized, $transformations); + $clone->is_singleline = $transformations->bool($normalized['is_singleline']); + $clone->feedback_setting = $transformations->int($normalized['feedback_setting']); + $clone->answers = array_map( + static fn(array $answer): ASS_AnswerBinaryStateImage => $transformations->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..2731005a3bd4 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\Test\Logging\AdditionalInformationGenerator; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\FromNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\ToNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\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, ToNormalized, FromNormalized { protected const HAS_SPECIFIC_FEEDBACK = false; @@ -142,9 +145,9 @@ public function loadFromDb(int $question_id): void $this->setKeywordRelation(($data['keyword_relation'])); try { - $this->setLifecycle(ilAssQuestionLifecycle::getInstance($data['lifecycle'])); + $this->setLifecycle(new ilAssQuestionLifecycle($data['lifecycle'])); } catch (ilTestQuestionPoolInvalidArgumentException $e) { - $this->setLifecycle(ilAssQuestionLifecycle::getDraftInstance()); + $this->setLifecycle(new ilAssQuestionLifecycle()); } try { @@ -855,4 +858,44 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): arra ); } } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + ...$transformations->normalize(parent::toNormalized($transformations, $context)), + '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' => $transformations->normalize($this->answers), + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + $clone = parent::fromNormalized($normalized, $transformations); + $clone->word_counter_enabled = $transformations->bool($normalized['word_counter_enabled']); + $clone->max_num_of_chars = $transformations->int($normalized['max_num_of_chars']); + $clone->text_rating = $transformations->string($normalized['text_rating']); + $clone->matchcondition = $transformations->int($normalized['matchcondition']); + $clone->keyword_relation = $transformations->string($normalized['keyword_relation']); + $clone->answers = array_map( + static fn(array $answer): ASS_AnswerMultipleResponseImage => $transformations->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..59fbee0d2d91 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\Test\Logging\AdditionalInformationGenerator; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\FromNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\ToNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\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, ToNormalized, FromNormalized { public array $answers = []; public int $correctanswers = 0; @@ -89,9 +92,9 @@ public function loadFromDb(int $question_id): void $this->setTextRating($data['textgap_rating'] ?? assClozeGap::TEXTGAP_RATING_CASEINSENSITIVE); try { - $this->setLifecycle(ilAssQuestionLifecycle::getInstance($data['lifecycle'])); + $this->setLifecycle(new ilAssQuestionLifecycle($data['lifecycle'])); } catch (ilTestQuestionPoolInvalidArgumentException $e) { - $this->setLifecycle(ilAssQuestionLifecycle::getDraftInstance()); + $this->setLifecycle(new ilAssQuestionLifecycle()); } try { @@ -755,4 +758,37 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): arra { return $this->getAvailableAnswers(); } + + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + ...$transformations->normalize(parent::toNormalized($transformations, $context)), + 'text_rating' => $this->text_rating, + 'correct_answers' => $this->correctanswers, + 'answers' => $transformations->normalize($this->answers), + ]; + } + + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + $clone = parent::fromNormalized($normalized, $transformations); + $clone->text_rating = $transformations->string($normalized['text_rating']); + $clone->correctanswers = $transformations->int($normalized['correct_answers']); + $clone->answers = array_map( + static fn(array $answer): ASS_AnswerBinaryStateImage => $transformations->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 538c8f556f9b..18c7d24cf4f0 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 $transformations, + array $context = [] + ): array|float|bool|int|string|null + { + return [ + 'position' => $this->position, + 'answertext' => $this->answertext, + 'image' => $this->imageFile + ? $transformations->normalize(new QuestionImage($this->imageFile, $context['question_id'] ?? null)) + : null, + 'correctness' => $this->correctness, + ]; + } + + public function fromNormalized( + array $normalized, + Transformations $transformations + ): static + { + $clone = clone $this; + $clone->position = $transformations->int($normalized['position']); + $clone->answertext = $transformations->nullableString($normalized['answertext']); + $clone->imageFile = $transformations->denormalize($normalized['image'], QuestionImage::class)?->getFilename(); + $clone->correctness = $transformations->int($normalized['correctness']); + return $clone; + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilBulkEditQuestionsGUI.php b/components/ILIAS/TestQuestionPool/classes/class.ilBulkEditQuestionsGUI.php index 58c4856b3367..4e8b94e81f28 100644 --- a/components/ILIAS/TestQuestionPool/classes/class.ilBulkEditQuestionsGUI.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilBulkEditQuestionsGUI.php @@ -175,7 +175,7 @@ protected function getAuthorUpdater(): \Closure protected function getFormLifecycle(): Form\Standard { - $lifecycle = \ilAssQuestionLifecycle::getDraftInstance(); + $lifecycle = new \ilAssQuestionLifecycle(); $options = $lifecycle->getSelectOptions($this->lng); return $this->ui_factory->input()->container()->form()->standard( $this->ctrl->getFormAction($this, self::CMD_SAVELIFECYCLE), @@ -191,7 +191,7 @@ protected function getFormLifecycle(): Form\Standard protected function getLifecycleUpdater(): \Closure { return function (array $questions, string $lifecycle) { - $lc = ilAssQuestionLifecycle::getInstance($lifecycle); + $lc = new ilAssQuestionLifecycle($lifecycle); foreach ($questions as $q) { $q->setLifecycle($lc); $q->saveToDb(); 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 965620232e13..607880181bdb 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\Import\ImportSession; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Import\ImportSessionRepository; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Import\ImportWizard; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Import\StageResult; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Import\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'), @@ -1299,6 +1096,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; @@ -1315,103 +1115,99 @@ 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); + $session = (new ImportSession())->withFileToImport($file_to_import); + $this->import_session_repository->setSession($session); + $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); + $result = $this->buildImportWizard()->run(); - $form = $this->buildImportQuestionsSelectionForm( - 'importVerifiedQuestionsFile', - $importdir, - $qtifile, - $path_to_uploaded_file_in_temp_dir - ); + match ($result->type) { + StageResultType::RequiresUserInteraction => $this->renderImportStage($result), + StageResultType::Error => $this->renderImportError($result), + StageResultType::CompletedSuccessfully => $this->renderImportSuccess($result), + }; + } - if ($form === null) { - return; - } + private function buildImportWizard(): ImportWizard + { + $form_action = $this->ctrl->getFormActionByClass(self::class, 'processImport'); - $panel = $this->ui_factory->panel()->standard( - $this->lng->txt('import_question'), + return new ImportWizard( [ - $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(StageResult $result): void { - list($subdir, $importdir, $xmlfile, $qtifile) = $this->buildImportDirectoriesFromImportFile($file_to_import); - - $options = (new ILIAS\Filesystem\Util\Archive\UnzipOptions()) - ->withZipOutputPath($this->getImportTempDirectory()); - - $unzip = $this->archives->unzip(Streams::ofResource(fopen($file_to_import, 'r')), $options); - $unzip->extract(); - - if (!file_exists($qtifile)) { - ilFileUtils::delDir($importdir); - $this->tpl->setOnScreenMessage('failure', $this->lng->txt('cannot_find_xml'), true); - return; - } - - ilSession::set('path_to_import_file', $file_to_import); - ilSession::set('path_to_uploaded_file_in_temp_dir', $path_to_uploaded_file_in_temp_dir); + $this->tpl->setContent( + $this->ui_renderer->render($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->session->poolObjId()); + $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..1bd8cea80a25 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolExporter.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolExporter.php @@ -16,131 +16,84 @@ * *********************************************************************/ -/** - * 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\Export\ExportPhase; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Export\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->context_holder = $local_dic['exportimport.export_context_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 context. + * The export context 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 context is not initialized yet. + if ( + !$this->context_holder->exists() + || $this->context_holder->get()->getPhase() !== ExportPhase::Init + ) { + $this->initExportContext( + '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..85c6e1f4bced 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php @@ -18,31 +18,33 @@ declare(strict_types=1); -use ILIAS\TestQuestionPool\Import\TestQuestionsImportTrait; +use ILIAS\Data\ReferenceId; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Import\ImportSessionRepository; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Serialize\XmlDeserializer; +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 +53,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 + $session = $this->session->getSession(); + if ($session->isLegacyImport()) { + $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( + XmlDeserializer::fromString($a_xml), + $a_mapping, + new ReferenceId($a_mapping->getTargetId()), + $session, ); - - - $new_obj->saveToDb(); + $this->session->setSession($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 + $session = $this->session->getSession(); + if ($session->isLegacyImport()) { + $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..4c6063afb1a2 --- /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()); + + $session = $this->session->getSession(); + $session = $session->withPoolObjId($this->pool_obj->getId()); + $this->session->setSession($session); + + $qpl_parser = new ilObjQuestionPoolXMLParser($this->pool_obj, $session->legacyXmlFile()); + $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( + $session->importBaseDir(), + $session->legacyQtiFile(), + ilQTIParser::IL_MO_PARSE_QTI, + $this->pool_obj->getId(), + $session->selectedQuestionIds() + ); + $qti_parser->startParsing(); + + $page_parser = new ilQuestionPageParser( + $this->pool_obj, + $session->legacyXmlFile(), + $session->importBaseDir() + ); + $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($session->legacyXmlFile(), $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 51aabfc9cb85..3f7c4ea39b80 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 { @@ -209,7 +211,7 @@ protected function addGeneralMetadata(ilQTIItem $item): void protected function fetchLifecycle(ilQTIItem $item): ilAssQuestionLifecycle { try { - $lifecycle = ilAssQuestionLifecycle::getInstance( + $lifecycle = new ilAssQuestionLifecycle( $item->getMetadataEntry('ilias_lifecycle') ); } catch (ilTestQuestionPoolInvalidArgumentException $e) { @@ -218,11 +220,11 @@ protected function fetchLifecycle(ilQTIItem $item): ilAssQuestionLifecycle $item->getMetadataEntry('lifecycle') ); - $lifecycle = ilAssQuestionLifecycle::getInstance( + $lifecycle = new ilAssQuestionLifecycle( $lomLifecycle->getMappedIliasLifecycleIdentifer() ); } catch (ilTestQuestionPoolInvalidArgumentException $e) { - $lifecycle = ilAssQuestionLifecycle::getDraftInstance(); + $lifecycle = new ilAssQuestionLifecycle(); } } 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..7fe46cb562a8 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\TestQuestionPool\ExportImport\Normalize\Envelopes\QuestionImage; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\FromNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\ToNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Envelopes\Id; + /** * Class represents an ordering element for assOrderingQuestion * @@ -25,7 +31,7 @@ * @version $Id$ * @package Modules/TestQuestionPool */ -class ilAssOrderingElement +class ilAssOrderingElement implements ToNormalized, FromNormalized { public const EXPORT_IDENT_PROPERTY_SEPARATOR = '_'; @@ -440,4 +446,38 @@ public function withContent(string $content): self $clone->content = $content; return $clone; } + + #[\Override] + public function toNormalized(Transformations $transformations, array $context = []): array|float|bool|int|string|null + { + return [ + 'id' => $transformations->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 ? $transformations->normalize( + new QuestionImage($this->content, $context['question_id'] ?? null) + ) : null, + ]; + } + + #[\Override] + public function fromNormalized(array $normalized, Transformations $transformations): self + { + $clone = $this->withRandomIdentifier($transformations->int($normalized['random_identifier'])) + ->withSolutionIdentifier($transformations->int($normalized['solution_identifier'])) + ->withPosition($transformations->int($normalized['position'])) + ->withIndentation($transformations->int($normalized['indentation'])); + + if (is_array($normalized['content'])) { + $clone->setContent($transformations->denormalize($normalized['content'], QuestionImage::class)?->getFilename()); + } else { + $clone->setContent((string) $normalized['content']); + } + + $clone->setId($transformations->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..16bd96421f84 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\Normalize\FromNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\ToNormalized; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalize\Transformations; + /** * Class ilAssQuestionLifecycle * @author Björn Heyser * @package components\ILIAS/TestQuestionPool */ -class ilAssQuestionLifecycle +class ilAssQuestionLifecycle implements ToNormalized, FromNormalized { public const DRAFT = 'draft'; public const REVIEW = 'review'; @@ -30,11 +34,12 @@ class ilAssQuestionLifecycle public const SHARABLE = 'sharable'; public const OUTDATED = 'outdated'; - protected string $identifier; + private string $identifier; - private function __construct() + public function __construct($identifier = self::DRAFT) { - $this->setIdentifier(self::DRAFT); + $this->validateIdentifier($identifier); + $this->setIdentifier($identifier); } public function getIdentifier(): string @@ -132,25 +137,23 @@ private function validateIdentifier($identifier): void } } - /** - * @param mixed $identifier - * @return self - * @throws ilTestQuestionPoolInvalidArgumentException - */ - public static function getInstance($identifier): self + #[\Override] + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null { - $lifecycle = new self(); - $lifecycle->validateIdentifier($identifier); - $lifecycle->setIdentifier($identifier); - - return $lifecycle; + return [ + 'identifier' => $this->getIdentifier(), + ]; } - public static function getDraftInstance(): self + #[\Override] + public function fromNormalized( + array $normalized, + Transformations $transformations + ): self { - $lifecycle = new self(); - $lifecycle->setIdentifier(self::DRAFT); - - return $lifecycle; + return new self($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/classes/tables/class.ilQuestionBrowserTableGUI.php b/components/ILIAS/TestQuestionPool/classes/tables/class.ilQuestionBrowserTableGUI.php index 2a7deefc1c74..f7a3d6b391a2 100755 --- a/components/ILIAS/TestQuestionPool/classes/tables/class.ilQuestionBrowserTableGUI.php +++ b/components/ILIAS/TestQuestionPool/classes/tables/class.ilQuestionBrowserTableGUI.php @@ -306,7 +306,7 @@ public function initFilter(): void // lifecycle $lifecycleOptions = array_merge( ['' => $this->lng->txt('qst_lifecycle_filter_all')], - ilAssQuestionLifecycle::getDraftInstance()->getSelectOptions($this->lng) + new ilAssQuestionLifecycle()->getSelectOptions($this->lng) ); $lifecycleInp = new ilSelectInputGUI($this->lng->txt('qst_lifecycle'), 'lifecycle'); $lifecycleInp->setOptions($lifecycleOptions); @@ -405,7 +405,7 @@ public function fillRow(array $a_set): void $this->tpl->parseCurrentBlock(); } if ($c == 'lifecycle') { - $lifecycle = ilAssQuestionLifecycle::getInstance($a_set['lifecycle']); + $lifecycle = new ilAssQuestionLifecycle($a_set['lifecycle']); $this->tpl->setCurrentBlock('lifecycle'); $this->tpl->setVariable("QUESTION_LIFECYCLE", $lifecycle->getTranslation($this->lng)); 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/Export/CollectsQuestions.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Export/CollectsQuestions.php new file mode 100644 index 000000000000..3d5d16a1eb6a --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Export/CollectsQuestions.php @@ -0,0 +1,205 @@ + + */ + 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<\assQuestion> + */ + 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<\assFormulaQuestionUnitCategory>, base_units: list<\assFormulaQuestionUnit>, units: list<\assFormulaQuestionUnit>} + */ + 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..5ac77acc357b --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Export/QuestionPoolCollector.php @@ -0,0 +1,83 @@ + $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 + */ + #[\Override] + 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..6fdd5eeb4499 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Export/QuestionPoolExporter.php @@ -0,0 +1,226 @@ +logger()->info('Preparing question pool export (1/3)...'); + $context->assertPhase(ExportPhase::Init); + $context->setPhase(ExportPhase::Prepare); + + $pool_id = $this->extractObjectId($context); + if ($pool_id === null) { + $context->logger()->warning('No question pool object ID found for export'); + return; + } + + $collector = new QuestionPoolCollector( + $this->question_repository, + $this->db, + $pool_id + ); + $context->setCollector($collector); + + $question_image_collector = new CollectQuestionImages(new UUIDFactory(), $pool_id); + + $transformations = $this->builder->forExport($question_image_collector); + $context->setTransformations($transformations); + + $context->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(ExportContext $context): void + { + $context->logger()->info('Processing question pool export (2/3)...'); + $context->assertPhase(ExportPhase::Prepare); + $context->setPhase(ExportPhase::Process); + + $context->serializer()->group( + 'general', + fn() => $this->exportObject( + $this->collector($context), + $context->transformations(), + $context->serializer(), + $context + ) + ); + $context->serializer()->group( + 'questions', + fn() => $this->exportQuestions( + $this->collector($context), + $context->transformations(), + $context->serializer(), + $context + ) + ); + $context->serializer()->group( + 'skill_assignments', + fn() => $this->exportSkillAssignments( + $this->collector($context), + $context->transformations(), + $context->serializer(), + ) + ); + + $context->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(ExportContext $context): void + { + $context->logger()->info('Writing question pool export (3/3)...'); + $context->assertPhase(ExportPhase::Process); + $context->setPhase(ExportPhase::Write); + + $export_dir = $context->path()->getPathToComponentExpDirInContainer(); + $question_image_collector = $context->transformations() + ->normalizationProcessor(CollectQuestionImages::class); + if (!($question_image_collector instanceof CollectQuestionImages)) { + throw new \LogicException('Unexpected question image processor'); + } + + foreach ($question_image_collector->getFiles() as $file) { + $context->writer()->writeFileByFilePath( + $file['from'], + "{$export_dir}/" . $file['to'] + ); + } + + $context->logger()->info('...Finished writing question pool export (3/3)'); + } + + + private function extractObjectId(ExportContext $context): ?ObjectId + { + $target_ids = $context->target()->getObjectIds(); + + if ($target_ids === []) { + $context->logger()->warning('No target object IDs found for question pool export'); + return null; + } + + if (count($target_ids) > 1) { + $context->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 collector(ExportContext $context): QuestionPoolCollector + { + $collector = $context->collector(); + if (!($collector instanceof QuestionPoolCollector)) { + throw new \LogicException('Unexpected question pool collector'); + } + + return $collector; + } + + private function exportObject( + QuestionPoolCollector $collector, + Transformations $transformations, + Serializer $serializer, + ExportContext $context + ): void { + $serializer->append('object', $transformations->normalize($collector->getObject())); + + $obj_id = $collector->getObjectId()->toInt(); + + $context->addDependency('components/ILIAS/ILIASObject', 'common', [$obj_id]); + $context->addDependency('components/ILIAS/MetaData', 'qpl', ["{$obj_id}:0:qpl"]); + $context->addDependency( + 'components/ILIAS/Taxonomy', + 'tax', + $this->taxonomy->getUsageOfObject($obj_id) + ); + } + + private function exportQuestions( + QuestionPoolCollector $collector, + Transformations $transformations, + Serializer $serializer, + ExportContext $context + ): 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); + $context->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/Export/ExportContext.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Export/ExportContext.php new file mode 100644 index 000000000000..1ed75e108611 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Export/ExportContext.php @@ -0,0 +1,195 @@ +}> $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 getPhase(): ExportPhase + { + return $this->phase; + } + + public function setPhase(ExportPhase $phase): void + { + $this->phase = $phase; + } + + public function assertPhase(ExportPhase $phase): void + { + if ($this->phase->value < $phase->value) { + throw new \RuntimeException("Expected phase {$phase->name}, but got {$this->phase->name} instead"); + } + + $this->phase = $phase; + } + + 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(): object + { + $this->assertNotNull($this->collector, 'collector'); + return $this->collector; + } + + public function setCollector(object $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; + } + + /** @return list}> */ + public function getDependencies(): array + { + return array_values($this->dependencies); + } + + /** @param list $ids */ + 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 phases are not executed in the correct order." + ); + } + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Export/ExportContextHolder.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Export/ExportContextHolder.php new file mode 100644 index 000000000000..b80efb6e2a4f --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Export/ExportContextHolder.php @@ -0,0 +1,65 @@ +export_context = new ExportContext($target, $config, $option); + } + + public function exists(): bool + { + return $this->export_context !== null; + } + + public function get(): ExportContext + { + if ($this->export_context === null) { + throw new \RuntimeException('Export context not found. You need to create the context first.'); + } + return $this->export_context; + } + + public function set(ExportContext $export_context): void + { + $this->export_context = $export_context; + } + + public function clear(): void + { + $this->export_context = null; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Export/ExportPhase.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Export/ExportPhase.php new file mode 100644 index 000000000000..d3c10b53304b --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Export/ExportPhase.php @@ -0,0 +1,29 @@ +context_holder->get(); + + if ($context->getPhase()->value < ExportPhase::Prepare->value) { + $context->setLogger($this->logger); + $this->exporter->prepare($context); + } + + if ($context->getPhase()->value < ExportPhase::Process->value) { + $context->setSerializer(XmlSerializer::inMemory()); + $this->exporter->process($context); + } + + $this->context_holder->set($context); + return $context; + } + + /** + * Finalizes the export by setting the path info and writer and calling the write phase of the exporter. It + * performs the prepare and process phases if not already done. + */ + private function finalizeExport(): ExportContext + { + $context = $this->processExport(); + + if ($context->getPhase()->value < ExportPhase::Write->value) { + $context->setPathInfo($this->createPathInfo()); + $context->setWriter($this->exp->getExportWriter()); + + $this->exporter->write($context); + } + + $this->context_holder->clear(); + return $context; + } + + private function initExportContext( + string $component, + string $target_release, + string $type, + array $object_ids, + string $option = '' + ): ExportContext { + $object_ids = array_slice($object_ids, 0, 1); + $target = $this->export_handler->target()->handler() + ->withType($type) + ->withTargetRelease($target_release) + ->withObjectIds($object_ids) + ->withClassname(static::class) + ->withComponent($component); + + $context = $this->context_holder->create( + $target, + $this->export_handler->consumer()->exportConfig()->collection(), + $option + ); + + return $context; + } + + 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/Import/ImportSession.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Import/ImportSession.php new file mode 100644 index 000000000000..927baf50ac75 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Import/ImportSession.php @@ -0,0 +1,377 @@ +, success: list} + * @phpstan-type SkillThresholdResult array{failed: list, success: list} + */ +final class ImportSession +{ + private ?string $file_to_import = null; + private ?string $component_import_file = null; + private ?string $import_base_dir = null; + private ?int $install_id = null; + private ?string $legacy_qti_file = null; + private ?string $legacy_xml_file = null; + /** @var list */ + private array $selectable_question_ids = []; + /** @var list */ + private array $selected_question_ids = []; + private ?int $pool_obj_id = null; + private ?int $test_obj_id = null; + private ?int $test_ref_id = null; + /** @var array|null */ + private ?array $user_mappings = null; + /** @var list> */ + private array $resource_mappings = []; + /** @var SkillAssignmentResult|null */ + private ?array $skill_assignments = null; + /** @var SkillThresholdResult|null */ + private ?array $skill_thresholds = null; + + public function withFileToImport(string $path): self + { + $clone = clone $this; + $clone->file_to_import = $path; + return $clone; + } + + public function hasFileToImport(): bool + { + return $this->file_to_import !== null; + } + + public function fileToImport(): string + { + if ($this->file_to_import === null) { + throw new \LogicException('File to import is not set'); + } + + return $this->file_to_import; + } + + public function withComponentImportFile(string $path): self + { + $clone = clone $this; + $clone->component_import_file = $path; + return $clone; + } + + public function componentImportFile(): string + { + if ($this->component_import_file === null) { + throw new \LogicException('Component import file is not set'); + } + + return $this->component_import_file; + } + + public function withImportBaseDir(string $path): self + { + $clone = clone $this; + $clone->import_base_dir = $path; + return $clone; + } + + public function hasImportBaseDir(): bool + { + return $this->import_base_dir !== null; + } + + public function importBaseDir(): string + { + if ($this->import_base_dir === null) { + throw new \LogicException('Import base dir is not set'); + } + + return $this->import_base_dir; + } + + public function withInstallId(int $install_id): self + { + $clone = clone $this; + $clone->install_id = $install_id; + return $clone; + } + + public function installId(): int + { + if ($this->install_id === null) { + throw new \LogicException('Install id is not set'); + } + + return $this->install_id; + } + + public function withLegacyQtiFile(string $path): self + { + $clone = clone $this; + $clone->legacy_qti_file = $path; + return $clone; + } + + public function legacyQtiFile(): string + { + if ($this->legacy_qti_file === null) { + throw new \LogicException('Legacy QTI file is not set'); + } + + return $this->legacy_qti_file; + } + + public function withLegacyXmlFile(string $path): self + { + $clone = clone $this; + $clone->legacy_xml_file = $path; + return $clone; + } + + public function legacyXmlFile(): string + { + if ($this->legacy_xml_file === null) { + throw new \LogicException('Legacy XML file is not set'); + } + + return $this->legacy_xml_file; + } + + /** + * @param list $ids + */ + public function withSelectableQuestionIds(array $ids): self + { + $clone = clone $this; + $clone->selectable_question_ids = $ids; + return $clone; + } + + /** + * @return list + */ + public function selectableQuestionIds(): array + { + return $this->selectable_question_ids; + } + + /** + * @param list $ids + */ + public function withSelectedQuestionIds(array $ids): self + { + $clone = clone $this; + $clone->selected_question_ids = $ids; + return $clone; + } + + /** + * @return list + */ + public function selectedQuestionIds(): array + { + return $this->selected_question_ids; + } + + public function withPoolObjId(int $pool_obj_id): self + { + $clone = clone $this; + $clone->pool_obj_id = $pool_obj_id; + return $clone; + } + + public function poolObjId(): int + { + if ($this->pool_obj_id === null) { + throw new \LogicException('Pool object id is not set'); + } + + return $this->pool_obj_id; + } + + public function withTestObjId(int $test_obj_id): self + { + $clone = clone $this; + $clone->test_obj_id = $test_obj_id; + return $clone; + } + + public function testObjId(): ?int + { + if ($this->test_obj_id === null) { + throw new \LogicException('Test object id is not set'); + } + + return $this->test_obj_id; + } + + public function withTestRefId(int $test_ref_id): self + { + $clone = clone $this; + $clone->test_ref_id = $test_ref_id; + return $clone; + } + + public function testRefId(): int + { + if ($this->test_ref_id === null) { + throw new \LogicException('Test reference id is not set'); + } + + return $this->test_ref_id; + } + + /** + * @param array $mappings + */ + public function withUserMappings(array $mappings): self + { + $clone = clone $this; + $clone->user_mappings = $mappings; + return $clone; + } + + /** + * @return array + */ + public function userMappings(): array + { + if ($this->user_mappings === null) { + throw new \LogicException('User mappings are not set'); + } + + return $this->user_mappings; + } + + /** + * @param list>|array $mappings + */ + public function withResourceMappings(array $mappings): self + { + $clone = clone $this; + $clone->resource_mappings = $mappings; + return $clone; + } + + /** + * @return list> + */ + public function resourceMappings(): array + { + return $this->resource_mappings; + } + + /** + * @param SkillAssignmentResult $result + */ + public function withSkillAssignments(array $result): self + { + $clone = clone $this; + $clone->skill_assignments = $result; + return $clone; + } + + /** + * @return SkillAssignmentResult + */ + public function skillAssignments(): array + { + if ($this->skill_assignments === null) { + throw new \LogicException('Skill assignments are not set'); + } + + return $this->skill_assignments; + } + + /** + * @param SkillThresholdResult $result + */ + public function withSkillThresholds(array $result): self + { + $clone = clone $this; + $clone->skill_thresholds = $result; + return $clone; + } + + /** + * @return SkillThresholdResult + */ + public function skillThresholds(): array + { + if ($this->skill_thresholds === null) { + throw new \LogicException('Skill thresholds are not set'); + } + + return $this->skill_thresholds; + } + + public function isLegacyImport(): bool + { + return $this->legacy_qti_file !== null && $this->legacy_xml_file !== null; + } + + public static function fromJson(string $json): self + { + $data = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + + $session = new self(); + $session->file_to_import = $data['file_to_import'] ?? null; + $session->component_import_file = $data['component_import_file'] ?? null; + $session->import_base_dir = $data['import_base_dir'] ?? null; + $session->install_id = $data['install_id'] ?? null; + $session->legacy_qti_file = $data['legacy_qti_file'] ?? null; + $session->legacy_xml_file = $data['legacy_xml_file'] ?? null; + $session->selectable_question_ids = $data['selectable_question_ids'] ?? []; + $session->selected_question_ids = $data['selected_question_ids'] ?? []; + $session->pool_obj_id = $data['pool_obj_id'] ?? null; + $session->test_obj_id = $data['test_obj_id'] ?? null; + $session->test_ref_id = $data['test_ref_id'] ?? null; + $session->user_mappings = $data['user_mappings'] ?? null; + $session->resource_mappings = $data['resource_mappings'] ?? []; + $session->skill_assignments = $data['skill_assignments'] ?? null; + $session->skill_thresholds = $data['skill_thresholds'] ?? null; + return $session; + } + + public function toJson(): string + { + $payload = [ + 'file_to_import' => $this->file_to_import, + 'component_import_file' => $this->component_import_file, + 'import_base_dir' => $this->import_base_dir, + 'install_id' => $this->install_id, + 'legacy_qti_file' => $this->legacy_qti_file, + 'legacy_xml_file' => $this->legacy_xml_file, + 'selectable_question_ids' => $this->selectable_question_ids, + 'selected_question_ids' => $this->selected_question_ids, + 'pool_obj_id' => $this->pool_obj_id, + 'test_obj_id' => $this->test_obj_id, + 'test_ref_id' => $this->test_ref_id, + 'user_mappings' => $this->user_mappings, + 'resource_mappings' => $this->resource_mappings, + 'skill_assignments' => $this->skill_assignments, + 'skill_thresholds' => $this->skill_thresholds, + ]; + + return json_encode($payload, JSON_THROW_ON_ERROR); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Import/ImportSessionRepository.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Import/ImportSessionRepository.php new file mode 100644 index 000000000000..c45a26011c50 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Import/ImportSessionRepository.php @@ -0,0 +1,76 @@ +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 getSession(): ImportSession + { + if (!\ilSession::has($this->key_context)) { + return new ImportSession(); + } + + $payload = \ilSession::get($this->key_context); + if (!is_string($payload)) { + return new ImportSession(); + } + + return ImportSession::fromJson($payload); + } + + public function setSession(ImportSession $session): void + { + \ilSession::set($this->key_context, $session->toJson()); + } + + public function clear(): void + { + \ilSession::clear($this->key_index); + \ilSession::clear($this->key_context); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Import/ImportStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Import/ImportStage.php new file mode 100644 index 000000000000..8707d515ac1b --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Import/ImportStage.php @@ -0,0 +1,54 @@ + $stages */ + public function __construct( + private readonly array $stages, + private readonly ImportSessionRepository $session_repository, + private readonly ?ImportStage $cleanup_stage = null, + ) { + } + + public function run(): StageResult + { + $index = $this->session_repository->getCurrentStageIndex(); + $session = $this->session_repository->getSession(); + + while ($index < count($this->stages)) { + $result = $this->stages[$index]->process($session); + + switch ($result->type) { + case StageResultType::CompletedSuccessfully: + $session = $result->session; + $index++; + $this->session_repository->setSession($session); + $this->session_repository->setCurrentStageIndex($index); + break; + + case StageResultType::RequiresUserInteraction: + $this->session_repository->setSession($result->session); + return $result; + + case StageResultType::Error: + $this->session_repository->setSession($result->session); + $this->reset(); + return $result; + + default: + throw new \LogicException("Invalid stage result type: {$result->type->name}"); + } + } + + $this->reset(); + + return StageResult::completedSuccessfully($session); + } + + /** + * Clears the stored import session. The cleanup stage runs first when one is configured. + */ + public function reset(): void + { + if ($this->cleanup_stage) { + $this->cleanup_stage->process($this->session_repository->getSession()); + } + + $this->session_repository->clear(); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Import/StageResult.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Import/StageResult.php new file mode 100644 index 000000000000..814a94a3b83b --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Import/StageResult.php @@ -0,0 +1,58 @@ + $components + */ + private function __construct( + public readonly StageResultType $type, + public readonly ImportSession $session, + public readonly array $components = [], + public readonly ?string $error_message = null, + ) { + } + + /** + * @param list $components + */ + public static function requiresUserInteraction(ImportSession $session, array $components): self + { + return new self(StageResultType::RequiresUserInteraction, $session, $components); + } + + public static function error(ImportSession $session, string $message): self + { + return new self(StageResultType::Error, $session, [], $message); + } + + public static function completedSuccessfully(ImportSession $session): self + { + return new self(StageResultType::CompletedSuccessfully, $session); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Import/StageResultType.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Import/StageResultType.php new file mode 100644 index 000000000000..79e101b0f365 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Import/StageResultType.php @@ -0,0 +1,28 @@ + Language-neutral normalized representation. + */ + public function unpack(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 pack(array $value, Transformations $tt): static; +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Envelopes/Id.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Envelopes/Id.php new file mode 100644 index 000000000000..5c8104d65220 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Envelopes/Id.php @@ -0,0 +1,76 @@ +id; + } + + public function getObject(): string + { + return $this->object; + } + + #[\Override] + public function unpack(Transformations $tt): array + { + return [ + 'id' => $tt->normalize($this->id), + 'type' => $this->id instanceof Uuid ? Uuid::class : gettype($this->id), + 'object' => $this->object, + ]; + + } + + #[\Override] + public static function pack(array $value, Transformations $tt): static + { + $raw_id = $value['id']; + $type = $value['type']; + + $id = match ($type) { + Uuid::class => $tt->denormalize($raw_id, Uuid::class), + 'integer' => (int) $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/Normalize/FromNormalized.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/FromNormalized.php new file mode 100644 index 000000000000..8146c1968dc8 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/FromNormalized.php @@ -0,0 +1,40 @@ + $normalized + */ + public function fromNormalized( + array $normalized, + Transformations $transformations + ): object; +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Normalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Normalizer.php new file mode 100644 index 000000000000..b04c537ea5c4 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Normalizer.php @@ -0,0 +1,57 @@ + + * + * @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/Normalize/Normalizer/DateTimeNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Normalizer/DateTimeNormalizer.php new file mode 100644 index 000000000000..0b89cad23119 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Normalizer/DateTimeNormalizer.php @@ -0,0 +1,50 @@ + + */ +class DateTimeNormalizer implements Normalizer +{ + #[\Override] + 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); + } + + #[\Override] + 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/Normalize/Normalizer/EnvelopeNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Normalizer/EnvelopeNormalizer.php new file mode 100644 index 000000000000..16f3c20cdb3e --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Normalizer/EnvelopeNormalizer.php @@ -0,0 +1,57 @@ + + */ +class EnvelopeNormalizer implements Normalizer +{ + public function __construct( + private readonly Transformations $tt, + ) { + } + + #[\Override] + public function normalize($value): array|float|bool|int|string|null + { + if (!($value instanceof Envelope)) { + throw new NormalizingException('Invalid envelope value', $value); + } + + return $value->unpack($this->tt); + } + + #[\Override] + 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::pack($value, $this->tt); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Normalizer/IlObjectNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Normalizer/IlObjectNormalizer.php new file mode 100644 index 000000000000..027bd93de0e2 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Normalizer/IlObjectNormalizer.php @@ -0,0 +1,146 @@ + + */ +class IlObjectNormalizer implements Normalizer +{ + public function __construct( + protected readonly Transformations $tt, + ) { + } + + #[\Override] + 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()), + ]; + } + + #[\Override] + 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/Normalize/Normalizer/Legacy11UUIDNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Normalizer/Legacy11UUIDNormalizer.php new file mode 100644 index 000000000000..ab5f548c8023 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Normalizer/Legacy11UUIDNormalizer.php @@ -0,0 +1,49 @@ +> */ + private array $type_map = []; + + public function register( + string $type, + Normalizer $normalizer, + ?string $legacy_version = null + ): void + { + $version = $legacy_version ?? ''; + if (isset($this->type_map[$type][$version])) { + $label = $legacy_version === null ? 'current' : $legacy_version; + throw new NormalizingException("Type {$type} and version {$label} are already registered"); + } + + $this->type_map[$type][$version] = $normalizer; + } + + public function forNormalization(string $type): ?Normalizer + { + return $this->normalizerFor($type, null); + } + + public function forDenormalization(string $type, ?string $legacy_version): ?Normalizer + { + return $this->normalizerFor($type, $legacy_version); + } + + private function normalizerFor(string $type, ?string $legacy_version): ?Normalizer + { + foreach ($this->candidateTypes($type) as $candidate) { + $versions = $this->type_map[$candidate]; + if ($legacy_version !== null) { + $legacy_key = $this->resolveLegacyVersion($legacy_version, $versions); + if ($legacy_key !== null) { + return $versions[$legacy_key]; + } + } + + if (isset($versions[''])) { + return $versions['']; + } + } + + return null; + } + + /** @return list */ + private function candidateTypes(string $type): array + { + $candidates = array_values( + array_filter( + array_keys($this->type_map), + static fn(string $registered_type): bool => + $type === $registered_type || is_subclass_of($type, $registered_type) + ) + ); + + usort($candidates, static 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 $candidates; + } + + /** + * @param array $versions + */ + private function resolveLegacyVersion(string $requested, array $versions): ?string + { + if (isset($versions[$requested])) { + return $requested; + } + + $requested_major = explode('.', $requested, 2)[0]; + $best = null; + foreach (array_keys($versions) as $version_key) { + $version = (string) $version_key; + if ($version === '' || $this->isWildcard($version)) { + continue; + } + + $major = explode('.', $version, 2)[0]; + if ( + $major === $requested_major + && version_compare($version, $requested, '<=') + && ($best === null || version_compare($version, $best, '>')) + ) { + $best = $version; + } + } + + if ($best !== null) { + return $best; + } + + foreach (["{$requested_major}.*", $requested_major] as $wildcard) { + if (isset($versions[$wildcard])) { + return $wildcard; + } + } + + return null; + } + + private function isWildcard(string $version): bool + { + return !str_contains($version, '.') || str_ends_with($version, '.*'); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Normalizer/ResourceNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Normalizer/ResourceNormalizer.php new file mode 100644 index 000000000000..6251c8fc44d5 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Normalizer/ResourceNormalizer.php @@ -0,0 +1,132 @@ + + */ +class ResourceNormalizer implements Normalizer +{ + private const string KEY_TYPE = 'type'; + private const string TYPE_RID = 'rid'; + private const string TYPE_RESOURCE = 'resource'; + + public function __construct( + private readonly Transformations $transformations, + private readonly ResourceRepository $resource_repository + ) { + } + + #[\Override] + 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->transformations->normalize($resource->getCurrentRevision()->getInformation()->getCreationDate()), + ]; + } + + #[\Override] + 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) { + return $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. + */ + private static function isStorableResource(mixed $value): bool + { + return ($value[self::KEY_TYPE] ?? false) === self::TYPE_RESOURCE; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Normalizer/TransformationNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Normalizer/TransformationNormalizer.php new file mode 100644 index 000000000000..5ee713845cb2 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Normalizer/TransformationNormalizer.php @@ -0,0 +1,51 @@ + + */ +class TransformationNormalizer implements Normalizer +{ + public function __construct(private readonly Refinery $refinery) { + } + + #[\Override] + public function normalize($value): array|float|bool|int|string|null + { + if ($value instanceof Transformation) { + return $value->transform([]); + } + + throw new NormalizingException('Invalid transformation value', $value); + } + + #[\Override] + 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/Normalize/Normalizer/UUIDNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Normalizer/UUIDNormalizer.php new file mode 100644 index 000000000000..0dee319572de --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Normalizer/UUIDNormalizer.php @@ -0,0 +1,55 @@ + + */ +class UUIDNormalizer implements Normalizer +{ + private readonly Factory $factory; + + public function __construct() + { + $this->factory = new Factory(); + } + + #[\Override] + public function normalize($value): array|float|bool|int|string|null + { + if ($value instanceof Uuid) { + return $value->toString(); + } + + throw new NormalizingException('Invalid UUID value', $value); + } + + #[\Override] + 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/Normalize/NormalizerRegistration.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/NormalizerRegistration.php new file mode 100644 index 000000000000..488354cf878b --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/NormalizerRegistration.php @@ -0,0 +1,68 @@ +register(\DateTime::class, $date_time); + $registry->register(\DateTimeImmutable::class, $date_time); + + $uuid = new UUIDNormalizer(); + $registry->register(Uuid::class, $uuid); + $registry->register(Uuid::class, new Legacy11UUIDNormalizer(), '11'); + + $registry->register(Envelope::class, new EnvelopeNormalizer($transformations)); + $registry->register(Transformation::class, new TransformationNormalizer($this->dic->refinery())); + + if (isset($this->dic[\InitResourceStorage::D_REPOSITORIES])) { + $resource = new ResourceNormalizer( + $transformations, + $this->dic[\InitResourceStorage::D_REPOSITORIES]->getResourceRepository() + ); + $registry->register(ResourceIdentification::class, $resource); + $registry->register(StorableResource::class, $resource); + } + + $registry->register(\ilObject::class, new IlObjectNormalizer($transformations)); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/NormalizingException.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/NormalizingException.php new file mode 100644 index 000000000000..49188b529eb8 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/NormalizingException.php @@ -0,0 +1,36 @@ + $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; + } + + public function process(object $carry): void + { + if ($carry instanceof NormalizeCarry && $carry->value() instanceof ResourceIdentification) { + $this->handleNormalization($carry->value()); + } + + if ( + $carry instanceof DenormalizeCarry + && $carry->expected() === ResourceIdentification::class + && $carry->hasResult() + ) { + $carry->setResult($this->replaceRid($carry->result())); + } + } + + 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/Normalize/Processors/DenormalizeCarry.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Processors/DenormalizeCarry.php new file mode 100644 index 000000000000..80adf035cd9f --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Processors/DenormalizeCarry.php @@ -0,0 +1,70 @@ +|float|bool|int|string|null $normalized + */ + public function __construct( + private readonly Transformations $transformations, + private readonly array|float|bool|int|string|null $normalized, + private readonly string|object $expected, + ) { + } + + public function transformations(): Transformations + { + return $this->transformations; + } + + /** @return array|float|bool|int|string|null */ + public function normalized(): array|float|bool|int|string|null + { + return $this->normalized; + } + + public function expected(): string|object + { + return $this->expected; + } + + public function hasResult(): bool + { + return $this->has_result; + } + + public function setResult(mixed $result): void + { + $this->result = $result; + $this->has_result = true; + } + + /** + * 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 ($this->has_result === false) { + throw new NormalizingException(sprintf( + 'Unsupported value, expected: %s, got: %s', + get_debug_type($this->expected), + get_debug_type($this->normalized) + )); + } + + return $this->result; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Processors/DenormalizingProcessor.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Processors/DenormalizingProcessor.php new file mode 100644 index 000000000000..6ea895f246f3 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Processors/DenormalizingProcessor.php @@ -0,0 +1,72 @@ +normalized() === null) { + $carry->setResult(null); + return; + } + + if (is_string($carry->expected())) { + $normalizer = $this->registry->forDenormalization( + $carry->expected(), + $this->legacy_version + ); + if ($normalizer !== null) { + $carry->setResult( + $normalizer->denormalize($carry->normalized(), $carry->expected()) + ); + return; + } + } + + if ($carry->expected() instanceof FromNormalized) { + if (!is_array($carry->normalized())) { + throw new NormalizingException( + 'Expected normalized array, got ' . get_debug_type($carry->normalized()) + ); + } + $carry->setResult($carry->expected()->fromNormalized( + $carry->normalized(), + $carry->transformations() + )); + } + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Processors/FinalizeNormalizing.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Processors/FinalizeNormalizing.php new file mode 100644 index 000000000000..99b28f742c7d --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Processors/FinalizeNormalizing.php @@ -0,0 +1,56 @@ +hasResult()) { + $this->ensureNormalized($carry->result()); + } + } + + 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/Normalize/Processors/IdMappingProcessor.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Processors/IdMappingProcessor.php new file mode 100644 index 000000000000..d4837ae92521 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Processors/IdMappingProcessor.php @@ -0,0 +1,88 @@ +> */ + private array $unresolved = []; + + public function __construct( + private readonly \ilImportMapping $mapping, + private readonly string $component, + private readonly LoggerInterface $log + ) { + } + + public function process(object $carry): void + { + if ( + !($carry instanceof DenormalizeCarry) + || $carry->expected() !== Id::class + || !$carry->hasResult() + ) { + return; + } + + $envelope = $carry->result(); + if (!($envelope instanceof Id)) { + throw new NormalizingException('Expected id envelope, got ' . get_debug_type($envelope)); + } + + $new_id = $this->mapping->getMapping( + $this->component, + $envelope->getObject(), + (string) $envelope->getId() + ); + if ($new_id === null) { + $this->unresolved[] = $envelope; + $this->log->warning( + "Unresolved id {$envelope->getObject()}:{$envelope->getId()}" + ); + return; + } + + if (is_int($envelope->getId())) { + $new_id = (int) $new_id; + } + + $carry->setResult(new Id($new_id, $envelope->getObject())); + $this->log->debug( + "Replaced id {$envelope->getObject()}:{$envelope->getId()} with {$new_id}" + ); + } + + /** @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/Normalize/Processors/NormalizeCarry.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Processors/NormalizeCarry.php new file mode 100644 index 000000000000..8437cc34a779 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Processors/NormalizeCarry.php @@ -0,0 +1,68 @@ +|float|bool|int|string|null */ + private array|float|bool|int|string|null $result = null; + + /** + * @param array $context + */ + public function __construct( + private readonly Transformations $transformations, + private readonly mixed $value, + private readonly array $context, + ) { + } + + public function transformations(): Transformations + { + return $this->transformations; + } + + public function value(): mixed + { + return $this->value; + } + + /** @return array */ + public function context(): array + { + return $this->context; + } + + public function hasResult(): bool + { + return $this->has_result; + } + + /** @param array|float|bool|int|string|null $result */ + public function setResult(array|float|bool|int|string|null $result): void + { + $this->result = $result; + $this->has_result = true; + } + + /** + * 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 + */ + /** @return array|float|bool|int|string|null */ + public function result(): array|float|bool|int|string|null + { + if ($this->has_result === false) { + throw new NormalizingException('Unsupported value', $this->value); + } + return $this->result; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Processors/NormalizingProcessor.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Processors/NormalizingProcessor.php new file mode 100644 index 000000000000..572b44c0104a --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Processors/NormalizingProcessor.php @@ -0,0 +1,58 @@ +value() === null || is_scalar($carry->value())) { + $carry->setResult($carry->value()); + return; + } + + if ($carry->value() instanceof ToNormalized) { + $carry->setResult($carry->value()->toNormalized( + $carry->transformations(), + $carry->context() + )); + return; + } + + $normalizer = $this->registry->forNormalization(get_class($carry->value())); + if ($normalizer !== null) { + $carry->setResult($normalizer->normalize($carry->value())); + } + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/ToNormalized.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/ToNormalized.php new file mode 100644 index 000000000000..25c84cd9e94d --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/ToNormalized.php @@ -0,0 +1,39 @@ + $context + * @return array|float|bool|int|string|null + */ + public function toNormalized( + Transformations $transformations, + array $context = [] + ): array|float|bool|int|string|null; +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Transformations.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Transformations.php new file mode 100644 index 000000000000..1f4ae0dfa799 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalize/Transformations.php @@ -0,0 +1,126 @@ + $context + * @return array|float|bool|int|string|null + */ + public function normalize(mixed $value, array $context = []): array|float|bool|int|string|null + { + if (is_object($value) && $value instanceof \Generator) { + $value = iterator_to_array($value); + } + + if (is_array($value)) { + return array_map(fn(mixed $value) => $this->normalize($value, $context), $value); + } + + $carry = new NormalizeCarry($this, $value, $context); + $this->normalization_queue->process($carry); + return $carry->result(); + } + + /** + * @param array|float|bool|int|string|null $normalized + */ + public function denormalize(array|float|bool|int|string|null $normalized, string|object $expected): mixed + { + $carry = new DenormalizeCarry($this, $normalized, $expected); + $this->denormalization_queue->process($carry); + return $carry->result(); + } + + public function normalizationProcessor(string $processor_class): Processor + { + return $this->normalization_queue->get($processor_class); + } + + /** + * @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/Queue/Processor.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Queue/Processor.php new file mode 100644 index 000000000000..b3bc835db02a --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Queue/Processor.php @@ -0,0 +1,26 @@ + */ +final class Queue implements \IteratorAggregate +{ + /** @var list */ + private readonly array $processors; + + public function __construct(Processor ...$processors) + { + $this->processors = $processors; + } + + public function process(object $carry): object + { + foreach ($this->processors as $processor) { + $processor->process($carry); + } + + return $carry; + } + + public function get(string $class): Processor + { + $matches = array_values( + array_filter( + $this->processors, + static fn(Processor $processor): bool => $processor instanceof $class + ) + ); + + if (count($matches) !== 1) { + throw new \InvalidArgumentException( + count($matches) === 0 + ? "Processor {$class} not found" + : "Processor {$class} is ambiguous" + ); + } + + return $matches[0]; + } + + public function getIterator(): \Traversable + { + return new \ArrayIterator($this->processors); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serialize/Deserializer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serialize/Deserializer.php new file mode 100644 index 000000000000..863230e49848 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serialize/Deserializer.php @@ -0,0 +1,40 @@ + $data + */ + public function append(string $name, array $data): void; +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serialize/XmlDeserializer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serialize/XmlDeserializer.php new file mode 100644 index 000000000000..e014ef8c9b03 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serialize/XmlDeserializer.php @@ -0,0 +1,234 @@ + */ + private array $handlers = []; + + private function __construct(private readonly \XMLReader $reader) + { + } + + public static function fromFile(string $path): self + { + if (!is_file($path) || !is_readable($path)) { + throw new \InvalidArgumentException("The file '{$path}' does not exist or is not readable."); + } + + $reader = new \XMLReader(); + if (!$reader->open($path, null, LIBXML_NONET)) { + throw new \RuntimeException("Unable to open XML file '{$path}'."); + } + + return new self($reader); + } + + public static function fromString(string $xml): self + { + $xml = trim($xml); + $xml = preg_replace('/^\s*<\?xml[^>]*\?>\s*/i', '', $xml) ?? $xml; + + $reader = new \XMLReader(); + if (!$reader->XML("{$xml}", null, LIBXML_NONET)) { + throw new \RuntimeException('Unable to open XML input.'); + } + + return new self($reader); + } + + #[\Override] + public function addHandler(string $group, callable $handler): void + { + $this->handlers[$group] = $handler; + } + + #[\Override] + public function process(): void + { + try { + while ($this->reader->read()) { + $node_name = $this->kebabToSnake($this->reader->name); + + if ( + $this->reader->nodeType !== \XMLReader::ELEMENT + || !isset($this->handlers[$node_name]) + ) { + continue; + } + + $this->handlers[$node_name]($this->readGroup()); + } + } finally { + $this->reader->close(); + } + } + + /** + * @return list + */ + private function readGroup(): array + { + if ($this->reader->isEmptyElement) { + return []; + } + + $group_depth = $this->reader->depth; + $group_name = $this->reader->name; + $group_data = []; + + while ($this->reader->read()) { + if ( + $this->reader->nodeType === \XMLReader::END_ELEMENT + && $this->reader->depth === $group_depth + && $this->reader->name === $group_name + ) { + break; + } + + if ( + $this->reader->nodeType !== \XMLReader::ELEMENT + || $this->reader->depth !== $group_depth + 1 + ) { + continue; + } + + $group_data[] = $this->readElementValue(); + } + + return $group_data; + } + + private function readElementValue(): mixed + { + $is_marked_empty_array = $this->isMarkedEmptyArray(); + + if ($this->reader->isEmptyElement) { + return $is_marked_empty_array ? [] : ''; + } + + $element_depth = $this->reader->depth; + $element_name = $this->reader->name; + $children = []; + $text_content = ''; + + while ($this->reader->read()) { + if ( + $this->reader->nodeType === \XMLReader::END_ELEMENT + && $this->reader->depth === $element_depth + && $this->reader->name === $element_name + ) { + break; + } + + if ( + $this->reader->nodeType === \XMLReader::ELEMENT + && $this->reader->depth === $element_depth + 1 + ) { + $child_key = $this->resolveElementKey(); + $child_value = $this->readElementValue(); + + if ($child_key === null) { + $children[] = $child_value; + continue; + } + + $this->appendValue($children, $child_key, $child_value); + continue; + } + + if ( + $this->reader->depth === $element_depth + 1 + && in_array( + $this->reader->nodeType, + [ + \XMLReader::TEXT, + \XMLReader::CDATA, + \XMLReader::SIGNIFICANT_WHITESPACE + ], + true + ) + ) { + $text_content .= $this->reader->value; + } + } + + if ($children !== []) { + return $children; + } + + if ($is_marked_empty_array && trim($text_content) === '') { + return []; + } + + return $this->decodeScalarValue($text_content); + } + + private function isMarkedEmptyArray(): bool + { + return $this->reader->getAttribute('type') === 'empty-array'; + } + + private function resolveElementKey(): ?string + { + if ($this->reader->name !== 'item') { + return $this->kebabToSnake($this->reader->name); + } + + $raw_key = $this->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/Serialize/XmlSerializer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serialize/XmlSerializer.php new file mode 100644 index 000000000000..d0186a4c8018 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serialize/XmlSerializer.php @@ -0,0 +1,159 @@ +openMemory(); + $writer->setIndent(true); + + return new self($writer); + } + + 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; + } + + #[\Override] + public function startGroup(string $name): void + { + $this->current_group = $name; + $this->writer->startElement($this->formatName($name)); + } + + #[\Override] + 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(); + } + + #[\Override] + public function group(string $name, callable $callback): void + { + $this->startGroup($name); + $callback(); + $this->endGroup($name); + } + + #[\Override] + 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(); + } + + #[\Override] + 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) { + $this->writer->writeRaw(match (gettype($value)) { + 'NULL' => 'NULL', + 'integer' => (string) $value, + 'float' => (string) $value, + 'boolean' => $value ? '1' : '0', + default => htmlspecialchars((string) $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 + { + $output = strtolower(preg_replace('/(?hasFileToImport()) { + $temp_dir = dirname($session->fileToImport()); + if ($temp_dir !== '' && file_exists($temp_dir) && is_dir($temp_dir)) { + $this->removeDirectory($temp_dir); + } else { + $this->log->warning("Temporary import directory does not exist: {$temp_dir}"); + } + } + + if ($session->hasImportBaseDir() && is_dir($session->importBaseDir())) { + $this->removeDirectory($session->importBaseDir()); + } else { + $this->log->warning("Import target base directory does not exist: {$session->importBaseDir()}"); + } + + return StageResult::completedSuccessfully($session); + } + + 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..12ac607659b9 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/DetectLegacyImportStage.php @@ -0,0 +1,68 @@ +importBaseDir()); + + $xml_file = $session->importBaseDir() . DIRECTORY_SEPARATOR . "{$import_name}.xml"; + $qti_file = $session->importBaseDir() . DIRECTORY_SEPARATOR . str_replace(['_qpl_', '_tst_'], '_qti_', $import_name) . '.xml'; + + if (!file_exists($qti_file) || !file_exists($xml_file)) { + return StageResult::completedSuccessfully($session); + } + + return StageResult::completedSuccessfully( + $session->withLegacyQtiFile($qti_file)->withLegacyXmlFile($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..468db6c270a1 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/PersistStage.php @@ -0,0 +1,73 @@ +lng->txt('qpl_import_step_persist'); + } + + public function getDescription(): ?string + { + return ''; + } + + public function process(ImportSession $session): StageResult + { + $importer = new \ilImport($this->request_data_collector->getRefId()); + $importer->importObject( + null, + $session->fileToImport(), + basename($session->fileToImport()), + 'qpl', + 'components/ILIAS/TestQuestionPool', + true, + ); + + // Context is updated by the QuestionPoolImporter so we need to reload it + return StageResult::completedSuccessfully($this->session->getSession()); + } +} 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..ebb425b4ae99 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionPoolImporter.php @@ -0,0 +1,152 @@ +data_factory->objId(0)); + $transformations = $this->builder->forImport( + null, + new IdMappingProcessor($mapping, 'components/ILIAS/TestQuestionPool', $this->log), + $images_collector + ); + + $deserializer->addHandler( + 'general', + function (array $objects) use ($transformations, $mapping, $parent_id, &$session): void { + $new_pool_id = $this->importQuestionPool( + array_pop($objects), + $transformations, + $mapping, + $parent_id + ); + $session = $session->withPoolObjId($new_pool_id); + } + ); + + $deserializer->addHandler( + 'questions', + function (array $questions) use ($transformations, $mapping, $session): void { + foreach ($questions as $question) { + $this->questions_importer->importQuestion( + $question, + $transformations, + $mapping, + $session->selectedQuestionIds() + ); + } + } + ); + + $deserializer->addHandler( + 'skill_assignments', + function (array $assignments) use ($transformations, $mapping, &$session): void { + $result = $this->skill_importer->import( + $assignments, + $session->installId(), + $transformations, + $mapping, + ); + $session = $session->withSkillAssignments($result); + } + ); + + $deserializer->process(); + + $this->questions_importer->importQuestionImages( + $session->poolObjId(), + $mapping, + $session, + $images_collector + ); + + return $session; + } + + /** + * 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->questions_importer->finalizeQuestionPages($mapping); + } + + private 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(); + + $pool_object->createReference(); + $pool_object->putInTree($parent_id->toInt()); + $pool_object->setPermissions($parent_id->toInt()); + + $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..d74f0234d7ee --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionSelectionStage.php @@ -0,0 +1,209 @@ + \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(ImportSession $session): StageResult + { + if ($session->selectableQuestionIds() !== []) { + $options = []; + foreach ($session->selectableQuestionIds() as $question) { + $options[$question] = (string) $question; + } + + $data = $this->buildSelectQuestionsForm($options) + ->withRequest($this->request) + ->getData(); + + if (is_array($data) && is_array($data['selected_questions'])) { + $selected_questions = array_map('intval', $data['selected_questions']); + return StageResult::completedSuccessfully( + $session->withSelectedQuestionIds($selected_questions) + ); + } + } + + $options = $session->isLegacyImport() + ? $this->readQuestionsFromQTI($session) + : $this->readQuestions($session); + + if ($options === []) { + $this->log->error('No questions found in import file'); + return StageResult::error($session, $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::requiresUserInteraction( + $session->withSelectableQuestionIds(array_keys($options)), + [$panel] + ); + } + + private function readQuestions(ImportSession $session): array + { + $options = []; + + $deserializer = XmlDeserializer::fromFile($session->componentImportFile()); + + $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); + + return $options; + } + + /** + * @deprecated This method is only used for legacy imports and will be removed with further ILIAS versions. + */ + private function readQuestionsFromQTI(ImportSession $session): array + { + $parser = new \ilQTIParser( + $session->importBaseDir(), + $session->legacyQtiFile(), + \ilQTIParser::IL_MO_VERIFY_QTI, + 0 + ); + $parser->startParsing(); + + $options = []; + foreach ($parser->getFoundItems() as $item) { + $options[$item['ident']] = "{$item['title']} ({$this->getLabelForQuestionType($item['type'])})"; + } + + 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..05117c8c0a09 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionsImporter.php @@ -0,0 +1,304 @@ +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)) { + 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->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(); + + $feedback = $transformations->denormalize($normalized['feedback'], Feedback::class); + $this->importFeedback($feedback, $question); + + return $question; + } + + + public function importQuestionImages( + int $parent_obj_id, + \ilImportMapping $mapping, + ImportSession $session, + CollectQuestionImages $collector, + ): void { + $import_dir = dirname($session->componentImportFile()) . '/expDir_1'; + + foreach ($collector->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); + + $thumbnail = $this->generateThumbnail($input_stream); + if (!($thumbnail instanceof FileStream)) { + continue; + } + + $thumbnail_path = "{$image_base_path}/thumb.{$envelope->getFilename()}"; + $this->filesystem->writeStream($thumbnail_path, $thumbnail); + + $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 === null || $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]; + + $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(); + 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'] + ); + } + } + + 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); + $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 processor 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); + } + + // 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..1510b59d6150 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/SkillAssignmentsImporter.php @@ -0,0 +1,145 @@ +> $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 processor 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']); + + $assignment->initSolutionComparisonExpressionList(); + foreach ($assignment->getSolutionComparisonExpressionList()->get() as $expression) { + $expression->setSkillBaseId($assignment->getSkillBaseId()); + $expression->setSkillTrefId($assignment->getSkillTrefId()); + } + + $assignment->saveToDb(); + $assignment->saveComparisonExpressions(); + + $this->skill_usage_service->addUsage( + $assignment->getParentObjId(), + $assignment->getSkillBaseId(), + $assignment->getSkillTrefId() + ); + + $result['success'][] = $this->buildResultData($assignment); + } + + return $result; + } + + private 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 + */ + private 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..645fa9bb9ab4 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/UploadValidationStage.php @@ -0,0 +1,102 @@ +lng->txt('upload'); + } + + public function getDescription(): ?string + { + return ''; + } + + public function process(ImportSession $session): StageResult + { + if ( + !$session->hasFileToImport() + || !is_file($session->fileToImport()) + || !str_ends_with(strtolower($session->fileToImport()), '.zip') + ) { + $this->log->error("Invalid import file: {$session->fileToImport()}"); + return StageResult::error($session, $this->lng->txt('obj_import_file_error')); + } + + $subdir = basename($session->fileToImport(), '.zip'); + $import_base_dir = self::IMPORT_TEMP_DIR . DIRECTORY_SEPARATOR . $subdir; + + $options = (new UnzipOptions())->withZipOutputPath(self::IMPORT_TEMP_DIR); + $handle = fopen($session->fileToImport(), 'r'); + $unzip = $this->archives->unzip(Streams::ofResource($handle), $options); + $unzip->extract(); + + $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($session, $this->lng->txt('obj_import_file_error')); + } + + $component_import_file = $import_base_dir . DIRECTORY_SEPARATOR . $export_file['path']; + + return StageResult::completedSuccessfully( + $session + ->withComponentImportFile($component_import_file) + ->withImportBaseDir($import_base_dir) + ->withInstallId((int) $manifest->getInstallId()) + ); + } +} 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/Normalize/Envelopes/Feedback.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalize/Envelopes/Feedback.php new file mode 100644 index 000000000000..72d64025d5eb --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalize/Envelopes/Feedback.php @@ -0,0 +1,80 @@ + */ + 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; + } + + #[\Override] + public function unpack(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, + ]; + } + + #[\Override] + public static function pack(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/Normalize/Envelopes/QuestionImage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalize/Envelopes/QuestionImage.php new file mode 100644 index 000000000000..8efae0f1e76b --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalize/Envelopes/QuestionImage.php @@ -0,0 +1,89 @@ +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; + } + + #[\Override] + public function unpack(Transformations $tt): array + { + return [ + 'filename' => $this->filename, + 'question_id' => $this->question_id, + 'type' => $this->type, + 'id' => $this->id, + ]; + } + + #[\Override] + public static function pack(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/Normalize/Normalizer/SuggestedSolutionNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalize/Normalizer/SuggestedSolutionNormalizer.php new file mode 100644 index 000000000000..61750788c616 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalize/Normalizer/SuggestedSolutionNormalizer.php @@ -0,0 +1,132 @@ + + */ +class SuggestedSolutionNormalizer implements Normalizer +{ + public function __construct( + private readonly Transformations $tt, + ) { + } + + #[\Override] + 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; + } + + #[\Override] + 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/Normalize/Normalizer/ilAssQuestionSkillAssignmentNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalize/Normalizer/ilAssQuestionSkillAssignmentNormalizer.php new file mode 100644 index 000000000000..c8a87fdc6889 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalize/Normalizer/ilAssQuestionSkillAssignmentNormalizer.php @@ -0,0 +1,132 @@ + + */ +class ilAssQuestionSkillAssignmentNormalizer implements Normalizer +{ + public function __construct( + private readonly Transformations $tt, + private readonly \ilDBInterface $db + ) { + } + + #[\Override] + 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; + } + + #[\Override] + 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/Normalize/Normalizer/ilObjQuestionPoolNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalize/Normalizer/ilObjQuestionPoolNormalizer.php new file mode 100644 index 000000000000..afb5fa39b9ee --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalize/Normalizer/ilObjQuestionPoolNormalizer.php @@ -0,0 +1,58 @@ + + */ +class ilObjQuestionPoolNormalizer extends IlObjectNormalizer implements Normalizer +{ + #[\Override] + 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; + } + + #[\Override] + 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/Normalize/Processors/CollectQuestionImages.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalize/Processors/CollectQuestionImages.php new file mode 100644 index 000000000000..d7674b2126c5 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalize/Processors/CollectQuestionImages.php @@ -0,0 +1,122 @@ + $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(); + } + + #[\Override] + public function process(object $carry): void + { + if ($carry instanceof NormalizeCarry && $carry->value() instanceof QuestionImage) { + $this->handleNormalization($carry->value()); + } + + if ( + $carry instanceof DenormalizeCarry + && $carry->expected() === QuestionImage::class + && $carry->hasResult() + ) { + $this->handleDenormalization($carry); + } + } + + 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 === null) { + return; + } + 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/ExportImport/QuestionPoolNormalizerRegistration.php b/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolNormalizerRegistration.php new file mode 100644 index 000000000000..14656ec22d73 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolNormalizerRegistration.php @@ -0,0 +1,51 @@ +register( + \ilObjQuestionPool::class, + new ilObjQuestionPoolNormalizer($transformations) + ); + $registry->register( + SuggestedSolution::class, + new SuggestedSolutionNormalizer($transformations) + ); + $registry->register( + \ilAssQuestionSkillAssignment::class, + new ilAssQuestionSkillAssignmentNormalizer($transformations, $this->dic->database()) + ); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/TransformationsBuilder.php b/components/ILIAS/TestQuestionPool/src/ExportImport/TransformationsBuilder.php new file mode 100644 index 000000000000..b68394d4a853 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/TransformationsBuilder.php @@ -0,0 +1,81 @@ +create(null, $runtime_processors, []); + } + + public function forImport( + ?string $legacy_version, + Processor ...$runtime_processors + ): Transformations { + return $this->create($legacy_version, [], $runtime_processors); + } + + /** + * @param list $normalization_processors + * @param list $denormalization_processors + */ + private function create( + ?string $legacy_version, + array $normalization_processors, + array $denormalization_processors + ): Transformations { + $registry = new Registry(); + $transformations = new Transformations( + $this->dic->refinery(), + new Queue(...[ + ...$normalization_processors, + new NormalizingProcessor($registry), + new FinalizeNormalizing() + ]), + new Queue(...[ + new DenormalizingProcessor($registry, $legacy_version), + ...$denormalization_processors + ]) + ); + + (new NormalizerRegistration($this->dic)) + ->register($registry, $transformations); + (new QuestionPoolNormalizerRegistration($this->dic)) + ->register($registry, $transformations); + + return $transformations; + } +} 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..9f34aabc57ac 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\Export\ExportContextHolder; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Import\ImportSessionRepository; +use ILIAS\TestQuestionPool\ExportImport\Export\QuestionPoolExporter; +use ILIAS\TestQuestionPool\ExportImport\Import\QuestionPoolImporter; +use ILIAS\TestQuestionPool\ExportImport\Import\QuestionsImporter; +use ILIAS\TestQuestionPool\ExportImport\TransformationsBuilder; +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,51 @@ 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.transformations_builder'] = static fn($c): TransformationsBuilder => + new TransformationsBuilder($DIC); + $dic['exportimport.export_context_holder'] = static fn($c): ExportContextHolder => + new ExportContextHolder(); + $dic['exportimport.exporter'] = static fn($c): QuestionPoolExporter => + new QuestionPoolExporter( + $c['exportimport.transformations_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.transformations_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/src/Questions/Presentation/QuestionTable.php b/components/ILIAS/TestQuestionPool/src/Questions/Presentation/QuestionTable.php index e7668c436057..dd75369d4d5c 100755 --- a/components/ILIAS/TestQuestionPool/src/Questions/Presentation/QuestionTable.php +++ b/components/ILIAS/TestQuestionPool/src/Questions/Presentation/QuestionTable.php @@ -109,7 +109,7 @@ public function getFilter(\ilUIService $ui_service, string $action): Filter { $lifecycle_options = array_merge( ['' => $this->lng->txt('qst_lifecycle_filter_all')], - \ilAssQuestionLifecycle::getDraftInstance()->getSelectOptions($this->lng) + new \ilAssQuestionLifecycle()->getSelectOptions($this->lng) ); $question_type_options = [ '' => $this->lng->txt('filter_all_question_types') @@ -320,7 +320,7 @@ public function getRows( $row_id = (string) $record['question_id']; $record['created'] = (new \DateTimeImmutable("@{$record['created']}"))->setTimezone($timezone); $record['tstamp'] = (new \DateTimeImmutable("@{$record['tstamp']}"))->setTimezone($timezone); - $lifecycle = \ilAssQuestionLifecycle::getInstance($record['lifecycle']); + $lifecycle = new \ilAssQuestionLifecycle($record['lifecycle']); $record['lifecycle'] = $lifecycle->getTranslation($this->lng); $title = $record['title']; diff --git a/components/ILIAS/TestQuestionPool/tests/ExportImport/ExportContextHolderTest.php b/components/ILIAS/TestQuestionPool/tests/ExportImport/ExportContextHolderTest.php new file mode 100644 index 000000000000..ad761fd951cb --- /dev/null +++ b/components/ILIAS/TestQuestionPool/tests/ExportImport/ExportContextHolderTest.php @@ -0,0 +1,57 @@ +createMock(ExportConfig::class); + $first_target = $this->createMock(ExportTarget::class); + $second_target = $this->createMock(ExportTarget::class); + + $first = $holder->create($first_target, $config, 'first'); + $second = $holder->create($second_target, $config, 'second'); + + $this->assertNotSame($first, $second); + $this->assertSame($second, $holder->get()); + $this->assertSame($second_target, $second->target()); + $this->assertSame('second', $second->getOption()); + } + + public function testClearRemovesExistingContext(): void + { + $holder = new ExportContextHolder(); + $holder->create( + $this->createMock(ExportTarget::class), + $this->createMock(ExportConfig::class) + ); + + $holder->clear(); + + $this->assertFalse($holder->exists()); + $this->expectException(RuntimeException::class); + $holder->get(); + } +} diff --git a/components/ILIAS/TestQuestionPool/tests/ExportImport/ExportImportCharacterizationTest.php b/components/ILIAS/TestQuestionPool/tests/ExportImport/ExportImportCharacterizationTest.php new file mode 100644 index 000000000000..f56f57e92d1d --- /dev/null +++ b/components/ILIAS/TestQuestionPool/tests/ExportImport/ExportImportCharacterizationTest.php @@ -0,0 +1,461 @@ +session = $_SESSION ?? []; + $_SESSION = []; + $this->setGlobalVariable( + 'refinery', + new RefineryFactory(new DataFactory(), $GLOBALS['DIC']['lng']) + ); + $this->addGlobal_mail(); + (new ReflectionClass(QuestionPoolDIC::class))->getProperty('dic')->setValue(null, null); + (new ReflectionClass(TestDIC::class))->getProperty('dic')->setValue(null, null); + } + + #[\Override] + protected function tearDown(): void + { + $_SESSION = $this->session; + + parent::tearDown(); + } + + public function testSingleChoiceRoundtripPreservesQuestionAndCollectsImages(): void + { + global $DIC; + + $question = new assSingleChoice( + 'Characterization question', + 'Question description', + 'Question author', + 17, + 'Which answer is correct?' + ); + $question->setId(23); + $question->setObjId(42); + $question->setAnswers([ + new ASS_AnswerBinaryStateImage('Correct', 2.5, 0, true, 'answer.png', 5), + new ASS_AnswerBinaryStateImage('Incorrect', 0.0, 1, false, null, 6), + ]); + + $export_images = new CollectQuestionImages( + new UUIDFactory(), + (new DataFactory())->objId(42) + ); + $factory = QuestionPoolDIC::dic()['exportimport.transformations_builder']; + $this->assertInstanceOf(TransformationsBuilder::class, $factory); + $export_transformations = $factory->forExport($export_images); + + $normalized = $export_transformations->normalize($question); + + $this->assertSame('Characterization question', $normalized['title']); + $this->assertSame('Which answer is correct?', $normalized['question_text']); + $this->assertNotSame('', $normalized['answers'][0]['image']['id']); + $this->assertSame( + [[ + 'from' => CLIENT_WEB_DIR . '/assessment/42/23/images/answer.png', + 'to' => $normalized['answers'][0]['image']['id'] . '.png' + ]], + $export_images->getFiles() + ); + + $import_images = new CollectQuestionImages( + new UUIDFactory(), + (new DataFactory())->objId(0) + ); + $import_transformations = $factory->forImport(null, $import_images); + + $restored = $import_transformations->denormalize($normalized, new assSingleChoice()); + + $this->assertInstanceOf(assSingleChoice::class, $restored); + $this->assertSame($question->getId(), $restored->getId()); + $this->assertSame($question->getObjId(), $restored->getObjId()); + $this->assertSame($question->getTitle(), $restored->getTitle()); + $this->assertSame($question->getAuthor(), $restored->getAuthor()); + $this->assertSame($question->getQuestion(), $restored->getQuestion()); + $this->assertCount(2, $restored->getAnswers()); + $this->assertSame('Correct', $restored->getAnswers()[0]->getAnswertext()); + $this->assertSame(2.5, $restored->getAnswers()[0]->getPoints()); + $this->assertSame('answer.png', $restored->getAnswers()[0]->getImage()); + $this->assertArrayHasKey( + $normalized['answers'][0]['image']['id'] . '.png', + $import_images->getEnvelopes() + ); + } + + public function testTransformationFactoriesKeepRuntimeProcessorsInTheirDirection(): void + { + global $DIC; + + $processor = new class () implements Processor { + public int $normalized = 0; + public int $denormalized = 0; + + public function process(object $carry): void + { + if ($carry instanceof NormalizeCarry) { + ++$this->normalized; + $carry->setResult('runtime result'); + } + if ($carry instanceof DenormalizeCarry) { + ++$this->denormalized; + } + } + }; + $factory = new TransformationsBuilder($DIC); + + $this->assertSame('value', $factory->forExport($processor)->normalize('value')); + $this->assertSame(1, $processor->normalized); + $this->assertSame(0, $processor->denormalized); + + $factory->forImport(null, $processor)->denormalize( + '123e4567-e89b-12d3-a456-426614174000', + Uuid::class + ); + $this->assertSame(1, $processor->normalized); + $this->assertSame(1, $processor->denormalized); + } + + public function testLegacyNormalizerSelectionIsLimitedToImport(): void + { + global $DIC; + + $factory = new TransformationsBuilder($DIC); + $uuid = '123e4567_e89b_12d3_a456_426614174000'; + + $this->assertNull($factory->forExport()->normalize(null)); + $this->assertNull($factory->forImport(null)->denormalize(null, Uuid::class)); + $this->assertSame( + '123e4567-e89b-12d3-a456-426614174000', + $factory->forImport('11.7')->denormalize($uuid, Uuid::class)->toString() + ); + $this->assertSame( + '123e4567-e89b-12d3-a456-426614174000', + $factory->forExport()->normalize( + $factory->forImport('11.7')->denormalize($uuid, Uuid::class) + ) + ); + } + + public function testTestDicProvidesConfiguredTransformationsFactory(): void + { + $factory = TestDIC::dic()['exportimport.transformations_builder']; + $uuid = (new UUIDFactory())->uuid4(); + + $this->assertInstanceOf(TestTransformationsBuilder::class, $factory); + $this->assertSame( + $uuid->toString(), + $factory->forExport()->normalize($uuid) + ); + } + + public function testXmlStringAndFileDeserializersProduceTheSameData(): void + { + $serializer = XmlSerializer::inMemory(); + $serializer->createDocument('Export/import characterization'); + $serializer->group('sample_group', function () use ($serializer): void { + $serializer->append('sample', [ + 'empty_list' => [], + 'enabled' => true, + 'count' => 3, + 'nothing' => null, + 'label' => 'A < B', + ]); + }); + $xml = $serializer->write(); + + $path = tempnam(sys_get_temp_dir(), 'ilias-export-import-'); + $this->assertNotFalse($path); + file_put_contents($path, $xml); + + try { + $from_string = $this->deserializeGroup( + XmlDeserializer::fromString($xml), + 'sample_group' + ); + $from_file = $this->deserializeGroup( + XmlDeserializer::fromFile($path), + 'sample_group' + ); + } finally { + unlink($path); + } + + $expected = [[ + 'empty_list' => [], + 'enabled' => '1', + 'count' => '3', + 'nothing' => null, + 'label' => 'A < B', + ]]; + $this->assertSame($expected, $from_string); + $this->assertSame($expected, $from_file); + } + + public function testResultXmlOnlyRenamesSolutionsToQuestionAttempts(): void + { + $serializer = XmlSerializer::inMemory(); + $serializer->createDocument('Result terminology characterization'); + $serializer->group('results', function () use ($serializer): void { + $serializer->append('set', [ + 'sequences' => [], + 'question_attempts' => [['attempt' => 1]], + 'results' => [], + 'attempts' => [['attempt' => 1]], + ]); + }); + + $sets = $this->deserializeGroup( + XmlDeserializer::fromString($serializer->write()), + 'results' + ); + + $this->assertSame( + [[ + 'sequences' => [], + 'question_attempts' => [['attempt' => '1']], + 'results' => [], + 'attempts' => [['attempt' => '1']], + ]], + $sets + ); + $this->assertArrayNotHasKey('solutions', $sets[0]); + } + + public function testXmlMemoryDeserializerProcessesDocumentFragments(): void + { + $groups = []; + $deserializer = XmlDeserializer::fromString( + '' + . 'first' + . 'second' + ); + $deserializer->addHandler( + 'sample_group', + static function (array $group_data) use (&$groups): void { + $groups[] = $group_data; + } + ); + + $deserializer->process(); + + $this->assertSame( + [ + [['value' => 'first']], + [['value' => 'second']], + ], + $groups + ); + } + + public function testXmlFileDeserializerRejectsMissingFiles(): void + { + $this->expectException(InvalidArgumentException::class); + + XmlDeserializer::fromFile(__DIR__ . '/missing.xml'); + } + + public function testXmlSerializerRejectsMismatchedGroups(): void + { + $serializer = XmlSerializer::inMemory(); + $serializer->startGroup('expected'); + + $this->expectException(LogicException::class); + + $serializer->endGroup('actual'); + } + + public function testImportSessionRepositoryPersistsNamedContextAsJson(): void + { + $repository = new ImportSessionRepository('characterization'); + $original = new ImportSession(); + $session = $original + ->withFileToImport('/tmp/import.zip') + ->withComponentImportFile('/tmp/import/qpl_data.xml') + ->withImportBaseDir('/tmp/import') + ->withInstallId(123) + ->withLegacyQtiFile('/tmp/import/qti.xml') + ->withLegacyXmlFile('/tmp/import/qpl.xml') + ->withSelectableQuestionIds([11, 12]) + ->withSelectedQuestionIds([12]) + ->withPoolObjId(45) + ->withTestObjId(88) + ->withTestRefId(99) + ->withUserMappings(['identifier' => 'login', 'mapping' => ['alice' => 'alice']]) + ->withResourceMappings([['id' => 'rid', 'suffix' => 'png', 'title' => 'img']]) + ->withSkillAssignments([ + 'failed' => [], + 'success' => [['skill_id' => 1, 'tref_id' => 0, 'title' => 'Skill', 'path' => '/']], + ]) + ->withSkillThresholds([ + 'failed' => [], + 'success' => [[ + 'skill_base_id' => 1, + 'skill_tref_id' => 0, + 'skill_level_id' => 4, + 'threshold' => 50, + ]], + ]); + + $repository->setCurrentStageIndex(3); + $repository->setSession($session); + + $this->assertFalse($original->hasFileToImport()); + $this->assertSame('/tmp/import.zip', $session->fileToImport()); + + $payload = ilSession::get('import_stage_characterization_context'); + $this->assertIsString($payload); + $decoded = json_decode($payload, true); + $this->assertIsArray($decoded); + $this->assertSame('/tmp/import.zip', $decoded['file_to_import']); + $this->assertSame(123, $decoded['install_id']); + + $restored = $repository->getSession(); + $this->assertSame(3, $repository->getCurrentStageIndex()); + $this->assertSame('/tmp/import.zip', $restored->fileToImport()); + $this->assertSame('/tmp/import/qpl_data.xml', $restored->componentImportFile()); + $this->assertSame('/tmp/import', $restored->importBaseDir()); + $this->assertSame(123, $restored->installId()); + $this->assertSame('/tmp/import/qti.xml', $restored->legacyQtiFile()); + $this->assertSame('/tmp/import/qpl.xml', $restored->legacyXmlFile()); + $this->assertTrue($restored->isLegacyImport()); + $this->assertSame([11, 12], $restored->selectableQuestionIds()); + $this->assertSame([12], $restored->selectedQuestionIds()); + $this->assertSame(45, $restored->poolObjId()); + $this->assertSame(88, $restored->testObjId()); + $this->assertSame(99, $restored->testRefId()); + $this->assertSame( + ['identifier' => 'login', 'mapping' => ['alice' => 'alice']], + $restored->userMappings() + ); + $this->assertSame( + [['id' => 'rid', 'suffix' => 'png', 'title' => 'img']], + $restored->resourceMappings() + ); + $this->assertSame( + [ + 'failed' => [], + 'success' => [['skill_id' => 1, 'tref_id' => 0, 'title' => 'Skill', 'path' => '/']], + ], + $restored->skillAssignments() + ); + $this->assertSame( + [ + 'failed' => [], + 'success' => [[ + 'skill_base_id' => 1, + 'skill_tref_id' => 0, + 'skill_level_id' => 4, + 'threshold' => 50, + ]], + ], + $restored->skillThresholds() + ); + + $repository->clear(); + + $this->assertSame(0, $repository->getCurrentStageIndex()); + $empty = $repository->getSession(); + $this->assertFalse($empty->hasFileToImport()); + $this->assertSame([], $empty->selectedQuestionIds()); + $this->assertFalse($empty->isLegacyImport()); + } + + public function testImportSessionRepositoryIgnoresUnknownJsonKeys(): void + { + $repository = new ImportSessionRepository('characterization'); + ilSession::set( + 'import_stage_characterization_context', + json_encode([ + 'file_to_import' => '/tmp/import.zip', + 'bridge_tmp' => 'ignored', + ], JSON_THROW_ON_ERROR) + ); + + $restored = $repository->getSession(); + $decoded = json_decode($restored->toJson(), true); + + $this->assertSame('/tmp/import.zip', $restored->fileToImport()); + $this->assertNull($decoded['component_import_file']); + $this->assertArrayNotHasKey('bridge_tmp', $decoded); + } + + public function testImportSessionIsLegacyImportOnlyWhenBothLegacyPathsAreSet(): void + { + $repository = new ImportSessionRepository('characterization'); + $repository->setSession( + (new ImportSession())->withLegacyQtiFile('/tmp/qti.xml') + ); + + $this->assertFalse($repository->getSession()->isLegacyImport()); + + $repository->setSession( + (new ImportSession()) + ->withLegacyQtiFile('/tmp/qti.xml') + ->withLegacyXmlFile('/tmp/qpl.xml') + ); + + $this->assertTrue($repository->getSession()->isLegacyImport()); + } + + private function deserializeGroup(Deserializer $deserializer, string $group): array + { + $data = null; + $deserializer->addHandler($group, static function (array $group_data) use (&$data): void { + $data = $group_data; + }); + $deserializer->process(); + + $this->assertIsArray($data); + return $data; + } +} diff --git a/components/ILIAS/TestQuestionPool/tests/ilAssQuestionLifecycleTest.php b/components/ILIAS/TestQuestionPool/tests/ilAssQuestionLifecycleTest.php index 6551b560389a..5be6810d66ca 100644 --- a/components/ILIAS/TestQuestionPool/tests/ilAssQuestionLifecycleTest.php +++ b/components/ILIAS/TestQuestionPool/tests/ilAssQuestionLifecycleTest.php @@ -35,15 +35,19 @@ protected function setUp(): void { parent::setUp(); - $this->object = new class () extends ilAssQuestionLifecycle { - public function __construct() - { - } - }; + $this->object = new ilAssQuestionLifecycle(); } public function testConstruct(): void { $this->assertInstanceOf(ilAssQuestionLifecycle::class, $this->object); + $this->assertSame(ilAssQuestionLifecycle::DRAFT, $this->object->getIdentifier()); + } + + public function testConstructAcceptsIdentifier(): void + { + $lifecycle = new ilAssQuestionLifecycle(ilAssQuestionLifecycle::FINAL); + + $this->assertSame(ilAssQuestionLifecycle::FINAL, $lifecycle->getIdentifier()); } } 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 c22c2c7cd160..f2373f3bef04 100644 --- a/lang/ilias_de.lang +++ b/lang/ilias_de.lang @@ -1067,6 +1067,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 d73220fcc889..c38826f49082 100644 --- a/lang/ilias_en.lang +++ b/lang/ilias_en.lang @@ -1067,6 +1067,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