Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
93e6cab
chore(foundation): introduce export/import foundation components
lukas-heinrich Apr 9, 2026
bbc0008
feat(qpl): implement normalizing/denormalizing in question pool compo…
lukas-heinrich Apr 9, 2026
16ac23e
feat(qpl): implement question pool export and integrate core export c…
lukas-heinrich Apr 9, 2026
a66a917
feat(qpl): implement question pool import and integrate user interfac…
lukas-heinrich Apr 9, 2026
3fd6f92
fix(qpl): support copage import
lukas-heinrich Apr 9, 2026
2470373
fix(qpl): metadata import fails due to xsd validation errors
lukas-heinrich Apr 9, 2026
6f285a8
fix(qpl): formula unit and categories import
lukas-heinrich Apr 10, 2026
31ebd27
feat(qpl): introduce question image file import
lukas-heinrich Apr 13, 2026
8979130
feat(qpl): introduce import cleanup stage
lukas-heinrich Apr 13, 2026
28e5c32
refactor(qpl): remove questions import in question pool
lukas-heinrich Apr 13, 2026
04d5a8c
fix(foundation): rename export context parameter
lukas-heinrich Apr 14, 2026
8a03945
fix(qpl): support legacy question pool import
lukas-heinrich Apr 15, 2026
323ef94
refactor(foundation,qpl): introduce export bridge to enhance dependen…
lukas-heinrich Apr 16, 2026
db73938
refactor(qpl): introduce trait to share question collection logic
lukas-heinrich Apr 17, 2026
8e5d2c2
fix(test): missing parameter in Repository::getTestAttemptResult
lukas-heinrich Apr 20, 2026
10a19a7
feat(test): introduce normalizing into test component
lukas-heinrich Apr 21, 2026
58c661b
feat(test): introduce test export classes and integrate into existing…
lukas-heinrich Apr 21, 2026
80b6ac9
feat(test): introduce skill threshold export
lukas-heinrich Apr 22, 2026
a27b330
fix(test): missing redirection leads to duplicate export
lukas-heinrich Apr 22, 2026
e78bc08
fix(test): incorrect resources export
lukas-heinrich Apr 22, 2026
d93cb4e
fix(test): include question set config in export
lukas-heinrich Apr 22, 2026
643f139
refactor: remove request dependency in import stages
lukas-heinrich Apr 23, 2026
4457bc7
refactor(qpl): create question importer class
lukas-heinrich Apr 24, 2026
44c345d
refactor(qpl): replace string constants with defined constants in imp…
lukas-heinrich Apr 27, 2026
2ef4be8
fix(test): normalizing bugs and test mapping
lukas-heinrich Apr 28, 2026
c0a6a56
feat(test): introduce user import resolving
lukas-heinrich Apr 28, 2026
18e72df
fix(test): add export for missing test result dependencies
lukas-heinrich Apr 29, 2026
3f125ef
fix(qpl): generate thumbnails for question images
lukas-heinrich Apr 29, 2026
14a4349
fix(filesystem): preserve uri and mode in ReattachableStream across d…
lukas-heinrich Apr 29, 2026
2fc8068
feat(test): implement test importer class and integrate into xml impo…
lukas-heinrich Apr 29, 2026
d950129
feat(test): add missing participant relations export/import
lukas-heinrich Apr 29, 2026
3b57c9e
refactor(qpl): introduce export/import logging
lukas-heinrich Apr 29, 2026
3ca3a2f
fix(foundation): improve legacy normalizer resolving
lukas-heinrich Apr 30, 2026
45f180e
refactor(foundation): introduce XMLFileDeserializer
lukas-heinrich May 4, 2026
943d623
feat(test): implement skill level thresholds import
lukas-heinrich May 4, 2026
303dcf9
feat(test): introduce random question set config import
lukas-heinrich May 4, 2026
61085fd
fix(qpl): remove todo annotations in normalizing steps
lukas-heinrich May 7, 2026
d50aeae
refactor(test): improve random test import and split importer class
lukas-heinrich May 7, 2026
962276e
fix(test): support random test question reference export/import
lukas-heinrich May 7, 2026
de93170
feat(test): support legacy test import and handle redirect after impo…
lukas-heinrich May 8, 2026
c757fe8
refactor(test/qpl): integrate logging into import/export processes fo…
lukas-heinrich May 13, 2026
d2faea4
style: fix coding style
lukas-heinrich May 13, 2026
3251bd9
refactor: remove legacy test classes
lukas-heinrich May 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ protected function init(): void
throw new ilExportException('Export class file "' . $export_class_file . '" not found.');
}
}
$this->sv = $this->getMinimalComponentExporter()->determineSchemaVersion($component, $this->getTarget()->getTargetRelease());
$this->sv = $this->getMinimalComponentExporter()->determineSchemaVersion($this->getTarget()->getType(), $this->getTarget()->getTargetRelease());
$this->sv["uses_dataset"] ??= false;
$this->sv['xsd_file'] ??= '';
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@
<xs:element name="lom">
<xs:complexType>
<xs:sequence>
<xs:element ref="general"/>
<xs:element ref="general" minOccurs="0" maxOccurs="1"/>
<xs:element ref="lifeCycle" minOccurs="0" maxOccurs="1"/>
<xs:element ref="metaMetadata" minOccurs="0" maxOccurs="1"/>
<xs:element ref="technical" minOccurs="0" maxOccurs="1"/>
Expand Down
18 changes: 13 additions & 5 deletions components/ILIAS/Filesystem/src/Stream/ReattachableStream.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,23 @@
*/
class ReattachableStream extends Stream
{
/**
* Checks if the stream is attached to the wrapper.
* If not, the stream is reattached.
*/
private ?string $reattach_uri = null;
private ?string $reattach_mode = null;

#[\Override]
public function detach()
{
$this->reattach_uri = $this->uri;
$this->reattach_mode = $this->_mode;

return parent::detach();
}

#[\Override]
protected function assertStreamAttached(): void
{
if ($this->stream === null) {
$this->stream = fopen($this->uri, $this->_mode);
$this->stream = fopen($this->reattach_uri, $this->reattach_mode);
}
}
}
255 changes: 107 additions & 148 deletions components/ILIAS/Test/classes/class.ilObjTestGUI.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

declare(strict_types=1);

use ILIAS\Test\ExportImport\Import\PersistStage;
use ILIAS\Test\Logging\AdditionalInformationGenerator;
use ILIAS\Skill\Service\SkillUsageService;
use ILIAS\Test\Results\Data\Repository as TestResultRepository;
Expand Down Expand Up @@ -61,6 +62,14 @@
use ILIAS\Test\Results\Toplist\TestTopListRepository;
use ILIAS\Test\ExportImport\Factory as ExportImportFactory;
use ILIAS\Test\ExportImport\DBRepository as ExportRepository;
use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportContext;
use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportSessionRepository;
use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportStageRunner;
use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\StageResultType;
use ILIAS\TestQuestionPool\ExportImport\Import\CleanupStage;
use ILIAS\TestQuestionPool\ExportImport\Import\DetectLegacyImportStage;
use ILIAS\TestQuestionPool\ExportImport\Import\QuestionSelectionStage;
use ILIAS\TestQuestionPool\ExportImport\Import\UploadValidationStage;
use ILIAS\TestQuestionPool\Questions\GeneralQuestionPropertiesRepository;
use ILIAS\TestQuestionPool\RequestDataCollector as QPLRequestDataCollector;
use ILIAS\TestQuestionPool\Import\TestQuestionsImportTrait;
Expand All @@ -81,6 +90,7 @@
use ILIAS\Style\Content\Service as ContentStyle;
use ILIAS\User\Profile\PublicProfileGUI;
use ILIAS\Test\GUIFactory;
use Psr\Log\LoggerInterface;

/**
* Class ilObjTestGUI
Expand Down Expand Up @@ -180,6 +190,8 @@ class ilObjTestGUI extends ilObjectGUI implements ilCtrlBaseClassInterface, ilDe
protected TaxonomyService $taxonomy;
protected GUIFactory $gui_factory;
protected SkillUsageService $skill_usage_service;
protected ImportSessionRepository $import_session_repository;
protected LoggerInterface $import_logger;

protected bool $create_question_mode;

Expand Down Expand Up @@ -234,6 +246,8 @@ public function __construct()
$this->mark_schema_factory = $local_dic['marks.factory'];
$this->additional_information_generator = $local_dic['logging.information_generator'];
$this->personal_settings_exporter = $local_dic['settings.personal_templates.exporter'];
$this->import_session_repository = $local_dic['exportimport.session'];
$this->import_logger = $local_dic['exportimport.logging']();

$ref_id = 0;
if ($this->testrequest->hasRefId() && is_numeric($this->testrequest->getRefId())) {
Expand Down Expand Up @@ -1362,81 +1376,116 @@ public function runObject()
$this->ctrl->redirectByClass([ilRepositoryGUI::class, self::class, ilInfoScreenGUI::class]);
}


protected function importFile(string $file_to_import, string $path_to_uploaded_file_in_temp_dir): void
{
list($subdir, $importdir, $xmlfile, $qtifile) = $this->buildImportDirectoriesFromImportFile($file_to_import);

$options = (new ILIAS\Filesystem\Util\Archive\UnzipOptions())
->withZipOutputPath($this->getImportTempDirectory());
$this->import_session_repository->clear();

$unzip = $this->archives->unzip(Streams::ofResource(fopen($file_to_import, 'r')), $options);
$unzip->extract();
$context = new ImportContext([UploadValidationStage::FILE_TO_IMPORT => $file_to_import]);
$this->import_session_repository->setContext($context);
$this->import_session_repository->setCurrentStageIndex(0);

if (!is_file($qtifile)) {
ilFileUtils::delDir($importdir);
$this->deleteUploadedImportFile($path_to_uploaded_file_in_temp_dir);
$this->tpl->setOnScreenMessage('failure', $this->lng->txt('tst_import_non_ilias_zip'), true);
}
$qtiParser = new ilQTIParser($importdir, $qtifile, ilQTIParser::IL_MO_VERIFY_QTI, 0, [], [], true);
try {
$qtiParser->startParsing();
} catch (ilSaxParserException) {
$this->tpl->setOnScreenMessage('failure', $this->lng->txt('import_file_not_valid'), true);
$this->ctrl->redirect($this, 'create');
}
$founditems = $qtiParser->getFoundItems();

$complete = 0;
$incomplete = 0;
foreach ($founditems as $item) {
if ($item["type"] !== '') {
$complete++;
} else {
$incomplete++;
}
}
$this->ctrl->redirectByClass(self::class, 'processImport');
}

if (count($founditems) && $complete == 0) {
ilFileUtils::delDir($importdir);
$this->deleteUploadedImportFile($path_to_uploaded_file_in_temp_dir);
$this->tpl->setOnScreenMessage('info', $this->lng->txt('qpl_import_non_ilias_files'));
public function processImportObject(): void
{
$permission = $this->creation_mode ? 'create' : 'read';
if (!$this->checkPermissionBool($permission, '', $this->object->getType())) {
$this->redirectAfterMissingWrite();
return;
}

ilSession::set('path_to_import_file', $file_to_import);
ilSession::set('path_to_uploaded_file_in_temp_dir', $path_to_uploaded_file_in_temp_dir);
$runner = $this->buildImportStageRunner();
$result = $runner->run();

if ($qtiParser->getQuestionSetType() !== ilObjTest::QUESTION_SET_TYPE_FIXED
|| file_exists($this->buildResultsFilePath($importdir, $subdir))
|| $founditems === []) {
$this->importVerifiedFileObject(true);
return;
switch ($result->type) {
case StageResultType::INTERACT:
$this->tpl->setContent(
$this->ui_renderer->render($result->components)
);
break;

case StageResultType::ADVANCE:
$this->ctrl->redirectByClass(self::class, 'processImport');
break;

case StageResultType::ERROR:
$this->tpl->setOnScreenMessage('failure', $result->error_message, true);
break;

case StageResultType::COMPLETE:
$this->afterImportCompleted($result->context);
break;
}
}

$form = $this->buildImportQuestionsSelectionForm(
'importVerifiedFile',
$importdir,
$qtifile,
$file_to_import,
$path_to_uploaded_file_in_temp_dir
);
private function afterImportCompleted(ImportContext $context): void
{
$new_obj = new ilObjTest(0, false);
$new_obj->setId($context->get('test_obj_id'));
$new_obj->setRefId($context->get('test_ref_id'));

if ($form === null) {
return;
if ($new_obj->getTestLogger()->isLoggingEnabled()) {
$new_obj->getTestLogger()->logTestAdministrationInteraction(
$new_obj->getTestLogger()->getInteractionFactory()->buildTestAdministrationInteraction(
$new_obj->getRefId(),
$this->user->getId(),
TestAdministrationInteractionTypes::NEW_TEST_CREATED,
[]
)
);
}

$panel = $this->ui_factory->panel()->standard(
$this->lng->txt('import_tst'),
$question_skill_assignments_import_fails = new ilAssQuestionSkillAssignmentImportFails($new_obj->getId());
if ($question_skill_assignments_import_fails->failedImportsRegistered()) {
$this->tpl->setOnScreenMessage(
'info',
$question_skill_assignments_import_fails->getFailedImportsMessage($this->lng),
true
);
}

$this->tpl->setOnScreenMessage('success', $this->lng->txt('object_imported'), true);
$this->ctrl->setParameterByClass(ilObjTestGUI::class, 'ref_id', $new_obj->getRefId());
$this->ctrl->redirectByClass(self::class, self::SHOW_QUESTIONS_CMD);
}

private function buildImportStageRunner(): ImportStageRunner
{
$form_action = $this->ctrl->getFormActionByClass(self::class, 'processImport');

return new ImportStageRunner(
[
$this->ui_factory->legacy()->content($this->lng->txt('qpl_import_verify_found_questions')),
$form
]
new UploadValidationStage(
$this->archives,
$this->lng,
$this->import_logger,
'components/ILIAS/Test'
),
new DetectLegacyImportStage($this->import_logger),
new QuestionSelectionStage(
$this->lng,
$this->import_logger,
$this->component_factory,
$this->ui_factory,
$this->request,
$form_action,
$this->lng->txt('import_tst')
),
new PersistStage(
$this->lng,
$this->import_logger,
$this->requested_ref_id,
$this->import_session_repository
),
],
$this->import_session_repository,
new CleanupStage($this->import_logger)
);
$this->tpl->setContent($this->ui_renderer->render($panel));
$this->tpl->printToStdout();
exit;
}


/**
* save object
* @access public
Expand Down Expand Up @@ -1478,97 +1527,6 @@ public function getTestObject(): ?ilObjTest
return $this->object;
}

/**
* imports question(s) into the questionpool (after verification)
*/
public function importVerifiedFileObject(
bool $skip_retrieve_selected_questions = false
): void {
if (!$this->checkPermissionBool('create', '', 'tst')) {
$this->tpl->setOnScreenMessage('failure', $this->lng->txt('no_permission'), true);
$this->ctrl->returnToParent($this);
}
$file_to_import = ilSession::get('path_to_import_file');
$path_to_uploaded_file_in_temp_dir = ilSession::get('path_to_uploaded_file_in_temp_dir');
list($subdir, $importdir, $xmlfile, $qtifile) = $this->buildImportDirectoriesFromImportFile($file_to_import);

$new_obj = new ilObjTest(0, true);
$new_obj->setTitle('dummy');
$new_obj->setDescription('test import');
$new_obj->create(true);
$new_obj->createReference();
$new_obj->putInTree($this->testrequest->getRefId());
$new_obj->setPermissions($this->testrequest->getRefId());
$new_obj->saveToDb();

$selected_questions = [];
if (!$skip_retrieve_selected_questions) {
$selected_questions = $this->retrieveSelectedQuestionsFromImportQuestionsSelectionForm(
'importVerifiedFile',
$importdir,
$qtifile,
$this->request
);
}

ilSession::set('tst_import_selected_questions', $selected_questions);

$imp = new ilImport($this->testrequest->getRefId());
$map = $imp->getMapping();
$map->addMapping('components/ILIAS/Test', 'tst', 'new_id', (string) $new_obj->getId());

/**
* 2025-03-22, sk: This is now only needed for legacy exports as
* now also exports with results do contain a manifest.xml.
*/
if (is_file($importdir . DIRECTORY_SEPARATOR . '/manifest.xml')) {
$imp->importObject($new_obj, $file_to_import, basename($file_to_import), 'tst', 'components/ILIAS/Test', true);
} else {
$test_importer = new ilTestImporter();
$test_importer->setImport($imp);
$test_importer->setInstallId(IL_INST_ID);
$test_importer->setImportDirectory($importdir . '/' . $subdir);
$test_importer->init();

$test_importer->importXmlRepresentation(
'',
'',
'',
$map,
);
}

if ($new_obj->getTestLogger()->isLoggingEnabled()) {
$new_obj->getTestLogger()->logTestAdministrationInteraction(
$new_obj->getTestLogger()->getInteractionFactory()->buildTestAdministrationInteraction(
$new_obj->getRefId(),
$this->user->getId(),
TestAdministrationInteractionTypes::NEW_TEST_CREATED,
[]
)
);
}

ilFileUtils::delDir($importdir);
$this->deleteUploadedImportFile($path_to_uploaded_file_in_temp_dir);
ilSession::clear('path_to_import_file');
ilSession::clear('path_to_uploaded_file_in_temp_dir');

$this->tpl->setOnScreenMessage('success', $this->lng->txt("object_imported"), true);

$question_skill_assignments_import_fails = new ilAssQuestionSkillAssignmentImportFails($new_obj->getId());
if ($question_skill_assignments_import_fails->failedImportsRegistered()) {
$this->tpl->setOnScreenMessage(
'info',
$question_skill_assignments_import_fails->getFailedImportsMessage($this->lng),
true
);
}

$this->ctrl->setParameterByClass(ilObjTestGUI::class, 'ref_id', $new_obj->getRefId());
$this->ctrl->redirectByClass(ilObjTestGUI::class);
}

/**
* download file
*/
Expand Down Expand Up @@ -2362,6 +2320,7 @@ public function addLocatorItems(): void
);
break;
case "importFile":
case "processImport":
case "cloneAll":
case "importVerifiedFile":
case "cancelImport":
Expand Down
Loading
Loading