From 93e6cabcc175e2dbb95905c992e756b8f5090728 Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Thu, 9 Apr 2026 11:01:20 +0200 Subject: [PATCH 01/43] chore(foundation): introduce export/import foundation components This commit was created from various other commits across multiple branches. These were combined to simplify the merge process and to separate the foundation from the implementations in the Questions and Test components. --- .../class.ilTestQuestionPoolSetupAgent.php | 6 + .../src/ExportImport/Foundation/Builder.php | 230 ++++++++++++++++ .../Foundation/Contracts/DataCollector.php | 35 +++ .../Foundation/Contracts/Deserializer.php | 53 ++++ .../Foundation/Contracts/Envelope.php | 46 ++++ .../Foundation/Contracts/ImportStage.php | 54 ++++ .../Foundation/Contracts/Normalizable.php | 62 +++++ .../Foundation/Contracts/Normalizer.php | 57 ++++ .../Foundation/Contracts/Pipe.php | 39 +++ .../Foundation/Contracts/Pipeline.php | 98 +++++++ .../Foundation/Contracts/Serializer.php | 78 ++++++ .../Foundation/Contracts/Transformations.php | 105 ++++++++ .../ExportImport/Foundation/ExportContext.php | 108 ++++++++ .../Foundation/Importing/ImportContext.php | 51 ++++ .../Importing/ImportSessionRepository.php | 84 ++++++ .../Importing/ImportStageRunner.php | 122 +++++++++ .../Foundation/Importing/StageResult.php | 76 ++++++ .../Foundation/Importing/StageResultType.php | 47 ++++ .../Normalizing/Attributes/Normalizes.php | 44 ++++ .../Attributes/NormalizesLegacy.php | 44 ++++ .../Foundation/Normalizing/Envelopes/Id.php | 99 +++++++ .../Normalizer/DateTimeNormalizer.php | 58 +++++ .../Normalizer/EnvelopeNormalizer.php | 63 +++++ .../Normalizer/IlObjectNormalizer.php | 155 +++++++++++ .../Normalizer/Legacy11UUIDNormalizer.php | 57 ++++ .../Normalizing/Normalizer/Registry.php | 133 ++++++++++ .../Normalizer/TransformationNormalizer.php | 63 +++++ .../Normalizing/Normalizer/UUIDNormalizer.php | 61 +++++ .../Normalizing/NormalizingException.php | 37 +++ .../Normalizing/Pipes/DenormalizeCarry.php | 47 ++++ .../Normalizing/Pipes/DenormalizingPipe.php | 62 +++++ .../Normalizing/Pipes/FinalizeNormalizing.php | 56 ++++ .../Normalizing/Pipes/IdMappingPipe.php | 83 ++++++ .../Normalizing/Pipes/NormalizeCarry.php | 45 ++++ .../Normalizing/Pipes/NormalizingPipe.php | 66 +++++ .../Normalizing/Transformations.php | 151 +++++++++++ .../src/ExportImport/Foundation/Pipeline.php | 200 ++++++++++++++ .../Serializing/JSONMemoryDeserializer.php | 70 +++++ .../Serializing/JSONMemorySerializer.php | 113 ++++++++ .../Serializing/SimpleXMLDeserializer.php | 246 ++++++++++++++++++ .../Serializing/SimpleXMLSerializer.php | 186 +++++++++++++ .../Setup/NormalizerArtifactObjective.php | 80 ++++++ 42 files changed, 3570 insertions(+) create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Builder.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/DataCollector.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/Deserializer.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/Envelope.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/ImportStage.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/Normalizable.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/Normalizer.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/Pipe.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/Pipeline.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/Serializer.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/Transformations.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/ExportContext.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportContext.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportSessionRepository.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportStageRunner.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/StageResult.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/StageResultType.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Attributes/Normalizes.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Attributes/NormalizesLegacy.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Envelopes/Id.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/DateTimeNormalizer.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/EnvelopeNormalizer.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/IlObjectNormalizer.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/Legacy11UUIDNormalizer.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/Registry.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/TransformationNormalizer.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/UUIDNormalizer.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/NormalizingException.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/DenormalizeCarry.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/DenormalizingPipe.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/FinalizeNormalizing.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/IdMappingPipe.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/NormalizeCarry.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/NormalizingPipe.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Transformations.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Pipeline.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/JSONMemoryDeserializer.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/JSONMemorySerializer.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/SimpleXMLDeserializer.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/SimpleXMLSerializer.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Setup/NormalizerArtifactObjective.php diff --git a/components/ILIAS/TestQuestionPool/classes/Setup/class.ilTestQuestionPoolSetupAgent.php b/components/ILIAS/TestQuestionPool/classes/Setup/class.ilTestQuestionPoolSetupAgent.php index 7a18db41545c..d3e34d84d37b 100755 --- a/components/ILIAS/TestQuestionPool/classes/Setup/class.ilTestQuestionPoolSetupAgent.php +++ b/components/ILIAS/TestQuestionPool/classes/Setup/class.ilTestQuestionPoolSetupAgent.php @@ -20,6 +20,7 @@ use ILIAS\Setup\Objective; use ILIAS\Setup\ObjectiveCollection; use ILIAS\Setup\Metrics; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Setup\NormalizerArtifactObjective; class ilTestQuestionPoolSetupAgent extends NullAgent { @@ -60,4 +61,9 @@ public function getMigrations(): array ]; } + public function getBuildObjective(): Objective + { + return new NormalizerArtifactObjective(); + } + } diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Builder.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Builder.php new file mode 100644 index 000000000000..ddab7b63f94c --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Builder.php @@ -0,0 +1,230 @@ + $prepend_pipes */ + private array $prepend_pipes = []; + + /** @var list $append_pipes */ + private array $append_pipes = []; + + /** @var list $containers */ + private array $containers = []; + + public function __construct( + private readonly ILIASContainer $dic, + Container ...$local_containers, + ) { + $this->containers = $local_containers; + } + + /* + Fluent interface methods + */ + + public function withDefaultNormalizers(bool $enable = true): self + { + $clone = clone $this; + $clone->default_normalizers = $enable; + return $clone; + } + + public function withLegacyNormalizers(string $version): self + { + $clone = clone $this; + $clone->legacy_version = $version; + return $clone; + } + + /** + * @param list $append + * @param list $prepend + */ + public function withAdditionalPipes(array $prepend = [], array $append = []): self + { + $clone = clone $this; + $clone->append_pipes += $append; + $clone->prepend_pipes += $prepend; + return $clone; + } + + /* + Object creation + */ + + /** + * Create a Transformations instance which was configured by the builder. + */ + + public function create(): TransformationsContract + { + $pipeline = new Pipeline(); + $object = new Transformations( + $this->dic->refinery(), + $pipeline + ); + + foreach ($this->prepend_pipes as $pipe) { + $pipeline->pipe($pipe); + } + + if ($this->legacy_version !== null) { + // TODO: Implement semver comparison here? Use ILIAS\Data\Version class? + + $registry = $this->buildRegistry($object, $this->legacy_version); + $pipeline->pipe(new NormalizingPipe($registry)); + $pipeline->pipe(new DenormalizingPipe($registry)); + } + + if ($this->default_normalizers) { + $registry = $this->buildRegistry($object); + $pipeline->pipe(new NormalizingPipe($registry)); + $pipeline->pipe(new DenormalizingPipe($registry)); + } + + foreach ($this->append_pipes as $pipe) { + $pipeline->pipe($pipe); + } + + $pipeline->pipe(new FinalizeNormalizing()); + + return $object; + } + + /** + * Register all normalizer classes from the type map artifact by checking for the given version and skipping if + * the normalizer is already registered. + */ + private function buildRegistry(Transformations $object, string $version = NormalizerArtifactObjective::DEFAULT_KEY): Registry + { + $type_map = require NormalizerArtifactObjective::PATH(); + $registry = new Registry(); + + foreach ($type_map as $type => $normalizer_classes) { + if (!isset($normalizer_classes[$version]) || $registry->hasNormalizer($type)) { + continue; + } + + $registry->registerNormalizer( + $type, + fn() => $this->createInstance($normalizer_classes[$version], $object) + ); + } + + return $registry; + } + + /* + Factory & Autowiring + */ + + /** + * Create an instance of a class by resolving the constructor arguments. + * + * @template T of object + * + * @param class-string $class_name + * @param Transformations $transformations + * @return T + */ + private function createInstance(string $class_name, Transformations $transformations): object + { + $reflection_class = new ReflectionClass($class_name); + $constructor = $reflection_class->getConstructor(); + + if ($constructor === null || $constructor->getNumberOfParameters() === 0) { + return $reflection_class->newInstance(); + } + + $arguments = []; + foreach ($constructor->getParameters() as $parameter) { + $arguments[] = $this->resolveConstructorArgument( + $parameter, + $transformations, + $class_name + ); + } + + return $reflection_class->newInstanceArgs($arguments); + } + + /** + * Resolve a constructor argument by trying to resolve it from the global ilias container, the local container or + * the default value. + * + * @throws RuntimeException if the argument cannot be resolved + */ + private function resolveConstructorArgument( + ReflectionParameter $parameter, + Transformations $transformations, + string $class_name + ) { + if ($parameter->isDefaultValueAvailable()) { + return $parameter->getDefaultValue(); + } + + $type = $parameter->getType(); + if ($type instanceof ReflectionNamedType && $type->allowsNull()) { + return null; + } + + + if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) { + $type_name = $type->getName(); + + if ($type_name === TransformationsContract::class || in_array(TransformationsContract::class, class_implements($type_name))) { + return $transformations; + } + + foreach ([$this->dic, ...$this->containers] as $container) { + if ($type_name === get_class($container)) { + return $container; + } + } + } + + $name = $parameter->getName(); + throw new RuntimeException("Unable to resolve constructor parameter \${$name} for class {$class_name}."); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/DataCollector.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/DataCollector.php new file mode 100644 index 000000000000..b23c8eb65133 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/DataCollector.php @@ -0,0 +1,35 @@ + Language-neutral normalized representation. + */ + public function toArray(Transformations $tt): array; + + /** + * Reconstruct an envelope instance from its normalized array representation. + * + * @param array $value Normalized envelope payload. + * @param Transformations $tt Transformation helpers used for value casting and mapping. + * @return static Reconstructed envelope instance. + */ + public static function fromArray(array $value, Transformations $tt): static; +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/ImportStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/ImportStage.php new file mode 100644 index 000000000000..2dcb1fa9ff3d --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/ImportStage.php @@ -0,0 +1,54 @@ + + * + * @template TValue + * @template TNormalized of null|scalar|NormalizedArray + */ +interface Normalizer +{ + /** + * Converts a value into a normalized form. + * + * @param TValue $value + * @return TNormalized + * + * @throws NormalizingException if the normalizer does not support the given type + */ + public function normalize($value): array|float|bool|int|string|null; + + /** + * Converts a normalized form back into a value. It uses the type hint to determine the expected type, which will be + * returned. + * + * @param TNormalized $normalized + * @param class-string $type + * @return TValue + * + * @throws NormalizingException if the normalizer does not support the given type + */ + public function denormalize(array|float|bool|int|string|null $normalized, string $type); +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/Pipe.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/Pipe.php new file mode 100644 index 000000000000..92b68b1a4e49 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/Pipe.php @@ -0,0 +1,39 @@ + $pipes + */ + public function through(array $pipes): self; + + /** + * Push additional pipe onto the pipeline. + * + * @param PipeParam $pipes + */ + public function pipe(\Closure|Pipe $pipe): self; + + /** + * Push additional pipe onto the pipeline which will be executed when the condition is met. + * + * @param \Closure(TPassable): bool $condition + * @param PipeParam $pipe + */ + public function pipeWhen(\Closure $condition, \Closure|Pipe $pipe): self; + + /** + * Push additional pipe onto the pipeline which will be executed when the condition is not met. + * + * @param \Closure(TPassable): bool $condition + * @param PipeParam $pipe + */ + public function pipeUnless(\Closure $condition, \Closure|Pipe $pipe): self; + + /** + * Run the pipeline with a final destination callback. + * + * @param \Closure(TPassable): TPassable $destination + * @return TPassable|mixed + */ + public function then(\Closure $destination): mixed; + + /** + * Run the pipeline and return the result. + * + * @return TPassable + */ + public function thenReturn(): mixed; + + /** + * Set a final callback to be executed after the pipeline ends regardless of the outcome. + * + * @param \Closure(TPassable): TPassable $callback + */ + public function finally(\Closure $callback): self; + + /** + * Get the array of pipes. + * + * @return list + */ + public function pipes(): array; +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/Serializer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/Serializer.php new file mode 100644 index 000000000000..71873e44e722 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/Serializer.php @@ -0,0 +1,78 @@ +|T $expected + * @return T|null + * + * @throws NormalizingException if the type is not supported + */ + public function denormalize(array|float|bool|int|string|null $normalized, string|object $expected): mixed; + + /* + Transformations + */ + + /** + * Returns the pipe of the given class from the pipeline. + * + * @template T of Pipe + * @param class-string $pipe_class + * @return T + * + * @throws \InvalidArgumentException if the pipe is not found + */ + public function context(string $pipe_class): Pipe; + + /** + * Returns a group of transformations that can be used to create custom transformations. + */ + public function custom(): Group; + + /** + * @throws \InvalidArgumentException if the value cannot be transformed into an integer + */ + public function int(mixed $value): int; + + /** + * @throws \InvalidArgumentException if the value cannot be transformed into a float + */ + public function float(mixed $value): float; + + /** + * @throws \InvalidArgumentException if the value cannot be transformed into a string + */ + public function string(mixed $value): string; + + /** + * @throws \InvalidArgumentException if the value cannot be transformed into a boolean + */ + public function bool(mixed $value): bool; + + public function nullableInt(mixed $value): ?int; + + public function nullableFloat(mixed $value): ?float; + + public function nullableString(mixed $value): ?string; + + public function nullableBool(mixed $value): ?bool; +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/ExportContext.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/ExportContext.php new file mode 100644 index 000000000000..b5e38b014d9f --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/ExportContext.php @@ -0,0 +1,108 @@ +}> $dependencies + */ + private array $dependencies = []; + + public function __construct( + private ObjectId $pool_id, + private ExportConfig $config, + private Transformations $transformations, + ) { + } + + public function getPoolId(): ObjectId + { + return $this->pool_id; + } + + public function getConfig(): ExportConfig + { + return $this->config; + } + + public function getTransformations(): Transformations + { + return $this->transformations; + } + + public function getSerializer(): Serializer + { + if ($this->serializer === null) { + throw new RuntimeException( + 'Serializer not set. This may happen if the exporter steps are not executed in the correct order.' + ); + } + + return $this->serializer; + } + + public function setSerializer(Serializer $serializer): void + { + $this->serializer = $serializer; + } + + public function getDependencies(): array + { + return array_values($this->dependencies); + } + + public function addDependency(string $component, string $entity, array $ids): void + { + $key = "{$component}::{$entity}"; + + if (!isset($this->dependencies[$key])) { + $this->dependencies[$key] = [ + 'component' => $component, + 'entity' => $entity, + 'ids' => [], + ]; + } + + $this->dependencies[$key]['ids'] = array_values(array_unique(array_merge( + $this->dependencies[$key]['ids'], + $ids + ))); + } + + public function getContent(): string + { + return $this->getSerializer()->write(); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportContext.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportContext.php new file mode 100644 index 000000000000..01b35fbae0b0 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportContext.php @@ -0,0 +1,51 @@ + $data */ + public function __construct( + private array $data = [], + ) { + } + + public function with(string $key, mixed $value): self + { + $clone = clone $this; + $clone->data[$key] = $value; + return $clone; + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->data[$key] ?? $default; + } + + public function has(string $key): bool + { + return array_key_exists($key, $this->data); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportSessionRepository.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportSessionRepository.php new file mode 100644 index 000000000000..f7eda4318390 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportSessionRepository.php @@ -0,0 +1,84 @@ +key_index = "{$base}index"; + $this->key_context = "{$base}context"; + } + + public function getCurrentStageIndex(): int + { + if (ilSession::has($this->key_index)) { + return (int) ilSession::get($this->key_index); + } + + return 0; + } + + public function setCurrentStageIndex(int $index): void + { + ilSession::set($this->key_index, $index); + } + + public function getContext(): ImportContext + { + if (ilSession::has($this->key_context)) { + return unserialize( + ilSession::get($this->key_context), + ['allowed_classes' => [ImportContext::class]] + ); + } + + return new ImportContext([]); + } + + public function setContext(ImportContext $context): void + { + ilSession::set($this->key_context, serialize($context)); + } + + public function clear(): void + { + if (ilSession::has($this->key_index)) { + ilSession::clear($this->key_index); + } + if (ilSession::has($this->key_context)) { + ilSession::clear($this->key_context); + } + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportStageRunner.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportStageRunner.php new file mode 100644 index 000000000000..c31a51d505b2 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportStageRunner.php @@ -0,0 +1,122 @@ + $stages */ + public function __construct( + private readonly array $stages, + private readonly ImportSessionRepository $session, + ) { + } + + /** + * Run the import stage runner. It manages the state of the import process and delegates to the current stage. + * It will return a StageResult that indicates the next action to take. + */ + public function run(ServerRequestInterface $request): StageResult + { + $index = $this->session->getCurrentStageIndex(); + $context = $this->session->getContext(); + + if ($index >= count($this->stages)) { + return StageResult::complete($context); + } + + $stage = $this->stages[$index]; + $result = $stage->process($context, $request); + + switch ($result->type) { + case StageResultType::ADVANCE: + $next_index = $index + 1; + $this->session->setContext($result->context); + $this->session->setCurrentStageIndex($next_index); + + if ($next_index >= count($this->stages)) { + return StageResult::complete($result->context); + } + + return $result; + + case StageResultType::ERROR: + case StageResultType::INTERACT: + $this->session->setContext($result->context); + return $result; + + case StageResultType::COMPLETE: + $this->session->clear(); + return $result; + } + + throw new \LogicException("Invalid stage result type: {$result->type->name}"); + } + + /** + * Build the workflow UI component for the import process. + */ + public function buildWorkflow(UIFactory $ui, string $title): Linear + { + $steps = []; + $active_index = $this->session->getCurrentStageIndex(); + + foreach ($this->stages as $i => $stage) { + $step = $ui->listing()->workflow()->step( + $stage->getLabel(), + $stage->getDescription() + ); + + if ($i < $active_index) { + $step = $step->withStatus(Step::SUCCESSFULLY) + ->withAvailability(Step::NOT_ANYMORE); + } elseif ($i === $active_index) { + $step = $step->withStatus(Step::IN_PROGRESS) + ->withAvailability(Step::AVAILABLE); + } else { + $step = $step->withStatus(Step::NOT_STARTED) + ->withAvailability(Step::NOT_AVAILABLE); + } + + $steps[] = $step; + } + + return $ui->listing()->workflow()->linear($title, $steps) + ->withActive($active_index); + } + + /** + * Reset the import stage session. + */ + public function reset(): void + { + $this->session->clear(); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/StageResult.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/StageResult.php new file mode 100644 index 000000000000..5e1dfa1d9a71 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/StageResult.php @@ -0,0 +1,76 @@ + $components + */ + private function __construct( + public readonly StageResultType $type, + public readonly ImportContext $context, + public readonly array $components = [], + public readonly ?string $error_message = null, + ) { + } + + /** + * Create a result that indicates the process should interrupt to display the given UI components. + * + * @param list $components + */ + public static function interact(ImportContext $context, array $components): self + { + return new self(StageResultType::INTERACT, $context, $components); + } + + /** + * Create a result that indicates the process should advance to the next stage. + */ + public static function advance(ImportContext $context): self + { + return new self(StageResultType::ADVANCE, $context); + } + + /** + * Create a result that indicates the process should fail with the given error message. + */ + public static function error(ImportContext $context, string $message): self + { + return new self(StageResultType::ERROR, $context, [], $message); + } + + /** + * Create a result that indicates the process should complete successfully. + */ + public static function complete(ImportContext $context): self + { + return new self(StageResultType::COMPLETE, $context); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/StageResultType.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/StageResultType.php new file mode 100644 index 000000000000..55e42ea50fa3 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/StageResultType.php @@ -0,0 +1,47 @@ +types = $types; + } + + /** @var list */ + public array $types; +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Attributes/NormalizesLegacy.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Attributes/NormalizesLegacy.php new file mode 100644 index 000000000000..c1ddb635de5f --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Attributes/NormalizesLegacy.php @@ -0,0 +1,44 @@ +types = $types; + } + + /** @var list */ + public array $types; +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Envelopes/Id.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Envelopes/Id.php new file mode 100644 index 000000000000..1fb2a36186f0 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Envelopes/Id.php @@ -0,0 +1,99 @@ +id; + } + + public function getObject(): string + { + return $this->object; + } + + /** + * @inheritDoc + */ + public function toArray(Transformations $tt): array + { + if (is_object($this->id)) { + return [ + 'id' => $tt->normalize($this->id), + 'type' => get_class($this->id), + 'object' => $this->object, + ]; + } else { + return [ + 'id' => (string) $this->id, + 'type' => gettype($this->id), + 'object' => $this->object, + ]; + } + } + + /** + * @inheritDoc + */ + public static function fromArray(array $value, Transformations $tt): static + { + $raw_id = $value['id']; + $type = $value['type']; + + if (class_exists($type)) { + return new Id($tt->denormalize($raw_id, $type), $value['object']); + } else { + $id = match($type) { + 'integer' => (int) $raw_id, + 'string' => (string) $raw_id, + 'float' => (float) $raw_id, + 'bool' => (bool) $raw_id, + 'null' => null, + default => throw new NormalizingException("Invalid type for id: {$type}") + }; + return new Id($id, $value['object']); + } + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/DateTimeNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/DateTimeNormalizer.php new file mode 100644 index 000000000000..e2fb07c8d584 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/DateTimeNormalizer.php @@ -0,0 +1,58 @@ + + */ +#[Normalizes(DateTime::class, DateTimeImmutable::class)] +class DateTimeNormalizer implements Normalizer +{ + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if ($value instanceof DateTimeImmutable || $value instanceof DateTime) { + return $value->format(DATE_ATOM); + } + + throw new NormalizingException('Invalid datetime value', $value); + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): DateTime|DateTimeImmutable + { + return match($type) { + DateTimeImmutable::class => DateTimeImmutable::createFromFormat(DATE_ATOM, $value), + DateTime::class => DateTime::createFromFormat(DATE_ATOM, $value), + default => throw new NormalizingException("Invalid type for datetime: {$type}") + }; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/EnvelopeNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/EnvelopeNormalizer.php new file mode 100644 index 000000000000..697a6765077f --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/EnvelopeNormalizer.php @@ -0,0 +1,63 @@ + + */ +#[Normalizes(Envelope::class)] +class EnvelopeNormalizer implements Normalizer +{ + public function __construct( + private readonly Transformations $tt, + ) { + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if (!$value instanceof Envelope) { + throw new NormalizingException('Invalid envelope value', $value); + } + + return $value->toArray($this->tt); + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): Envelope + { + if (!in_array(Envelope::class, class_implements($type))) { + throw new NormalizingException('Invalid envelope type', $type); + } + + return $type::fromArray($value, $this->tt); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/IlObjectNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/IlObjectNormalizer.php new file mode 100644 index 000000000000..50aadff251f6 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/IlObjectNormalizer.php @@ -0,0 +1,155 @@ + + */ +#[Normalizes(ilObject::class)] +class IlObjectNormalizer implements Normalizer +{ + public function __construct( + protected readonly Transformations $tt, + ) { + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if (!$value instanceof ilObject) { + throw new NormalizingException('Invalid value', $value); + } + + // TODO: Icon + // TODO: Tile Image + // TODO: Translations + // TODO: Container Settings + + 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()), + // 'tile_image' => $properties->getPropertyTileImage(), + // 'icon' => $this->normalizeProperty($properties->getPropertyIcon()), + 'translations' => $this->normalizeTranslations($properties->getPropertyTranslations()), + ]; + } + + private function normalizeProperty(Property $property): array|bool|int|string|null + { + if ($property instanceof TitleAndDescription) { + return [ + 'title' => $property->getTitle(), + 'description' => $property->getDescription(), + 'long_description' => $property->getLongDescription(), + ]; + } + if ($property instanceof Online) { + return $property->getIsOnline(); + } + if ($property instanceof TitleAndIconVisibility || $property instanceof HeaderActionVisibility || $property instanceof InfoTabVisibility) { + return $property->getVisibility(); + } + + return null; + } + + private function normalizeTranslations(Translations $translations): array + { + return [ + 'default_language' => $translations->getDefaultLanguage(), + 'base_language' => $translations->getBaseLanguage(), + 'languages' => array_map(fn(Language $language): array => [ + 'language_code' => $language->getLanguageCode(), + 'title' => $language->getTitle(), + 'description' => $language->getDescription(), + ], $translations->getLanguages()), + ]; + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): ilObject + { + if ($type !== ilObject::class && !in_array(ilObject::class, class_parents($type))) { + throw new NormalizingException("Invalid type for ilObject: {$type}"); + } + + // Validate the class of the object by its type field + $object_type = $this->tt->string($value['type']); + $object_class = ilObjectFactory::getClassByType($object_type); + if ($object_class !== $type) { + throw new NormalizingException("Expected {$type}, got object of type {$object_type} ({$object_class})"); + } + + // Create new object instance without id to avoid reading the object from the database + $object = new $object_class(0, false); + + $object->setId($this->tt->denormalize($value['obj_id'], Id::class)->getId()); + $object->setTitle($this->tt->string($value['title'])); + $object->setDescription($this->tt->string($value['description'])); + $object->setType($this->tt->string($value['type'])); + $object->setOwner($this->tt->int($value['owner'])); + $object->setImportId($this->tt->string($value['import_id'])); + + // TODO: Properties + + return $object; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/Legacy11UUIDNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/Legacy11UUIDNormalizer.php new file mode 100644 index 000000000000..01c61026d6ac --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/Legacy11UUIDNormalizer.php @@ -0,0 +1,57 @@ + + */ + private array $type_map = []; + + /** + * Register a normalizer resolving callable for a type. The callable allows to defer the instantiation of the + * normalizer until it is actually needed. + * + * @param class-string $type + * @param callable():Normalizer $normalizer + * + * @throws NormalizingException if the type is already registered + */ + public function registerNormalizer(string $type, callable $normalizer): void + { + if (isset($this->type_map[$type])) { + throw new NormalizingException("Type {$type} is already registered"); + } + $this->type_map[$type] = $normalizer; + } + + /** + * Check if a normalizer is registered for a type. + * + * @param class-string $type + */ + public function hasNormalizer(string $type): bool + { + return isset($this->type_map[$type]); + } + + /** + * Return the normalizer that should handle the given type. Resolves to the most specific + * registered type (child classes / implementing classes before parents/interfaces). + * + * @param class-string $type + * @return Normalizer|null null if no normalizer supports this type + */ + public function getNormalizerFor(string $type): ?Normalizer + { + $candidates = $this->findCandidateTypes($type); + if ($candidates === []) { + return null; + } + $key = $this->selectMostSpecificType($type, $candidates); + + // Instantiate the normalizer on demand + if (is_callable($this->type_map[$key])) { + $this->type_map[$key] = $this->type_map[$key](); + } + + return $this->type_map[$key]; + } + + /** + * Types S from registry such that $type is assignable to S (same class or subclass/implementation). + * + * @param class-string $type + * @return list + */ + private function findCandidateTypes(string $type): array + { + $candidates = []; + foreach (array_keys($this->type_map) as $registeredType) { + if ($type === $registeredType || is_subclass_of($type, $registeredType)) { + $candidates[] = $registeredType; + } + } + + return $candidates; + } + + /** + * Among candidate types (all are assignable from $type), return the most specific one + * (the one closest to $type: child classes before parent classes/interfaces). + * + * @param class-string $type + * @param list $candidate_types + * @return class-string + */ + private function selectMostSpecificType(string $type, array $candidate_types): string + { + if (count($candidate_types) === 1) { + return $candidate_types[0]; + } + + usort($candidate_types, function (string $a, string $b): int { + if ($a === $b) { + return 0; + } + if (is_subclass_of($a, $b)) { + return -1; + } + if (is_subclass_of($b, $a)) { + return 1; + } + return 0; + }); + + return $candidate_types[0]; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/TransformationNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/TransformationNormalizer.php new file mode 100644 index 000000000000..1f55670c6238 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/TransformationNormalizer.php @@ -0,0 +1,63 @@ + + */ +#[Normalizes(Transformation::class)] +class TransformationNormalizer implements Normalizer +{ + private readonly Refinery $refinery; + + public function __construct( + Container $dic + ) { + $this->refinery = $dic->refinery(); + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if ($value instanceof Transformation) { + return $value->transform([]); + } + + throw new NormalizingException('Invalid transformation value', $value); + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): Transformation + { + return $this->refinery->custom()->transformation(fn() => $value); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/UUIDNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/UUIDNormalizer.php new file mode 100644 index 000000000000..a0ef76fd1bfe --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/UUIDNormalizer.php @@ -0,0 +1,61 @@ + + */ +#[Normalizes(Uuid::class)] +class UUIDNormalizer implements Normalizer +{ + private readonly Factory $factory; + + public function __construct() + { + $this->factory = new Factory(); + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if ($value instanceof Uuid) { + return $value->toString(); + } + + throw new NormalizingException('Invalid UUID value', $value); + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): Uuid + { + return $this->factory->fromString((string) $value); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/NormalizingException.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/NormalizingException.php new file mode 100644 index 000000000000..ebf6c5344d12 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/NormalizingException.php @@ -0,0 +1,37 @@ +result = $result; + return $this; + } + + /** + * Get the result of the denormalization carry. If no result is set, an exception will be thrown. + * + * @throws NormalizingException if the result is not set + */ + public function result(): mixed + { + if (!isset($this->result)) { + $expected_type = get_debug_type($this->expected); + $normalized_type = get_debug_type($this->normalized); + throw new NormalizingException("Unsupported value, expected: {$expected_type}, got: {$normalized_type}"); + } + return $this->result; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/DenormalizingPipe.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/DenormalizingPipe.php new file mode 100644 index 000000000000..233b8b825bf2 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/DenormalizingPipe.php @@ -0,0 +1,62 @@ + + */ +class DenormalizingPipe implements Pipe +{ + public function __construct( + private readonly Registry $registry + ) { + } + + public function handle(mixed $passable, \Closure $next): mixed + { + if (!$passable instanceof DenormalizeCarry) { + return $next($passable); + } + + // Check if normalizer for the expected type is registered. + if (is_string($passable->expected) && $normalizer = $this->registry->getNormalizerFor($passable->expected)) { + return $next( + $passable->setResult($normalizer->denormalize($passable->normalized, $passable->expected)) + ); + } + + // Use the fromNormalized method of the expected object to set the state of the object from the normalized form. + if ($passable->expected instanceof Normalizable) { + return $next( + $passable->setResult($passable->expected->fromNormalized($passable->transformations)->transform($passable->normalized)) + ); + } + + return $next($passable); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/FinalizeNormalizing.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/FinalizeNormalizing.php new file mode 100644 index 000000000000..4964f65e3bd1 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/FinalizeNormalizing.php @@ -0,0 +1,56 @@ +ensureNormalized($passable->result()); + return $next($passable); + } + + private function ensureNormalized(mixed $value): mixed + { + if (is_scalar($value) || $value === null) { + return $value; + } + + if (is_array($value)) { + return array_map($this->ensureNormalized(...), $value); + } + + throw new NormalizingException('Value is not normalized: ' . get_debug_type($value)); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/IdMappingPipe.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/IdMappingPipe.php new file mode 100644 index 000000000000..138c4d6533bc --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/IdMappingPipe.php @@ -0,0 +1,83 @@ + + */ +class IdMappingPipe implements Pipe +{ + private array $unresolved = []; + + public function __construct( + private readonly ilImportMapping $mapping, + private readonly string $component + ) { + } + + public function handle(mixed $passable, \Closure $next): mixed + { + if (!$passable instanceof DenormalizeCarry || $passable->expected !== Id::class) { + return $next($passable); + } + + $envelope = $passable->result(); + if (!$envelope instanceof Id) { + throw new NormalizingException('Expected id envelope, got ' . get_debug_type($envelope)); + } + + if ($new_id = $this->mapping->getMapping($this->component, $envelope->getObject(), (string) $envelope->getId())) { + // Replace the envelope with the mapped new id + if (is_int($envelope->getId())) { + $new_id = (int) $new_id; + } + + $passable->setResult(new Id($new_id, $envelope->getObject())); + } else { + $this->unresolved[] = $envelope; + } + + return $next($passable); + } + + /** + * @return list + */ + public function unresolved(): array + { + return $this->unresolved; + } + + public function mapping(): ilImportMapping + { + return $this->mapping; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/NormalizeCarry.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/NormalizeCarry.php new file mode 100644 index 000000000000..27cebb51fdad --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/NormalizeCarry.php @@ -0,0 +1,45 @@ +result = $result; + return $this; + } + + /** + * Get the result of the normalization carry. If no result is set, an exception will be thrown. + * + * @throws NormalizingException if the result is not set + */ + public function result(): array|float|bool|int|string|null + { + if ($this->result === null) { + throw new NormalizingException('Unsupported value', $this->value); + } + return $this->result; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/NormalizingPipe.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/NormalizingPipe.php new file mode 100644 index 000000000000..fa255289d8c9 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/NormalizingPipe.php @@ -0,0 +1,66 @@ + + */ +class NormalizingPipe implements Pipe +{ + public function __construct( + private readonly Registry $registry + ) { + } + + public function handle(mixed $passable, \Closure $next): mixed + { + if (!$passable instanceof NormalizeCarry) { + return $next($passable); + } + + if (is_scalar($passable->value)) { + return $next($passable->setResult($passable->value)); + } + + // Check if object is self-normalizable and use the toNormalized method + if ($passable->value instanceof Normalizable) { + $normalized = $passable->value->toNormalized($passable->transformations)->transform($passable->context); + + return $next($passable->setResult($normalized)); + } + + // Lookup normalizer for the object type and use the normalize method + if ($normalizer = $this->registry->getNormalizerFor(get_class($passable->value))) { + return $next( + $passable->setResult($normalizer->normalize($passable->value)) + ); + } + + return $next($passable); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Transformations.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Transformations.php new file mode 100644 index 000000000000..0cc194358ad5 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Transformations.php @@ -0,0 +1,151 @@ + $this->normalize($value, $context), $value); + } + + return $this->pipeline->send(new NormalizeCarry($this, $value, $context)) + ->then(fn(NormalizeCarry $carry) => $carry->result()); + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $normalized, string|object $expected): mixed + { + if ($normalized === null) { + return null; + } + + return $this->pipeline->send(new DenormalizeCarry($this, $normalized, $expected)) + ->then(fn(DenormalizeCarry $carry) => $carry->result()); + } + + /* + Transformations + */ + + /** + * @inheritDoc + */ + public function context(string $pipe_class): Pipe + { + foreach ($this->pipeline->pipes() as $pipe) { + if ($pipe instanceof $pipe_class) { + return $pipe; + } + } + throw new InvalidArgumentException("Pipe {$pipe_class} not found"); + } + + public function custom(): Group + { + return $this->refinery->custom(); + } + + /** + * @throws InvalidArgumentException if the value cannot be transformed into an integer + */ + public function int(mixed $value): int + { + return $this->refinery->kindlyTo()->int()->transform($value); + } + + /** + * @throws InvalidArgumentException if the value cannot be transformed into a float + */ + public function float(mixed $value): float + { + return $this->refinery->kindlyTo()->float()->transform($value); + } + + /** + * @throws InvalidArgumentException if the value cannot be transformed into a string + */ + public function string(mixed $value): string + { + return $this->refinery->kindlyTo()->string()->transform($value); + } + + /** + * @throws InvalidArgumentException if the value cannot be transformed into a boolean + */ + public function bool(mixed $value): bool + { + return $this->refinery->kindlyTo()->bool()->transform($value); + } + + public function nullableInt(mixed $value): ?int + { + return $this->refinery->byTrying([ + $this->refinery->kindlyTo()->int(), + $this->refinery->always(null) + ])->transform($value); + } + + public function nullableFloat(mixed $value): ?float + { + return $this->refinery->byTrying([ + $this->refinery->kindlyTo()->float(), + $this->refinery->always(null) + ])->transform($value); + } + + public function nullableString(mixed $value): ?string + { + return $this->refinery->byTrying([ + $this->refinery->kindlyTo()->string(), + $this->refinery->always(null) + ])->transform($value); + } + + public function nullableBool(mixed $value): ?bool + { + return $this->refinery->byTrying([ + $this->refinery->kindlyTo()->bool(), + $this->refinery->always(null) + ])->transform($value); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Pipeline.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Pipeline.php new file mode 100644 index 000000000000..fe32b10d38eb --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Pipeline.php @@ -0,0 +1,200 @@ + + * + * @phpstan-type PipeParam \Closure(TPassable, PipeParam)|Pipe: TPassable + */ +class Pipeline implements PipelineContract +{ + /** + * The object being passed through the pipeline. + * + * @var TPassable $passable + */ + protected mixed $passable; + + /** + * The array of class pipes. + * + * @var list $pipes + */ + protected array $pipes = []; + + /** + * The final callback to be executed after the pipeline ends regardless of the outcome. + */ + protected ?Closure $finally = null; + + + /** + * @inheritDoc + */ + public function send(mixed $passable): self + { + $this->passable = $passable; + return $this; + } + + /** + * @inheritDoc + */ + public function through(array $pipes): self + { + $this->pipes = $pipes; + return $this; + } + + /** + * @inheritDoc + */ + public function pipe(Closure|Pipe $pipe): self + { + $this->pipes[] = $pipe; + return $this; + } + + /** + * @inheritDoc + */ + public function pipeWhen(Closure $condition, Closure|Pipe $pipe): self + { + return $this->pipe(fn($passable, $next) => $condition($passable) + ? $this->executePipe($pipe, $passable, $next) + : $next($passable)); + } + + /** + * @inheritDoc + */ + public function pipeUnless(Closure $condition, Closure|Pipe $pipe): self + { + return $this->pipeWhen(fn($passable) => !$condition($passable), $pipe); + } + + /** + * @inheritDoc + */ + public function then(Closure $destination): mixed + { + $pipeline = array_reduce( + array_reverse($this->pipes), + $this->carry(), + $this->prepareDestination($destination) + ); + + try { + return $pipeline($this->passable); + } finally { + if ($this->finally) { + ($this->finally)($this->passable); + } + } + } + + /** + * @inheritDoc + */ + public function thenReturn(): mixed + { + return $this->then(fn($passable) => $passable); + } + + /** + * @inheritDoc + */ + public function finally(Closure $callback): self + { + $this->finally = $callback; + return $this; + } + + /** + * @inheritDoc + */ + public function pipes(): array + { + return $this->pipes; + } + + /** + * Get the final piece of the Closure onion. + */ + protected function prepareDestination(Closure $destination) + { + return function ($passable) use ($destination) { + try { + return $destination($passable); + } catch (Throwable $e) { + return $this->handleException($passable, $e); + } + }; + } + + /** + * Get a Closure that represents a slice of the application onion. + */ + protected function carry() + { + return fn($stack, $pipe) => function ($passable) use ($stack, $pipe) { + try { + return $this->executePipe($pipe, $passable, $stack); + } catch (Throwable $e) { + return $this->handleException($passable, $e); + } + }; + } + + /** + * Execute a single pipe, handling both Closure and Pipe instances. + * Pipe instances are skipped when their skip() method returns true. + */ + protected function executePipe(Closure|Pipe $pipe, mixed $passable, Closure $next): mixed + { + if ($pipe instanceof Pipe) { + return $pipe->handle($passable, $next); + } + + if (is_callable($pipe)) { + return $pipe($passable, $next); + } + + throw new \InvalidArgumentException('Invalid pipe'); + } + + /** + * Handle the given exception. + * + * @throws \Throwable + */ + protected function handleException(mixed $passable, Throwable $e): void + { + throw $e; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/JSONMemoryDeserializer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/JSONMemoryDeserializer.php new file mode 100644 index 000000000000..3ea8e3904b3b --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/JSONMemoryDeserializer.php @@ -0,0 +1,70 @@ + $handler */ + private array $handler = []; + + /** + * @inheritDoc + */ + public function open(string $json): static + { + $clone = clone $this; + $clone->decoded = json_decode($json, true); + return $clone; + } + + /** + * @inheritDoc + */ + public function addHandler(string $group, callable $handler): void + { + $this->handler[$group] = $handler; + } + + /** + * @inheritDoc + */ + public function process(): void + { + foreach ($this->decoded as $key => $value) { + if (is_array($value)) { + $head = $value[array_key_first($value)]; + $value = (!array_is_list($head)) ? [$head] : $head; + } + + if (isset($this->handler[$key])) { + $this->handler[$key]($value); + } + } + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/JSONMemorySerializer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/JSONMemorySerializer.php new file mode 100644 index 000000000000..dcf935c8dfd5 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/JSONMemorySerializer.php @@ -0,0 +1,113 @@ + Stack of for nested structure + */ + private array $stack = []; + + /** + * @inheritDoc + */ + public function open(string $path): static + { + $clone = clone $this; + $clone->stack = [ + ['name' => '', 'data' => []], + ]; + return $clone; + } + + /** + * @inheritDoc + */ + public function startGroup(string $name): void + { + $this->stack[] = ['name' => $name, 'data' => []]; + } + + /** + * @inheritDoc + */ + public function endGroup(string $name): void + { + $frame = array_pop($this->stack); + if ($frame['name'] !== $name) { + throw new \LogicException( + "Group name mismatch: expected end of '{$frame['name']}', got '{$name}'" + ); + } + $top = &$this->stack[array_key_last($this->stack)]; + $top['data'][$name] = $frame['data']; + } + + /** + * @inheritDoc + */ + public function group(string $name, callable $callback): void + { + $this->startGroup($name); + $callback(); + $this->endGroup($name); + } + + /** + * @inheritDoc + */ + public function append(string $name, array $data): void + { + $top = &$this->stack[array_key_last($this->stack)]; + if (array_key_exists($name, $top['data'])) { + $existing = $top['data'][$name]; + if (is_array($existing) && array_is_list($existing)) { + $top['data'][$name][] = $data; + } else { + $top['data'][$name] = [$existing, $data]; + } + } else { + $top['data'][$name] = $data; + } + } + + /** + * @inheritDoc + */ + public function write(): string + { + if ($this->stack === []) { + return '{}'; + } + $root = $this->stack[0]['data']; + $json = json_encode($root, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + $this->stack = [ + ['name' => '', 'data' => []], + ]; + return $json; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/SimpleXMLDeserializer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/SimpleXMLDeserializer.php new file mode 100644 index 000000000000..7e93b5eac22b --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/SimpleXMLDeserializer.php @@ -0,0 +1,246 @@ + $handler */ + private array $handler = []; + + /** + * @inheritDoc + */ + public function open(string $path): static + { + $clone = clone $this; + $clone->xml = $path; + return $clone; + } + + /** + * @inheritDoc + */ + public function addHandler(string $group, callable $handler): void + { + $this->handler[$group] = $handler; + } + + /** + * @inheritDoc + */ + public function process(): void + { + $reader = new \XMLReader(); + $xml = $this->prepareXmlInput($this->xml); + + if (!$reader->XML($xml, null, LIBXML_NONET)) { + throw new \RuntimeException('Unable to read XML input.'); + } + + while ($reader->read()) { + $node_name = $this->kebabToSnake($reader->name); + + if ($reader->nodeType !== \XMLReader::ELEMENT || !isset($this->handler[$node_name])) { + continue; + } + + $group_data = $this->readGroup($reader); + $this->handler[$node_name]($group_data); + } + + $reader->close(); + } + + /** + * @return list + */ + private function readGroup(\XMLReader $reader): array + { + if ($reader->isEmptyElement) { + return []; + } + + // Track the current group boundary so we can stop exactly at its closing tag + $group_depth = $reader->depth; + $group_name = $reader->name; + $group_data = []; + + while ($reader->read()) { + if ( + $reader->nodeType === \XMLReader::END_ELEMENT + && $reader->depth === $group_depth + && $reader->name === $group_name + ) { + break; + } + + if ( + $reader->nodeType !== \XMLReader::ELEMENT + || $reader->depth !== $group_depth + 1 + ) { + // Only direct children belong to this group entry list + continue; + } + + $group_data[] = $this->readElementValue($reader); + } + + return $group_data; + } + + private function readElementValue(\XMLReader $reader): mixed + { + $is_marked_empty_array = $this->isMarkedEmptyArray($reader); + + // Keep empty elements as empty strings to preserve the legacy XML shape + if ($reader->isEmptyElement) { + if ($is_marked_empty_array) { + return []; + } + return ''; + } + + $element_depth = $reader->depth; + $element_name = $reader->name; + $children = []; + $text_content = ''; + + while ($reader->read()) { + if ( + $reader->nodeType === \XMLReader::END_ELEMENT + && $reader->depth === $element_depth + && $reader->name === $element_name + ) { + break; + } + + if ( + $reader->nodeType === \XMLReader::ELEMENT + && $reader->depth === $element_depth + 1 + ) { + // Direct child elements are deserialized recursively + $child_key = $this->resolveElementKey($reader); + $child_value = $this->readElementValue($reader); + + if ($child_key === null) { + // Unkeyed nodes are appended as list entries + $children[] = $child_value; + continue; + } + + // Repeated keys are normalized to list values via appendValue() + $this->appendValue($children, $child_key, $child_value); + continue; + } + + if ( + $reader->depth === $element_depth + 1 + && in_array( + $reader->nodeType, + [ + \XMLReader::TEXT, + \XMLReader::CDATA, + \XMLReader::SIGNIFICANT_WHITESPACE + ], + true + ) + ) { + // Keep raw text content and decode scalar tokens after traversal + $text_content .= $reader->value; + } + } + + if ($children !== []) { + // Structured child data has precedence over accumulated text content + return $children; + } + + if ($is_marked_empty_array && trim($text_content) === '') { + return []; + } + + return $this->decodeScalarValue($text_content); + } + + private function isMarkedEmptyArray(\XMLReader $reader): bool + { + return $reader->getAttribute('type') === 'empty-array'; + } + + private function resolveElementKey(\XMLReader $reader): ?string + { + // Regular element names map directly to associative keys + if ($reader->name !== 'item') { + return $this->kebabToSnake($reader->name); + } + + // nodes are list-like unless they define an explicit key attribute + $raw_key = $reader->getAttribute('key'); + if ($raw_key === null || $raw_key === '') { + return null; + } + + // Normalize kebab-case keys to snake_case for downstream consumers + return $this->kebabToSnake($raw_key); + } + + /** + * @param array $target + * @param mixed $value + */ + 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); + } + + private function prepareXmlInput(string $xml): string + { + $xml = preg_replace('/^\s*<\?xml[^>]*\?>\s*/i', '', trim($xml)) ?? trim($xml); + return "{$xml}"; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/SimpleXMLSerializer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/SimpleXMLSerializer.php new file mode 100644 index 000000000000..70c73271663c --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/SimpleXMLSerializer.php @@ -0,0 +1,186 @@ +writer = new \XMLWriter(); + } + + /** + * @inheritDoc + */ + public function open(string $path): static + { + $clone = clone $this; + $clone->writer->openMemory(); + $clone->writer->setIndent(true); + return $clone; + } + + /** + * Start a new xml document in the current writer. if a document has already been started, an exception will be + * thrown. + * + * @throws \LogicException if a document has already been started + */ + private function createDocument(string $comment): void + { + if ($this->has_document) { + throw new \LogicException('XML document already started'); + } + + $this->writer->startDocument('1.0', 'UTF-8'); + $this->writer->writeComment($comment); + $this->has_document = true; + } + + + /** + * @inheritDoc + */ + public function startGroup(string $name): void + { + $this->current_group = $name; + $this->writer->startElement($this->formatName($name)); + } + + /** + * @inheritDoc + */ + public function endGroup(string $name): void + { + if ($this->current_group !== $name) { + throw new \LogicException( + "Group name mismatch: expected end of '{$this->current_group}', got '{$name}'" + ); + } + + $this->current_group = ''; + $this->writer->endElement(); + } + + /** + * @inheritDoc + */ + public function group(string $name, callable $callback): void + { + $this->startGroup($name); + $callback(); + $this->endGroup($name); + } + + /** + * @inheritDoc + */ + public function append(string $name, array $data): void + { + $this->writer->startElement($this->formatName($name)); + $this->appendRecursive($data); + $this->writer->endElement(); + } + + /** + * @inheritDoc + */ + public function write(): string + { + if ($this->has_document) { + $this->writer->endDocument(); + } + + return $this->writer->outputMemory(true); + } + + /** + * @param array $data + */ + private function appendRecursive(array $data): void + { + foreach ($data as $key => $value) { + $is_nested = is_array($value); + $formatted_key = $this->formatName($key); + + if ($this->shouldUseItemElement($key, $formatted_key)) { + $this->writer->startElement('item'); + + if (!array_is_list($data)) { + $this->writer->writeAttribute('key', (string) $key); + } + } else { + $this->writer->startElement($formatted_key); + } + + if (!$is_nested) { + $value = match (gettype($value)) { + 'NULL' => 'NULL', + 'integer' => (string) $value, + 'float' => (string) $value, + 'boolean' => $value ? '1' : '0', + default => htmlspecialchars((string) $value), + }; + + $this->writer->writeRaw($value); + } else { + if (count($value) === 0) { + $this->writer->writeAttribute('type', 'empty-array'); + } + $this->appendRecursive($value); + } + + $this->writer->endElement(); + } + } + + private function shouldUseItemElement(int|string $key, string $formatted_key): bool + { + if (is_numeric($key) || str_contains((string) $key, '-') || $key === '') { + return true; + } + + return !$this->isValidXmlElementName($formatted_key); + } + + private function isValidXmlElementName(string $name): bool + { + return $name !== '' && preg_match('/^[A-Za-z_][A-Za-z0-9._-]*$/', $name) === 1; + } + + private function formatName(int|string $name): string + { + // Transform key to kebab-case + $output = strtolower(preg_replace('/(?getMatchingClassNames(Normalizer::class) as $class_name) { + $ref = new ReflectionClass($class_name); + + $attrs = $ref->getAttributes(Normalizes::class); + foreach ($attrs as $attr) { + $instance = $attr->newInstance(); + foreach ($instance->types as $type) { + $type_map[$type][self::DEFAULT_KEY] = $class_name; + } + } + + $attrs = $ref->getAttributes(NormalizesLegacy::class); + foreach ($attrs as $attr) { + $instance = $attr->newInstance(); + foreach ($instance->types as $type) { + $type_map[$type][$instance->version] = $class_name; + } + } + } + + return new ArrayArtifact($type_map); + } +} From bbc0008e1a1498a69fb286e340722f61ce8f6caa Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Thu, 9 Apr 2026 12:43:17 +0200 Subject: [PATCH 02/43] feat(qpl): implement normalizing/denormalizing in question pool component --- .../classes/class.assAnswerBinaryState.php | 26 ++++ .../class.assAnswerBinaryStateImage.php | 33 +++- .../classes/class.assAnswerCloze.php | 30 ++++ .../classes/class.assAnswerErrorText.php | 32 +++- .../classes/class.assAnswerImagemap.php | 30 ++++ .../classes/class.assAnswerMatching.php | 34 +++- .../classes/class.assAnswerMatchingPair.php | 30 +++- .../classes/class.assAnswerMatchingTerm.php | 33 +++- .../class.assAnswerMultipleResponse.php | 26 ++++ .../class.assAnswerMultipleResponseImage.php | 29 ++++ .../classes/class.assAnswerSimple.php | 33 +++- .../classes/class.assAnswerTrueFalse.php | 26 ++++ .../classes/class.assClozeGap.php | 36 ++++- .../classes/class.assClozeTest.php | 47 +++++- .../classes/class.assErrorText.php | 38 ++++- .../classes/class.assFileUpload.php | 33 +++- .../classes/class.assFormulaQuestion.php | 40 ++++- .../class.assFormulaQuestionResult.php | 58 ++++++- .../classes/class.assFormulaQuestionUnit.php | 42 ++++- .../class.assFormulaQuestionUnitCategory.php | 34 +++- .../class.assFormulaQuestionVariable.php | 43 +++++- .../classes/class.assImagemapQuestion.php | 37 ++++- .../classes/class.assKprimChoice.php | 46 +++++- .../classes/class.assLongMenu.php | 39 ++++- .../classes/class.assMatchingQuestion.php | 45 +++++- .../classes/class.assMultipleChoice.php | 37 ++++- .../classes/class.assNumeric.php | 33 +++- .../classes/class.assOrderingHorizontal.php | 35 ++++- .../classes/class.assOrderingQuestion.php | 34 +++- .../classes/class.assQuestion.php | 62 ++++++++ .../classes/class.assSingleChoice.php | 36 ++++- .../classes/class.assTextQuestion.php | 41 ++++- .../classes/class.assTextSubset.php | 35 ++++- .../classes/class.ilAssKprimChoiceAnswer.php | 33 +++- .../questions/class.ilAssOrderingElement.php | 42 ++++- .../class.ilAssQuestionLifecycle.php | 26 +++- .../SuggestedSolutionNormalizer.php | 139 +++++++++++++++++ ...ilAssQuestionSkillAssignmentNormalizer.php | 145 ++++++++++++++++++ .../ilObjQuestionPoolNormalizer.php | 65 ++++++++ 39 files changed, 1632 insertions(+), 31 deletions(-) create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/SuggestedSolutionNormalizer.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/ilAssQuestionSkillAssignmentNormalizer.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/ilObjQuestionPoolNormalizer.php diff --git a/components/ILIAS/TestQuestionPool/classes/class.assAnswerBinaryState.php b/components/ILIAS/TestQuestionPool/classes/class.assAnswerBinaryState.php index dde732f7ecb9..1c44eb1ed9cf 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerBinaryState.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerBinaryState.php @@ -16,6 +16,9 @@ * *********************************************************************/ +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; + /** * Class for true/false or yes/no answers * @@ -180,4 +183,27 @@ public function setUnchecked(): void { $this->checked = false; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'checked' => $this->checked, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->setState($tt->bool($normalized['checked'])); + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assAnswerBinaryStateImage.php b/components/ILIAS/TestQuestionPool/classes/class.assAnswerBinaryStateImage.php index f2f19ca3afa4..999ba3865d36 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerBinaryStateImage.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerBinaryStateImage.php @@ -18,6 +18,10 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; +use ILIAS\TestQuestionPool\ExportImport\Envelopes\QuestionImage; + /** * Class for answers with a binary state indicator * @@ -43,11 +47,11 @@ class ASS_AnswerBinaryStateImage extends ASS_AnswerBinaryState * @param string $answertext A string defining the answer text * @param double $points The number of points given for the selected answer * @param integer $order A nonnegative value representing a possible display or sort order - * @param integer $state A integer value indicating the state of the answer + * @param bool $state A integer value indicating the state of the answer * @param string $a_image The image filename * @param integer $id The database id of the answer */ - public function __construct($answertext = "", $points = 0.0, $order = 0, $state = false, ?string $a_image = null, int $id = -1) + public function __construct(string $answertext = "", float $points = 0.0, int $order = 0, bool $state = false, ?string $a_image = null, int $id = -1) { parent::__construct($answertext, (float) $points, $order, $state, $id); $this->setImage($a_image); @@ -70,4 +74,29 @@ public function hasImage(): bool { return $this->image !== null; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(array $context): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'image' => $this->image ? $tt->normalize( + new QuestionImage($this->image, $context['question_id'] ?? null) + ) : null, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->setImage($tt->denormalize($normalized['image'], QuestionImage::class)?->getFilename()); + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assAnswerCloze.php b/components/ILIAS/TestQuestionPool/classes/class.assAnswerCloze.php index 1b6915fd607b..04f4bd4381d7 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerCloze.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerCloze.php @@ -16,6 +16,9 @@ * *********************************************************************/ +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; + /** * Class for cloze question numeric answers * @@ -150,4 +153,31 @@ public function getGapSize(): int { return $this->gap_size; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'lower_bound' => $this->lowerBound, + 'upper_bound' => $this->upperBound, + 'gap_size' => $this->gap_size, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->lowerBound = $tt->nullableString($normalized['lower_bound']); + $clone->upperBound = $tt->nullableString($normalized['upper_bound']); + $clone->gap_size = $tt->int($normalized['gap_size']); + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assAnswerErrorText.php b/components/ILIAS/TestQuestionPool/classes/class.assAnswerErrorText.php index 1736454b78ca..0567ce2925a4 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerErrorText.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerErrorText.php @@ -16,6 +16,10 @@ * *********************************************************************/ +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; + /** * Class for error text answers * @@ -24,7 +28,7 @@ * * @ingroup components\ILIASTestQuestionPool */ -class assAnswerErrorText +class assAnswerErrorText implements Normalizable { protected string $text_wrong; protected string $text_correct; @@ -94,4 +98,30 @@ public function getLength(): int { return $this->length; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + 'text_wrong' => $this->text_wrong, + 'text_correct' => $this->text_correct, + 'points' => $this->points, + 'position' => $this->position, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(array $normalized): self => new self( + $tt->string($normalized['text_wrong']), + $tt->string($normalized['text_correct']), + $tt->float($normalized['points']), + $tt->int($normalized['position']) + )); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assAnswerImagemap.php b/components/ILIAS/TestQuestionPool/classes/class.assAnswerImagemap.php index 7356acba7457..b3b69c42d554 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerImagemap.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerImagemap.php @@ -16,6 +16,9 @@ * *********************************************************************/ +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; + /** * Class for true/false or yes/no answers * @@ -142,4 +145,31 @@ public function setPointsUnchecked($points_unchecked): void $this->points_unchecked = 0.0; } } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'coords' => $this->coords, + 'area' => $this->area, + 'points_unchecked' => $this->points_unchecked, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->setCoords($tt->string($normalized['coords'])); + $clone->setArea($tt->string($normalized['area'])); + $clone->setPointsUnchecked($tt->float($normalized['points_unchecked'])); + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assAnswerMatching.php b/components/ILIAS/TestQuestionPool/classes/class.assAnswerMatching.php index ce7292c05eba..71137aac7763 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerMatching.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerMatching.php @@ -16,6 +16,10 @@ * *********************************************************************/ +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; + /** * Class for matching question answers * @@ -24,7 +28,7 @@ * @author Helmut Schottmüller * @ingroup components\ILIASTestQuestionPool */ -class ASS_AnswerMatching +class ASS_AnswerMatching implements Normalizable { public float $points; @@ -236,4 +240,32 @@ public function setPoints(float $points = 0.0): void { $this->points = $points; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + 'points' => $this->points, + 'picture_or_definition' => $this->picture_or_definition, + 'picture_or_definition_id' => $this->picture_or_definition_id, + 'term_id' => $this->term_id, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = clone $this; + $clone->setPoints($tt->float($normalized['points'])); + $clone->setPicture($tt->string($normalized['picture_or_definition'])); + $clone->setPictureId($tt->int($normalized['picture_or_definition_id'])); + $clone->setTermId($tt->int($normalized['term_id'])); + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assAnswerMatchingPair.php b/components/ILIAS/TestQuestionPool/classes/class.assAnswerMatchingPair.php index 4c628ee66d7e..9601aabf9026 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerMatchingPair.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerMatchingPair.php @@ -18,13 +18,17 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; + /** * Class for matching question pairs * * @author Helmut Schottmüller * @ingroup components\ILIASTestQuestionPool */ -class assAnswerMatchingPair +class assAnswerMatchingPair implements Normalizable { protected assAnswerMatchingTerm $term; protected assAnswerMatchingDefinition $definition; @@ -72,4 +76,28 @@ public function withPoints(float $points): self $clone->points = $points; return $clone; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(array $context): array => [ + 'points' => $this->points, + 'term' => $tt->normalize($this->term, $context), + 'definition' => $tt->normalize($this->definition, $context), + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + return $this->withPoints($tt->float($normalized['points'])) + ->withTerm($tt->denormalize($normalized['term'], $this->term)) + ->withDefinition($tt->denormalize($normalized['definition'], $this->definition)); + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assAnswerMatchingTerm.php b/components/ILIAS/TestQuestionPool/classes/class.assAnswerMatchingTerm.php index b2500c8e93d6..c2a986f71671 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerMatchingTerm.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerMatchingTerm.php @@ -18,13 +18,18 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; +use ILIAS\TestQuestionPool\ExportImport\Envelopes\QuestionImage; + /** * Class for matching question terms * * @author Helmut Schottmüller * @ingroup components\ILIASTestQuestionPool */ -class assAnswerMatchingTerm +class assAnswerMatchingTerm implements Normalizable { protected string $text; protected string $picture; @@ -76,4 +81,30 @@ public function withIdentifier(int $identifier): self $clone->identifier = $identifier; return $clone; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(array $context): array => [ + 'text' => $this->text, + 'picture' => $this->picture ? $tt->normalize( + new QuestionImage($this->picture, $context['question_id'] ?? null) + ) : null, + 'identifier' => $this->identifier, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + return $this->withText($tt->string($normalized['text'])) + ->withPicture($tt->denormalize($normalized['picture'], QuestionImage::class)?->getFilename() ?? '') + ->withIdentifier($tt->int($normalized['identifier'])); + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assAnswerMultipleResponse.php b/components/ILIAS/TestQuestionPool/classes/class.assAnswerMultipleResponse.php index ace7464d312e..331fd04594e2 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerMultipleResponse.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerMultipleResponse.php @@ -16,6 +16,9 @@ * *********************************************************************/ +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; + /** * Class for true/false or yes/no answers * @@ -97,4 +100,27 @@ public function getPointsChecked(): float { return $this->getPoints(); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'points_unchecked' => $this->points_unchecked, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->points_unchecked = $tt->nullableFloat($normalized['points_unchecked']); + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assAnswerMultipleResponseImage.php b/components/ILIAS/TestQuestionPool/classes/class.assAnswerMultipleResponseImage.php index 559c06ef0b49..c410a7e23463 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerMultipleResponseImage.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerMultipleResponseImage.php @@ -18,6 +18,10 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; +use ILIAS\TestQuestionPool\ExportImport\Envelopes\QuestionImage; + /** * ASS_AnswerBinaryStateImage is a class for answers with a binary state * indicator (checked/unchecked, set/unset) and an image file @@ -74,4 +78,29 @@ public function hasImage(): bool { return $this->image !== null; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(array $context): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'image' => $this->image ? $tt->normalize( + new QuestionImage($this->image, $context['question_id'] ?? null) + ) : null, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->setImage($tt->denormalize($normalized['image'], QuestionImage::class)?->getFilename()); + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assAnswerSimple.php b/components/ILIAS/TestQuestionPool/classes/class.assAnswerSimple.php index b6c6cff29fea..72b59cab0c11 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerSimple.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerSimple.php @@ -16,6 +16,11 @@ * *********************************************************************/ +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Envelopes\Id; +use ILIAS\Refinery\Transformation; + /** * Class for simple answers * @@ -26,7 +31,7 @@ * * @ingroup components\ILIASTestQuestionPool */ -class ASS_AnswerSimple +class ASS_AnswerSimple implements Normalizable { protected string $answertext; @@ -211,4 +216,30 @@ public function setPoints($points = 0.0): void $this->points = 0.0; } } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + 'id' => $tt->normalize(new Id($this->id, 'answer')), + 'answertext' => $this->answertext, + 'points' => $this->points, + 'order' => $this->order, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(array $normalized) => new static( + $tt->string($normalized['answertext']), + $tt->float($normalized['points']), + $tt->int($normalized['order']), + $tt->denormalize($normalized['id'], Id::class)->getId() + )); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assAnswerTrueFalse.php b/components/ILIAS/TestQuestionPool/classes/class.assAnswerTrueFalse.php index d366c5330d05..2ce73b3e9940 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assAnswerTrueFalse.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assAnswerTrueFalse.php @@ -16,6 +16,9 @@ * *********************************************************************/ +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; + /** * Class for true/false or yes/no answers * @@ -147,4 +150,27 @@ public function setFalse(): void { $this->correctness = "0"; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'correctness' => $this->correctness, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->setCorrectness($tt->string($normalized['correctness'])); + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assClozeGap.php b/components/ILIAS/TestQuestionPool/classes/class.assClozeGap.php index 89c5b8b22c80..1f12458585bb 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assClozeGap.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assClozeGap.php @@ -1,4 +1,5 @@ custom()->transformation(fn(): array => [ + 'type' => $this->type, + 'shuffle' => $this->shuffle, + 'gap_size' => $this->gap_size, + 'items' => $tt->normalize($this->items), + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = new self($normalized['type']); + $clone->setShuffle($normalized['shuffle']); + $clone->setGapSize($normalized['gap_size']); + $clone->items = array_map( + fn(array $item) => $tt->denormalize($item, new assAnswerCloze()), + $normalized['items'] + ); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assClozeTest.php b/components/ILIAS/TestQuestionPool/classes/class.assClozeTest.php index 34adaf3e51e6..d07959644629 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assClozeTest.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assClozeTest.php @@ -18,6 +18,9 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\TestQuestionPool\Questions\QuestionPartiallySaveable; @@ -35,7 +38,7 @@ * * @ingroup ModulesTestQuestionPool */ -class assClozeTest extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionPartiallySaveable, QuestionLMExportable, QuestionAutosaveable +class assClozeTest extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionPartiallySaveable, QuestionLMExportable, QuestionAutosaveable, Normalizable { /** * The gaps of the cloze question @@ -1727,4 +1730,46 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): arra } return $answers; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'feedback_mode' => $this->feedbackMode, + 'cloze_text' => $this->cloze_text, + 'textgap_rating' => $this->textgap_rating, + 'identical_scoring' => $this->identical_scoring, + 'fixed_text_length' => $this->fixed_text_length, + 'gaps' => $tt->normalize($this->gaps), + 'gap_combinations' => $this->gap_combinations, + 'gap_combinations_exist' => $this->gap_combinations_exist, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->feedbackMode = $tt->string($normalized['feedback_mode']); + $clone->cloze_text = $tt->string($normalized['cloze_text']); + $clone->textgap_rating = $tt->string($normalized['textgap_rating']); + $clone->identical_scoring = $tt->bool($normalized['identical_scoring']); + $clone->fixed_text_length = $tt->int($normalized['fixed_text_length']); + $clone->gap_combinations_exist = $tt->bool($normalized['gap_combinations_exist']); + $clone->gap_combinations = $normalized['gap_combinations']; + + foreach ($normalized['gaps'] as $gap) { + $type = $tt->int($gap['type']); + $clone->gaps[] = $tt->denormalize($gap, new assClozeGap($type)); + } + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assErrorText.php b/components/ILIAS/TestQuestionPool/classes/class.assErrorText.php index c3cc6ff3d1fc..b98d14cab935 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assErrorText.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assErrorText.php @@ -18,6 +18,9 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\Test\Logging\AdditionalInformationGenerator; @@ -34,7 +37,7 @@ * * @ingroup ModulesTestQuestionPool */ -class assErrorText extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionLMExportable, QuestionAutosaveable +class assErrorText extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionLMExportable, QuestionAutosaveable, Normalizable { protected const ERROR_TYPE_WORD = 1; protected const ERROR_TYPE_PASSAGE = 2; @@ -1000,4 +1003,37 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): stri { return $this->createErrorTextExport($this->getBestSelection()); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'errortext' => $this->errortext, + 'errortext_parsed' => $this->parsed_errortext, + 'errordata' => $tt->normalize($this->errordata), + 'points_wrong' => $this->points_wrong, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->errortext = $tt->string($normalized['errortext']); + $clone->parsed_errortext = $normalized['errortext_parsed']; + $clone->points_wrong = $tt->nullableFloat($normalized['points_wrong']); + $clone->errordata = array_map( + fn(array $error) => $tt->denormalize($error, new assAnswerErrorText()), + $normalized['errordata'] + ); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assFileUpload.php b/components/ILIAS/TestQuestionPool/classes/class.assFileUpload.php index 87fd4898d2ba..8bdffab786e8 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assFileUpload.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assFileUpload.php @@ -18,6 +18,9 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; use ILIAS\TestQuestionPool\QuestionPoolDIC; use ILIAS\Test\Participants\ParticipantRepository; use ILIAS\Test\Logging\AdditionalInformationGenerator; @@ -35,7 +38,7 @@ * * @ingroup ModulesTestQuestionPool */ -class assFileUpload extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjFileHandlingQuestionType +class assFileUpload extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjFileHandlingQuestionType, Normalizable { public const REUSE_FILES_TBL_POSTVAR = 'reusefiles'; public const DELETE_FILES_TBL_POSTVAR = 'deletefiles'; @@ -950,4 +953,32 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): stri { return ''; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'maxsize' => $this->maxsize, + 'allowedextensions' => $this->allowedextensions, + 'completion_by_submission' => $this->completion_by_submission, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->maxsize = $tt->nullableInt($normalized['maxsize']); + $clone->allowedextensions = $tt->string($normalized['allowedextensions']); + $clone->completion_by_submission = $tt->bool($normalized['completion_by_submission']); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestion.php b/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestion.php index 8f389d7462f5..6f86c8f55ca6 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestion.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestion.php @@ -18,7 +18,11 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; +use ILIAS\TestQuestionPool\QuestionPoolDIC; use ILIAS\Test\Logging\AdditionalInformationGenerator; /** @@ -28,7 +32,7 @@ * @version $Id: class.assFormulaQuestion.php 1236 2010-02-15 15:44:16Z hschottm $ * @ingroup components\ILIASTestQuestionPool */ -class assFormulaQuestion extends assQuestion implements iQuestionCondition, QuestionAutosaveable +class assFormulaQuestion extends assQuestion implements iQuestionCondition, QuestionAutosaveable, Normalizable { private array $variables; private array $results; @@ -1431,4 +1435,38 @@ function (string $v) use ($variables): string { array_keys($variables) ); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'variables' => $tt->normalize($this->variables), + 'results' => $tt->normalize($this->results) + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + + foreach ($normalized['variables'] as $key => $data) { + $dummy = new assFormulaQuestionVariable('', '', ''); + $clone->variables[$key] = $tt->denormalize($data, $dummy); + } + + foreach ($normalized['results'] as $key => $data) { + $dummy = new assFormulaQuestionResult('', '', '', 0, null, '', 0, 0); + $clone->results[$key] = $tt->denormalize($data, $dummy); + } + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionResult.php b/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionResult.php index d1bb686d78a1..2786e088e461 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionResult.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionResult.php @@ -16,7 +16,10 @@ * *********************************************************************/ +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\Refinery\Factory as Refinery; +use ILIAS\Refinery\Transformation; /** * Formula Question Result @@ -24,7 +27,7 @@ * @version $Id: class.assFormulaQuestionResult.php 944 2009-11-09 16:11:30Z hschottm $ * @ingroup components\ILIASTestQuestionPool * */ -class assFormulaQuestionResult +class assFormulaQuestionResult implements Normalizable { public const RESULT_NO_SELECTION = 0; public const RESULT_DEC = 1; @@ -846,4 +849,57 @@ public function getAvailableResultUnits($question_id): array return $this->available_units; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + 'available_units' => $tt->normalize($this->available_units), + 'range_min' => $this->range_min, + 'range_max' => $this->range_max, + 'range_min_txt' => $this->range_min_txt, + 'range_max_txt' => $this->range_max_txt, + 'result' => $this->result, + 'tolerance' => $this->tolerance, + 'unit' => $tt->normalize($this->unit), + 'formula' => $this->formula, + 'points' => $this->points, + 'precision' => $this->precision, + 'rating_simple' => $this->rating_simple, + 'rating_sign' => $this->rating_sign, + 'rating_value' => $this->rating_value, + 'rating_unit' => $this->rating_unit, + 'result_type' => $this->result_type, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = clone $this; + $clone->available_units = array_map(fn(array $unit) => $tt->denormalize($unit, new assFormulaQuestionUnit()), $normalized['available_units']); + $clone->range_min = $tt->float($normalized['range_min']); + $clone->range_max = $tt->float($normalized['range_max']); + $clone->range_min_txt = $tt->string($normalized['range_min_txt']); + $clone->range_max_txt = $tt->string($normalized['range_max_txt']); + $clone->result = $tt->string($normalized['result']); + $clone->tolerance = $tt->float($normalized['tolerance']); + $clone->unit = $tt->denormalize($normalized['unit'], new assFormulaQuestionUnit()); + $clone->formula = $tt->string($normalized['formula']); + $clone->points = $tt->float($normalized['points']); + $clone->precision = $tt->int($normalized['precision']); + $clone->rating_simple = $tt->bool($normalized['rating_simple']); + $clone->rating_sign = $tt->float($normalized['rating_sign']); + $clone->rating_value = $tt->float($normalized['rating_value']); + $clone->rating_unit = $tt->float($normalized['rating_unit']); + $clone->result_type = $tt->int($normalized['result_type']); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionUnit.php b/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionUnit.php index 959c8362192a..aecd91246e3f 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionUnit.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionUnit.php @@ -18,12 +18,17 @@ declare(strict_types=1); +use ILIAS\Refinery\Transformation; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Envelopes\Id; + /** * Formula Question Unit * @author Helmut Schottmüller * @ingroup components\ILIASTestQuestionPool */ -class assFormulaQuestionUnit +class assFormulaQuestionUnit implements Normalizable { private int $id = 0; private string $unit = ''; @@ -159,4 +164,39 @@ private function sanitizeString(string $string): string { return htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE, 'utf-8'); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + 'id' => $tt->normalize(new Id($this->id, 'unit')), + 'unit' => $this->unit, + 'factor' => $this->factor, + 'category_id' => $tt->normalize(new Id($this->category, 'unit_category')), + 'sequence' => $this->sequence, + 'baseunit' => $this->baseunit, + 'baseunit_title' => $this->baseunit_title, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = clone $this; + $clone->id = $tt->denormalize($normalized['id'], Id::class)->getId(); + $clone->unit = $tt->string($normalized['unit']); + $clone->factor = $tt->float($normalized['factor']); + $clone->category = $tt->denormalize($normalized['category_id'], Id::class)->getId(); + $clone->sequence = $tt->int($normalized['sequence']); + $clone->baseunit = $tt->int($normalized['baseunit']); + $clone->baseunit_title = $tt->string($normalized['baseunit_title']); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionUnitCategory.php b/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionUnitCategory.php index 963c0d7637e5..4ce48ecb7caf 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionUnitCategory.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionUnitCategory.php @@ -18,12 +18,17 @@ declare(strict_types=1); +use ILIAS\Refinery\Transformation; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Envelopes\Id; + /** * Formula Question Unit Category * @author Helmut Schottmüller * @ingroup components\ILIASTestQuestionPool */ -class assFormulaQuestionUnitCategory +class assFormulaQuestionUnitCategory implements Normalizable { private int $id = 0; private string $category = ''; @@ -86,4 +91,31 @@ private function sanitizeString(string $string): string { return htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE, 'utf-8'); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + 'id' => $tt->normalize(new Id($this->id, 'unit_category')), + 'name' => $this->category, + 'question_id' => $tt->normalize(new Id($this->question_fi, 'question')), + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = clone $this; + $clone->id = $tt->denormalize($normalized['id'], Id::class)->getId(); + $clone->category = $tt->string($normalized['name']); + $clone->question_fi = $tt->denormalize($normalized['question_id'], Id::class)->getId(); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionVariable.php b/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionVariable.php index b568847e9442..3469ca82afcd 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionVariable.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionVariable.php @@ -18,13 +18,17 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; + /** * Formula Question Variable * @author Helmut Schottmüller * @version $Id: class.assFormulaQuestionVariable.php 465 2009-06-29 08:27:36Z hschottm $ * @ingroup components\ILIASTestQuestionPool * */ -class assFormulaQuestionVariable +class assFormulaQuestionVariable implements Normalizable { private $value = null; private float $range_min; @@ -197,4 +201,41 @@ public function getRangeMinTxt(): string { return $this->range_min_txt; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + 'variable' => $this->variable, + 'range_min' => $this->range_min, + 'range_max' => $this->range_max, + 'range_min_txt' => $this->range_min_txt, + 'range_max_txt' => $this->range_max_txt, + 'unit' => $tt->normalize($this->unit), + 'precision' => $this->precision, + 'intprecision' => $this->intprecision, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = clone $this; + $clone->variable = $tt->string($normalized['variable']); + $clone->range_min = $tt->float($normalized['range_min']); + $clone->range_max = $tt->float($normalized['range_max']); + $clone->range_min_txt = $tt->string($normalized['range_min_txt']); + $clone->range_max_txt = $tt->string($normalized['range_max_txt']); + $clone->unit = $tt->denormalize($normalized['unit'], new assFormulaQuestionUnit()); + $clone->precision = $tt->int($normalized['precision']); + $clone->intprecision = $tt->int($normalized['intprecision']); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assImagemapQuestion.php b/components/ILIAS/TestQuestionPool/classes/class.assImagemapQuestion.php index 67e0de5a6874..5e37ed712f7d 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assImagemapQuestion.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assImagemapQuestion.php @@ -18,10 +18,14 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\TestQuestionPool\ExportImport\Envelopes\QuestionImage; use ILIAS\TestQuestionPool\QuestionPoolDIC; use ILIAS\TestQuestionPool\RequestDataCollector; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\Test\Logging\AdditionalInformationGenerator; +use ILIAS\Refinery\Transformation; /** * Class for image map questions @@ -36,7 +40,7 @@ * * @ingroup ModulesTestQuestionPool */ -class assImagemapQuestion extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionLMExportable +class assImagemapQuestion extends assQuestion implements ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionLMExportable, Normalizable { private RequestDataCollector $request; // Hate it. @@ -915,4 +919,35 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): arra $this->getAnswers() ); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'image' => $tt->normalize(new QuestionImage($this->image_filename, $this->getId())), + 'multiple_choice' => $this->is_multiple_choice, + 'answers' => $tt->normalize($this->answers), + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->image_filename = $tt->denormalize($normalized['image'], QuestionImage::class)->getFilename(); + $clone->is_multiple_choice = $tt->bool($normalized['multiple_choice']); + $clone->answers = array_map( + fn(array $answer) => $tt->denormalize($answer, new ASS_AnswerImagemap()), + $normalized['answers'] + ); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assKprimChoice.php b/components/ILIAS/TestQuestionPool/classes/class.assKprimChoice.php index c8356c52e5b6..d25f05d1b540 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assKprimChoice.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assKprimChoice.php @@ -18,10 +18,13 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\TestQuestionPool\ManipulateImagesInChoiceQuestionsTrait; use ILIAS\Test\Logging\AdditionalInformationGenerator; +use ILIAS\Refinery\Transformation; /** * @author Björn Heyser @@ -29,7 +32,7 @@ * * @package components\ILIAS/TestQuestionPool */ -class assKprimChoice extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, ilAssSpecificFeedbackOptionLabelProvider, QuestionLMExportable, QuestionAutosaveable +class assKprimChoice extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, ilAssSpecificFeedbackOptionLabelProvider, QuestionLMExportable, QuestionAutosaveable, Normalizable { use ManipulateImagesInChoiceQuestionsTrait; @@ -920,4 +923,45 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): arra $this->getAnswers() ); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'shuffle_answers' => $this->shuffle_answers_enabled, + 'answer_type' => $this->answerType, + 'option_label' => $this->option_label, + 'custom_true_option_label' => $this->customTrueOptionLabel, + 'custom_false_option_label' => $this->customFalseOptionLabel, + 'score_partial_solution' => $this->scorePartialSolutionEnabled, + 'specific_feedback_setting' => $this->specific_feedback_setting, + 'answers' => $tt->normalize($this->answers, ['question_id' => $this->getId()]), + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->shuffle_answers_enabled = $tt->bool($normalized['shuffle_answers']); + $clone->answerType = $tt->string($normalized['answer_type']); + $clone->option_label = $tt->string($normalized['option_label']); + $clone->customTrueOptionLabel = $tt->string($normalized['custom_true_option_label']); + $clone->customFalseOptionLabel = $tt->string($normalized['custom_false_option_label']); + $clone->scorePartialSolutionEnabled = $tt->bool($normalized['score_partial_solution']); + $clone->specific_feedback_setting = $tt->int($normalized['specific_feedback_setting']); + $clone->answers = array_map( + fn(array $answer) => $tt->denormalize($answer, new ilAssKprimChoiceAnswer()), + $normalized['answers'] + ); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assLongMenu.php b/components/ILIAS/TestQuestionPool/classes/class.assLongMenu.php index 81dde64d6bad..addc75f602c3 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assLongMenu.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assLongMenu.php @@ -18,11 +18,14 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\Test\Logging\AdditionalInformationGenerator; +use ILIAS\Refinery\Transformation; -class assLongMenu extends assQuestion implements ilObjQuestionScoringAdjustable, QuestionLMExportable, QuestionAutosaveable +class assLongMenu extends assQuestion implements ilObjQuestionScoringAdjustable, QuestionLMExportable, QuestionAutosaveable, Normalizable { public const ANSWER_TYPE_SELECT_VAL = 0; public const ANSWER_TYPE_TEXT_VAL = 1; @@ -833,4 +836,38 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): arra } return $correct_answers; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'long_menu_text' => $this->long_menu_text, + 'json_structure' => $this->json_structure, + 'min_auto_complete' => $this->minAutoComplete, + 'identical_scoring' => $this->identical_scoring, + 'correct_answers' => $this->correct_answers, + 'answers' => $this->answers, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->long_menu_text = $tt->string($normalized['long_menu_text']); + $clone->json_structure = $tt->string($normalized['json_structure']); + $clone->minAutoComplete = $tt->int($normalized['min_auto_complete']); + $clone->identical_scoring = $tt->bool($normalized['identical_scoring']); + $clone->correct_answers = $normalized['correct_answers']; + $clone->answers = $normalized['answers']; + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assMatchingQuestion.php b/components/ILIAS/TestQuestionPool/classes/class.assMatchingQuestion.php index fa5c34243f11..3b09b41dabeb 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assMatchingQuestion.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assMatchingQuestion.php @@ -18,11 +18,14 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\Test\Logging\AdditionalInformationGenerator; use ILIAS\Refinery\Random\Group as RandomGroup; use ILIAS\Refinery\Random\Seed\RandomSeed; +use ILIAS\Refinery\Transformation; /** * Class for matching questions @@ -37,7 +40,7 @@ * * @ingroup ModulesTestQuestionPool */ -class assMatchingQuestion extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionLMExportable, QuestionAutosaveable +class assMatchingQuestion extends assQuestion implements ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionLMExportable, QuestionAutosaveable, Normalizable { public const MT_TERMS_PICTURES = 0; public const MT_TERMS_DEFINITIONS = 1; @@ -1423,4 +1426,44 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): arra $this->getMatchingPairs() ); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'shuffle_mode' => $this->shufflemode, + 'matching_mode' => $this->matching_mode, + 'matching_type' => $this->matching_type, + 'thumb_geometry' => $this->thumb_geometry, + 'matching_pairs' => $tt->normalize($this->matchingpairs, ['question_id' => $this->getId()]), + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->shufflemode = $tt->int($normalized['shuffle_mode']); + $clone->matching_mode = $tt->string($normalized['matching_mode']); + $clone->matching_type = $tt->int($normalized['matching_type']); + $clone->thumb_geometry = $tt->int($normalized['thumb_geometry']); + + foreach ($normalized['matching_pairs'] as $matching_pair) { + $term = $tt->denormalize($matching_pair['term'], new assAnswerMatchingTerm()); + $definition = $tt->denormalize($matching_pair['definition'], new assAnswerMatchingDefinition()); + + $clone->matchingpairs[] = new assAnswerMatchingPair($term, $definition, $tt->float($matching_pair['points'])); + $clone->terms[] = $term; + $clone->definitions[] = $definition; + } + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assMultipleChoice.php b/components/ILIAS/TestQuestionPool/classes/class.assMultipleChoice.php index 5de214063b0c..1b10f9168133 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assMultipleChoice.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assMultipleChoice.php @@ -18,11 +18,13 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\TestQuestionPool\ManipulateImagesInChoiceQuestionsTrait; use ILIAS\Test\Logging\AdditionalInformationGenerator; -use ILIAS\TestQuestionPool\RequestDataCollector; +use ILIAS\Refinery\Transformation; /** * Class for multiple choice tests. @@ -39,7 +41,7 @@ * * @ingroup ModulesTestQuestionPool */ -class assMultipleChoice extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, ilAssSpecificFeedbackOptionLabelProvider, QuestionLMExportable, QuestionAutosaveable +class assMultipleChoice extends assQuestion implements ilObjAnswerScoringAdjustable, iQuestionCondition, ilAssSpecificFeedbackOptionLabelProvider, QuestionLMExportable, QuestionAutosaveable, Normalizable { use ManipulateImagesInChoiceQuestionsTrait; @@ -956,4 +958,35 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): arra $this->getAnswers() ); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'selection_limit' => $this->selection_limit, + 'single_line' => $this->is_singleline, + 'answers' => $tt->normalize($this->answers, ['question_id' => $this->getId()]), + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->selection_limit = $tt->nullableInt($normalized['selection_limit']); + $clone->is_singleline = $tt->bool($normalized['single_line']); + $clone->answers = array_map( + fn(array $answer) => $tt->denormalize($answer, new ASS_AnswerMultipleResponseImage()), + $normalized['answers'] + ); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assNumeric.php b/components/ILIAS/TestQuestionPool/classes/class.assNumeric.php index e2d6e1ee9fe4..cb598474a73e 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assNumeric.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assNumeric.php @@ -18,8 +18,11 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\Test\Logging\AdditionalInformationGenerator; +use ILIAS\Refinery\Transformation; /** * Class for numeric questions @@ -36,7 +39,7 @@ * * @ingroup ModulesTestQuestionPool */ -class assNumeric extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionAutosaveable +class assNumeric extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionAutosaveable, Normalizable { protected $lower_limit; protected $upper_limit; @@ -463,4 +466,32 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): stri { return "{$this->getLowerLimit()}-{$this->getUpperLimit()}"; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'lower_limit' => $this->lower_limit, + 'upper_limit' => $this->upper_limit, + 'maxchars' => $this->maxchars, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->lower_limit = $tt->string($normalized['lower_limit']); + $clone->upper_limit = $tt->string($normalized['upper_limit']); + $clone->maxchars = $tt->int($normalized['maxchars']); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assOrderingHorizontal.php b/components/ILIAS/TestQuestionPool/classes/class.assOrderingHorizontal.php index d08e5d863841..d38540a1d712 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assOrderingHorizontal.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assOrderingHorizontal.php @@ -18,9 +18,12 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\Test\Logging\AdditionalInformationGenerator; +use ILIAS\Refinery\Transformation; /** * Class for horizontal ordering questions @@ -33,7 +36,7 @@ * * @ingroup ModulesTestQuestionPool */ -class assOrderingHorizontal extends assQuestion implements ilObjQuestionScoringAdjustable, iQuestionCondition, QuestionLMExportable, QuestionAutosaveable +class assOrderingHorizontal extends assQuestion implements ilObjQuestionScoringAdjustable, iQuestionCondition, QuestionLMExportable, QuestionAutosaveable, Normalizable { protected const HAS_SPECIFIC_FEEDBACK = false; protected const DEFAULT_TEXT_SIZE = 100; @@ -582,4 +585,34 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): stri { return $this->getOrderText(); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'ordertext' => $this->ordertext, + 'textsize' => $this->textsize, + 'separator' => $this->separator, + 'answer_separator' => $this->answer_separator, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->ordertext = $tt->string($normalized['ordertext']); + $clone->textsize = $tt->float($normalized['textsize']); + $clone->separator = $tt->string($normalized['separator']); + $clone->answer_separator = $tt->string($normalized['answer_separator']); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assOrderingQuestion.php b/components/ILIAS/TestQuestionPool/classes/class.assOrderingQuestion.php index 3b6f13861f80..df0280bdfa3e 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assOrderingQuestion.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assOrderingQuestion.php @@ -18,10 +18,13 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\TestQuestionPool\Questions\Ordering\OrderingQuestionDatabaseRepository as OQRepository; use ILIAS\Test\Logging\AdditionalInformationGenerator; +use ILIAS\Refinery\Transformation; /** * Class for ordering questions @@ -37,7 +40,7 @@ * * @ingroup components\ILIASTestQuestionPool */ -class assOrderingQuestion extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionLMExportable, QuestionAutosaveable +class assOrderingQuestion extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionLMExportable, QuestionAutosaveable, Normalizable { public const ORDERING_ELEMENT_FORM_FIELD_POSTVAR = 'order_elems'; @@ -1365,4 +1368,33 @@ function (ilAssOrderingElement $v): string { $elements ); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'ordering_type' => $this->ordering_type, + 'ordering_elements' => $tt->normalize($this->getOrderingElementList()->getElements(), ['question_id' => $this->getId()]), + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->ordering_type = $tt->int($normalized['ordering_type']); + $clone->getOrderingElementList()->setElements(array_map( + fn(array $element) => $tt->denormalize($element, new ilAssOrderingElement()), + $normalized['ordering_elements'] + )); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assQuestion.php b/components/ILIAS/TestQuestionPool/classes/class.assQuestion.php index 8a8cf44b2daf..22c8bfcd2474 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assQuestion.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assQuestion.php @@ -18,6 +18,8 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Envelopes\Id; use ILIAS\Test\Results\Data\Repository as TestResultRepository; use ILIAS\Test\TestDIC; use ILIAS\TestQuestionPool\Questions\QuestionPartiallySaveable; @@ -2947,4 +2949,64 @@ public function getVariablesAsTextArray( ): array { return []; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + 'id' => $tt->normalize(new Id($this->id, 'question')), + 'pool_id' => $tt->normalize(new Id($this->obj_id, 'qpl')), + 'original_id' => $this->original_id, + 'external_id' => $this->external_id, + 'type' => $this->getQuestionType(), + 'owner' => $this->owner, + 'title' => $this->title, + 'description' => $this->comment, + 'question_text' => $this->question, + 'available_points' => $this->points, + 'nr_of_tries' => $this->nr_of_tries, + 'lifecycle' => $tt->normalize($this->lifecycle), + 'author' => $this->author, + 'updated_timestamp' => $this->lastChange, + 'additional_content_editing_mode' => $this->additionalContentEditingMode, + 'thumb_size' => $this->thumb_size, + 'shuffle' => $this->shuffle, + 'suggested_solutions' => $tt->normalize($this->suggested_solutions), + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = clone $this; + $clone->id = $tt->denormalize($normalized['id'], Id::class)->getId(); + $clone->obj_id = $tt->denormalize($normalized['pool_id'], Id::class)->getId(); + $clone->original_id = $tt->nullableInt($normalized['original_id']); + $clone->external_id = $tt->nullableString($normalized['external_id']); + $clone->owner = $tt->int($normalized['owner']); + $clone->title = $tt->string($normalized['title']); + $clone->comment = $tt->string($normalized['description']); + $clone->question = $tt->string($normalized['question_text']); + $clone->points = $tt->float($normalized['available_points']); + $clone->nr_of_tries = $tt->int($normalized['nr_of_tries']); + $clone->lifecycle = $tt->denormalize($normalized['lifecycle'], $clone->lifecycle); + $clone->author = $tt->string($normalized['author']); + $clone->lastChange = $tt->nullableInt($normalized['updated_timestamp']); + $clone->additionalContentEditingMode = $tt->string($normalized['additional_content_editing_mode']); + $clone->thumb_size = $tt->int($normalized['thumb_size']); + $clone->shuffle = $tt->bool($normalized['shuffle']); + + $clone->suggested_solutions = array_map( + fn(array $suggested_solution) => $tt->denormalize($suggested_solution, SuggestedSolution::class), + $normalized['suggested_solutions'] + ); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assSingleChoice.php b/components/ILIAS/TestQuestionPool/classes/class.assSingleChoice.php index e90cb6c53ec0..8a15886c81a0 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assSingleChoice.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assSingleChoice.php @@ -18,10 +18,13 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\TestQuestionPool\ManipulateImagesInChoiceQuestionsTrait; use ILIAS\Test\Logging\AdditionalInformationGenerator; +use ILIAS\Refinery\Transformation; /** * Class for single choice questions @@ -36,7 +39,7 @@ * * @ingroup ModulesTestQuestionPool */ -class assSingleChoice extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, ilAssSpecificFeedbackOptionLabelProvider, QuestionLMExportable, QuestionAutosaveable +class assSingleChoice extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, ilAssSpecificFeedbackOptionLabelProvider, QuestionLMExportable, QuestionAutosaveable, Normalizable { use ManipulateImagesInChoiceQuestionsTrait; @@ -944,4 +947,35 @@ function (array $c, ASS_AnswerBinaryStateImage $v): array { [] ); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): array { + $normalized = $tt->normalize(parent::toNormalized($tt)); + $normalized['is_singleline'] = $this->is_singleline; + $normalized['feedback_setting'] = $this->feedback_setting; + $normalized['answers'] = $tt->normalize($this->answers, ['question_id' => $this->getId()]); + return $normalized; + }); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->is_singleline = $tt->bool($normalized['is_singleline']); + $clone->feedback_setting = $tt->int($normalized['feedback_setting']); + $clone->answers = array_map( + fn(array $answer) => $tt->denormalize($answer, new ASS_AnswerBinaryStateImage()), + $normalized['answers'] + ); + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assTextQuestion.php b/components/ILIAS/TestQuestionPool/classes/class.assTextQuestion.php index 7f7be1a73ffa..7228f5cb4f04 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assTextQuestion.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assTextQuestion.php @@ -18,9 +18,12 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\Test\Logging\AdditionalInformationGenerator; +use ILIAS\Refinery\Transformation; /** * Class for text questions @@ -35,7 +38,7 @@ * * @ingroup ModulesTestQuestionPool */ -class assTextQuestion extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, QuestionLMExportable, QuestionAutosaveable +class assTextQuestion extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, QuestionLMExportable, QuestionAutosaveable, Normalizable { protected const HAS_SPECIFIC_FEEDBACK = false; @@ -855,4 +858,40 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): arra ); } } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'word_counter_enabled' => $this->word_counter_enabled, + 'max_num_of_chars' => $this->max_num_of_chars, + 'text_rating' => $this->text_rating, + 'matchcondition' => $this->matchcondition, + 'keyword_relation' => $this->keyword_relation, + 'answers' => $tt->normalize($this->answers), + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->word_counter_enabled = $tt->bool($normalized['word_counter_enabled']); + $clone->max_num_of_chars = $tt->int($normalized['max_num_of_chars']); + $clone->text_rating = $tt->string($normalized['text_rating']); + $clone->matchcondition = $tt->int($normalized['matchcondition']); + $clone->keyword_relation = $tt->string($normalized['keyword_relation']); + $clone->answers = array_map( + fn(array $answer) => $tt->denormalize($answer, new ASS_AnswerMultipleResponseImage()), + $normalized['answers'] + ); + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.assTextSubset.php b/components/ILIAS/TestQuestionPool/classes/class.assTextSubset.php index d85b05d91412..09bb06fe7f91 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assTextSubset.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assTextSubset.php @@ -18,9 +18,12 @@ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\TestQuestionPool\Questions\QuestionLMExportable; use ILIAS\TestQuestionPool\Questions\QuestionAutosaveable; use ILIAS\Test\Logging\AdditionalInformationGenerator; +use ILIAS\Refinery\Transformation; /** * Class for TextSubset questions @@ -37,7 +40,7 @@ * * @ingroup ModulesTestQuestionPool */ -class assTextSubset extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionLMExportable, QuestionAutosaveable +class assTextSubset extends assQuestion implements ilObjQuestionScoringAdjustable, ilObjAnswerScoringAdjustable, iQuestionCondition, QuestionLMExportable, QuestionAutosaveable, Normalizable { public array $answers = []; public int $correctanswers = 0; @@ -755,4 +758,34 @@ public function getCorrectSolutionForTextOutput(int $active_id, int $pass): arra { return $this->getAvailableAnswers(); } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + ...$tt->normalize(parent::toNormalized($tt)), + 'text_rating' => $this->text_rating, + 'correct_answers' => $this->correctanswers, + 'answers' => $tt->normalize($this->answers), + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = parent::fromNormalized($tt)->transform($normalized); + $clone->text_rating = $tt->string($normalized['text_rating']); + $clone->correctanswers = $tt->int($normalized['correct_answers']); + $clone->answers = array_map( + fn(array $answer) => $tt->denormalize($answer, new ASS_AnswerBinaryStateImage()), + $normalized['answers'] + ); + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilAssKprimChoiceAnswer.php b/components/ILIAS/TestQuestionPool/classes/class.ilAssKprimChoiceAnswer.php index bc833382d8a6..e2a0842316e6 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilAssKprimChoiceAnswer.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilAssKprimChoiceAnswer.php @@ -1,5 +1,10 @@ getImageWebDir() . $this->getThumbPrefix() . $this->getImageFile(); } + + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(array $context): array => [ + 'position' => $this->position, + 'answertext' => $this->answertext, + 'image' => $this->imageFile ? $tt->normalize( + new QuestionImage($this->imageFile, $context['question_id'] ?? null) + ) : null, + 'correctness' => $this->correctness, + ]); + } + + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = clone $this; + $clone->position = $tt->int($normalized['position']); + $clone->answertext = $tt->nullableString($normalized['answertext']); + $clone->imageFile = $tt->denormalize($normalized['image'], QuestionImage::class)?->getFilename(); + $clone->correctness = $tt->int($normalized['correctness']); + + //TODO: imageFSDir, imageWebDir, thumbPrefix ? + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssOrderingElement.php b/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssOrderingElement.php index 0d8222ef76e3..2f65ab116890 100755 --- a/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssOrderingElement.php +++ b/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssOrderingElement.php @@ -17,6 +17,11 @@ *********************************************************************/ declare(strict_types=1); +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Envelopes\Id; +use ILIAS\Refinery\Transformation; +use ILIAS\TestQuestionPool\ExportImport\Envelopes\QuestionImage; /** * Class represents an ordering element for assOrderingQuestion @@ -25,7 +30,7 @@ * @version $Id$ * @package Modules/TestQuestionPool */ -class ilAssOrderingElement +class ilAssOrderingElement implements Normalizable { public const EXPORT_IDENT_PROPERTY_SEPARATOR = '_'; @@ -440,4 +445,39 @@ public function withContent(string $content): self $clone->content = $content; return $clone; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(array $options): array => [ + 'id' => $tt->normalize(new Id($this->id, 'ordering')), + 'random_identifier' => $this->random_identifier, + 'solution_identifier' => $this->solution_identifier, + 'position' => $this->position, + 'indentation' => $this->indentation, + 'content' => $this->content ? $tt->normalize( + new QuestionImage($this->content, $options['question_id'] ?? null) + ) : null, + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = $this->withRandomIdentifier($tt->int($normalized['random_identifier'])) + ->withSolutionIdentifier($tt->int($normalized['solution_identifier'])) + ->withPosition($tt->int($normalized['position'])) + ->withIndentation($tt->int($normalized['indentation'])); + + $clone->setContent($tt->denormalize($normalized['content'], QuestionImage::class)?->getFilename()); + $clone->setId($tt->denormalize($normalized['id'], Id::class)->getId()); + + return $clone; + }); + } } diff --git a/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionLifecycle.php b/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionLifecycle.php index 5273eec999b3..7b43c435e033 100755 --- a/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionLifecycle.php +++ b/components/ILIAS/TestQuestionPool/classes/questions/class.ilAssQuestionLifecycle.php @@ -16,12 +16,16 @@ * *********************************************************************/ +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\Refinery\Transformation; + /** * Class ilAssQuestionLifecycle * @author Björn Heyser * @package components\ILIAS/TestQuestionPool */ -class ilAssQuestionLifecycle +class ilAssQuestionLifecycle implements Normalizable { public const DRAFT = 'draft'; public const REVIEW = 'review'; @@ -153,4 +157,24 @@ public static function getDraftInstance(): self return $lifecycle; } + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + 'identifier' => $this->getIdentifier(), + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation( + fn(array $normalized) => self::getInstance($normalized['identifier']) + ); + } } diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/SuggestedSolutionNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/SuggestedSolutionNormalizer.php new file mode 100644 index 000000000000..5cefef06f6aa --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/SuggestedSolutionNormalizer.php @@ -0,0 +1,139 @@ + + */ +#[Normalizes(SuggestedSolution::class)] +class SuggestedSolutionNormalizer implements Normalizer +{ + public function __construct( + private readonly Transformations $tt, + ) { + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if (!$value instanceof SuggestedSolution) { + throw new NormalizingException('Invalid value', $value); + } + + $normalized = [ + 'id' => $this->tt->normalize(new Id($value->getId(), 'suggested_solution')), + 'question_id' => $this->tt->normalize(new Id($value->getQuestionId(), 'question')), + 'subquestion_index' => $value->getSubquestionIndex(), + 'import_id' => $value->getImportId(), + 'last_update' => $this->tt->normalize($value->getLastUpdate()), + ]; + + if ($value instanceof SuggestedSolutionLink) { + $normalized['type'] = $value->getType(); + $normalized['internal_link'] = $value->getInternalLink(); + } + + if ($value instanceof SuggestedSolutionFile) { + $normalized['type'] = $value->getType(); + $normalized['title'] = $value->getTitle(); + $normalized['mime'] = $value->getMime(); + $normalized['size'] = $value->getSize(); + + $normalized['file'] = $this->tt->normalize( + new QuestionImage($value->getFilename(), $value->getQuestionId(), QuestionImage::TYPE_SOLUTION) + ); + + } + + return $normalized; + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): SuggestedSolution + { + if ($type !== SuggestedSolution::class && !in_array(SuggestedSolution::class, class_parents($type))) { + throw new NormalizingException("Invalid type: {$type}"); + } + + // If abstract class expected we need to lookup the concrete type from the normalized value + $denormalized_type = $this->tt->string($value['type']); + if ($type === SuggestedSolution::class) { + $type = match($denormalized_type) { + SuggestedSolution::TYPE_FILE => SuggestedSolutionFile::class, + SuggestedSolution::TYPE_LM => SuggestedSolutionLink::class, + SuggestedSolution::TYPE_LM_CHAPTER => SuggestedSolutionLink::class, + SuggestedSolution::TYPE_LM_PAGE => SuggestedSolutionLink::class, + SuggestedSolution::TYPE_GLOSARY_TERM => SuggestedSolutionLink::class, + default => throw new NormalizingException("Invalid denormalized type: {$denormalized_type}"), + }; + } + + $id = $this->tt->denormalize($value['id'], Id::class)->getId(); + $question_id = $this->tt->denormalize($value['question_id'], Id::class)->getId(); + $subquestion_index = $this->tt->int($value['subquestion_index']); + $import_id = $this->tt->string($value['import_id']); + $last_update = $this->tt->denormalize($value['last_update'], DateTimeImmutable::class); + + switch ($type) { + case SuggestedSolutionFile::class: + return new SuggestedSolutionFile( + $id, + $question_id, + $subquestion_index, + $import_id, + $last_update, + $denormalized_type, + '' + ) + ->withTitle($this->tt->string($value['title'])) + ->withFilename($this->tt->denormalize($value['file'], QuestionImage::class)->getFilename()) + ->withMime($this->tt->string($value['mime'])) + ->withSize($this->tt->int($value['size'])); + case SuggestedSolutionLink::class: + return new SuggestedSolutionLink( + $id, + $question_id, + $subquestion_index, + $import_id, + $last_update, + $denormalized_type, + $this->tt->string($value['internal_link']) + ); + default: + throw new NormalizingException("Invalid type: {$type}"); + } + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/ilAssQuestionSkillAssignmentNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/ilAssQuestionSkillAssignmentNormalizer.php new file mode 100644 index 000000000000..43bbd70f552c --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/ilAssQuestionSkillAssignmentNormalizer.php @@ -0,0 +1,145 @@ + + */ +#[Normalizes(ilAssQuestionSkillAssignment::class)] +class ilAssQuestionSkillAssignmentNormalizer implements Normalizer +{ + private readonly ilDBInterface $db; + + public function __construct( + private readonly Transformations $tt, + Container $dic + ) { + $this->db = $dic->database(); + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if (!$value instanceof ilAssQuestionSkillAssignment) { + throw new NormalizingException('Invalid value', $value); + } + + $normalized = [ + 'parent_id' => $this->tt->normalize(new Id($value->getParentObjId(), 'object')), + 'question_id' => $this->tt->normalize(new Id($value->getQuestionId(), 'question')), + 'base_id' => $value->getSkillBaseId(), + 'tref_id' => $value->getSkillTrefId(), + 'original_title' => $value->getSkillTitle(), + 'original_path' => $value->getSkillPath(), + 'eval_mode' => $value->getEvalMode(), + ]; + + switch ($value->getEvalMode()) { + case ilAssQuestionSkillAssignment::EVAL_MODE_BY_QUESTION_RESULT: + $normalized['points'] = $value->getSkillPoints(); + break; + + case ilAssQuestionSkillAssignment::EVAL_MODE_BY_QUESTION_SOLUTION: + $normalized['solution_comparison_expressions'] = $this->normalizeExpressionList($value); + break; + } + return $normalized; + } + + private function normalizeExpressionList(ilAssQuestionSkillAssignment $value): array + { + $value->initSolutionComparisonExpressionList(); + + $list = []; + foreach ($value->getSolutionComparisonExpressionList()->get() as $expression) { + $list[] = [ + 'points' => $expression->getPoints(), + 'expression' => $expression->getExpression(), + 'order_index' => $expression->getOrderIndex(), + ]; + } + + return $list; + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): ilAssQuestionSkillAssignment + { + if ($type !== ilAssQuestionSkillAssignment::class) { + throw new NormalizingException("Invalid type for ilAssQuestionSkillAssignment: {$type}"); + } + + $assignment = new ilAssQuestionSkillAssignment($this->db); + $assignment->setParentObjId($this->tt->denormalize($value['parent_id'], Id::class)->getId()); + $assignment->setQuestionId($this->tt->denormalize($value['question_id'], Id::class)->getId()); + $assignment->setSkillBaseId($this->tt->int($value['base_id'])); + $assignment->setSkillTrefId($this->tt->int($value['tref_id'])); + $assignment->setSkillTitle($this->tt->string($value['original_title'])); + $assignment->setSkillPath($this->tt->string($value['original_path'])); + $assignment->setEvalMode($this->tt->string($value['eval_mode'])); + $assignment->initSolutionComparisonExpressionList(); + + switch ($assignment->getEvalMode()) { + case ilAssQuestionSkillAssignment::EVAL_MODE_BY_QUESTION_RESULT: + $assignment->setSkillPoints($this->tt->int($value['points'])); + break; + + case ilAssQuestionSkillAssignment::EVAL_MODE_BY_QUESTION_SOLUTION: + $list = $assignment->getSolutionComparisonExpressionList(); + foreach ($value['solution_comparison_expressions'] as $normalized) { + $list->add($this->denormalizeExpression($normalized, $assignment)); + } + break; + } + + return $assignment; + } + + private function denormalizeExpression( + array $normalized, + ilAssQuestionSkillAssignment $assignment + ): ilAssQuestionSolutionComparisonExpression { + $expression = new ilAssQuestionSolutionComparisonExpression(); + $expression->setQuestionId($assignment->getQuestionId()); + $expression->setSkillBaseId($assignment->getSkillBaseId()); + $expression->setSkillTrefId($assignment->getSkillTrefId()); + + $expression->setOrderIndex($this->tt->int($normalized['order_index'])); + $expression->setExpression($this->tt->string($normalized['expression'])); + $expression->setPoints($this->tt->int($normalized['points'])); + + return $expression; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/ilObjQuestionPoolNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/ilObjQuestionPoolNormalizer.php new file mode 100644 index 000000000000..d4cfb429eadd --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/ilObjQuestionPoolNormalizer.php @@ -0,0 +1,65 @@ + + */ +#[Normalizes(ilObjQuestionPool::class)] +class ilObjQuestionPoolNormalizer extends IlObjectNormalizer implements Normalizer +{ + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if (!$value instanceof ilObjQuestionPool) { + throw new NormalizingException('Invalid value', $value); + } + + $normalized = parent::normalize($value); + $normalized['skill_service_enabled'] = $value->isSkillServiceEnabled(); + + return $normalized; + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): ilObjQuestionPool + { + if ($type !== ilObjQuestionPool::class) { + throw new NormalizingException("Invalid type for ilObjQuestionPool: {$type}"); + } + + /** @var ilObjQuestionPool $object */ + $object = parent::denormalize($value, ilObjQuestionPool::class); + $object->setSkillServiceEnabled($this->tt->bool($value['skill_service_enabled'])); + + return $object; + } +} From 16ac23edd69de0934ed81ebb79ab67fdd7994224 Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Thu, 9 Apr 2026 12:45:45 +0200 Subject: [PATCH 03/43] feat(qpl): implement question pool export and integrate core export components --- .../classes/class.ilQuestionpoolExport.php | 186 -------------- .../class.ilTestQuestionPoolExporter.php | 147 ++++++------ .../src/ExportImport/Envelopes/Feedback.php | 84 +++++++ .../Pipes/CollectQuestionImages.php | 93 +++++++ .../ExportImport/QuestionPoolCollector.php | 226 ++++++++++++++++++ .../src/ExportImport/QuestionPoolExporter.php | 206 ++++++++++++++++ .../TestQuestionPool/src/QuestionPoolDIC.php | 15 ++ 7 files changed, 694 insertions(+), 263 deletions(-) delete mode 100755 components/ILIAS/TestQuestionPool/classes/class.ilQuestionpoolExport.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Envelopes/Feedback.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Pipes/CollectQuestionImages.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolCollector.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolExporter.php diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilQuestionpoolExport.php b/components/ILIAS/TestQuestionPool/classes/class.ilQuestionpoolExport.php deleted file mode 100755 index acaf7943deae..000000000000 --- a/components/ILIAS/TestQuestionPool/classes/class.ilQuestionpoolExport.php +++ /dev/null @@ -1,186 +0,0 @@ - -* -* @version $Id$ -* -* @ingroup components\ILIASTestQuestionPool -*/ -class ilQuestionpoolExport -{ - public ilErrorHandling $err; // error object - public ilDBInterface $db; // database object - public ILIAS $ilias; // ilias object - public string $inst_id; // installation id - public ilLanguage $lng; - - private string $export_dir = ''; - private string $subdir = ''; - private string $filename = ''; - private string $zipfilename = ''; - private string $qti_filename = ''; - private ilXmlWriter $xml; - - /** - * Constructor - * @access public - */ - public function __construct( - protected ilObjQuestionPool $qpl_obj, - protected string $mode = "xml", - protected ?array $questions = null - ) { - global $DIC; - $this->err = $DIC['ilErr']; - $this->db = $DIC['ilDB']; - $this->ilias = $DIC['ilias']; - $this->lng = $DIC['lng']; - - if (!is_array($this->questions)) { - $this->questions = $this->qpl_obj->getAllQuestionIds(); - } - - $this->inst_id = IL_INST_ID; - $date = time(); - - $this->qpl_obj->createExportDirectory(); - switch ($this->mode) { - case "xml": - $this->export_dir = $this->qpl_obj->getExportDirectory('xml'); - $this->subdir = $date . "__" . $this->inst_id . "__" . - "qpl" . "_" . $this->qpl_obj->getId(); - $this->filename = $this->subdir . ".xml"; - $this->qti_filename = $date . "__" . $this->inst_id . "__" . - "qti" . "_" . $this->qpl_obj->getId() . ".xml"; - break; - case "xlsx": - $this->export_dir = $this->qpl_obj->getExportDirectory('xlsx'); - $this->filename = $date . "__" . $this->inst_id . "__" . - "qpl" . "_" . $this->qpl_obj->getId() . ".xlsx"; - $this->zipfilename = $date . "__" . $this->inst_id . "__" . - "qpl" . "_" . $this->qpl_obj->getId() . ".zip"; - break; - default: - $this->export_dir = $this->qpl_obj->getExportDirectory('zip'); - $this->subdir = $date . "__" . $this->inst_id . "__" . - "qpl" . "_" . $this->qpl_obj->getId(); - $this->filename = $this->subdir . ".xml"; - $this->qti_filename = $date . "__" . $this->inst_id . "__" . - "qti" . "_" . $this->qpl_obj->getId() . ".xml"; - break; - } - } - - public function getInstId() - { - return $this->inst_id; - } - - - /** - * build export file (complete zip file) - */ - public function buildExportFile(): string - { - switch ($this->mode) { - case "xlsx": - return $this->buildExportFileXLSX(); - case "xml": - default: - return $this->buildExportFileXML(); - } - } - - /** - * build xml export file - */ - public function buildExportFileXML(): string - { - global $DIC; - $ilBench = $DIC['ilBench']; - - $ilBench->start("QuestionpoolExport", "buildExportFile"); - - $this->xml = new ilXmlWriter(); - $this->xml->xmlSetDtdDef(""); - $this->xml->xmlSetGenCmt("Export of ILIAS Test Questionpool " . - $this->qpl_obj->getId() . " of installation " . $this->inst_id); - $this->xml->xmlHeader(); - - ilFileUtils::makeDir($this->export_dir . "/" . $this->subdir); - ilFileUtils::makeDir($this->export_dir . "/" . $this->subdir . "/objects"); - - $expDir = $this->qpl_obj->getExportDirectory(); - ilFileUtils::makeDirParents($expDir); - - $expLog = new ilLog($expDir, "export.log"); - $expLog->delete(); - $expLog->setLogFormat(""); - $expLog->write(date("[y-m-d H:i:s] ") . "Start Export"); - - $qti_file = fopen($this->export_dir . "/" . $this->subdir . "/" . $this->qti_filename, "w"); - fwrite($qti_file, $this->qpl_obj->questionsToXML($this->questions)); - fclose($qti_file); - - $ilBench->start("QuestionpoolExport", "buildExportFile_getXML"); - $this->qpl_obj->objectToXmlWriter( - $this->xml, - $this->inst_id, - $this->export_dir . "/" . $this->subdir, - $expLog, - $this->questions - ); - $ilBench->stop("QuestionpoolExport", "buildExportFile_getXML"); - - $ilBench->start("QuestionpoolExport", "buildExportFile_dumpToFile"); - $this->xml->xmlDumpFile($this->export_dir . "/" . $this->subdir . "/" . $this->filename, false); - $ilBench->stop("QuestionpoolExport", "buildExportFile_dumpToFile"); - - $ilBench->start("QuestionpoolExport", "buildExportFile_saveAdditionalMobs"); - $this->exportXHTMLMediaObjects($this->export_dir . "/" . $this->subdir); - $ilBench->stop("QuestionpoolExport", "buildExportFile_saveAdditionalMobs"); - - $ilBench->start("QuestionpoolExport", "buildExportFile_zipFile"); - ilFileUtils::zip($this->export_dir . "/" . $this->subdir, $this->export_dir . "/" . $this->subdir . ".zip"); - - $ilBench->stop("QuestionpoolExport", "buildExportFile_zipFile"); - - $expLog->write(date("[y-m-d H:i:s] ") . "Finished Export"); - $ilBench->stop("QuestionpoolExport", "buildExportFile"); - - return $this->export_dir . "/" . $this->subdir . ".zip"; - } - - public function exportXHTMLMediaObjects($a_export_dir): void - { - foreach ($this->questions as $question_id) { - $mobs = ilObjMediaObject::_getMobsOfObject("qpl:html", $question_id); - foreach ($mobs as $mob) { - if (ilObjMediaObject::_exists($mob)) { - $mob_obj = new ilObjMediaObject($mob); - $mob_obj->exportFiles($a_export_dir); - unset($mob_obj); - } - } - } - } -} diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolExporter.php b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolExporter.php index 9234ff485c35..8d4536da9a38 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolExporter.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolExporter.php @@ -16,6 +16,12 @@ * *********************************************************************/ +use ILIAS\Data\ObjectId; +use ILIAS\TestQuestionPool\ExportImport\Foundation\ExportContext; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Serializing\SimpleXMLSerializer; +use ILIAS\TestQuestionPool\ExportImport\QuestionPoolExporter; +use ILIAS\TestQuestionPool\QuestionPoolDIC; + /** * Used for container export with tests * @@ -25,105 +31,54 @@ */ class ilTestQuestionPoolExporter extends ilXmlExporter { - private $ds; + private QuestionPoolExporter $exporter; /** - * Initialisation + * @var array $batches */ + private array $batches = []; + + public function init(): void { + $this->exporter = QuestionPoolDIC::dic()['exportimport.exporter']; } /** - * 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; - } - - - /** - * Get xml representation - * @param string entity - * @param string schema version - * @param string id - * @return string xml string + * Returns the final XML content for one question pool. + * + * This method is called after `getXmlExportTailDependencies()`. At this point the export writer and export + * directory are available, so the prepared batch can be written to disk and finalized. */ public function getXmlRepresentation(string $a_entity, string $a_schema_version, string $a_id): string { - $qpl = new ilObjQuestionPool($a_id, false); - $qpl->loadFromDb(); - - $qpl_exp = new ilQuestionpoolExport($qpl, 'xml'); - $qpl_exp->buildExportFile(); + if ($a_entity !== 'qpl') { + throw new InvalidArgumentException("Invalid entity for question pool export: {$a_entity}"); + } - global $DIC; /* @var ILIAS\DI\Container $DIC */ - $DIC['ilLog']->write(__METHOD__ . ': Created zip file'); - return ''; // sagt mjansen + return $this->finalizeExport((int) $a_id)->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 one or more question pools. + * + * The export framework calls this method before `getXmlRepresentation()`. Therefore this method only prepares and + * processes the export batch in memory and caches the context, because writer and export directory are not yet + * initialized here. */ 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; - } + $dependencies = []; + foreach ($a_ids as $id) { + $context = $this->processExport((int) $id); + $dependencies = array_merge($dependencies, $context->getDependencies()); } - return $taxIds; + return $dependencies; } /** @@ -143,4 +98,42 @@ public function getValidSchemaVersions(string $a_entity): array "max" => ""] ]; } + + /** + * Prepares and processes a question pool export in memory. The resulting context is cached per pool and reused + * across calls. + */ + private function processExport(int $pool_id): ExportContext + { + if (isset($this->batches[$pool_id])) { + return $this->batches[$pool_id]; + } + + $context = $this->exporter->prepare( + new ObjectId($pool_id), + $this->exp->getExportConfigs() + ); + + $context = $this->exporter->process( + $context, + new SimpleXMLSerializer()->open('memory') + ); + + $this->batches[$pool_id] = $context; + return $context; + } + + /** + * Finalizes a prepared export context and writes it to the export directory. + */ + private function finalizeExport(int $pool_id): ExportContext + { + $context = $this->processExport($pool_id); + + return $this->exporter->write( + $context, + $this->exp->getExportWriter(), + $this->exp->getPathToComponentExpDirInContainer() + ); + } } diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Envelopes/Feedback.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Envelopes/Feedback.php new file mode 100644 index 000000000000..7450284c25d0 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Envelopes/Feedback.php @@ -0,0 +1,84 @@ + */ + private array $specific_feedback = [], + ) { + } + + public function getGenericUncompleted(): string + { + return $this->generic_uncompleted; + } + + public function getGenericCompleted(): string + { + return $this->generic_completed; + } + + /** + * @return list + */ + public function getSpecificFeedback(): array + { + return $this->specific_feedback; + } + + /** + * @inheritDoc + */ + public function toArray(Transformations $tt): array + { + return [ + 'question_id' => $tt->normalize($this->question_id), + 'generic_uncompleted' => $this->generic_uncompleted, + 'generic_completed' => $this->generic_completed, + 'specific' => $this->specific_feedback, + ]; + } + + /** + * @inheritDoc + */ + public static function fromArray(array $value, Transformations $tt): static + { + return new self( + $tt->denormalize($value['question_id'], Id::class), + $value['generic_uncompleted'], + $value['generic_completed'], + $value['specific'], + ); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Pipes/CollectQuestionImages.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Pipes/CollectQuestionImages.php new file mode 100644 index 000000000000..5b82ef56272d --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Pipes/CollectQuestionImages.php @@ -0,0 +1,93 @@ + $files + */ + private array $files = []; + + public function __construct( + private readonly Factory $uuid_factory, + private readonly ObjectId $pool_id, + ) { + $this->question_files = new QuestionFiles(); + } + + /** + * @inheritDoc + */ + public function handle(mixed $passable, \Closure $next): mixed + { + if ($passable instanceof NormalizeCarry && $passable->value instanceof QuestionImage) { + $this->handleNormalization($passable->value); + } + + return $next($passable); + } + + private function handleNormalization(QuestionImage $envelope): void + { + // Build the absolute source path + $base_dir = $this->question_files->buildImagePath( + $envelope->getQuestionId(), + $this->pool_id->toInt() + ); + + if ($envelope->getType() === QuestionImage::TYPE_SOLUTION) { + $base_dir = str_replace('images/', 'solution/', $base_dir); + } + + $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]; + } + + /** + * @return list + */ + public function getFiles(): array + { + return $this->files; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolCollector.php b/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolCollector.php new file mode 100644 index 000000000000..ecba542b2c15 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolCollector.php @@ -0,0 +1,226 @@ + $questions */ + private ?array $questions = null; + private ?ilObjQuestionPool $pool_object = null; + private ?ilAssQuestionSkillAssignmentList $skill_assignments = null; + + public function __construct( + private readonly GeneralQuestionPropertiesRepository $question_repository, + private readonly ilDBInterface $db, + private readonly ObjectId $pool_id + ) { + } + + /** + * Get the ID of the question pool. + * + * @return ObjectId + */ + public function getPoolId(): ObjectId + { + return $this->pool_id; + } + + /** + * Get the object of the question pool. It will be loaded from the database if not already loaded. + */ + public function getObject(): ilObjQuestionPool + { + if ($this->pool_object === null) { + $this->pool_object = new ilObjQuestionPool($this->pool_id->toInt(), false); + $this->pool_object->read(); + } + + return $this->pool_object; + } + + /** + * Collect the question properties for all questions in the question pool. + * + * @return array + */ + public function getQuestionProperties(): array + { + if ($this->questions === null) { + $this->questions = $this->question_repository->getForParentObjectId($this->pool_id->toInt()); + } + return $this->questions; + } + + /** + * Collect the question objects for all questions in the question pool. + * + * @return Generator + */ + public function getQuestionObjects(): Generator + { + foreach ($this->getQuestionProperties() as $question) { + yield assQuestion::instantiateQuestion($question->getQuestionId()); + } + } + + /* + Units + */ + + /** + * Collect the unit categories and their units for all formula questions in the question pool. + * + * @return Generator<\assFormulaQuestionUnit|\assFormulaQuestionUnitCategory> + */ + public function getUnits(): Generator + { + foreach ($this->getQuestionProperties() as $question) { + if ($question->getClassName() === 'assFormulaQuestion') { + $repository = new ilUnitConfigurationRepository($question->getQuestionId()); + yield from $repository->getCategorizedUnits(); + } + } + } + + /* + Feedback + */ + + /** + * Collect the feedback content for a question and return it as a Feedback transfer object. + */ + public function getFeedback(assQuestion $question): Feedback + { + $feedback = new Feedback( + new Id($question->getId(), 'question'), + $question->feedbackOBJ->getGenericFeedbackExportPresentation($question->getId(), false), + $question->feedbackOBJ->getGenericFeedbackExportPresentation($question->getId(), true), + $this->loadSpecificFeedback($question), + ); + + return $feedback; + } + + private function loadSpecificFeedback(assQuestion $question): array + { + // Skip if specific feedback is not available or supported by the question type. + if ( + !$question->feedbackOBJ instanceof ilAssMultiOptionQuestionFeedback || + !$question->feedbackOBJ->isSpecificAnswerFeedbackAvailable($question->getId()) + ) { + return []; + } + + // Cloze question type specific feedback uses the identifier list to load the answer-specific feedback. + if ($question->feedbackOBJ instanceof ilAssClozeTestFeedback) { + $feedback_list = new ilAssSpecificFeedbackIdentifierList(); + $feedback_list->load($question->getId()); + + $feedback = []; + foreach ($feedback_list as $identifier) { + $feedback[$identifier->getAnswerIndex()] = [ + 'answer_index' => $identifier->getAnswerIndex(), + 'question_index' => $identifier->getQuestionIndex(), + 'feedback' => $question->feedbackOBJ->getSpecificAnswerFeedbackExportPresentation( + $question->getId(), + $identifier->getQuestionIndex(), + $identifier->getAnswerIndex() + ), + ]; + } + return $feedback; + } + + // Other question types with multiple answer options share the same approach + foreach (array_keys($question->feedbackOBJ->getAnswerOptionsByAnswerIndex()) as $answer_index) { + $feedback[$answer_index] = [ + 'answer_index' => $answer_index, + 'question_index' => 0, + 'feedback' => $question->feedbackOBJ->getSpecificAnswerFeedbackExportPresentation( + $question->getId(), + 0, + $answer_index + ), + ]; + } + return $feedback; + } + + /* + Skill Assignments + */ + + /** + * @return array<\ilAssQuestionSkillAssignment> + */ + public function getSkillAssignments(): array + { + if ($this->skill_assignments === null) { + $this->skill_assignments = new ilAssQuestionSkillAssignmentList($this->db); + $this->skill_assignments->setParentObjId($this->pool_id->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/QuestionPoolExporter.php b/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolExporter.php new file mode 100644 index 000000000000..441d942f6b05 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolExporter.php @@ -0,0 +1,206 @@ +prepare($pool_id, $config); + $context = $this->process($context, $serializer); + return $this->write($context, $writer, $export_dir); + } + + /** + * Prepares the export context by creating the transformations and the question image pipe. It returns the export + * context which is used to share the context between the prepare, process and write steps. + */ + public function prepare(ObjectId $pool_id, ExportConfig $config): ExportContext + { + $question_image_pipe = new CollectQuestionImages( + new UUIDFactory(), + $pool_id + ); + + $transformations = $this->builder->withAdditionalPipes([$question_image_pipe]) + ->create(); + + return new ExportContext($pool_id, $config, $transformations); + } + + /** + * 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, Serializer $serializer): ExportContext + { + $context->setSerializer($serializer); + $tt = $context->getTransformations(); + + $collector = new QuestionPoolCollector( + $this->question_repository, + $this->db, + $context->getPoolId() + ); + + $serializer->group( + 'general', + fn() => $this->exportObject($collector, $tt, $serializer, $context) + ); + $serializer->group( + 'units', + fn() => $this->exportUnits($collector, $tt, $serializer) + ); + $serializer->group( + 'questions', + fn() => $this->exportQuestions($collector, $tt, $serializer, $context) + ); + $serializer->group( + 'skill_assignments', + fn() => $this->exportSkillAssignments($collector, $tt, $serializer) + ); + + return $context; + } + + /** + * Finalizes the export by copying the question images to the export directory and returning the export context. + */ + public function write(ExportContext $export, ExportWriter $writer, string $export_dir): ExportContext + { + // Copy the question images to the export directory + $question_image_pipe = $export->getTransformations()->context(CollectQuestionImages::class); + foreach ($question_image_pipe->getFiles() as $file) { + $writer->writeFileByFilePath($file['from'], "{$export_dir}/" . $file['to']); + } + + return $export; + } + + + protected function exportObject( + QuestionPoolCollector $collector, + Transformations $transformations, + Serializer $serializer, + ExportContext $export + ): void { + $serializer->append('object', $transformations->normalize($collector->getObject())); + + $obj_id = $collector->getPoolId()->toInt(); + + $export->addDependency('components/ILIAS/ILIASObject', 'common', [$obj_id]); + $export->addDependency('components/ILIAS/MetaData', 'qpl', ["{$obj_id}:0:qpl"]); + $export->addDependency( + 'components/ILIAS/Taxonomy', + 'tax', + $this->taxonomy->getUsageOfObject($obj_id) + ); + } + + protected function exportUnits( + QuestionPoolCollector $collector, + Transformations $transformations, + Serializer $serializer + ): void { + $categories = []; + + foreach ($collector->getUnits() as $item) { + if($item instanceof assFormulaQuestionUnitCategory) { + $categories[$item->getId()] = $transformations->normalize($item); + } + + if($item instanceof assFormulaQuestionUnit) { + $categories[$item->getCategory()]['units'][] = $transformations->normalize($item); + } + } + + foreach($categories as $category) { + $serializer->append('category', $category); + } + } + + protected function exportQuestions( + QuestionPoolCollector $collector, + Transformations $transformations, + Serializer $serializer, + ExportContext $export + ): void { + foreach ($collector->getQuestionObjects() as $question) { + $serializer->append('question', [ + ... $transformations->normalize($question), + 'feedback' => $transformations->normalize( + $collector->getFeedback($question) + ) + ]); + + $export->addDependency('components/ILIAS/COPage', 'pg', ["qpl:{$question->getId()}"]); + } + } + + protected 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/QuestionPoolDIC.php b/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php index f2cce4c0a366..1f32c1fbb89f 100755 --- a/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php +++ b/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php @@ -20,6 +20,8 @@ namespace ILIAS\TestQuestionPool; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Builder; +use ILIAS\TestQuestionPool\ExportImport\QuestionPoolExporter; use Pimple\Container as PimpleContainer; use ILIAS\DI\Container as ILIASContainer; use ILIAS\TestQuestionPool\Questions\SuggestedSolution\SuggestedSolutionsDatabaseRepository; @@ -67,6 +69,19 @@ 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.builder'] = static fn($c): Builder => + new Builder( + $DIC, + $c + ); + $dic['exportimport.exporter'] = static fn($c): QuestionPoolExporter => + new QuestionPoolExporter( + $c['exportimport.builder'], + $c['question.general_properties.repository'], + $DIC->database(), + $DIC->taxonomy()->domain() + ); + return $dic; } } From a66a9175117741389c2a2d91ebba8324ca702522 Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Thu, 9 Apr 2026 12:55:00 +0200 Subject: [PATCH 04/43] feat(qpl): implement question pool import and integrate user interface steps --- .../classes/class.ilObjQuestionPool.php | 257 ------------------ .../classes/class.ilObjQuestionPoolGUI.php | 167 +++++------- .../class.ilObjQuestionPoolXMLParser.php | 138 ---------- .../class.ilTestQuestionPoolImporter.php | 152 ++--------- .../ExportImport/Envelopes/QuestionImage.php | 94 +++++++ .../src/ExportImport/Import/PersistStage.php | 75 +++++ .../Import/QuestionSelectionStage.php | 181 ++++++++++++ .../Import/UploadValidationStage.php | 101 +++++++ .../src/ExportImport/QuestionPoolImporter.php | 191 +++++++++++++ .../ExportImport/SkillAssignmentsImporter.php | 131 +++++++++ .../TestQuestionPool/src/QuestionPoolDIC.php | 20 ++ lang/ilias_de.lang | 2 + lang/ilias_en.lang | 2 + 13 files changed, 890 insertions(+), 621 deletions(-) delete mode 100755 components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolXMLParser.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Envelopes/QuestionImage.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Import/PersistStage.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionSelectionStage.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Import/UploadValidationStage.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolImporter.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/SkillAssignmentsImporter.php diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPool.php b/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPool.php index 2aeefb63a399..2a666800da05 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPool.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPool.php @@ -35,8 +35,6 @@ class ilObjQuestionPool extends ilObject private ilComponentRepository $component_repository; private ilBenchmark $benchmark; - private array $mob_ids; - private array $file_ids; private bool $skill_service_enabled; private GeneralQuestionPropertiesRepository $questionrepository; @@ -305,246 +303,6 @@ public function getPrintviewQuestions(): array return $rows; } - /** - * @param ilXmlWriter $xmlWriter - */ - private function exportXMLSettings($xmlWriter): void - { - $xmlWriter->xmlStartTag('Settings'); - $xmlWriter->xmlElement('SkillService', null, (int) $this->isSkillServiceEnabled()); - $xmlWriter->xmlEndTag('Settings'); - } - - /** - * export pages of test to xml (see ilias_co.dtd) - * - * @param object $a_xml_writer ilXmlWriter object that receives the - * xml data - */ - public function objectToXmlWriter(ilXmlWriter &$a_xml_writer, $a_inst, $a_target_dir, &$expLog, $questions): void - { - $ilBench = $this->benchmark; - - $this->mob_ids = []; - $this->file_ids = []; - - $attrs = []; - $attrs['Type'] = 'Questionpool_Test'; - $a_xml_writer->xmlStartTag('ContentObject', $attrs); - - // MetaData - $this->exportTitleAndDescription($a_xml_writer); - - // Settings - $this->exportXMLSettings($a_xml_writer); - - // PageObjects - $expLog->write(date('[y-m-d H:i:s] ') . 'Start Export Page Objects'); - $ilBench->start('ContentObjectExport', 'exportPageObjects'); - $this->exportXMLPageObjects($a_xml_writer, $a_inst, $expLog, $questions); - $ilBench->stop('ContentObjectExport', 'exportPageObjects'); - $expLog->write(date('[y-m-d H:i:s] ') . 'Finished Export Page Objects'); - - // MediaObjects - $expLog->write(date('[y-m-d H:i:s] ') . 'Start Export Media Objects'); - $ilBench->start('ContentObjectExport', 'exportMediaObjects'); - $this->exportXMLMediaObjects($a_xml_writer, $a_inst, $a_target_dir, $expLog); - $ilBench->stop('ContentObjectExport', 'exportMediaObjects'); - $expLog->write(date('[y-m-d H:i:s] ') . 'Finished Export Media Objects'); - - // FileItems - $expLog->write(date('[y-m-d H:i:s] ') . 'Start Export File Items'); - $ilBench->start('ContentObjectExport', 'exportFileItems'); - $this->exportFileItems($a_target_dir, $expLog); - $ilBench->stop('ContentObjectExport', 'exportFileItems'); - $expLog->write(date('[y-m-d H:i:s] ') . 'Finished Export File Items'); - - // skill assignments - $this->populateQuestionSkillAssignmentsXml($a_xml_writer, $questions); - - $a_xml_writer->xmlEndTag('ContentObject'); - } - - /** - * @param ilXmlWriter $a_xml_writer - * @param $questions - */ - protected function populateQuestionSkillAssignmentsXml(ilXmlWriter &$a_xml_writer, $questions): void - { - $assignmentList = new ilAssQuestionSkillAssignmentList($this->db); - $assignmentList->setParentObjId($this->getId()); - $assignmentList->loadFromDb(); - $assignmentList->loadAdditionalSkillData(); - - $skillQuestionAssignmentExporter = new ilAssQuestionSkillAssignmentExporter(); - $skillQuestionAssignmentExporter->setXmlWriter($a_xml_writer); - $skillQuestionAssignmentExporter->setQuestionIds($questions); - $skillQuestionAssignmentExporter->setAssignmentList($assignmentList); - $skillQuestionAssignmentExporter->export(); - } - - public function exportTitleAndDescription(ilXmlWriter &$a_xml_writer): void - { - $a_xml_writer->xmlElement('Title', null, $this->getTitle()); - $a_xml_writer->xmlElement('Description', null, $this->getDescription()); - } - - public function modifyExportIdentifier($a_tag, $a_param, $a_value) - { - if ($a_tag == 'Identifier' && $a_param == 'Entry') { - $a_value = ilUtil::insertInstIntoID($a_value); - } - - return $a_value; - } - - /** - * export page objects to xml (see ilias_co.dtd) - * - * @param object $a_xml_writer ilXmlWriter object that receives the - * xml data - */ - public function exportXMLPageObjects(&$a_xml_writer, $a_inst, &$expLog, $questions): void - { - $ilBench = $this->benchmark; - - foreach ($questions as $question_id) { - $ilBench->start('ContentObjectExport', 'exportPageObject'); - $expLog->write(date('[y-m-d H:i:s] ') . 'Page Object ' . $question_id); - - $attrs = []; - $a_xml_writer->xmlStartTag('PageObject', $attrs); - - // export xml to writer object - $ilBench->start('ContentObjectExport', 'exportPageObject_XML'); - $page_object = new ilAssQuestionPage($question_id); - $page_object->buildDom(); - $page_object->insertInstIntoIDs($a_inst); - $mob_ids = $page_object->collectMediaObjects(false); - $file_ids = ilPCFileList::collectFileItems($page_object, $page_object->getDomDoc()); - $xml = $page_object->getXMLFromDom(false, false, false, '', true); - $xml = str_replace('&', '&', $xml); - $a_xml_writer->appendXML($xml); - $page_object->freeDom(); - unset($page_object); - $ilBench->stop('ContentObjectExport', 'exportPageObject_XML'); - - $ilBench->start("ContentObjectExport", "exportPageObject_CollectMedia"); - foreach ($mob_ids as $mob_id) { - $this->mob_ids[$mob_id] = $mob_id; - } - $ilBench->stop('ContentObjectExport', 'exportPageObject_CollectMedia'); - - // collect all file items - $ilBench->start('ContentObjectExport', 'exportPageObject_CollectFileItems'); - //$file_ids = $page_obj->getFileItemIds(); - foreach ($file_ids as $file_id) { - $this->file_ids[$file_id] = $file_id; - } - $ilBench->stop('ContentObjectExport', 'exportPageObject_CollectFileItems'); - - $a_xml_writer->xmlEndTag("PageObject"); - - $ilBench->stop('ContentObjectExport', 'exportPageObject'); - } - } - - public function exportXMLMediaObjects(&$a_xml_writer, $a_inst, $a_target_dir, &$expLog): void - { - foreach ($this->mob_ids as $mob_id) { - $expLog->write(date('[y-m-d H:i:s] ') . 'Media Object ' . $mob_id); - if (ilObjMediaObject::_exists((int) $mob_id)) { - $target_dir = $a_target_dir . DIRECTORY_SEPARATOR . 'objects' - . DIRECTORY_SEPARATOR . 'il_' . IL_INST_ID . '_mob_' . $mob_id; - ilFileUtils::createDirectory($target_dir); - $media_obj = new ilObjMediaObject((int) $mob_id); - $media_obj->exportXML($a_xml_writer, (int) $a_inst); - foreach ($media_obj->getMediaItems() as $item) { - $stream = $item->getLocationStream(); - file_put_contents($target_dir . DIRECTORY_SEPARATOR . $item->getLocation(), $stream); - $stream->close(); - } - unset($media_obj); - } - } - } - - /** - * export files of file itmes - * - */ - public function exportFileItems($target_dir, &$expLog): void - { - foreach ($this->file_ids as $file_id) { - $expLog->write(date("[y-m-d H:i:s] ") . "File Item " . $file_id); - $file_dir = $target_dir . '/objects/il_' . IL_INST_ID . '_file_' . $file_id; - ilFileUtils::makeDir($file_dir); - $file_obj = new ilObjFile((int) $file_id, false); - $source_file = $file_obj->getFile($file_obj->getVersion()); - if (!is_file($source_file)) { - $source_file = $file_obj->getFile(); - } - if (is_file($source_file)) { - copy($source_file, $file_dir . '/' . $file_obj->getFileName()); - } - unset($file_obj); - } - } - - /** - * creates data directory for export files - * (data_dir/qpl_data/qpl_/export, depending on data - * directory that is set in ILIAS setup/ini) - */ - public function createExportDirectory(): void - { - $qpl_data_dir = ilFileUtils::getDataDir() . '/qpl_data'; - ilFileUtils::makeDir($qpl_data_dir); - if (!is_writable($qpl_data_dir)) { - $this->error->raiseError( - 'Questionpool Data Directory (' . $qpl_data_dir - . ') not writeable.', - $this->error->FATAL - ); - } - - // create learning module directory (data_dir/lm_data/lm_) - $qpl_dir = $qpl_data_dir . '/qpl_' . $this->getId(); - ilFileUtils::makeDir($qpl_dir); - if (!@is_dir($qpl_dir)) { - $this->error->raiseError('Creation of Questionpool Directory failed.', $this->error->FATAL); - } - // create Export subdirectory (data_dir/lm_data/lm_/Export) - ilFileUtils::makeDir($this->getExportDirectory('xlsx')); - if (!@is_dir($this->getExportDirectory('xlsx'))) { - $this->error->raiseError('Creation of Export Directory failed.', $this->error->FATAL); - } - ilFileUtils::makeDir($this->getExportDirectory('zip')); - if (!@is_dir($this->getExportDirectory('zip'))) { - $this->error->raiseError('Creation of Export Directory failed.', $this->error->FATAL); - } - } - - /** - * get export directory of questionpool - */ - public function getExportDirectory($type = ''): string - { - switch ($type) { - case 'xml': - $export_dir = ilExport::_getExportDirectory($this->getId(), $type, $this->getType()); - break; - case 'xlsx': - case 'zip': - $export_dir = ilFileUtils::getDataDir() . "/qpl_data/qpl_{$this->getId()}/export_{$type}"; - break; - default: - $export_dir = ilFileUtils::getDataDir() . '/qpl_data' . '/qpl_' . $this->getId() . '/export'; - break; - } - return $export_dir; - } - /** * Retrieve an array containing all question ids of the questionpool * @@ -599,15 +357,6 @@ public function checkQuestionParent(int $question_id): bool return (bool) $row['cnt']; } - /** - * get array of (two) new created questions for - * import id - */ - public function getImportMapping(): array - { - return []; - } - /** * Returns a QTI xml representation of a list of questions * @@ -1242,10 +991,4 @@ public static function isSkillManagementGloballyActivated(): ?bool return self::$isSkillManagementGloballyActivated; } - - public function fromXML(?string $xml_file): void - { - $parser = new ilObjQuestionPoolXMLParser($this, $xml_file); - $parser->startParsing(); - } } diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php b/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php index 98195c86b9e0..8475a5bad25c 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php @@ -20,6 +20,15 @@ use ILIAS\Skill\Service\SkillUsageService; use ILIAS\TestQuestionPool\QuestionPoolDIC; +use ILIAS\TestQuestionPool\Import\TestQuestionsImportTrait; +use ILIAS\TestQuestionPool\ExportImport\Import\UploadValidationStage; +use ILIAS\TestQuestionPool\ExportImport\Import\QuestionSelectionStage; +use ILIAS\TestQuestionPool\ExportImport\Import\PersistStage; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportStageRunner; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportSessionRepository; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportContext; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\StageResult; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\StageResultType; use ILIAS\TestQuestionPool\RequestDataCollector; use ILIAS\TestQuestionPool\Questions\Presentation\QuestionTable; use ILIAS\TestQuestionPool\Questions\GeneralQuestionPropertiesRepository; @@ -32,9 +41,7 @@ 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; @@ -95,6 +102,7 @@ class ilObjQuestionPoolGUI extends ilObjectGUI implements ilCtrlBaseClassInterfa protected RequestDataCollector $request_data_collector; protected GeneralQuestionPropertiesRepository $questionrepository; protected GlobalTestSettings $global_test_settings; + protected ImportSessionRepository $import_session_repository; public function __construct() { @@ -120,7 +128,8 @@ public function __construct() $local_dic = QuestionPoolDIC::dic(); $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->global_test_settings = $local_dic['global_test_settings'];; + $this->import_session_repository = $local_dic['exportimport.session']; parent::__construct('', $this->request_data_collector->getRefId(), true, false); @@ -731,62 +740,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'); @@ -877,18 +830,6 @@ function ($vs): bool { )->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, @@ -1357,49 +1298,77 @@ protected function importQuestionsFile(string $path_to_uploaded_file_in_temp_dir exit; } + + 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); + $this->import_session_repository->clear(); - $options = (new ILIAS\Filesystem\Util\Archive\UnzipOptions()) - ->withZipOutputPath($this->getImportTempDirectory()); + $context = new ImportContext(['file_to_import' => $file_to_import]); + $this->import_session_repository->setContext($context); + $this->import_session_repository->setCurrentStageIndex(0); - $unzip = $this->archives->unzip(Streams::ofResource(fopen($file_to_import, 'r')), $options); - $unzip->extract(); + $this->ctrl->redirectByClass(self::class, 'processImport'); + } - if (!file_exists($qtifile)) { - ilFileUtils::delDir($importdir); - $this->tpl->setOnScreenMessage('failure', $this->lng->txt('cannot_find_xml'), true); + public function processImportObject(): void + { + $permission = $this->creation_mode ? 'create' : 'read'; + if (!$this->checkPermissionBool($permission, '', $this->object->getType())) { + $this->redirectAfterMissingWrite(); return; } - ilSession::set('path_to_import_file', $file_to_import); - ilSession::set('path_to_uploaded_file_in_temp_dir', $path_to_uploaded_file_in_temp_dir); + $runner = $this->buildImportStageRunner(); + $result = $runner->run($this->request); - $this->ctrl->setParameterByClass(self::class, 'new_type', $this->type); - $form = $this->buildImportQuestionsSelectionForm( - 'importVerifiedFile', - $importdir, - $qtifile, - $path_to_uploaded_file_in_temp_dir - ); + match ($result->type) { + StageResultType::INTERACT => $this->renderImportStage($runner, $result), + StageResultType::ADVANCE => $this->ctrl->redirectByClass(self::class, 'processImport'), + StageResultType::ERROR => $this->renderImportError($runner, $result), + StageResultType::COMPLETE => $this->renderImportSuccess($runner, $result), + }; + } - if ($form === null) { - return; - } + private function buildImportStageRunner(): ImportStageRunner + { + $form_action = $this->ctrl->getFormActionByClass(self::class, 'processImport'); - $panel = $this->ui_factory->panel()->standard( - $this->lng->txt('import_qpl'), + return new ImportStageRunner( [ - $this->ui_factory->legacy()->content($this->lng->txt('qpl_import_verify_found_questions')), - $form - ] + new UploadValidationStage($this->archives, $this->lng, 'components/ILIAS/TestQuestionPool'), + new QuestionSelectionStage($this->lng, $this->component_factory, $this->ui_factory, $form_action), + new PersistStage($this->lng, $this->request_data_collector, $this->import_session_repository), + ], + $this->import_session_repository, + ); + } + + private function renderImportStage(ImportStageRunner $runner, StageResult $result): void + { + $workflow = $runner->buildWorkflow($this->ui_factory, $this->lng->txt('import')); + $this->tpl->setContent( + $this->ui_renderer->render([$workflow, ...$result->components]) ); - $this->tpl->setContent($this->ui_renderer->render($panel)); - $this->tpl->printToStdout(); - exit; } + private function renderImportError(ImportStageRunner $runner, StageResult $result): void + { + $runner->reset(); + $this->tpl->setOnScreenMessage('failure', $result->error_message, true); + $this->ctrl->redirectByClass(self::class, self::DEFAULT_CMD); + } + + private function renderImportSuccess(ImportStageRunner $runner, StageResult $result): void + { + $runner->reset(); + $this->tpl->setOnScreenMessage('success', $this->lng->txt('object_imported'), true); + $this->ctrl->setParameter($this, 'ref_id', $result->context->get('pool_obj_id')); + $this->ctrl->redirectByClass(self::class, self::DEFAULT_CMD); + } + + + public function addLocatorItems(): void { $ilLocator = $this->locator; diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolXMLParser.php b/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolXMLParser.php deleted file mode 100755 index ee4ab378571e..000000000000 --- a/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolXMLParser.php +++ /dev/null @@ -1,138 +0,0 @@ - - * @version $Id$ - * - * @package components\ILIAS/Test - */ -class ilObjQuestionPoolXMLParser extends ilSaxParser -{ - private \ilObjQuestionPool $poolOBJ; - - private $inSettingsTag; - - private $inMetaDataTag; - private $inMdGeneralTag; - private bool $title_processed = false; - private bool $description_processed = false; - private string $cdata = ""; - - /** - * @param ilObjQuestionPool $poolOBJ - * @param $xmlFile - */ - public function __construct(ilObjQuestionPool $poolOBJ, ?string $xmlFile) - { - $this->poolOBJ = $poolOBJ; - - $this->inSettingsTag = false; - $this->inMetaDataTag = false; - $this->inMdGeneralTag = false; - - parent::__construct($xmlFile); - } - - public function setHandlers($a_xml_parser): void - { - xml_set_element_handler($a_xml_parser, $this->handlerBeginTag(...), $this->handlerEndTag(...)); - xml_set_character_data_handler($a_xml_parser, $this->handlerCharacterData(...)); - } - - public function handlerBeginTag($xmlParser, $tagName, $tagAttributes): void - { - switch ($tagName) { - case 'MetaData': - $this->inMetaDataTag = true; - break; - - case 'General': - if ($this->inMetaDataTag) { - $this->inMdGeneralTag = true; - } - break; - - case 'Title': - case 'Description': - $this->cdata = ''; - break; - - case 'Settings': - $this->inSettingsTag = true; - break; - - case 'NavTaxonomy': - case 'SkillService': - if ($this->inSettingsTag) { - $this->cdata = ''; - } - break; - } - } - - public function handlerEndTag($xmlParser, $tagName): void - { - switch ($tagName) { - case 'MetaData': - $this->inMetaDataTag = false; - break; - - case 'General': - if ($this->inMetaDataTag) { - $this->inMdGeneralTag = false; - } - break; - - case 'Title': - if (!$this->title_processed) { - $this->poolOBJ->setTitle($this->cdata); - $this->title_processed = true; - $this->cdata = ''; - } - break; - - case 'Description': - if (!$this->description_processed) { - $this->poolOBJ->setDescription($this->cdata); - $this->description_processed = true; - $this->cdata = ''; - } - break; - - case 'Settings': - $this->inSettingsTag = false; - break; - - case 'SkillService': - $this->poolOBJ->setSkillServiceEnabled((bool) $this->cdata); - $this->cdata = ''; - break; - } - } - - public function handlerCharacterData($xmlParser, $charData): void - { - if ($charData != "\n") { - // Replace multiple tabs with one space - $charData = preg_replace("/\t+/", " ", $charData); - - $this->cdata .= $charData; - } - } -} diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php index f8d033a24518..15420a6c6900 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php @@ -18,9 +18,11 @@ declare(strict_types=1); -use ILIAS\TestQuestionPool\Import\TestQuestionsImportTrait; +use ILIAS\Data\ReferenceId; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportSessionRepository; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Serializing\SimpleXMLDeserializer; +use ILIAS\TestQuestionPool\ExportImport\QuestionPoolImporter; use ILIAS\TestQuestionPool\QuestionPoolDIC; -use ILIAS\TestQuestionPool\RequestDataCollector; /** * Importer class for question pools @@ -32,17 +34,16 @@ class ilTestQuestionPoolImporter extends ilXmlImporter { - use TestQuestionsImportTrait; - - private ilObjQuestionPool $pool_obj; - protected readonly RequestDataCollector $request_data_collector; + protected readonly ImportSessionRepository $session; + protected readonly QuestionPoolImporter $importer; public function __construct() { parent::__construct(); $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 importXmlRepresentation( @@ -51,58 +52,15 @@ public function importXmlRepresentation( string $a_xml, ilImportMapping $a_mapping ): void { - global $DIC; - // Container import => pool object already created - if (($new_id = $a_mapping->getMapping('components/ILIAS/Container', 'objs', $a_id)) !== null) { - $new_obj = ilObjectFactory::getInstanceByObjId((int) $new_id, false); - $new_obj->getObjectProperties()->storePropertyIsOnline($new_obj->getObjectProperties()->getPropertyIsOnline()->withOffline()); // sets Question pools to always online - - $selected_questions = []; - [$importdir, $xmlfile, $qtifile] = $this->buildImportDirectoriesFromContainerImport( - $this->getImportDirectory() - ); - } elseif (($new_id = $a_mapping->getMapping('components/ILIAS/TestQuestionPool', 'qpl', 'new_id')) !== null) { - $new_obj = ilObjectFactory::getInstanceByObjId((int) $new_id, false); - - $selected_questions = ilSession::get('qpl_import_selected_questions'); - [$subdir, $importdir, $xmlfile, $qtifile] = $this->buildImportDirectoriesFromImportFile( - ilSession::get('path_to_import_file') - ); - ilSession::clear('qpl_import_selected_questions'); - } else { - // Shouldn't happen - $DIC['ilLog']->write(__METHOD__ . ': non container and no tax mapping, perhaps old qpl export'); - return; - } - - if (!file_exists($xmlfile)) { - $DIC['ilLog']->write(__METHOD__ . ': Cannot find xml definition: ' . $xmlfile); - return; - } - if (!file_exists($qtifile)) { - $DIC['ilLog']->write(__METHOD__ . ': Cannot find qti definition: ' . $qtifile); - return; - } - - $this->pool_obj = $new_obj; - - $new_obj->fromXML($xmlfile); - - $qpl_new = $this->request_data_collector->string('qpl_new'); - - // set another question pool name (if possible) - if ($qpl_new !== '') { - $new_obj->setTitle($qpl_new); - } - - $new_obj->update(); - $new_obj->saveToDb(); - - // FIXME: Copied from ilObjQuestionPoolGUI::importVerifiedFileObject - // TODO: move all logic to ilObjQuestionPoolGUI::importVerifiedFile and call - // this method from ilObjQuestionPoolGUI and ilTestImporter + $result = $this->importer->import( + new SimpleXMLDeserializer()->open($a_xml), + $a_mapping, + new ReferenceId($a_mapping->getTargetId()), + $this->session->getContext(), + ); + $this->session->setContext($result); + return; - $DIC['ilLog']->write(__METHOD__ . ': xml file: ' . $xmlfile . ', qti file:' . $qtifile); $qtiParser = new ilQTIParser( $importdir, @@ -120,58 +78,20 @@ public function importXmlRepresentation( ); $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' - ); - - - $new_obj->saveToDb(); } - /** - * 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) { + $qpl_mappings = $a_mapping->getMappingsOfEntity('components/ILIAS/TestQuestionPool', 'qpl'); + + foreach ($qpl_mappings as $old => $new) { if ($old !== 'new_id' && (int) $old > 0) { - $new_tax_ids = $a_mapping->getMapping('components/ILIAS/Taxonomy', 'tax_usage_of_obj', (string) $old); + $new_tax_ids = $a_mapping->getMapping( + 'components/ILIAS/Taxonomy', + 'tax_usage_of_obj', + (string) $old + ); + if ($new_tax_ids !== null) { $tax_ids = explode(':', $new_tax_ids); foreach ($tax_ids as $tid) { @@ -181,26 +101,4 @@ public function finalProcessing(ilImportMapping $a_mapping): void } } } - - protected function importQuestionSkillAssignments($xmlFile, ilImportMapping $mappingRegistry, $targetParentObjId): void - { - $parser = new ilAssQuestionSkillAssignmentXmlParser($xmlFile); - $parser->startParsing(); - - $importer = new ilAssQuestionSkillAssignmentImporter(); - $importer->setTargetParentObjId($targetParentObjId); - $importer->setImportInstallationId($this->getInstallId()); - $importer->setImportMappingRegistry($mappingRegistry); - $importer->setImportMappingComponent('components/ILIAS/TestQuestionPool'); - $importer->setImportAssignmentList($parser->getAssignmentList()); - - $importer->import(); - - if ($importer->getFailedImportAssignmentList()->assignmentsExist()) { - $qsaImportFails = new ilAssQuestionSkillAssignmentImportFails($targetParentObjId); - $qsaImportFails->registerFailedImports($importer->getFailedImportAssignmentList()); - - $this->pool_obj->getObjectProperties()->storePropertyIsOnline($this->pool_obj->getObjectProperties()->getPropertyIsOnline()->withOffline()); - } - } } diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Envelopes/QuestionImage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Envelopes/QuestionImage.php new file mode 100644 index 000000000000..a766438425eb --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Envelopes/QuestionImage.php @@ -0,0 +1,94 @@ +filename; + } + + public function getQuestionId(): ?int + { + return $this->question_id; + } + + public function getType(): int + { + return $this->type; + } + + public function getId(): string + { + return $this->id; + } + + public function setId(string $id): static + { + $this->id = $id; + return $this; + } + + /** + * @inheritDoc + */ + public function toArray(Transformations $tt): array + { + return [ + 'filename' => $this->filename, + 'question_id' => $this->question_id, + 'type' => $this->type, + 'id' => $this->id, + ]; + } + + /** + * @inheritDoc + */ + public static function fromArray(array $value, Transformations $tt): static + { + return new self( + $value['filename'], + $tt->int($value['question_id']), + $tt->int($value['type']), + $value['id'] + ); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/PersistStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/PersistStage.php new file mode 100644 index 000000000000..4a0a23f215ad --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/PersistStage.php @@ -0,0 +1,75 @@ +lng->txt('qpl_import_step_persist'); + } + + public function getDescription(): string + { + return ''; + } + + public function process(ImportContext $context, ServerRequestInterface $request): StageResult + { + $importer = new ilImport($this->request_data_collector->getRefId()); + $importer->importObject( + null, + $context->get('file_to_import'), + basename($context->get('file_to_import')), + 'qpl', + 'components/ILIAS/TestQuestionPool', + true, + ); + + // Context is updated by the QuestionPoolImporter so we need to reload it + return StageResult::complete($this->session->getContext()); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionSelectionStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionSelectionStage.php new file mode 100644 index 000000000000..e7cabc668d30 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionSelectionStage.php @@ -0,0 +1,181 @@ + \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 ilComponentFactory $component_factory, + private readonly UIFactory $ui_factory, + private readonly string $form_action, + ) { + } + + public function getIdentifier(): string + { + return 'question_selection'; + } + + public function getLabel(): string + { + return $this->lng->txt('qpl_import_step_select'); + } + + public function getDescription(): string + { + return ''; + } + + public function process(ImportContext $context, ServerRequestInterface $request): StageResult + { + if ($context->has('selectable_questions')) { + $options = []; + foreach ($context->get('selectable_questions') as $question) { + $options[$question] = $question; + } + + $data = $this->buildSelectQuestionsForm($options) + ->withRequest($request) + ->getData(); + + if (isset($data['selected_questions'])) { + return StageResult::advance($context->with('selected_questions', $data['selected_questions'])); + } + } + + if (!$context->has('import_file')) { + return StageResult::error($context, $this->lng->txt('qpl_import_file_not_found')); + } + + $options = []; + $deserializer = new SimpleXMLDeserializer()->open(file_get_contents($context->get('import_file'))); + $deserializer->addHandler('questions', function (array $questions) use (&$options): void { + foreach ($questions as $question) { + if (!isset($question['title']) || !isset($question['type'])) { + continue; + } + + $raw_id = $question['id']; + $id = is_array($raw_id) ? (string) ($raw_id['id'] ?? '') : (string) $raw_id; + $options[$id] = "{$question['title']} ({$this->getLabelForQuestionType($question['type'])})"; + } + }); + $deserializer->process(); + + if ($options === []) { + return StageResult::error($context, $this->lng->txt('qpl_import_no_items')); + } + + $panel = $this->ui_factory->panel()->standard( + $this->lng->txt('import_qpl'), + [ + $this->ui_factory->legacy()->content($this->lng->txt('qpl_import_verify_found_questions')), + $this->buildSelectQuestionsForm($options) + ] + ); + + return StageResult::interact( + $context->with('selectable_questions', array_keys($options)), + [$panel] + ); + } + + /** + * @return list + */ + public static function getSelectedQuestions(ImportContext $context): array + { + return array_map('intval', $context->get('selected_questions', [])); + } + + private function buildSelectQuestionsForm(array $options): Form + { + $input = $this->ui_factory->input()->field()->multiSelect( + $this->lng->txt('questions'), + $options + )->withValue(array_keys($options)); + + $form = $this->ui_factory->input()->container()->form()->standard( + $this->form_action, + ['selected_questions' => $input] + )->withSubmitLabel($this->lng->txt('import')); + + return $form; + } + + private function getLabelForQuestionType(string $type): string + { + if ($this->lng->exists($type)) { + return $this->lng->txt($type); + } + + /** + * @todo Remove with ILIAS 12: This is here for backward compatibility. + * As we support the import of a previous version this should go with + * ILIAS 11, but being generous: ILIAS 12 it is. + */ + if (array_key_exists($type, $this->old_export_question_types)) { + return $this->lng->txt($this->old_export_question_types[$type]); + } + return $this->getLabelForPluginQuestionTypes($type); + } + + private function getLabelForPluginQuestionTypes(string $type): string + { + foreach ($this->component_factory->getActivePluginsInSlot('qst') as $pl) { + if ($pl->getQuestionType() === $type) { + return $pl->getQuestionTypeTranslation(); + } + } + return $type; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/UploadValidationStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/UploadValidationStage.php new file mode 100644 index 000000000000..0cb137a55fc6 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/UploadValidationStage.php @@ -0,0 +1,101 @@ +lng->txt('upload'); + } + + public function getDescription(): string + { + return ''; + } + + public function process(ImportContext $context, ServerRequestInterface $request): StageResult + { + $file_to_import = $context->get('file_to_import'); + if ( + $file_to_import === null + || !is_file($file_to_import) + || !str_ends_with(strtolower($file_to_import), '.zip') + ) { + return StageResult::error($context, $this->lng->txt('obj_import_file_error')); + } + + $subdir = basename($file_to_import, '.zip'); + $import_base_dir = self::IMPORT_TEMP_DIR . DIRECTORY_SEPARATOR . $subdir; + + $options = (new UnzipOptions())->withZipOutputPath(self::IMPORT_TEMP_DIR); + $unzip = $this->archives->unzip(Streams::ofResource(fopen($file_to_import, 'r')), $options); + $unzip->extract(); + + $manifest = new ilManifestParser($import_base_dir . DIRECTORY_SEPARATOR . 'manifest.xml'); + $export_file = array_find( + $manifest->getExportFiles(), + fn($file): bool => $file['component'] === $this->component + ); + + if ($export_file === null) { + return StageResult::error($context, $this->lng->txt('obj_import_file_error')); + } + + return StageResult::advance( + $context->with('import_file', $import_base_dir . DIRECTORY_SEPARATOR . $export_file['path']) + ->with('install_id', $manifest->getInstallId()) + ); + } + + public static function getInstallId(ImportContext $context): int + { + return intval($context->get('install_id')); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolImporter.php b/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolImporter.php new file mode 100644 index 000000000000..5b3d36b3d4d9 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolImporter.php @@ -0,0 +1,191 @@ +builder->withAdditionalPipes(append: [$id_mapping_pipe])->create(); + + $selected_questions = QuestionSelectionStage::getSelectedQuestions($context); + + $deserializer->addHandler( + 'general', + function (array $objects) use ($tt, $mapping, $parent_id, &$context): void { + $new_pool_id = $this->importQuestionPool( + array_pop($objects), + $tt, + $mapping, + $parent_id + ); + $context = $context->with('pool_obj_id', $new_pool_id); + } + ); + + $deserializer->addHandler( + 'questions', + function (array $questions) use ($tt, $mapping, $selected_questions): void { + foreach ($questions as $question) { + $this->importQuestion( + $question, + $tt, + $mapping, + $selected_questions + ); + } + } + ); + + $deserializer->addHandler( + 'skill_assignments', + function (array $assignments) use ($tt, &$context): void { + $result = $this->skill_importer->import( + $assignments, + UploadValidationStage::getInstallId($context), + $tt, + ); + $context = $context->with('skill_assignments', $result); + } + ); + + $deserializer->process(); + + return $context; + } + + protected function importQuestionPool( + array $normalized, + Transformations $transformations, + ilImportMapping $mapping, + ReferenceId $parent_id + ): int { + $pool_object = $transformations->denormalize($normalized, ilObjQuestionPool::class); + $old_pool_id = $pool_object->getId(); + + $pool_object->setTitle('Imported'); //TODO: Remove after testing + $new_pool_id = $pool_object->create(true); + $pool_object->getObjectProperties()->storePropertyIsOnline( + $pool_object->getObjectProperties()->getPropertyIsOnline()->withOffline() + ); + $pool_object->saveToDb(); + + $pool_object->createReference(); + $pool_object->putInTree($parent_id->toInt()); + $pool_object->setPermissions($parent_id->toInt()); + + $mapping->addMapping(self::COMPONENT, 'qpl', (string) $old_pool_id, (string) $new_pool_id); + $mapping->addMapping(self::COMPONENT, '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; + } + + protected function importQuestion( + array $normalized, + Transformations $transformations, + ilImportMapping $mapping, + array $selected_questions + ): void { + $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; + } + + $feedback_class = $question::getFeedbackClassNameByQuestionType($question->getQuestionType()); + $question->feedbackOBJ = new $feedback_class($question, $this->ctrl, $this->database, $this->language); + + $new_question_id = $question->createNewQuestion(false); + $question->saveToDb(); + + $mapping->addMapping(self::COMPONENT, 'question', (string) $old_question_id, (string) $new_question_id); + $mapping->addMapping('components/ILIAS/Taxonomy', 'tax_item', "qpl:quest:{$old_question_id}", (string) $new_question_id); + $mapping->addMapping('components/ILIAS/Taxonomy', 'tax_item_obj_id', "qpl:quest:{$old_question_id}", (string) $question->getObjId()); + $mapping->addMapping('components/ILIAS/COPage', 'pg', "qpl:{$old_question_id}", "qpl:{$new_question_id}"); + + $feedback = $transformations->denormalize($normalized['feedback'], Feedback::class); + $this->importFeedback($feedback, $question); + } + + protected 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'] + ); + } + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/SkillAssignmentsImporter.php b/components/ILIAS/TestQuestionPool/src/ExportImport/SkillAssignmentsImporter.php new file mode 100644 index 000000000000..a22364815c72 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/SkillAssignmentsImporter.php @@ -0,0 +1,131 @@ +> $normalized_assignments + * @return array{failed: list, success: list} + */ + public function import( + array $normalized_assignments, + int $import_install_id, + Transformations $transformations, + ): array { + $result = ['failed' => [], 'success' => []]; + + foreach ($normalized_assignments as $item) { + $assignment = $transformations->denormalize($item, ilAssQuestionSkillAssignment::class); + + $skill_data = $this->getSkillIdMapping( + $assignment->getSkillBaseId(), + $assignment->getSkillTrefId(), + $import_install_id + ); + if ($skill_data === null) { + $result['failed'][] = $this->buildResultData($assignment); + continue; + } + + // Map imported skill ids to local skill ids. Question id and object id are already replaced in the id + // mapping pipe. + $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; + } + + protected function getSkillIdMapping(int $skill_base_id, int $skill_tref_id, int $import_install_id): ?array + { + if ($import_install_id === $this->local_install_id) { + return [ + 'skill_id' => $skill_base_id, + 'tref_id' => $skill_tref_id, + ]; + } + + $found_skill_data = $this->skill_repo->getCommonSkillIdForImportId( + $import_install_id, + $skill_base_id, + $skill_tref_id + ); + + $skill_data = current($found_skill_data); + if (!is_array($skill_data) || !isset($skill_data['skill_id']) || !isset($skill_data['tref_id'])) { + return null; + } + + return $skill_data; + } + + /** + * @return ImportResultData + */ + protected function buildResultData(ilAssQuestionSkillAssignment $assignment): array + { + return [ + 'skill_id' => $assignment->getSkillBaseId(), + 'tref_id' => $assignment->getSkillTrefId(), + 'title' => $assignment->getSkillTitle(), + 'path' => $assignment->getSkillPath(), + ]; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php b/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php index 1f32c1fbb89f..425580ba50fa 100755 --- a/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php +++ b/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php @@ -21,7 +21,10 @@ namespace ILIAS\TestQuestionPool; use ILIAS\TestQuestionPool\ExportImport\Foundation\Builder; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportSessionRepository; use ILIAS\TestQuestionPool\ExportImport\QuestionPoolExporter; +use ILIAS\TestQuestionPool\ExportImport\QuestionPoolImporter; +use ILIAS\TestQuestionPool\ExportImport\SkillAssignmentsImporter; use Pimple\Container as PimpleContainer; use ILIAS\DI\Container as ILIASContainer; use ILIAS\TestQuestionPool\Questions\SuggestedSolution\SuggestedSolutionsDatabaseRepository; @@ -82,6 +85,23 @@ protected static function buildDIC(ILIASContainer $DIC): self $DIC->taxonomy()->domain() ); + $dic['exportimport.session'] = static fn($c): ImportSessionRepository => + new ImportSessionRepository('qpl'); + $dic['exportimport.skill_assignments_importer'] = static fn($c): SkillAssignmentsImporter => + new SkillAssignmentsImporter( + $DIC->skills()->internal()->repo()->getTreeRepo(), + $DIC->skills()->usage(), + (int) $DIC->settings()->get('inst_id', '0') + ); + $dic['exportimport.importer'] = static fn($c): QuestionPoolImporter => + new QuestionPoolImporter( + $c['exportimport.builder'], + $DIC->ctrl(), + $DIC->database(), + $DIC->language(), + $c['exportimport.skill_assignments_importer'] + ); + return $dic; } } diff --git a/lang/ilias_de.lang b/lang/ilias_de.lang index ec6d7552ec0e..d4d012738ebe 100644 --- a/lang/ilias_de.lang +++ b/lang/ilias_de.lang @@ -1066,6 +1066,8 @@ assessment#:#qpl_imagemap_preview_missing#:#ILIAS konnte keine Vorschaudatei mit assessment#:#qpl_import_create_new_qpl#:#In einen neuen Fragenpool importieren assessment#:#qpl_import_no_items#:#Fehler: die Importdatei enthält keine Fragen! assessment#:#qpl_import_non_ilias_files#:#Fehler: Die Importdatei enthält QTI-Dateien, die nicht von einem ILIAS-System erstellt wurden. Bitte kontaktieren Sie das ILIAS-Team, um einen Importfilter für Ihr QTI-Dateiformat zu bekommen. +assessment#:#qpl_import_step_persist#:#Fragenpool in ILIAS importieren +assessment#:#qpl_import_step_select#:#Fragen auswählen assessment#:#qpl_import_verify_found_questions#:#ILIAS hat die folgenden Fragen in der Importdatei gefunden. Bitte wählen Sie die Fragen aus, die Sie importieren wollen. assessment#:#qpl_lac_desc_brackets#:#Klammerung assessment#:#qpl_lac_desc_compare_answer_exist#:#Vergleiche ob für eine Frage/Lücke einer Frage keine Antwort gegeben wurde diff --git a/lang/ilias_en.lang b/lang/ilias_en.lang index 404b0f4aa53b..42467261257a 100644 --- a/lang/ilias_en.lang +++ b/lang/ilias_en.lang @@ -1066,6 +1066,8 @@ assessment#:#qpl_imagemap_preview_missing#:#ILIAS could not create the temporary assessment#:#qpl_import_create_new_qpl#:#Import the questions in a new question pool assessment#:#qpl_import_no_items#:#Error: The import file contains no questions! assessment#:#qpl_import_non_ilias_files#:#Error: The import file contains QTI files which are not created by an ILIAS system. Please contact the ILIAS team to get in import filter for your QTI file format. +assessment#:#qpl_import_step_persist#:#Import question pool into ILIAS +assessment#:#qpl_import_step_select#:#Select questions to import assessment#:#qpl_import_verify_found_questions#:#ILIAS found the following questions in the import file. Please select the questions you want to import. assessment#:#qpl_lac_desc_brackets#:#Brackets assessment#:#qpl_lac_desc_compare_answer_exist#:#Compare if there is an Answer for a Question/Gap From 3fd6f9209beb544c9e4ce21c1878b4932896e4ee Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Thu, 9 Apr 2026 15:21:41 +0200 Subject: [PATCH 05/43] fix(qpl): support copage import --- .../Info/Export/Component/Handler.php | 2 +- .../class.ilTestQuestionPoolImporter.php | 64 ++++++++++++++----- .../src/ExportImport/QuestionPoolImporter.php | 1 + 3 files changed, 49 insertions(+), 18 deletions(-) diff --git a/components/ILIAS/Export/classes/ExportHandler/Info/Export/Component/Handler.php b/components/ILIAS/Export/classes/ExportHandler/Info/Export/Component/Handler.php index 6d6e5146d674..949003fb0670 100644 --- a/components/ILIAS/Export/classes/ExportHandler/Info/Export/Component/Handler.php +++ b/components/ILIAS/Export/classes/ExportHandler/Info/Export/Component/Handler.php @@ -58,7 +58,7 @@ protected function init(): void throw new ilExportException('Export class file "' . $export_class_file . '" not found.'); } } - $this->sv = $this->getMinimalComponentExporter()->determineSchemaVersion($component, $this->getTarget()->getTargetRelease()); + $this->sv = $this->getMinimalComponentExporter()->determineSchemaVersion($this->getTarget()->getType(), $this->getTarget()->getTargetRelease()); $this->sv["uses_dataset"] ??= false; $this->sv['xsd_file'] ??= ''; } diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php index 15420a6c6900..27cae121f9b1 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php @@ -59,28 +59,58 @@ public function importXmlRepresentation( $this->session->getContext(), ); $this->session->setContext($result); - return; + } + public function finalProcessing(ilImportMapping $a_mapping): void + { + $this->finalizeQuestionPages($a_mapping); + $this->finalizeTaxonomyUsage($a_mapping); + } - $qtiParser = new ilQTIParser( - $importdir, - $qtifile, - ilQTIParser::IL_MO_PARSE_QTI, - $new_obj->getId(), - $selected_questions - ); - $qtiParser->startParsing(); + /** + * Finalize the imported question pages by replacing the old question ids with the new question ids. + */ + private function finalizeQuestionPages(ilImportMapping $a_mapping): void + { + $page_mappings = $a_mapping->getMappingsOfEntity('components/ILIAS/COPage', 'pg'); - $questionPageParser = new ilQuestionPageParser( - $new_obj, - $xmlfile, - $importdir - ); - $questionPageParser->setQuestionMapping($qtiParser->getImportMapping()); - $questionPageParser->startParsing(); + 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 = $a_mapping->getMapping( + 'components/ILIAS/TestQuestionPool', + 'question_assignment', + $new_question_id + ); + if ($parent_obj_id !== null) { + $page->setParentId((int) $parent_obj_id); + } + + $page->updateFromXML(); + unset($page); + } } - public function finalProcessing(ilImportMapping $a_mapping): void + private function finalizeTaxonomyUsage(ilImportMapping $a_mapping): void { $qpl_mappings = $a_mapping->getMappingsOfEntity('components/ILIAS/TestQuestionPool', 'qpl'); diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolImporter.php b/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolImporter.php index 5b3d36b3d4d9..e96b6f314ac2 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolImporter.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolImporter.php @@ -165,6 +165,7 @@ protected function importQuestion( $question->saveToDb(); $mapping->addMapping(self::COMPONENT, 'question', (string) $old_question_id, (string) $new_question_id); + $mapping->addMapping(self::COMPONENT, 'question_assignment', (string) $new_question_id, (string) $question->getObjId()); $mapping->addMapping('components/ILIAS/Taxonomy', 'tax_item', "qpl:quest:{$old_question_id}", (string) $new_question_id); $mapping->addMapping('components/ILIAS/Taxonomy', 'tax_item_obj_id', "qpl:quest:{$old_question_id}", (string) $question->getObjId()); $mapping->addMapping('components/ILIAS/COPage', 'pg', "qpl:{$old_question_id}", "qpl:{$new_question_id}"); From 2470373c0e9a879ca51bc39249a7d32194ac34f8 Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Thu, 9 Apr 2026 15:28:50 +0200 Subject: [PATCH 06/43] fix(qpl): metadata import fails due to xsd validation errors --- components/ILIAS/Export/xml/SchemaValidation/ilias_md_10_0.xsd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/ILIAS/Export/xml/SchemaValidation/ilias_md_10_0.xsd b/components/ILIAS/Export/xml/SchemaValidation/ilias_md_10_0.xsd index df685edf75cd..f9a02f452173 100644 --- a/components/ILIAS/Export/xml/SchemaValidation/ilias_md_10_0.xsd +++ b/components/ILIAS/Export/xml/SchemaValidation/ilias_md_10_0.xsd @@ -197,7 +197,7 @@ - + From 6f285a86022472dc432adb7a29745e084e8fa1cc Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Fri, 10 Apr 2026 09:58:32 +0200 Subject: [PATCH 07/43] fix(qpl): formula unit and categories import --- .../classes/class.assFormulaQuestionUnit.php | 4 +- .../ExportImport/QuestionPoolCollector.php | 34 +++++++--- .../src/ExportImport/QuestionPoolExporter.php | 40 +++--------- .../src/ExportImport/QuestionPoolImporter.php | 62 ++++++++++++++++++- 4 files changed, 96 insertions(+), 44 deletions(-) diff --git a/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionUnit.php b/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionUnit.php index aecd91246e3f..085204feb2fe 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionUnit.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assFormulaQuestionUnit.php @@ -176,7 +176,7 @@ public function toNormalized(Transformations $tt): Transformation 'factor' => $this->factor, 'category_id' => $tt->normalize(new Id($this->category, 'unit_category')), 'sequence' => $this->sequence, - 'baseunit' => $this->baseunit, + 'baseunit' => $tt->normalize(new Id($this->baseunit, 'unit')), 'baseunit_title' => $this->baseunit_title, ]); } @@ -193,7 +193,7 @@ public function fromNormalized(Transformations $tt): Transformation $clone->factor = $tt->float($normalized['factor']); $clone->category = $tt->denormalize($normalized['category_id'], Id::class)->getId(); $clone->sequence = $tt->int($normalized['sequence']); - $clone->baseunit = $tt->int($normalized['baseunit']); + $clone->baseunit = $tt->denormalize($normalized['baseunit'], Id::class)->getId(); $clone->baseunit_title = $tt->string($normalized['baseunit_title']); return $clone; diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolCollector.php b/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolCollector.php index ecba542b2c15..860cfb630b0a 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolCollector.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolCollector.php @@ -20,6 +20,8 @@ namespace ILIAS\TestQuestionPool\ExportImport; +use assFormulaQuestionUnit; +use assFormulaQuestionUnitCategory; use assQuestion; use Generator; use ilAssQuestionSkillAssignmentList; @@ -106,18 +108,34 @@ public function getQuestionObjects(): Generator */ /** - * Collect the unit categories and their units for all formula questions in the question pool. - * - * @return Generator<\assFormulaQuestionUnit|\assFormulaQuestionUnitCategory> + * Get all unit categories and units for a formula question. + * + * @return array{categories: list, base_units: list, units: list} */ - public function getUnits(): Generator + public function getUnitsAndCategories(int $question_id): array { - foreach ($this->getQuestionProperties() as $question) { - if ($question->getClassName() === 'assFormulaQuestion') { - $repository = new ilUnitConfigurationRepository($question->getQuestionId()); - yield from $repository->getCategorizedUnits(); + $repository = new ilUnitConfigurationRepository($question_id); + $data = [ + 'categories' => [], + 'base_units' => [], + 'units' => [], + ]; + + foreach ($repository->getCategorizedUnits() as $item) { + if($item instanceof assFormulaQuestionUnitCategory) { + $data['categories'][] = $item; + } + + if($item instanceof assFormulaQuestionUnit) { + if($item->getBaseUnit() === 0 || $item->getBaseUnit() === $item->getId()) { + $data['base_units'][] = $item; + } else { + $data['units'][] = $item; + } } } + + return $data; } /* diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolExporter.php b/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolExporter.php index 441d942f6b05..71d6821a2942 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolExporter.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolExporter.php @@ -20,8 +20,7 @@ namespace ILIAS\TestQuestionPool\ExportImport; -use assFormulaQuestionUnit; -use assFormulaQuestionUnitCategory; +use assFormulaQuestion; use ilDBInterface; use ILIAS\Data\ObjectId; use ILIAS\Data\UUID\Factory as UUIDFactory; @@ -31,11 +30,9 @@ use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Serializer; use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\TestQuestionPool\ExportImport\Foundation\ExportContext; -use ILIAS\Questions\Units\Repository as UnitsRepository; use ILIAS\Taxonomy\DomainService as Taxonomy; use ILIAS\TestQuestionPool\ExportImport\Pipes\CollectQuestionImages; use ILIAS\TestQuestionPool\Questions\GeneralQuestionPropertiesRepository; -use SebastianBergmann\CodeCoverage\Report\Xml\Unit; /** * Orchestrates the export of a question pool. It uses the Builder to create a pipeline of transformations that are used @@ -104,10 +101,6 @@ public function process(ExportContext $context, Serializer $serializer): ExportC 'general', fn() => $this->exportObject($collector, $tt, $serializer, $context) ); - $serializer->group( - 'units', - fn() => $this->exportUnits($collector, $tt, $serializer) - ); $serializer->group( 'questions', fn() => $this->exportQuestions($collector, $tt, $serializer, $context) @@ -154,28 +147,6 @@ protected function exportObject( ); } - protected function exportUnits( - QuestionPoolCollector $collector, - Transformations $transformations, - Serializer $serializer - ): void { - $categories = []; - - foreach ($collector->getUnits() as $item) { - if($item instanceof assFormulaQuestionUnitCategory) { - $categories[$item->getId()] = $transformations->normalize($item); - } - - if($item instanceof assFormulaQuestionUnit) { - $categories[$item->getCategory()]['units'][] = $transformations->normalize($item); - } - } - - foreach($categories as $category) { - $serializer->append('category', $category); - } - } - protected function exportQuestions( QuestionPoolCollector $collector, Transformations $transformations, @@ -183,13 +154,18 @@ protected function exportQuestions( ExportContext $export ): void { foreach ($collector->getQuestionObjects() as $question) { - $serializer->append('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); $export->addDependency('components/ILIAS/COPage', 'pg', ["qpl:{$question->getId()}"]); } } diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolImporter.php b/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolImporter.php index e96b6f314ac2..24fba228ccbf 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolImporter.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolImporter.php @@ -20,6 +20,9 @@ namespace ILIAS\TestQuestionPool\ExportImport; +use assFormulaQuestion; +use assFormulaQuestionUnit; +use assFormulaQuestionUnitCategory; use assQuestion; use ilCtrl; use ilDBInterface; @@ -29,12 +32,14 @@ use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Deserializer; use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportContext; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Envelopes\Id; use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Pipes\IdMappingPipe; use ILIAS\TestQuestionPool\ExportImport\Envelopes\Feedback; use ILIAS\TestQuestionPool\ExportImport\Import\QuestionSelectionStage; use ILIAS\TestQuestionPool\ExportImport\Import\UploadValidationStage; use ilImportMapping; use ilObjQuestionPool; +use ilUnitConfigurationRepository; /** * Orchestrates the import of a question pool. It uses the Builder to create a pipeline of transformations that are used @@ -158,18 +163,25 @@ protected function importQuestion( return; } + // 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); - $question->saveToDb(); - $mapping->addMapping(self::COMPONENT, 'question', (string) $old_question_id, (string) $new_question_id); $mapping->addMapping(self::COMPONENT, 'question_assignment', (string) $new_question_id, (string) $question->getObjId()); $mapping->addMapping('components/ILIAS/Taxonomy', 'tax_item', "qpl:quest:{$old_question_id}", (string) $new_question_id); $mapping->addMapping('components/ILIAS/Taxonomy', 'tax_item_obj_id', "qpl:quest:{$old_question_id}", (string) $question->getObjId()); $mapping->addMapping('components/ILIAS/COPage', 'pg', "qpl:{$old_question_id}", "qpl:{$new_question_id}"); + 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); } @@ -189,4 +201,50 @@ protected function importFeedback(Feedback $feedback, assQuestion $question): vo ); } } + + protected 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(self::COMPONENT, 'unit_category', (string) $old_category_id, (string) $category->getId()); + } + + // Ensure base units are imported first so they can be referenced by the units. The mapping pipe will ensure + // that the category id, question id and base unit id are mapped to the new ids. + $normalized_units = array_merge($formula['base_units'], $formula['units']); + foreach ($normalized_units as $normalized_unit) { + $old_unit_id = $transformations->denormalize($normalized_unit['id'], Id::class)->getId(); + + $unit = new assFormulaQuestionUnit(); + $repository->createNewUnit($unit); + $mapping->addMapping(self::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); + } + } } From 31ebd27b0453962157b4472b3672f7bca2fc7968 Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Mon, 13 Apr 2026 11:59:04 +0200 Subject: [PATCH 08/43] feat(qpl): introduce question image file import --- .../Pipes/CollectQuestionImages.php | 46 +++++++++++++++---- .../src/ExportImport/QuestionPoolImporter.php | 37 ++++++++++++++- .../src/Questions/Files/QuestionFiles.php | 5 ++ 3 files changed, 78 insertions(+), 10 deletions(-) diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Pipes/CollectQuestionImages.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Pipes/CollectQuestionImages.php index 5b82ef56272d..b32beb3e1d88 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Pipes/CollectQuestionImages.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Pipes/CollectQuestionImages.php @@ -23,6 +23,8 @@ use ILIAS\Data\ObjectId; use ILIAS\Data\UUID\Factory; use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Pipe; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\NormalizingException; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Pipes\DenormalizeCarry; use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Pipes\NormalizeCarry; use ILIAS\TestQuestionPool\ExportImport\Envelopes\QuestionImage; use ILIAS\TestQuestionPool\Questions\Files\QuestionFiles; @@ -40,6 +42,11 @@ class CollectQuestionImages implements Pipe */ private array $files = []; + /** + * @var array $envelopes + */ + private array $envelopes = []; + public function __construct( private readonly Factory $uuid_factory, private readonly ObjectId $pool_id, @@ -56,20 +63,20 @@ public function handle(mixed $passable, \Closure $next): mixed $this->handleNormalization($passable->value); } + if ($passable instanceof DenormalizeCarry && $passable->expected === QuestionImage::class) { + $this->handleDenormalization($passable); + } + return $next($passable); } private function handleNormalization(QuestionImage $envelope): void { - // Build the absolute source path - $base_dir = $this->question_files->buildImagePath( - $envelope->getQuestionId(), - $this->pool_id->toInt() - ); - - if ($envelope->getType() === QuestionImage::TYPE_SOLUTION) { - $base_dir = str_replace('images/', 'solution/', $base_dir); - } + $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(); @@ -83,6 +90,19 @@ private function handleNormalization(QuestionImage $envelope): void $this->files[] = ['from' => $source_path, 'to' => $target_path]; } + private function handleDenormalization(DenormalizeCarry $passable): void + { + $envelope = $passable->result(); + if (!$envelope instanceof QuestionImage) { + throw new NormalizingException('Expected question image envelope, got ' . get_debug_type($envelope)); + } + + $extension = pathinfo($envelope->getFilename(), PATHINFO_EXTENSION); + $path = $envelope->getId() . '.' . $extension; + + $this->envelopes[$path] = $envelope; + } + /** * @return list */ @@ -90,4 +110,12 @@ public function getFiles(): array { return $this->files; } + + /** + * @return array + */ + public function getEnvelopes(): array + { + return $this->envelopes; + } } diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolImporter.php b/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolImporter.php index 24fba228ccbf..ec98b3fd5dea 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolImporter.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolImporter.php @@ -27,7 +27,10 @@ use ilCtrl; use ilDBInterface; use ILIAS\Data\ReferenceId; +use ILIAS\Data\ObjectId; +use ILIAS\Data\UUID\Factory; use ILIAS\Language\Language; +use ILIAS\TestQuestionPool\ExportImport\Envelopes\QuestionImage; use ILIAS\TestQuestionPool\ExportImport\Foundation\Builder; use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Deserializer; use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; @@ -37,6 +40,8 @@ use ILIAS\TestQuestionPool\ExportImport\Envelopes\Feedback; use ILIAS\TestQuestionPool\ExportImport\Import\QuestionSelectionStage; use ILIAS\TestQuestionPool\ExportImport\Import\UploadValidationStage; +use ILIAS\TestQuestionPool\ExportImport\Pipes\CollectQuestionImages; +use ILIAS\TestQuestionPool\Questions\Files\QuestionFiles; use ilImportMapping; use ilObjQuestionPool; use ilUnitConfigurationRepository; @@ -70,7 +75,8 @@ public function import( ImportContext $context ): ImportContext { $id_mapping_pipe = new IdMappingPipe($mapping, self::COMPONENT); - $tt = $this->builder->withAdditionalPipes(append: [$id_mapping_pipe])->create(); + $images_pipe = new CollectQuestionImages(new Factory(), new ObjectId(0)); + $tt = $this->builder->withAdditionalPipes(append: [$id_mapping_pipe, $images_pipe])->create(); $selected_questions = QuestionSelectionStage::getSelectedQuestions($context); @@ -115,6 +121,9 @@ function (array $assignments) use ($tt, &$context): void { $deserializer->process(); + // Copy the question images from the temporary import directory to the question pool directory + $this->importQuestionImages($mapping, $context, $images_pipe); + return $context; } @@ -247,4 +256,30 @@ protected function importFormulaQuestion( $question->addResult($result); } } + + protected function importQuestionImages( + ilImportMapping $mapping, + ImportContext $context, + CollectQuestionImages $pipe, + ): void { + $import_dir = dirname($context->get('import_file')) . DIRECTORY_SEPARATOR . 'expDir_1'; + + $question_files = new QuestionFiles(); + foreach ($pipe->getEnvelopes() as $from_path => $envelope) { + $question_id = $mapping->getMapping('components/ILIAS/TestQuestionPool', 'question', (string) $envelope->getQuestionId()); + if (!$question_id) { + continue; + } + + $base_dir = $envelope->getType() === QuestionImage::TYPE_ANSWER + ? $question_files->buildImagePath($question_id, $context->get('pool_obj_id')) + : $question_files->buildSolutionPath($question_id, $context->get('pool_obj_id')); + + if (!file_exists($base_dir)) { + mkdir($base_dir, 0755, true); + } + + copy($import_dir . DIRECTORY_SEPARATOR . $from_path, $base_dir . $envelope->getFilename()); + } + } } 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/'; + } } From 89791305b07bfdef8ec5f971d0303164102c0d50 Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Mon, 13 Apr 2026 16:24:31 +0200 Subject: [PATCH 09/43] feat(qpl): introduce import cleanup stage --- .../classes/class.ilObjQuestionPoolGUI.php | 8 +- .../Importing/ImportStageRunner.php | 16 +++- .../src/ExportImport/Import/CleanupStage.php | 80 +++++++++++++++++++ .../Import/UploadValidationStage.php | 1 + 4 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Import/CleanupStage.php diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php b/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php index 8475a5bad25c..adfec0f14280 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php @@ -19,6 +19,7 @@ declare(strict_types=1); use ILIAS\Skill\Service\SkillUsageService; +use ILIAS\TestQuestionPool\ExportImport\Import\CleanupStage; use ILIAS\TestQuestionPool\QuestionPoolDIC; use ILIAS\TestQuestionPool\Import\TestQuestionsImportTrait; use ILIAS\TestQuestionPool\ExportImport\Import\UploadValidationStage; @@ -128,7 +129,8 @@ public function __construct() $local_dic = QuestionPoolDIC::dic(); $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->global_test_settings = $local_dic['global_test_settings']; + ; $this->import_session_repository = $local_dic['exportimport.session']; parent::__construct('', $this->request_data_collector->getRefId(), true, false); @@ -1341,6 +1343,7 @@ private function buildImportStageRunner(): ImportStageRunner new PersistStage($this->lng, $this->request_data_collector, $this->import_session_repository), ], $this->import_session_repository, + new CleanupStage() ); } @@ -1354,14 +1357,11 @@ private function renderImportStage(ImportStageRunner $runner, StageResult $resul private function renderImportError(ImportStageRunner $runner, StageResult $result): void { - $runner->reset(); $this->tpl->setOnScreenMessage('failure', $result->error_message, true); - $this->ctrl->redirectByClass(self::class, self::DEFAULT_CMD); } private function renderImportSuccess(ImportStageRunner $runner, StageResult $result): void { - $runner->reset(); $this->tpl->setOnScreenMessage('success', $this->lng->txt('object_imported'), true); $this->ctrl->setParameter($this, 'ref_id', $result->context->get('pool_obj_id')); $this->ctrl->redirectByClass(self::class, self::DEFAULT_CMD); diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportStageRunner.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportStageRunner.php index c31a51d505b2..29ba3497b506 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportStageRunner.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportStageRunner.php @@ -20,6 +20,7 @@ namespace ILIAS\TestQuestionPool\ExportImport\Foundation\Importing; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\ImportStage; use ILIAS\UI\Component\Listing\Workflow\Linear; use ILIAS\UI\Component\Listing\Workflow\Step; use ILIAS\UI\Factory as UIFactory; @@ -36,6 +37,7 @@ class ImportStageRunner public function __construct( private readonly array $stages, private readonly ImportSessionRepository $session, + private readonly ?ImportStage $final_stage = null, ) { } @@ -68,12 +70,16 @@ public function run(ServerRequestInterface $request): StageResult return $result; case StageResultType::ERROR: + $this->session->setContext($result->context); + $this->reset($request); + return $result; + case StageResultType::INTERACT: $this->session->setContext($result->context); return $result; case StageResultType::COMPLETE: - $this->session->clear(); + $this->reset($request); return $result; } @@ -113,10 +119,14 @@ public function buildWorkflow(UIFactory $ui, string $title): Linear } /** - * Reset the import stage session. + * Reset the import stage session. If a final stage is set, it will be processed. */ - public function reset(): void + public function reset(ServerRequestInterface $request): void { + if ($this->final_stage) { + $this->final_stage->process($this->session->getContext(), $request); + } + $this->session->clear(); } } diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/CleanupStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/CleanupStage.php new file mode 100644 index 000000000000..e02a2b04a961 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/CleanupStage.php @@ -0,0 +1,80 @@ +get('file_to_import'); + if ($file_to_import !== null) { + $temp_dir = dirname($file_to_import); + if (file_exists($temp_dir) && is_dir($temp_dir)) { + $this->removeDirectory($temp_dir); + } + } + + $import_base_dir = $context->get('import_base_dir'); + if (file_exists($import_base_dir) && is_dir($import_base_dir)) { + $this->removeDirectory($import_base_dir); + } + + return StageResult::complete($context); + } + + private function removeDirectory(string $path): void + { + $files = array_diff(scandir($path), ['.', '..']); + foreach ($files as $file) { + if (is_dir("$path/$file")) { + $this->removeDirectory("$path/$file"); + } else { + unlink("$path/$file"); + } + } + + rmdir($path); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/UploadValidationStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/UploadValidationStage.php index 0cb137a55fc6..155c53229e99 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/UploadValidationStage.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/UploadValidationStage.php @@ -90,6 +90,7 @@ public function process(ImportContext $context, ServerRequestInterface $request) return StageResult::advance( $context->with('import_file', $import_base_dir . DIRECTORY_SEPARATOR . $export_file['path']) + ->with('import_base_dir', $import_base_dir) ->with('install_id', $manifest->getInstallId()) ); } From 28e5c32019132959868a7ca71d15a6f2c27c34e1 Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Mon, 13 Apr 2026 16:38:10 +0200 Subject: [PATCH 10/43] refactor(qpl): remove questions import in question pool --- .../classes/class.ilObjQuestionPoolGUI.php | 210 +----------------- 1 file changed, 1 insertion(+), 209 deletions(-) diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php b/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php index adfec0f14280..6892313f1885 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php @@ -742,133 +742,6 @@ public function download_paragraphObject(): void exit; } - 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 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); @@ -999,24 +872,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(); @@ -1046,17 +905,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'), @@ -1246,61 +1094,6 @@ public function editQuestionForTestObject(): void $this->ctrl->redirectByClass(ilAssQuestionPreviewGUI::class, 'show'); } - protected function importQuestionsFile(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); - } - - $file_to_import = $this->import_temp_directory . DIRECTORY_SEPARATOR . $files[0]->getPath(); - $qtifile = $file_to_import; - $importdir = dirname($file_to_import); - - - 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(); - 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); - - $form = $this->buildImportQuestionsSelectionForm( - 'importVerifiedQuestionsFile', - $importdir, - $qtifile, - $path_to_uploaded_file_in_temp_dir - ); - - if ($form === null) { - return; - } - - $panel = $this->ui_factory->panel()->standard( - $this->lng->txt('import_question'), - [ - $this->ui_factory->legacy()->content($this->lng->txt('qpl_import_verify_found_questions')), - $form - ] - ); - $this->tpl->setContent($this->ui_renderer->render($panel)); - $this->tpl->printToStdout(); - exit; - } - - protected function importFile(string $file_to_import, string $path_to_uploaded_file_in_temp_dir): void { @@ -1368,7 +1161,6 @@ private function renderImportSuccess(ImportStageRunner $runner, StageResult $res } - public function addLocatorItems(): void { $ilLocator = $this->locator; From 04d5a8c8ac60c3e23a715063eb986b2b061d6b97 Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Tue, 14 Apr 2026 10:20:57 +0200 Subject: [PATCH 11/43] fix(foundation): rename export context parameter --- .../src/ExportImport/Foundation/ExportContext.php | 6 +++--- .../src/ExportImport/QuestionPoolExporter.php | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/ExportContext.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/ExportContext.php index b5e38b014d9f..ecef26b222f0 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/ExportContext.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/ExportContext.php @@ -41,15 +41,15 @@ class ExportContext private array $dependencies = []; public function __construct( - private ObjectId $pool_id, + private ObjectId $object_id, private ExportConfig $config, private Transformations $transformations, ) { } - public function getPoolId(): ObjectId + public function getObjectId(): ObjectId { - return $this->pool_id; + return $this->object_id; } public function getConfig(): ExportConfig diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolExporter.php b/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolExporter.php index 71d6821a2942..0cd53b5f4e0f 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolExporter.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolExporter.php @@ -94,7 +94,7 @@ public function process(ExportContext $context, Serializer $serializer): ExportC $collector = new QuestionPoolCollector( $this->question_repository, $this->db, - $context->getPoolId() + $context->getObjectId() ); $serializer->group( From 8a03945732abe91a122850ac2b581c2edae8b6b0 Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Wed, 15 Apr 2026 09:40:02 +0200 Subject: [PATCH 12/43] fix(qpl): support legacy question pool import --- .../classes/class.ilObjQuestionPoolGUI.php | 2 + .../class.ilObjQuestionPoolXMLParser.php | 135 ++++++++++++++ .../class.ilTestQuestionPoolImporter.php | 38 +++- ...class.ilTestQuestionPoolLegacyImporter.php | 170 ++++++++++++++++++ .../Foundation/Contracts/ImportStage.php | 10 +- .../Importing/ImportStageRunner.php | 4 + .../src/ExportImport/Import/CleanupStage.php | 8 +- .../Import/DetectLegacyImportStage.php | 70 ++++++++ .../src/ExportImport/Import/PersistStage.php | 4 +- .../Import/QuestionSelectionStage.php | 65 +++++-- .../Import/UploadValidationStage.php | 4 +- 11 files changed, 471 insertions(+), 39 deletions(-) create mode 100755 components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolXMLParser.php create mode 100755 components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolLegacyImporter.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Import/DetectLegacyImportStage.php diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php b/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php index 6892313f1885..065c7bf4f867 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php @@ -20,6 +20,7 @@ use ILIAS\Skill\Service\SkillUsageService; use ILIAS\TestQuestionPool\ExportImport\Import\CleanupStage; +use ILIAS\TestQuestionPool\ExportImport\Import\DetectLegacyImportStage; use ILIAS\TestQuestionPool\QuestionPoolDIC; use ILIAS\TestQuestionPool\Import\TestQuestionsImportTrait; use ILIAS\TestQuestionPool\ExportImport\Import\UploadValidationStage; @@ -1132,6 +1133,7 @@ private function buildImportStageRunner(): ImportStageRunner return new ImportStageRunner( [ new UploadValidationStage($this->archives, $this->lng, 'components/ILIAS/TestQuestionPool'), + new DetectLegacyImportStage(), new QuestionSelectionStage($this->lng, $this->component_factory, $this->ui_factory, $form_action), new PersistStage($this->lng, $this->request_data_collector, $this->import_session_repository), ], diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolXMLParser.php b/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolXMLParser.php new file mode 100755 index 000000000000..bc2632ef42f5 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolXMLParser.php @@ -0,0 +1,135 @@ +poolOBJ = $poolOBJ; + + $this->inSettingsTag = false; + $this->inMetaDataTag = false; + $this->inMdGeneralTag = false; + + parent::__construct($xmlFile); + } + + public function setHandlers($a_xml_parser): void + { + xml_set_element_handler($a_xml_parser, $this->handlerBeginTag(...), $this->handlerEndTag(...)); + xml_set_character_data_handler($a_xml_parser, $this->handlerCharacterData(...)); + } + + public function handlerBeginTag($xmlParser, $tagName, $tagAttributes): void + { + switch ($tagName) { + case 'MetaData': + $this->inMetaDataTag = true; + break; + + case 'General': + if ($this->inMetaDataTag) { + $this->inMdGeneralTag = true; + } + break; + + case 'Title': + case 'Description': + $this->cdata = ''; + break; + + case 'Settings': + $this->inSettingsTag = true; + break; + + case 'NavTaxonomy': + case 'SkillService': + if ($this->inSettingsTag) { + $this->cdata = ''; + } + break; + } + } + + public function handlerEndTag($xmlParser, $tagName): void + { + switch ($tagName) { + case 'MetaData': + $this->inMetaDataTag = false; + break; + + case 'General': + if ($this->inMetaDataTag) { + $this->inMdGeneralTag = false; + } + break; + + case 'Title': + if (!$this->title_processed) { + $this->poolOBJ->setTitle($this->cdata); + $this->title_processed = true; + $this->cdata = ''; + } + break; + + case 'Description': + if (!$this->description_processed) { + $this->poolOBJ->setDescription($this->cdata); + $this->description_processed = true; + $this->cdata = ''; + } + break; + + case 'Settings': + $this->inSettingsTag = false; + break; + + case 'SkillService': + $this->poolOBJ->setSkillServiceEnabled((bool) $this->cdata); + $this->cdata = ''; + break; + } + } + + public function handlerCharacterData($xmlParser, $charData): void + { + if ($charData != "\n") { + // Replace multiple tabs with one space + $charData = preg_replace("/\t+/", " ", $charData); + + $this->cdata .= $charData; + } + } +} diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php index 27cae121f9b1..bb9be798b3db 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php @@ -21,48 +21,68 @@ use ILIAS\Data\ReferenceId; use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportSessionRepository; use ILIAS\TestQuestionPool\ExportImport\Foundation\Serializing\SimpleXMLDeserializer; +use ILIAS\TestQuestionPool\ExportImport\Import\DetectLegacyImportStage; use ILIAS\TestQuestionPool\ExportImport\QuestionPoolImporter; use ILIAS\TestQuestionPool\QuestionPoolDIC; -/** - * Importer class for question pools - * - * @author Helmut Schottmüller - * @version $Id$ - * @ingroup components\ILIASLearningModule - */ - class ilTestQuestionPoolImporter extends ilXmlImporter { 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->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( string $a_entity, string $a_id, string $a_xml, ilImportMapping $a_mapping ): void { + // Check if forward to legacy importer is needed + $context = $this->session->getContext(); + if (DetectLegacyImportStage::isLegacyImport($context)) { + $this->legacy_importer->setInstallId($this->getInstallId()); + $this->legacy_importer->setInstallUrl($this->getInstallUrl()); + $this->legacy_importer->setSchemaVersion($this->getSchemaVersion()); + $this->legacy_importer->setSkipEntities($this->getSkipEntities()); + $this->legacy_importer->importXmlRepresentation($a_entity, $a_id, $a_xml, $a_mapping); + return; + } + $result = $this->importer->import( new SimpleXMLDeserializer()->open($a_xml), $a_mapping, new ReferenceId($a_mapping->getTargetId()), - $this->session->getContext(), + $context, ); $this->session->setContext($result); } public function finalProcessing(ilImportMapping $a_mapping): void { + // Check if forward to legacy importer is needed + $context = $this->session->getContext(); + if (DetectLegacyImportStage::isLegacyImport($context)) { + $this->legacy_importer->finalProcessing($a_mapping); + return; + } + $this->finalizeQuestionPages($a_mapping); $this->finalizeTaxonomyUsage($a_mapping); } diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolLegacyImporter.php b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolLegacyImporter.php new file mode 100755 index 000000000000..921d9958f542 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolLegacyImporter.php @@ -0,0 +1,170 @@ +session = $local_dic['exportimport.session']; + $this->request_data_collector = $local_dic['request_data_collector']; + } + + public function importXmlRepresentation( + string $a_entity, + string $a_id, + string $a_xml, + ilImportMapping $a_mapping + ): void { + $this->pool_obj = new ilObjQuestionPool(0, true); + $this->pool_obj->setType('qpl'); + $this->pool_obj->setTitle('dummy'); + $this->pool_obj->setDescription('questionpool import'); + $this->pool_obj->create(true); + $this->pool_obj->createReference(); + $this->pool_obj->putInTree($this->request_data_collector->getRefId()); + $this->pool_obj->setPermissions($this->request_data_collector->getRefId()); + + $a_mapping->addMapping('components/ILIAS/TestQuestionPool', 'qpl', $a_id, (string) $this->pool_obj->getId()); + + $context = $this->session->getContext(); + $import_base_dir = $context->get('import_base_dir'); + $xml_file = $context->get('xml_file'); + $context = $context->with('pool_obj_id', $this->pool_obj->getId()); + $this->session->setContext($context); + + $qpl_parser = new ilObjQuestionPoolXMLParser( + $this->pool_obj, + $xml_file + ); + $qpl_parser->startParsing(); + + // set another question pool name (if possible) + $qpl_new = $this->request_data_collector->string('qpl_new'); + if ($qpl_new !== '') { + $this->pool_obj->setTitle($qpl_new); + } + + $this->pool_obj->update(); + $this->pool_obj->saveToDb(); + + $qti_parser = new ilQTIParser( + $import_base_dir, + $context->get('qti_file'), + ilQTIParser::IL_MO_PARSE_QTI, + $this->pool_obj->getId(), + $context->get('selected_questions') + ); + $qti_parser->startParsing(); + + $page_parser = new ilQuestionPageParser( + $this->pool_obj, + $xml_file, + $import_base_dir + ); + $page_parser->setQuestionMapping($qti_parser->getImportMapping()); + $page_parser->startParsing(); + + foreach ($qti_parser->getImportMapping() as $k => $v) { + $old_question_id = substr($k, strpos($k, 'qst_') + strlen('qst_')); + $new_question_id = (string) $v['pool']; // yes, this is the new question id ^^ + + $a_mapping->addMapping( + 'components/ILIAS/Taxonomy', + 'tax_item', + "qpl:quest:{$old_question_id}", + $new_question_id + ); + + $a_mapping->addMapping( + 'components/ILIAS/Taxonomy', + 'tax_item_obj_id', + "qpl:quest:{$old_question_id}", + (string) $this->pool_obj->getId() + ); + + $a_mapping->addMapping( + 'components/ILIAS/TestQuestionPool', + 'quest', + $old_question_id, + $new_question_id + ); + } + + $this->importQuestionSkillAssignments($xml_file, $a_mapping, $this->pool_obj->getId()); + + $a_mapping->addMapping( + 'components/ILIAS/MetaData', + 'md', + "{$a_id}:0:qpl", + "{$this->pool_obj->getId()}:0:qpl" + ); + + $this->pool_obj->saveToDb(); + } + + public function finalProcessing(ilImportMapping $a_mapping): void + { + $maps = $a_mapping->getMappingsOfEntity('components/ILIAS/TestQuestionPool', 'qpl'); + foreach ($maps as $old => $new) { + if ($old !== 'new_id' && (int) $old > 0) { + $new_tax_ids = $a_mapping->getMapping('components/ILIAS/Taxonomy', 'tax_usage_of_obj', (string) $old); + if ($new_tax_ids !== null) { + $tax_ids = explode(':', $new_tax_ids); + foreach ($tax_ids as $tid) { + ilObjTaxonomy::saveUsage((int) $tid, (int) $new); + } + } + } + } + } + + protected function importQuestionSkillAssignments($xmlFile, ilImportMapping $mappingRegistry, $targetParentObjId): void + { + $parser = new ilAssQuestionSkillAssignmentXmlParser($xmlFile); + $parser->startParsing(); + + $importer = new ilAssQuestionSkillAssignmentImporter(); + $importer->setTargetParentObjId($targetParentObjId); + $importer->setImportInstallationId($this->getInstallId()); + $importer->setImportMappingRegistry($mappingRegistry); + $importer->setImportMappingComponent('components/ILIAS/TestQuestionPool'); + $importer->setImportAssignmentList($parser->getAssignmentList()); + + $importer->import(); + + if ($importer->getFailedImportAssignmentList()->assignmentsExist()) { + $fails = new ilAssQuestionSkillAssignmentImportFails($targetParentObjId); + $fails->registerFailedImports($importer->getFailedImportAssignmentList()); + + $this->pool_obj->getObjectProperties()->storePropertyIsOnline($this->pool_obj->getObjectProperties()->getPropertyIsOnline()->withOffline()); + } + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/ImportStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/ImportStage.php index 2dcb1fa9ff3d..bdb78f8e391d 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/ImportStage.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/ImportStage.php @@ -36,14 +36,16 @@ interface ImportStage public function getIdentifier(): string; /** - * Get the label of the stage which will be displayed in the workflow UI. + * Get the label of the stage which will be displayed in the workflow UI. If null, the stage will not be displayed + * in the workflow UI. */ - public function getLabel(): string; + public function getLabel(): ?string; /** - * Get the description of the stage which will be displayed in the workflow UI. + * Get the description of the stage which will be displayed in the workflow UI. If null, the stage will not be + * displayed in the workflow UI. */ - public function getDescription(): string; + public function getDescription(): ?string; /** * Process the current stage. On the first invocation the stage should return `StageResult::interact()` with the UI diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportStageRunner.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportStageRunner.php index 29ba3497b506..3a4ba77dd938 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportStageRunner.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportStageRunner.php @@ -95,6 +95,10 @@ public function buildWorkflow(UIFactory $ui, string $title): Linear $active_index = $this->session->getCurrentStageIndex(); foreach ($this->stages as $i => $stage) { + if ($stage->getLabel() === null) { + continue; + } + $step = $ui->listing()->workflow()->step( $stage->getLabel(), $stage->getDescription() diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/CleanupStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/CleanupStage.php index e02a2b04a961..fbd99c94b976 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/CleanupStage.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/CleanupStage.php @@ -36,14 +36,14 @@ public function getIdentifier(): string return 'cleanup'; } - public function getLabel(): string + public function getLabel(): ?string { - return ''; + return null; } - public function getDescription(): string + public function getDescription(): ?string { - return ''; + return null; } public function process(ImportContext $context, ServerRequestInterface $request): StageResult 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..da921c6afd50 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/DetectLegacyImportStage.php @@ -0,0 +1,70 @@ +get('import_base_dir'); + $import_name = basename($import_base_dir); + + $xml_file = $import_base_dir . DIRECTORY_SEPARATOR . $import_name . '.xml'; + $qti_file = $import_base_dir . DIRECTORY_SEPARATOR . str_replace('_qpl_', '_qti_', $import_name) . '.xml'; + + if (!file_exists($qti_file) || !file_exists($xml_file)) { + return StageResult::advance($context); + } + + return StageResult::advance( + $context->with('qti_file', $qti_file) + ->with('xml_file', $xml_file) + ); + } + + public static function isLegacyImport(ImportContext $context): bool + { + return $context->has('qti_file') && $context->has('xml_file'); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/PersistStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/PersistStage.php index 4a0a23f215ad..83ca3be6e343 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/PersistStage.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/PersistStage.php @@ -47,12 +47,12 @@ public function getIdentifier(): string return 'persist'; } - public function getLabel(): string + public function getLabel(): ?string { return $this->lng->txt('qpl_import_step_persist'); } - public function getDescription(): string + public function getDescription(): ?string { return ''; } diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionSelectionStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionSelectionStage.php index e7cabc668d30..e7fd2b79bb9c 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionSelectionStage.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionSelectionStage.php @@ -26,8 +26,6 @@ use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportContext; use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\StageResult; use ILIAS\TestQuestionPool\ExportImport\Foundation\Serializing\SimpleXMLDeserializer; -use ILIAS\TestQuestionPool\QuestionPoolDIC; -use ILIAS\TestQuestionPool\Questions\GeneralQuestionProperties; use ILIAS\UI\Component\Input\Container\Form\Form; use ILIAS\UI\Factory as UIFactory; use Psr\Http\Message\ServerRequestInterface; @@ -65,12 +63,12 @@ public function getIdentifier(): string return 'question_selection'; } - public function getLabel(): string + public function getLabel(): ?string { return $this->lng->txt('qpl_import_step_select'); } - public function getDescription(): string + public function getDescription(): ?string { return ''; } @@ -96,20 +94,9 @@ public function process(ImportContext $context, ServerRequestInterface $request) return StageResult::error($context, $this->lng->txt('qpl_import_file_not_found')); } - $options = []; - $deserializer = new SimpleXMLDeserializer()->open(file_get_contents($context->get('import_file'))); - $deserializer->addHandler('questions', function (array $questions) use (&$options): void { - foreach ($questions as $question) { - if (!isset($question['title']) || !isset($question['type'])) { - continue; - } - - $raw_id = $question['id']; - $id = is_array($raw_id) ? (string) ($raw_id['id'] ?? '') : (string) $raw_id; - $options[$id] = "{$question['title']} ({$this->getLabelForQuestionType($question['type'])})"; - } - }); - $deserializer->process(); + $options = DetectLegacyImportStage::isLegacyImport($context) + ? $this->readQuestionsFromQTI($context) + : $this->readQuestions($context); if ($options === []) { return StageResult::error($context, $this->lng->txt('qpl_import_no_items')); @@ -137,6 +124,48 @@ public static function getSelectedQuestions(ImportContext $context): array return array_map('intval', $context->get('selected_questions', [])); } + private function readQuestions(ImportContext $context): array + { + $options = []; + + $deserializer = new SimpleXMLDeserializer()->open(file_get_contents($context->get('import_file'))); + $deserializer->addHandler('questions', function (array $questions) use (&$options): void { + foreach ($questions as $question) { + if (!isset($question['title']) || !isset($question['type'])) { + continue; + } + + $raw_id = $question['id']; + $id = is_array($raw_id) ? (string) ($raw_id['id'] ?? '') : (string) $raw_id; + $options[$id] = "{$question['title']} ({$this->getLabelForQuestionType($question['type'])})"; + } + }); + $deserializer->process(); + + return $options; + } + + /** + * @deprecated This method is only used for legacy imports and will be removed with further ILIAS versions. + */ + private function readQuestionsFromQTI(ImportContext $context): array + { + $parser = new \ilQTIParser( + $context->get('import_base_dir'), + $context->get('qti_file'), + \ilQTIParser::IL_MO_VERIFY_QTI, + 0 + ); + $parser->startParsing(); + + $options = []; + foreach ($parser->getFoundItems() as $item) { + $options[$item['ident']] = "{$item['title']} ({$this->getLabelForQuestionType($item['type'])})"; + } + + return $options; + } + private function buildSelectQuestionsForm(array $options): Form { $input = $this->ui_factory->input()->field()->multiSelect( diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/UploadValidationStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/UploadValidationStage.php index 155c53229e99..28e602c7cd87 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/UploadValidationStage.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/UploadValidationStage.php @@ -50,12 +50,12 @@ public function getIdentifier(): string return 'upload_and_validate'; } - public function getLabel(): string + public function getLabel(): ?string { return $this->lng->txt('upload'); } - public function getDescription(): string + public function getDescription(): ?string { return ''; } From 323ef945f894ab3805673beebc4fcdce8e412fe5 Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Thu, 16 Apr 2026 11:44:54 +0200 Subject: [PATCH 13/43] refactor(foundation,qpl): introduce export bridge to enhance dependency management --- .../class.ilTestQuestionPoolExporter.php | 111 +++------- .../Foundation/Bridge/ExportState.php | 190 ++++++++++++++++++ .../Foundation/Bridge/ExportStep.php | 29 +++ .../Foundation/Bridge/StateHolder.php | 59 ++++++ .../Foundation/Bridge/XmlExporterBridge.php | 114 +++++++++++ .../Foundation/Contracts/Exporter.php | 66 ++++++ .../ExportImport/Foundation/ExportContext.php | 108 ---------- .../src/ExportImport/QuestionPoolExporter.php | 148 ++++++++------ .../TestQuestionPool/src/QuestionPoolDIC.php | 5 + 9 files changed, 585 insertions(+), 245 deletions(-) create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/ExportState.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/ExportStep.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/StateHolder.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/XmlExporterBridge.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/Exporter.php delete mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/ExportContext.php diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolExporter.php b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolExporter.php index 8d4536da9a38..ba4e24df7c65 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolExporter.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolExporter.php @@ -16,39 +16,30 @@ * *********************************************************************/ -use ILIAS\Data\ObjectId; -use ILIAS\TestQuestionPool\ExportImport\Foundation\ExportContext; -use ILIAS\TestQuestionPool\ExportImport\Foundation\Serializing\SimpleXMLSerializer; -use ILIAS\TestQuestionPool\ExportImport\QuestionPoolExporter; +use ILIAS\Export\ExportHandler\Factory as ExportHandler; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Bridge\XmlExporterBridge; use ILIAS\TestQuestionPool\QuestionPoolDIC; -/** - * Used for container export with tests - * - * @author Helmut Schottmüller - * @version $Id$ - * @ingroup components\ILIASTest - */ class ilTestQuestionPoolExporter extends ilXmlExporter { - private QuestionPoolExporter $exporter; - - /** - * @var array $batches - */ - private array $batches = []; - + use XmlExporterBridge; public function init(): void { - $this->exporter = QuestionPoolDIC::dic()['exportimport.exporter']; + global $DIC; + $local_dic = QuestionPoolDIC::dic(); + + $this->export_handler = new ExportHandler(); + $this->state_holder = $local_dic['exportimport.state_holder']; + $this->exporter = $local_dic['exportimport.exporter']; + $this->logger = $DIC->logger()->qpl()->getLogger(); } /** - * Returns the final XML content for one question pool. + * 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 prepared batch can be written to disk and finalized. + * 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 { @@ -56,15 +47,14 @@ public function getXmlRepresentation(string $a_entity, string $a_schema_version, throw new InvalidArgumentException("Invalid entity for question pool export: {$a_entity}"); } - return $this->finalizeExport((int) $a_id)->getContent(); + return $this->finalizeExport()->getContent(); } /** - * Collects export tail dependencies for one or more question pools. + * 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 batch in memory and caches the context, because writer and export directory are not yet - * initialized here. + * processes the export in memory using the export state. The export state is created if it does not exist yet. */ public function getXmlExportTailDependencies(string $a_entity, string $a_target_release, array $a_ids): array { @@ -72,68 +62,33 @@ public function getXmlExportTailDependencies(string $a_entity, string $a_target_ throw new InvalidArgumentException("Invalid entity for question pool export: {$a_entity}"); } - $dependencies = []; - foreach ($a_ids as $id) { - $context = $this->processExport((int) $id); - $dependencies = array_merge($dependencies, $context->getDependencies()); + // If the default export option was used, the state is not initialized yet. + if ($this->state_holder->exists() === false) { + $this->initExportState( + 'components/ILIAS/TestQuestionPool', + $a_target_release, + $a_entity, + $a_ids + ); } - return $dependencies; + 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' => '' + ] ]; } - - /** - * Prepares and processes a question pool export in memory. The resulting context is cached per pool and reused - * across calls. - */ - private function processExport(int $pool_id): ExportContext - { - if (isset($this->batches[$pool_id])) { - return $this->batches[$pool_id]; - } - - $context = $this->exporter->prepare( - new ObjectId($pool_id), - $this->exp->getExportConfigs() - ); - - $context = $this->exporter->process( - $context, - new SimpleXMLSerializer()->open('memory') - ); - - $this->batches[$pool_id] = $context; - return $context; - } - - /** - * Finalizes a prepared export context and writes it to the export directory. - */ - private function finalizeExport(int $pool_id): ExportContext - { - $context = $this->processExport($pool_id); - - return $this->exporter->write( - $context, - $this->exp->getExportWriter(), - $this->exp->getPathToComponentExpDirInContainer() - ); - } } diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/ExportState.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/ExportState.php new file mode 100644 index 000000000000..1afb13e04b03 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/ExportState.php @@ -0,0 +1,190 @@ +}> $dependencies + */ + private array $dependencies = []; + + public function __construct( + private ExportTarget $target, + private ExportConfig $config + ) { + $this->step = ExportStep::INIT; + } + + public function target(): ExportTarget + { + return $this->target; + } + + public function config(): ExportConfig + { + return $this->config; + } + + public function getStep(): ExportStep + { + return $this->step; + } + + public function setStep(ExportStep $step): void + { + $this->step = $step; + } + + public function assertStep(ExportStep $step): void + { + if ($this->step->value < $step->value) { + throw new RuntimeException("Expected step {$step->name}, but got {$this->step->name} instead"); + } + + $this->step = $step; + } + + public function logger(): Logger + { + $this->assertNotNull($this->logger, 'logger'); + return $this->logger; + } + + public function setLogger(Logger $logger): void + { + $this->logger = $logger; + } + + public function path(): ExportPath + { + $this->assertNotNull($this->path_info, 'path_info'); + return $this->path_info; + } + + public function setPathInfo(ExportPath $path_info): void + { + $this->path_info = $path_info; + } + + public function collector(): DataCollector + { + $this->assertNotNull($this->collector, 'collector'); + return $this->collector; + } + + public function setCollector(DataCollector $collector): void + { + $this->collector = $collector; + } + + public function transformations(): Transformations + { + $this->assertNotNull($this->transformations, 'transformations'); + return $this->transformations; + } + + public function setTransformations(Transformations $transformations): void + { + $this->transformations = $transformations; + } + + public function serializer(): Serializer + { + $this->assertNotNull($this->serializer, 'serializer'); + return $this->serializer; + } + + public function setSerializer(Serializer $serializer): void + { + $this->serializer = $serializer; + } + + public function writer(): ExportWriter + { + $this->assertNotNull($this->writer, 'writer'); + return $this->writer; + } + + public function setWriter(ExportWriter $writer): void + { + $this->writer = $writer; + } + + public function getDependencies(): array + { + return array_values($this->dependencies); + } + + public function addDependency(string $component, string $entity, array $ids): void + { + $key = "{$component}::{$entity}"; + + if (!isset($this->dependencies[$key])) { + $this->dependencies[$key] = [ + 'component' => $component, + 'entity' => $entity, + 'ids' => [], + ]; + } + + $this->dependencies[$key]['ids'] = array_values(array_unique(array_merge( + $this->dependencies[$key]['ids'], + $ids + ))); + } + + public function getContent(): string + { + return $this->serializer()->write(); + } + + private function assertNotNull(mixed $value, string $property): void + { + if ($value === null) { + throw new RuntimeException( + "{$property} not set. This may happen if the exporter steps are not executed in the correct order." + ); + } + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/ExportStep.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/ExportStep.php new file mode 100644 index 000000000000..bcbcf3f04b53 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/ExportStep.php @@ -0,0 +1,29 @@ +export_state = new ExportState($target, $config); + return $this->export_state; + } + + public function exists(): bool + { + return $this->export_state !== null; + } + + public function get(): ExportState + { + if ($this->export_state === null) { + throw new RuntimeException('Export state not found. You need to create the state first.'); + } + return $this->export_state; + } + + public function set(ExportState $export_state): void + { + $this->export_state = $export_state; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/XmlExporterBridge.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/XmlExporterBridge.php new file mode 100644 index 000000000000..3c652352a049 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/XmlExporterBridge.php @@ -0,0 +1,114 @@ +state_holder->get(); + + if ($state->getStep()->value < ExportStep::PREPARE->value) { + $state->setLogger($this->logger); + $this->exporter->prepare($state); + } + + if ($state->getStep()->value < ExportStep::PROCESS->value) { + $state->setSerializer(new SimpleXMLSerializer()->open('memory')); + $this->exporter->process($state); + } + + $this->state_holder->set($state); + return $state; + } + + /** + * Finalizes the export by setting the path info and writer and calling the write step of the exporter. It performs + * the prepare and process steps if not already done. + */ + private function finalizeExport(): ExportState + { + $state = $this->processExport(); + + if ($state->getStep()->value < ExportStep::WRITE->value) { + $state->setPathInfo($this->createPathInfo()); + $state->setWriter($this->exp->getExportWriter()); + + $this->exporter->write($state); + } + + $this->state_holder->set($state); + return $state; + } + + private function initExportState( + string $component, + string $target_release, + string $type, + array $object_ids + ): ExportState { + $target = $this->export_handler->target()->handler() + ->withType($type) + ->withTargetRelease($target_release) + ->withObjectIds($object_ids) + ->withClassname(static::class) + ->withComponent($component); + + return $this->state_holder->create( + $target, + $this->export_handler->consumer()->exportConfig()->collection() + ); + } + + 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/Contracts/Exporter.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/Exporter.php new file mode 100644 index 000000000000..0dcd56624270 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/Exporter.php @@ -0,0 +1,66 @@ +}> $dependencies - */ - private array $dependencies = []; - - public function __construct( - private ObjectId $object_id, - private ExportConfig $config, - private Transformations $transformations, - ) { - } - - public function getObjectId(): ObjectId - { - return $this->object_id; - } - - public function getConfig(): ExportConfig - { - return $this->config; - } - - public function getTransformations(): Transformations - { - return $this->transformations; - } - - public function getSerializer(): Serializer - { - if ($this->serializer === null) { - throw new RuntimeException( - 'Serializer not set. This may happen if the exporter steps are not executed in the correct order.' - ); - } - - return $this->serializer; - } - - public function setSerializer(Serializer $serializer): void - { - $this->serializer = $serializer; - } - - public function getDependencies(): array - { - return array_values($this->dependencies); - } - - public function addDependency(string $component, string $entity, array $ids): void - { - $key = "{$component}::{$entity}"; - - if (!isset($this->dependencies[$key])) { - $this->dependencies[$key] = [ - 'component' => $component, - 'entity' => $entity, - 'ids' => [], - ]; - } - - $this->dependencies[$key]['ids'] = array_values(array_unique(array_merge( - $this->dependencies[$key]['ids'], - $ids - ))); - } - - public function getContent(): string - { - return $this->getSerializer()->write(); - } -} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolExporter.php b/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolExporter.php index 0cd53b5f4e0f..5fd121dee14e 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolExporter.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolExporter.php @@ -22,14 +22,15 @@ use assFormulaQuestion; use ilDBInterface; +use ILIAS\Data\Factory as DataFactory; use ILIAS\Data\ObjectId; use ILIAS\Data\UUID\Factory as UUIDFactory; -use ILIAS\Export\ExportHandler\I\Consumer\ExportWriter\HandlerInterface as ExportWriter; -use ILIAS\Export\ExportHandler\I\Consumer\ExportConfig\CollectionInterface as ExportConfig; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Bridge\ExportState; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Bridge\ExportStep; use ILIAS\TestQuestionPool\ExportImport\Foundation\Builder; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Exporter; use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Serializer; use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; -use ILIAS\TestQuestionPool\ExportImport\Foundation\ExportContext; use ILIAS\Taxonomy\DomainService as Taxonomy; use ILIAS\TestQuestionPool\ExportImport\Pipes\CollectQuestionImages; use ILIAS\TestQuestionPool\Questions\GeneralQuestionPropertiesRepository; @@ -39,10 +40,11 @@ * to normalize the data and then writes the normalized data to the serializer. It also copies the needed files to the * export directory. */ -class QuestionPoolExporter +class QuestionPoolExporter implements Exporter { public function __construct( private readonly Builder $builder, + private readonly DataFactory $data_factory, private readonly GeneralQuestionPropertiesRepository $question_repository, private readonly ilDBInterface $db, private readonly Taxonomy $taxonomy @@ -50,108 +52,135 @@ public function __construct( } /** - * Performs the export for a given question pool. It returns the export context which contains the serialized data - * and the dependencies of the export. + * Prepares the export by creating the transformations and the question image pipe. */ - public function export( - ObjectId $pool_id, - ExportConfig $config, - Serializer $serializer, - ExportWriter $writer, - string $export_dir - ): ExportContext { - $context = $this->prepare($pool_id, $config); - $context = $this->process($context, $serializer); - return $this->write($context, $writer, $export_dir); - } - - /** - * Prepares the export context by creating the transformations and the question image pipe. It returns the export - * context which is used to share the context between the prepare, process and write steps. - */ - public function prepare(ObjectId $pool_id, ExportConfig $config): ExportContext + public function prepare(ExportState $state): void { + $state->assertStep(ExportStep::INIT); + $state->setStep(ExportStep::PREPARE); + + $pool_id = $this->extractObjectId($state); + if ($pool_id === null) { + return; + } + + $collector = new QuestionPoolCollector( + $this->question_repository, + $this->db, + $pool_id + ); + $state->setCollector($collector); + $question_image_pipe = new CollectQuestionImages( new UUIDFactory(), $pool_id ); - $transformations = $this->builder->withAdditionalPipes([$question_image_pipe]) + $transformations = $this->builder + ->withAdditionalPipes([$question_image_pipe]) ->create(); - - return new ExportContext($pool_id, $config, $transformations); + $state->setTransformations($transformations); } /** * 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, Serializer $serializer): ExportContext + public function process(ExportState $state): void { - $context->setSerializer($serializer); - $tt = $context->getTransformations(); - - $collector = new QuestionPoolCollector( - $this->question_repository, - $this->db, - $context->getObjectId() - ); + $state->assertStep(ExportStep::PREPARE); + $state->setStep(ExportStep::PROCESS); - $serializer->group( + $state->serializer()->group( 'general', - fn() => $this->exportObject($collector, $tt, $serializer, $context) + fn() => $this->exportObject( + $state->collector(), + $state->transformations(), + $state->serializer(), + $state + ) ); - $serializer->group( + $state->serializer()->group( 'questions', - fn() => $this->exportQuestions($collector, $tt, $serializer, $context) + fn() => $this->exportQuestions( + $state->collector(), + $state->transformations(), + $state->serializer(), + $state + ) ); - $serializer->group( + $state->serializer()->group( 'skill_assignments', - fn() => $this->exportSkillAssignments($collector, $tt, $serializer) + fn() => $this->exportSkillAssignments( + $state->collector(), + $state->transformations(), + $state->serializer(), + ) ); - - return $context; } /** * Finalizes the export by copying the question images to the export directory and returning the export context. */ - public function write(ExportContext $export, ExportWriter $writer, string $export_dir): ExportContext + public function write(ExportState $state): void { - // Copy the question images to the export directory - $question_image_pipe = $export->getTransformations()->context(CollectQuestionImages::class); + $state->assertStep(ExportStep::PROCESS); + $state->setStep(ExportStep::WRITE); + + $export_dir = $state->path()->getPathToComponentExpDirInContainer(); + $question_image_pipe = $state->transformations()->context(CollectQuestionImages::class); + foreach ($question_image_pipe->getFiles() as $file) { - $writer->writeFileByFilePath($file['from'], "{$export_dir}/" . $file['to']); + $state->writer()->writeFileByFilePath( + $file['from'], + "{$export_dir}/" . $file['to'] + ); } - - return $export; } - protected function exportObject( + private function extractObjectId(ExportState $state): ?ObjectId + { + $target_ids = $state->target()->getObjectIds(); + + if (count($target_ids) === 0) { + $state->logger()->warning('No target object IDs found for question pool export'); + return null; + } + + if (count($target_ids) > 1) { + $state->logger()->warning( + 'Multiple target object IDs found for question pool export. Only the first one will be used.' + ); + } + + return $this->data_factory->objId(array_shift($target_ids)); + } + + private function exportObject( QuestionPoolCollector $collector, Transformations $transformations, Serializer $serializer, - ExportContext $export + ExportState $state ): void { $serializer->append('object', $transformations->normalize($collector->getObject())); $obj_id = $collector->getPoolId()->toInt(); - $export->addDependency('components/ILIAS/ILIASObject', 'common', [$obj_id]); - $export->addDependency('components/ILIAS/MetaData', 'qpl', ["{$obj_id}:0:qpl"]); - $export->addDependency( + $state->addDependency('components/ILIAS/ILIASObject', 'common', [$obj_id]); + $state->addDependency('components/ILIAS/MetaData', 'qpl', ["{$obj_id}:0:qpl"]); + $state->addDependency( 'components/ILIAS/Taxonomy', 'tax', $this->taxonomy->getUsageOfObject($obj_id) ); } - protected function exportQuestions( + private function exportQuestions( QuestionPoolCollector $collector, Transformations $transformations, Serializer $serializer, - ExportContext $export + ExportState $state ): void { foreach ($collector->getQuestionObjects() as $question) { $normalized = [ @@ -162,15 +191,16 @@ protected function exportQuestions( ]; if ($question instanceof assFormulaQuestion) { - $normalized['formula_data'] = $transformations->normalize($collector->getUnitsAndCategories($question->getId())); + $data = $collector->getUnitsAndCategories($question->getId()); + $normalized['formula_data'] = $transformations->normalize($data); } $serializer->append('question', $normalized); - $export->addDependency('components/ILIAS/COPage', 'pg', ["qpl:{$question->getId()}"]); + $state->addDependency('components/ILIAS/COPage', 'pg', ["qpl:{$question->getId()}"]); } } - protected function exportSkillAssignments( + private function exportSkillAssignments( QuestionPoolCollector $collector, Transformations $transformations, Serializer $serializer, diff --git a/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php b/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php index 425580ba50fa..6ef905266324 100755 --- a/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php +++ b/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php @@ -20,6 +20,8 @@ namespace ILIAS\TestQuestionPool; +use ILIAS\Data\Factory as DataFactory; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Bridge\StateHolder; use ILIAS\TestQuestionPool\ExportImport\Foundation\Builder; use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportSessionRepository; use ILIAS\TestQuestionPool\ExportImport\QuestionPoolExporter; @@ -77,9 +79,12 @@ protected static function buildDIC(ILIASContainer $DIC): self $DIC, $c ); + $dic['exportimport.state_holder'] = static fn($c): StateHolder => + new StateHolder(); $dic['exportimport.exporter'] = static fn($c): QuestionPoolExporter => new QuestionPoolExporter( $c['exportimport.builder'], + new DataFactory(), $c['question.general_properties.repository'], $DIC->database(), $DIC->taxonomy()->domain() From db739385dc8c9617d6e2f40a5b3eeef5fa0ddeff Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Fri, 17 Apr 2026 09:48:02 +0200 Subject: [PATCH 14/43] refactor(qpl): introduce trait to share question collection logic --- .../CollectsQuestions.php} | 81 ++++++----------- .../Export/QuestionPoolCollector.php | 88 +++++++++++++++++++ .../{ => Export}/QuestionPoolExporter.php | 4 +- .../TestQuestionPool/src/QuestionPoolDIC.php | 2 +- 4 files changed, 116 insertions(+), 59 deletions(-) rename components/ILIAS/TestQuestionPool/src/ExportImport/{QuestionPoolCollector.php => Export/CollectsQuestions.php} (74%) create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Export/QuestionPoolCollector.php rename components/ILIAS/TestQuestionPool/src/ExportImport/{ => Export}/QuestionPoolExporter.php (98%) diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolCollector.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Export/CollectsQuestions.php similarity index 74% rename from components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolCollector.php rename to components/ILIAS/TestQuestionPool/src/ExportImport/Export/CollectsQuestions.php index 860cfb630b0a..0cfb3dde61f8 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolCollector.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Export/CollectsQuestions.php @@ -18,84 +18,53 @@ declare(strict_types=1); -namespace ILIAS\TestQuestionPool\ExportImport; +namespace ILIAS\TestQuestionPool\ExportImport\Export; use assFormulaQuestionUnit; use assFormulaQuestionUnitCategory; use assQuestion; use Generator; use ilAssQuestionSkillAssignmentList; -use ilDBInterface; -use ilObjQuestionPool; use ilAssClozeTestFeedback; use ilAssMultiOptionQuestionFeedback; use ilAssSpecificFeedbackIdentifierList; +use ilDBInterface; use ILIAS\Data\ObjectId; -use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\DataCollector; use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Envelopes\Id; use ILIAS\TestQuestionPool\ExportImport\Envelopes\Feedback; use ILIAS\TestQuestionPool\Questions\GeneralQuestionProperties; -use ILIAS\TestQuestionPool\Questions\GeneralQuestionPropertiesRepository; use ilUnitConfigurationRepository; /** - * Collector to aggregate data from the question pool for export. + * Trait to collect questions and related data from a question pool or test object. */ -class QuestionPoolCollector implements DataCollector +trait CollectsQuestions { - /** @var array $questions */ - private ?array $questions = null; - private ?ilObjQuestionPool $pool_object = null; - private ?ilAssQuestionSkillAssignmentList $skill_assignments = 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. + * Get the question properties for all questions related to the target object. * - * @return ObjectId + * @return array */ - public function getPoolId(): ObjectId - { - return $this->pool_id; - } + abstract public function getQuestionProperties(): array; /** - * Get the object of the question pool. It will be loaded from the database if not already loaded. + * Get the object ID of the object that contains the questions. */ - 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; - } + abstract public function getObjectId(): ObjectId; /** - * Collect the question properties for all questions in the question pool. - * - * @return array + * Get the database interface. */ - public function getQuestionProperties(): array - { - if ($this->questions === null) { - $this->questions = $this->question_repository->getForParentObjectId($this->pool_id->toInt()); - } - return $this->questions; - } + abstract private function database(): ilDBInterface; + + + private ?ilAssQuestionSkillAssignmentList $skill_assignments = null; /** - * Collect the question objects for all questions in the question pool. - * - * @return Generator - */ + * Collect the question objects by instantiating the question objects. + * + * @return Generator + */ public function getQuestionObjects(): Generator { foreach ($this->getQuestionProperties() as $question) { @@ -109,7 +78,7 @@ public function getQuestionObjects(): Generator /** * Get all unit categories and units for a formula question. - * + * * @return array{categories: list, base_units: list, units: list} */ public function getUnitsAndCategories(int $question_id): array @@ -122,12 +91,12 @@ public function getUnitsAndCategories(int $question_id): array ]; foreach ($repository->getCategorizedUnits() as $item) { - if($item instanceof assFormulaQuestionUnitCategory) { + if ($item instanceof assFormulaQuestionUnitCategory) { $data['categories'][] = $item; } - if($item instanceof assFormulaQuestionUnit) { - if($item->getBaseUnit() === 0 || $item->getBaseUnit() === $item->getId()) { + if ($item instanceof assFormulaQuestionUnit) { + if ($item->getBaseUnit() === 0 || $item->getBaseUnit() === $item->getId()) { $data['base_units'][] = $item; } else { $data['units'][] = $item; @@ -212,8 +181,8 @@ private function loadSpecificFeedback(assQuestion $question): array public function getSkillAssignments(): array { if ($this->skill_assignments === null) { - $this->skill_assignments = new ilAssQuestionSkillAssignmentList($this->db); - $this->skill_assignments->setParentObjId($this->pool_id->toInt()); + $this->skill_assignments = new ilAssQuestionSkillAssignmentList($this->database()); + $this->skill_assignments->setParentObjId($this->getObjectId()->toInt()); $this->skill_assignments->loadFromDb(); $this->skill_assignments->loadAdditionalSkillData(); } @@ -225,7 +194,7 @@ public function getSkillAssignments(): array $this->skill_assignments->getAssignmentsByQuestionId($question->getQuestionId()) ); } - + return $assignments; } diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Export/QuestionPoolCollector.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Export/QuestionPoolCollector.php new file mode 100644 index 000000000000..28f91dfb8be1 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Export/QuestionPoolCollector.php @@ -0,0 +1,88 @@ + $questions */ + private ?array $questions = null; + private ?ilObjQuestionPool $pool_object = null; + + public function __construct( + private readonly GeneralQuestionPropertiesRepository $question_repository, + private readonly ilDBInterface $db, + private readonly ObjectId $pool_id + ) { + } + + /** + * Get the ID of the question pool. + * + * @return ObjectId + */ + public function getObjectId(): ObjectId + { + return $this->pool_id; + } + + /** + * Get the object of the question pool. It will be loaded from the database if not already loaded. + */ + public function getObject(): ilObjQuestionPool + { + if ($this->pool_object === null) { + $this->pool_object = new ilObjQuestionPool($this->pool_id->toInt(), false); + $this->pool_object->read(); + } + + return $this->pool_object; + } + + /** + * Collect the question properties for all questions in the question pool. + * + * @return array + */ + public function getQuestionProperties(): array + { + if ($this->questions === null) { + $this->questions = $this->question_repository->getForParentObjectId($this->pool_id->toInt()); + } + return $this->questions; + } + + private function database(): ilDBInterface + { + return $this->db; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolExporter.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Export/QuestionPoolExporter.php similarity index 98% rename from components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolExporter.php rename to components/ILIAS/TestQuestionPool/src/ExportImport/Export/QuestionPoolExporter.php index 5fd121dee14e..b4d72eab3b86 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolExporter.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Export/QuestionPoolExporter.php @@ -18,7 +18,7 @@ declare(strict_types=1); -namespace ILIAS\TestQuestionPool\ExportImport; +namespace ILIAS\TestQuestionPool\ExportImport\Export; use assFormulaQuestion; use ilDBInterface; @@ -165,7 +165,7 @@ private function exportObject( ): void { $serializer->append('object', $transformations->normalize($collector->getObject())); - $obj_id = $collector->getPoolId()->toInt(); + $obj_id = $collector->getObjectId()->toInt(); $state->addDependency('components/ILIAS/ILIASObject', 'common', [$obj_id]); $state->addDependency('components/ILIAS/MetaData', 'qpl', ["{$obj_id}:0:qpl"]); diff --git a/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php b/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php index 6ef905266324..95987154f8b0 100755 --- a/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php +++ b/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php @@ -24,7 +24,7 @@ use ILIAS\TestQuestionPool\ExportImport\Foundation\Bridge\StateHolder; use ILIAS\TestQuestionPool\ExportImport\Foundation\Builder; use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportSessionRepository; -use ILIAS\TestQuestionPool\ExportImport\QuestionPoolExporter; +use ILIAS\TestQuestionPool\ExportImport\Export\QuestionPoolExporter; use ILIAS\TestQuestionPool\ExportImport\QuestionPoolImporter; use ILIAS\TestQuestionPool\ExportImport\SkillAssignmentsImporter; use Pimple\Container as PimpleContainer; From 8e5d2c2c68f82478b81caf3d115a524ddf2aedee Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Mon, 20 Apr 2026 09:04:34 +0200 Subject: [PATCH 15/43] fix(test): missing parameter in Repository::getTestAttemptResult --- .../Test/src/Results/Data/Repository.php | 19 +++++++++++++++++-- .../Results/Data/TestResultRepositoryTest.php | 4 ++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/components/ILIAS/Test/src/Results/Data/Repository.php b/components/ILIAS/Test/src/Results/Data/Repository.php index 70c1499d41b4..f7168d7850d2 100644 --- a/components/ILIAS/Test/src/Results/Data/Repository.php +++ b/components/ILIAS/Test/src/Results/Data/Repository.php @@ -150,14 +150,29 @@ public function updateTestResultCache(int $active_id, ?\ilAssQuestionProcessLock return $result; } - public function getTestAttemptResult(int $active_id): ?AttemptResult + public function getTestAttemptResult(int $active_id, int $attempt): ?AttemptResult + { + $result = $this->db->queryF( + "SELECT * FROM tst_pass_result WHERE active_fi = %s AND pass = %s", + [\ilDBConstants::T_INTEGER, \ilDBConstants::T_INTEGER], + [$active_id, $attempt] + ); + return $this->toTestAttemptResult($this->db->fetchAssoc($result)); + } + + public function getTestAttemptResults(int $active_id): array { $result = $this->db->queryF( "SELECT * FROM tst_pass_result WHERE active_fi = %s", [\ilDBConstants::T_INTEGER], [$active_id] ); - return $this->toTestAttemptResult($this->db->fetchAssoc($result)); + + $results = []; + while ($row = $this->db->fetchAssoc($result)) { + $results[$row['pass']] = $this->toTestAttemptResult($row); + } + return $results; } public function updateTestAttemptResult( diff --git a/components/ILIAS/Test/tests/Results/Data/TestResultRepositoryTest.php b/components/ILIAS/Test/tests/Results/Data/TestResultRepositoryTest.php index 3f39deae32d4..71870d8050e4 100644 --- a/components/ILIAS/Test/tests/Results/Data/TestResultRepositoryTest.php +++ b/components/ILIAS/Test/tests/Results/Data/TestResultRepositoryTest.php @@ -174,7 +174,7 @@ public function testGetTestAttemptResult(array $query_result, array $expected): $this->mockGetTestPassResultQuery($query_result); $repository = $this->createInstance(); - $actual = $repository->getTestAttemptResult($query_result['active_fi']); + $actual = $repository->getTestAttemptResult($query_result['active_fi'], $query_result['pass']); $this->assertNotNull($actual); $this->assertInstanceOf(AttemptResult::class, $actual); @@ -188,7 +188,7 @@ public function testGetTestAttemptResultNotFound(): void $this->mockGetTestPassResultQuery(null); $repository = $this->createInstance(); - $actual = $repository->getTestAttemptResult(1000); + $actual = $repository->getTestAttemptResult(1000, 0); $this->assertNull($actual); } From 10a19a74e980650da4d8dcafc98bdfdfa63a1e6f Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Tue, 21 Apr 2026 10:25:42 +0200 Subject: [PATCH 16/43] feat(test): introduce normalizing into test component --- .../ExportImport/Envelopes/ManualFeedback.php | 84 ++++++ .../src/ExportImport/Envelopes/Solution.php | 101 ++++++++ .../ExportImport/Envelopes/WorkingTime.php | 72 ++++++ .../Normalizer/AttemptResultNormalizer.php | 86 +++++++ .../Normalizer/ExportableNormalizer.php | 63 +++++ .../Normalizer/ParticipantNormalizer.php | 109 ++++++++ .../ParticipantResultNormalizer.php | 81 ++++++ .../Normalizer/ilObjTestNormalizer.php | 68 +++++ .../src/ExportImport/Pipes/CollectUserIds.php | 61 +++++ .../Test/src/ExportImport/TestCollector.php | 241 ++++++++++++++++++ .../Test/src/Participants/Participant.php | 5 + .../classes/class.assQuestion.php | 4 +- .../Normalizer/ResourceNormalizer.php | 147 +++++++++++ .../Normalizing/Pipes/CollectResources.php | 72 ++++++ 14 files changed, 1192 insertions(+), 2 deletions(-) create mode 100644 components/ILIAS/Test/src/ExportImport/Envelopes/ManualFeedback.php create mode 100644 components/ILIAS/Test/src/ExportImport/Envelopes/Solution.php create mode 100644 components/ILIAS/Test/src/ExportImport/Envelopes/WorkingTime.php create mode 100644 components/ILIAS/Test/src/ExportImport/Normalizer/AttemptResultNormalizer.php create mode 100644 components/ILIAS/Test/src/ExportImport/Normalizer/ExportableNormalizer.php create mode 100644 components/ILIAS/Test/src/ExportImport/Normalizer/ParticipantNormalizer.php create mode 100644 components/ILIAS/Test/src/ExportImport/Normalizer/ParticipantResultNormalizer.php create mode 100644 components/ILIAS/Test/src/ExportImport/Normalizer/ilObjTestNormalizer.php create mode 100644 components/ILIAS/Test/src/ExportImport/Pipes/CollectUserIds.php create mode 100644 components/ILIAS/Test/src/ExportImport/TestCollector.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/ResourceNormalizer.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/CollectResources.php diff --git a/components/ILIAS/Test/src/ExportImport/Envelopes/ManualFeedback.php b/components/ILIAS/Test/src/ExportImport/Envelopes/ManualFeedback.php new file mode 100644 index 000000000000..5531a916bd81 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Envelopes/ManualFeedback.php @@ -0,0 +1,84 @@ + $tt->normalize($this->active_id), + 'question_id' => $tt->normalize($this->question_id), + 'attempt' => $this->attempt, + 'feedback' => $this->feedback, + 'finalized_evaluation' => $this->finalized_evaluation, + 'finalized_timestamp' => $this->finalized_timestamp, + 'finalized_by' => $tt->normalize($this->finalized_by), + ]; + } + + /** + * @inheritDoc + */ + public static function fromArray(array $value, Transformations $tt): static + { + return new self( + $tt->denormalize($value['active_id'], Id::class)->getId(), + $tt->denormalize($value['question_id'], Id::class)->getId(), + $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)->getId(), + ); + } + + public static function fromRow(array $row): static + { + return new self( + new Id($row['active_fi'], 'participant'), + new Id($row['question_fi'], 'question'), + (int) $row['pass'], + $row['feedback'], + (bool) $row['finalized_evaluation'], + (int) $row['finalized_tstamp'], + new Id($row['finalized_by_usr_id'], 'user'), + ); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Envelopes/Solution.php b/components/ILIAS/Test/src/ExportImport/Envelopes/Solution.php new file mode 100644 index 000000000000..2bdc8d75ba32 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Envelopes/Solution.php @@ -0,0 +1,101 @@ + $tt->normalize($this->active_id), + 'question_id' => $tt->normalize($this->question_id), + 'attempt' => $this->attempt, + 'points' => $this->points, + 'timestamp' => $this->timestamp, + 'value1' => $tt->normalize($this->value1), + 'value2' => $this->value2, + 'step' => $this->step, + 'authorized' => $this->authorized + ]; + } + + /** + * @inheritDoc + */ + public static function fromArray(array $value, Transformations $tt): static + { + return new self( + $tt->denormalize($value['active_id'], Id::class)->getId(), + $tt->denormalize($value['question_id'], Id::class)->getId(), + $tt->int($value['attempt']), + $tt->nullableFloat($value['points']), + $tt->int($value['timestamp']), + ResourceNormalizer::isResourceIdentification($value['value1']) + ? $tt->denormalize($value['value1'], ResourceIdentification::class) + : $tt->nullableString($value['value1']), + $tt->nullableString($value['value2']), + $tt->nullableInt($value['step']), + $tt->bool($value['authorized']), + ); + } + + public static function fromRow(array $row): static + { + $value1 = $row['value1']; + if ($row['value2'] === 'rid' && is_string($value1)) { + $value1 = new ResourceIdentification($value1); + } + + return new self( + new Id($row['active_fi'], 'participant'), + new Id($row['question_fi'], 'question'), + (int) $row['pass'], + $row['points'] ? (float) $row['points'] : null, + (int) $row['tstamp'], + $value1, + $row['value2'], + $row['step'] ? (int) $row['step'] : null, + (bool) $row['authorized'] + ); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Envelopes/WorkingTime.php b/components/ILIAS/Test/src/ExportImport/Envelopes/WorkingTime.php new file mode 100644 index 000000000000..17deb3dabbd3 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Envelopes/WorkingTime.php @@ -0,0 +1,72 @@ + $tt->normalize($this->active_id), + 'attempt' => $this->attempt, + 'started' => $this->started, + 'finished' => $this->finished, + ]; + } + + /** + * @inheritDoc + */ + public static function fromArray(array $value, Transformations $tt): static + { + return new self( + $tt->denormalize($value['active_id'], Id::class)->getId(), + $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/Normalizer/AttemptResultNormalizer.php b/components/ILIAS/Test/src/ExportImport/Normalizer/AttemptResultNormalizer.php new file mode 100644 index 000000000000..15a1a4349b90 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalizer/AttemptResultNormalizer.php @@ -0,0 +1,86 @@ + + */ +#[Normalizes(AttemptResult::class)] +class AttemptResultNormalizer implements Normalizer +{ + public function __construct( + private readonly Transformations $tt, + ) { + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if (!$value instanceof AttemptResult) { + throw new NormalizingException('Invalid value', $value); + } + + return [ + 'active_id' => $this->tt->normalize(new Id($value->getActiveId(), 'participant')), + 'attempt' => $value->getAttempt(), + 'max_points' => $value->getMaxPoints(), + 'reached_points' => $value->getReachedPoints(), + 'question_count' => $value->getQuestionCount(), + 'answered_questions' => $value->getAnsweredQuestions(), + 'working_time' => $value->getWorkingTime(), + 'timestamp' => $value->getTimestamp(), + 'exam_id' => $value->getExamId(), + 'finalized_by' => $value->getFinalizedBy(), + ]; + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): AttemptResult + { + if ($type !== AttemptResult::class) { + throw new NormalizingException("Invalid type for AttemptResult: {$type}"); + } + + return new AttemptResult( + $this->tt->denormalize($value['active_id'], Id::class)->getId(), + $this->tt->int($value['attempt']), + $this->tt->float($value['max_points']), + $this->tt->float($value['reached_points']), + $this->tt->int($value['question_count']), + $this->tt->int($value['answered_questions']), + $this->tt->int($value['working_time']), + $this->tt->int($value['timestamp']), + $this->tt->string($value['exam_id']), + $this->tt->string($value['finalized_by']), + ); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Normalizer/ExportableNormalizer.php b/components/ILIAS/Test/src/ExportImport/Normalizer/ExportableNormalizer.php new file mode 100644 index 000000000000..1490132728dd --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalizer/ExportableNormalizer.php @@ -0,0 +1,63 @@ + + */ +#[Normalizes(Exportable::class)] +class ExportableNormalizer implements Normalizer +{ + public function __construct( + private readonly Transformations $tt, + ) { + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if (!$value instanceof Exportable) { + throw new NormalizingException('Invalid exportable value', $value); + } + + return $value->toExport(); + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): Exportable + { + if (!in_array(Exportable::class, class_implements($type))) { + throw new NormalizingException('Invalid exportable type', $type); + } + + return $type::fromExport($value); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Normalizer/ParticipantNormalizer.php b/components/ILIAS/Test/src/ExportImport/Normalizer/ParticipantNormalizer.php new file mode 100644 index 000000000000..27d6dc082585 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalizer/ParticipantNormalizer.php @@ -0,0 +1,109 @@ + + */ +#[Normalizes(Participant::class)] +class ParticipantNormalizer implements Normalizer +{ + public function __construct( + private readonly Transformations $tt, + ) { + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if (!$value instanceof Participant) { + throw new NormalizingException('Invalid value', $value); + } + + return [ + 'user_id' => $this->tt->normalize(new Id($value->getUserId(), 'user')), + 'active_id' => $this->tt->normalize(new Id($value->getActiveId(), 'participant')), + 'test_id' => $this->tt->normalize(new Id($value->getTestId(), 'test')), + 'anonymous_id' => $value->getAnonymousId(), + 'firstname' => $value->getFirstname(), + 'lastname' => $value->getLastname(), + 'login' => $value->getLogin(), + 'importname' => $value->getImportname(), + 'matriculation' => $value->getMatriculation(), + 'extra_time' => $value->getExtraTime(), + 'attempts' => $value->getAttempts(), + 'client_ip_from' => $value->getClientIpFrom(), + 'client_ip_to' => $value->getClientIpTo(), + 'invitation_date' => $value->getInvitationDate(), + 'submitted' => $value->getSubmitted(), + 'last_started_attempt' => $value->getLastStartedAttempt(), + 'last_finished_attempt' => $value->getLastFinishedAttempt(), + 'unfinished_attempts' => $value->hasUnfinishedAttempts(), + 'first_access' => $this->tt->normalize($value->getFirstAccess()), + 'last_access' => $this->tt->normalize($value->getLastAccess()), + 'scoring_finalized' => $value->isScoringFinalized(), + ]; + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): Participant + { + if ($type !== Participant::class) { + throw new NormalizingException("Invalid type for Participant: {$type}"); + } + + return new Participant( + $this->tt->denormalize($value['user_id'], Id::class)->getId(), + $this->tt->denormalize($value['active_id'], Id::class)->getId(), + $this->tt->denormalize($value['test_id'], Id::class)->getId(), + $this->tt->nullableString($value['anonymous_id']), + $this->tt->string($value['firstname']), + $this->tt->string($value['lastname']), + $this->tt->string($value['login']), + $this->tt->nullableString($value['importname']), + $this->tt->string($value['matriculation']), + $this->tt->int($value['extra_time']), + $this->tt->int($value['attempts']), + $this->tt->nullableString($value['client_ip_from']), + $this->tt->nullableString($value['client_ip_to']), + $this->tt->nullableInt($value['invitation_date']), + $this->tt->nullableBool($value['submitted']), + $this->tt->nullableInt($value['last_started_attempt']), + $this->tt->nullableInt($value['last_finished_attempt']), + $this->tt->bool($value['unfinished_attempts']), + $this->tt->denormalize($value['first_access'], DateTimeImmutable::class), + $this->tt->denormalize($value['last_access'], DateTimeImmutable::class), + $this->tt->bool($value['scoring_finalized']), + ); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Normalizer/ParticipantResultNormalizer.php b/components/ILIAS/Test/src/ExportImport/Normalizer/ParticipantResultNormalizer.php new file mode 100644 index 000000000000..b31df4ca9e0a --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalizer/ParticipantResultNormalizer.php @@ -0,0 +1,81 @@ + + */ +#[Normalizes(ParticipantResult::class)] +class ParticipantResultNormalizer implements Normalizer +{ + public function __construct( + private readonly Transformations $tt, + ) { + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if (!$value instanceof ParticipantResult) { + throw new NormalizingException('Invalid value', $value); + } + + return [ + 'active_id' => $this->tt->normalize(new Id($value->getActiveId(), 'participant')), + 'attempt' => $value->getAttempt(), + 'max_points' => $value->getMaxPoints(), + 'reached_points' => $value->getReachedPoints(), + 'mark' => $this->tt->normalize($value->getMark()), + 'timestamp' => $value->getTimestamp(), + 'passed_once' => $value->isPassedOnce(), + ]; + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): ParticipantResult + { + if ($type !== ParticipantResult::class) { + throw new NormalizingException("Invalid type for ParticipantResult: {$type}"); + } + + return new ParticipantResult( + $this->tt->denormalize($value['active_id'], Id::class)->getId(), + $this->tt->int($value['attempt']), + $this->tt->float($value['max_points']), + $this->tt->float($value['reached_points']), + $this->tt->denormalize($value['mark'], Mark::class), + $this->tt->int($value['timestamp']), + $this->tt->bool($value['passed_once']), + ); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Normalizer/ilObjTestNormalizer.php b/components/ILIAS/Test/src/ExportImport/Normalizer/ilObjTestNormalizer.php new file mode 100644 index 000000000000..083dd5c228fa --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalizer/ilObjTestNormalizer.php @@ -0,0 +1,68 @@ + + */ +#[Normalizes(ilObjTest::class)] +class ilObjTestNormalizer extends IlObjectNormalizer implements Normalizer +{ + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if (!$value instanceof ilObjTest) { + throw new NormalizingException('Invalid value', $value); + } + + $normalized = parent::normalize($value); + $normalized['test_id'] = $this->tt->normalize(new Id($value->getTestId(), 'test')); + + return $normalized; + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): ilObjTest + { + if ($type !== ilObjTest::class) { + throw new NormalizingException("Invalid type for ilObjTest: {$type}"); + } + + /** @var ilObjTest $object */ + $object = parent::denormalize($value, ilObjTest::class); + $object->setTestId( + $this->tt->denormalize($value['test_id'], Id::class)->getId() + ); + + return $object; + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Pipes/CollectUserIds.php b/components/ILIAS/Test/src/ExportImport/Pipes/CollectUserIds.php new file mode 100644 index 000000000000..5f727a3fd49e --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Pipes/CollectUserIds.php @@ -0,0 +1,61 @@ + $ids + */ + private array $ids = []; + + /** + * @inheritDoc + */ + public function handle(mixed $passable, \Closure $next): mixed + { + if ($passable instanceof NormalizeCarry && $passable->value instanceof Id) { + if ($passable->value->getObject() === 'user') { + $this->ids[$passable->value->getId()] = true; + } + } + + return $next($passable); + } + + /** + * Get all user IDs collected during normalization. + * + * @return list + */ + public function getIds(): array + { + return array_keys($this->ids); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/TestCollector.php b/components/ILIAS/Test/src/ExportImport/TestCollector.php new file mode 100644 index 000000000000..dc80c6012540 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/TestCollector.php @@ -0,0 +1,241 @@ + $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 ilDBInterface $db, + private readonly ObjectId $object_id + ) { + } + + private function database(): ilDBInterface + { + return $this->db; + } + + public function getObjectId(): ObjectId + { + return $this->object_id; + } + + public function getTestId(): int + { + return $this->getObject()->getTestId(); + } + + public function getObject(): ilObjTest + { + if ($this->test === null) { + $this->test = new ilObjTest($this->object_id->toInt(), false); + } + + return $this->test; + } + + public function getSettings(): array + { + return [ + 'main' => $this->test->getMainSettings(), + 'scoring' => $this->test->getScoreSettings(), + 'marks' => $this->test->getMarkSchema(), + ]; + } + + /** + * Create a mapping of user IDs to the user identifier field specified in the test object's global settings. + * + * @param list $user_ids + * @return array{identifier: string, mapping: array} + */ + public function getUserMapping(array $user_ids): array + { + $export_identifier = $this->getObject()->getGlobalSettings()->getUserIdentifier(); + + $mapping = []; + if ($export_identifier === UserIdentifiers::USER_ID) { + foreach ($user_ids as $user_id) { + $mapping[$user_id] = $user_id; + } + } else { + $in_clause = $this->db->in('usr_id', $user_ids, false, 'integer'); + $query = $this->db->query("SELECT usr_id, {$export_identifier->value} FROM usr_data WHERE {$in_clause}"); + + foreach ($this->db->fetchAll($query) as $row) { + $mapping[$row['usr_id']] = $row[$export_identifier->value]; + } + }; + + return [ + 'identifier' => $export_identifier->value, + 'mapping' => $mapping, + ]; + } + + /* + Questions + */ + + /** + * @inheritDoc + */ + public function getQuestionProperties(): array + { + return array_map( + fn(Properties $property) => $property->getGeneralQuestionProperties(), + $this->getTestQuestionProperties() + ); + } + + /** + * @return array + */ + public function getTestQuestionProperties(): array + { + if ($this->questions === null) { + $this->questions = $this->questions_repository->getQuestionPropertiesForTest($this->getObject()); + } + return $this->questions; + } + + /* + Participants + */ + + /** + * @return Generator + */ + public function getParticipants(): Generator + { + return $this->participant_repository->getParticipants($this->getTestId()); + } + + /** + * @return list + */ + public function getParticipantsIds(): array + { + if ($this->participants === null) { + $this->participants = []; + foreach ($this->getParticipants() as $participant) { + $this->participants[] = $participant->getActiveId(); + } + } + return $this->participants; + } + + /* + Results + */ + + public function getResults(int $participant_id): array + { + return [ + 'results' => $this->results_repository->getTestResult($participant_id), + 'attempts' => $this->results_repository->getTestAttemptResults($participant_id), + 'solutions' => $this->getSolutions($participant_id), + 'working_times' => $this->getWorkingTimes($participant_id), + 'manual_feedback' => $this->getManualFeedback($participant_id), + ]; + } + + /** + * @return list + */ + public function getSolutions(int $participant_id): array + { + $query = $this->db->queryF( + "SELECT * FROM tst_solutions WHERE active_fi = %s", + [ilDBConstants::T_INTEGER], + [$participant_id] + ); + + return array_map( + fn(array $row): Solution => Solution::fromRow($row), + $this->db->fetchAll($query) + ); + } + + /** + * @return list + */ + public function 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) + ); + } +} diff --git a/components/ILIAS/Test/src/Participants/Participant.php b/components/ILIAS/Test/src/Participants/Participant.php index 21bf4258b985..83b90b75e82b 100644 --- a/components/ILIAS/Test/src/Participants/Participant.php +++ b/components/ILIAS/Test/src/Participants/Participant.php @@ -145,6 +145,11 @@ public function withClientIpTo(?string $ip): self return $clone; } + public function getInvitationDate(): ?int + { + return $this->invitation_date; + } + public function isInvitedParticipant(): bool { return $this->invitation_date > 0; diff --git a/components/ILIAS/TestQuestionPool/classes/class.assQuestion.php b/components/ILIAS/TestQuestionPool/classes/class.assQuestion.php index 22c8bfcd2474..64f4831830ae 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.assQuestion.php +++ b/components/ILIAS/TestQuestionPool/classes/class.assQuestion.php @@ -2957,7 +2957,7 @@ public function toNormalized(Transformations $tt): Transformation { return $tt->custom()->transformation(fn(): array => [ 'id' => $tt->normalize(new Id($this->id, 'question')), - 'pool_id' => $tt->normalize(new Id($this->obj_id, 'qpl')), + 'parent_id' => $tt->normalize(new Id($this->obj_id, 'object')), 'original_id' => $this->original_id, 'external_id' => $this->external_id, 'type' => $this->getQuestionType(), @@ -2985,7 +2985,7 @@ public function fromNormalized(Transformations $tt): Transformation return $tt->custom()->transformation(function (array $normalized) use ($tt): self { $clone = clone $this; $clone->id = $tt->denormalize($normalized['id'], Id::class)->getId(); - $clone->obj_id = $tt->denormalize($normalized['pool_id'], Id::class)->getId(); + $clone->obj_id = $tt->denormalize($normalized['parent_id'], Id::class)->getId(); $clone->original_id = $tt->nullableInt($normalized['original_id']); $clone->external_id = $tt->nullableString($normalized['external_id']); $clone->owner = $tt->int($normalized['owner']); diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/ResourceNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/ResourceNormalizer.php new file mode 100644 index 000000000000..64e7e3dc2c89 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/ResourceNormalizer.php @@ -0,0 +1,147 @@ + + */ +#[Normalizes(ResourceIdentification::class, StorableResource::class)] +class ResourceNormalizer implements Normalizer +{ + private const string KEY_TYPE = '_type'; + private const string TYPE_RID = 'rid'; + private const string TYPE_RESOURCE = 'resource'; + + private readonly ResourceRepository $resource_repository; + + public function __construct( + private readonly Transformations $tt, + Container $dic + ) { + $this->resource_repository = $dic[InitResourceStorage::D_REPOSITORIES]->getResourceRepository(); + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if ($value instanceof ResourceIdentification) { + return $this->normalizeIdentification($value); + } + + if ($value instanceof StorableResource) { + return $this->normalizeResource($value); + } + + throw new NormalizingException('Invalid value', $value); + } + + private function normalizeIdentification(ResourceIdentification $rid): array + { + return [ + self::KEY_TYPE => self::TYPE_RID, + 'id' => $rid->serialize(), + ]; + } + + private function normalizeResource(StorableResource $resource): array + { + return [ + self::KEY_TYPE => self::TYPE_RESOURCE, + 'resource_type' => $resource->getType()->value, + 'id' => $resource->getIdentification()->serialize(), + 'revision' => $resource->getCurrentRevision()->getVersionNumber(), + 'title' => $resource->getCurrentRevision()->getTitle(), + 'mime_type' => $resource->getCurrentRevision()->getInformation()->getMimeType(), + 'suffix' => $resource->getCurrentRevision()->getInformation()->getSuffix(), + 'creation_date' => $this->tt->normalize($resource->getCurrentRevision()->getInformation()->getCreationDate()), + ]; + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): ResourceIdentification|StorableResource + { + if ($type === ResourceIdentification::class) { + return $this->denormalizeIdentification($value); + } + + if ($type === StorableResource::class) { + $this->denormalizeResource($value); + } + + throw new NormalizingException('Invalid type', $type); + } + + private function denormalizeIdentification(array $value): ResourceIdentification + { + if (!self::isResourceIdentification($value)) { + throw new NormalizingException('Invalid resource identification', $value); + } + + return new ResourceIdentification($value['id']); + } + + private function denormalizeResource(array $value): StorableResource + { + if (!self::isStorableResource($value)) { + throw new NormalizingException('Invalid storable resource', $value); + } + + $id = new ResourceIdentification($value['id']); + $type = ResourceType::from($value['resource_type']); + + return $this->resource_repository->blank($id, $type); + } + + /** + * Returns true if the value is a normalized resource identification. + */ + public static function isResourceIdentification(mixed $value): bool + { + return is_array($value) + && isset($value[self::KEY_TYPE]) + && $value[self::KEY_TYPE] === self::TYPE_RID; + } + + /** + * Returns true if the value is a normalized storable resource. + */ + public static function isStorableResource(mixed $value): bool + { + return is_array($value) + && isset($value[self::KEY_TYPE]) + && $value[self::KEY_TYPE] === self::TYPE_RESOURCE; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/CollectResources.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/CollectResources.php new file mode 100644 index 000000000000..ea9c3b4b4c8b --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/CollectResources.php @@ -0,0 +1,72 @@ + $resources + */ + private array $resources = []; + + public function __construct( + private readonly IRSS $irss + ) { + } + + /** + * @inheritDoc + */ + public function handle(mixed $passable, \Closure $next): mixed + { + if ($passable instanceof NormalizeCarry && $passable->value instanceof ResourceIdentification) { + $this->handleNormalization($passable->value); + } + + return $next($passable); + } + + private function handleNormalization(ResourceIdentification $rid): void + { + $resource = $this->irss->manage()->getResource($rid); + + $this->resources[$rid->serialize()] = $resource; + } + + /** + * Get all resources collected during normalization. + * + * @return array + */ + public function getResources(): array + { + return $this->resources; + } +} From 58c661b61f9b3e48620433bebacf592e555867cb Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Tue, 21 Apr 2026 12:47:47 +0200 Subject: [PATCH 17/43] feat(test): introduce test export classes and integrate into existing structure --- .../Test/classes/class.ilTestExportGUI.php | 58 --- .../class.ilTestExportOptionXMLRES.php | 152 -------- .../Test/classes/class.ilTestExporter.php | 176 ++------- .../ExportOptions/XMLWithResultsOption.php | 94 +++++ .../Test/src/ExportImport/TestExporter.php | 343 ++++++++++++++++++ components/ILIAS/Test/src/TestDIC.php | 24 ++ .../Foundation/Bridge/ExportState.php | 8 +- .../Foundation/Bridge/StateHolder.php | 9 +- .../Foundation/Bridge/XmlExporterBridge.php | 6 +- 9 files changed, 513 insertions(+), 357 deletions(-) delete mode 100644 components/ILIAS/Test/classes/class.ilTestExportOptionXMLRES.php create mode 100644 components/ILIAS/Test/src/ExportImport/ExportOptions/XMLWithResultsOption.php create mode 100644 components/ILIAS/Test/src/ExportImport/TestExporter.php diff --git a/components/ILIAS/Test/classes/class.ilTestExportGUI.php b/components/ILIAS/Test/classes/class.ilTestExportGUI.php index 88e324fde351..15946674465a 100755 --- a/components/ILIAS/Test/classes/class.ilTestExportGUI.php +++ b/components/ILIAS/Test/classes/class.ilTestExportGUI.php @@ -20,13 +20,11 @@ use ILIAS\Test\Scoring\Manual\TestScoring; use ILIAS\Test\ExportImport\DBRepository; -use ILIAS\Test\ExportImport\ResultsExportStakeholder; use ILIAS\Test\Results\Data\Repository as TestResultsRepository; use ILIAS\UI\Factory as UIFactory; use ILIAS\UI\Renderer as UIRenderer; use ILIAS\ResourceStorage\Services as IRSS; use ILIAS\Filesystem\Filesystem; -use ILIAS\Test\ExportImport\Types as ExportImportTypes; use Psr\Http\Message\ServerRequestInterface; /** @@ -56,62 +54,6 @@ public function __construct( parent::__construct($parent_gui, null); } - /** - * Create test export file - */ - public function createTestExportWithResults() - { - $this->ctrl->setParameterByClass(self::class, 'export_results', 1); - $manager = $this->export_handler->manager()->handler(); - $export_info = $manager->getExportInfoWithObject( - $this->obj, - time(), - $this->export_handler->consumer()->exportConfig()->allExportConfigs() - ); - $element = $manager->createExport( - $this->il_user->getId(), - $export_info, - '' - ); - - $file_name = $element->getIRSSInfo()->getFileName(); - $this->temp_file_system->writeStream( - $file_name, - $this->irss->consume()->stream($element->getIRSSInfo()->getResourceId())->getStream() - ); - $temp_stream = $this->temp_file_system->readStream($file_name); - $rid = $this->irss->manage()->stream( - $temp_stream, - new ResultsExportStakeholder(), - $element->getIRSSInfo()->getFileName() - ); - - $temp_stream->close(); - - $this->temp_file_system->delete($file_name); - - $this->export_repository->store( - $this->obj->getId(), - ExportImportTypes::XML_WITH_RESULTS, - $rid - ); - $this->export_options->getById('expxml')->onDeleteFiles( - $this->context, - $this->export_handler->consumer()->file()->identifier()->collection()->withElement( - $this->export_handler->consumer()->file()->identifier()->handler()->withIdentifier( - $element->getIRSSInfo()->getResourceIdSerialized() - ) - ) - ); - - $this->tpl->setOnScreenMessage( - ilGlobalTemplateInterface::MESSAGE_TYPE_SUCCESS, - $this->lng->txt("exp_file_created"), - true - ); - $this->ctrl->redirect($this, self::CMD_LIST_EXPORT_FILES); - } - public function createTestArchiveExport() { if ($this->access->checkAccess('write', '', $this->obj->getRefId())) { diff --git a/components/ILIAS/Test/classes/class.ilTestExportOptionXMLRES.php b/components/ILIAS/Test/classes/class.ilTestExportOptionXMLRES.php deleted file mode 100644 index 154460c6622a..000000000000 --- a/components/ILIAS/Test/classes/class.ilTestExportOptionXMLRES.php +++ /dev/null @@ -1,152 +0,0 @@ -lng = $DIC['lng']; - $this->irss = $DIC['resource_storage']; - $this->data_factory = new DataFactory(); - $this->repository = TestDIC::dic()['exportimport.repository']; - } - - public function getExportType(): string - { - return 'ZIP Results'; - } - - public function getExportOptionId(): string - { - return self::OPTIONS_ID; - } - - public function getSupportedRepositoryObjectTypes(): array - { - return ['tst']; - } - - public function getLabel(): string - { - $this->lng->loadLanguageModule('exp'); - $this->lng->loadLanguageModule('assessment'); - return $this->lng->txt("exp_format_dropdown-xml") . " (" . $this->lng->txt('ass_create_export_file_with_results') . ")"; - } - - public function onDeleteFiles( - ilExportHandlerConsumerContextInterface $context, - ilExportHandlerConsumerFileIdentifierCollectionInterface $file_identifiers - ): void { - $object_id = new ObjectId($context->exportObject()->getId()); - foreach ($file_identifiers as $file_identifier) { - $rid = $this->irss->manage()->find($file_identifier->getIdentifier()); - $this->repository->delete($rid); - $this->irss->manage()->remove($rid, new ResultsExportStakeholder()); - } - } - - public function onDownloadFiles( - ilExportHandlerConsumerContextInterface $context, - ilExportHandlerConsumerFileIdentifierCollectionInterface $file_identifiers - ): void { - $object_id = new ObjectId($context->exportObject()->getId()); - foreach ($file_identifiers as $file_identifier) { - $this->irss->consume()->download( - $this->irss->manage()->find($file_identifier->getIdentifier()) - )->run(); - } - } - - public function onDownloadWithLink( - ReferenceId $reference_id, - ilExportHandlerConsumerFileIdentifierInterface $file_identifier - ): void { - $this->irss->consume()->download($reference_id)->run(); - } - - public function getFiles( - ilExportHandlerConsumerContextInterface $context - ): ilExportHandlerFileInfoCollectionInterface { - return $this->buildElements( - $context, - $this->data_factory->objId($context->exportObject()->getId()) - ); - } - - public function getFileSelection( - ilExportHandlerConsumerContextInterface $context, - ilExportHandlerConsumerFileIdentifierCollectionInterface $file_identifiers - ): ilExportHandlerFileInfoCollectionInterface { - return $this->buildElements( - $context, - $this->data_factory->objId($context->exportObject()->getId()), - $file_identifiers->toStringArray() - ); - } - - public function onExportOptionSelected( - ilExportHandlerConsumerContextInterface $context - ): void { - $context->exportGUIObject()->createTestExportWithResults(); - } - - protected function buildElements( - ilExportHandlerConsumerContextInterface $context, - ObjectId $object_id, - ?array $file_identifiers = null - ): ilExportHandlerFileInfoCollectionInterface { - if ($file_identifiers === null) { - $file_identifiers = array_map( - static fn(array $v): string => $v['rid'], - $this->repository->getFor($object_id->toInt()) - ); - } - $collection_builder = $context->fileCollectionBuilder(); - foreach ($file_identifiers as $file_identifier) { - $collection_builder = $collection_builder->withResourceIdentifier( - $this->irss->manage()->find($file_identifier), - $object_id, - $this - ); - } - return $collection_builder->collection(); - } -} diff --git a/components/ILIAS/Test/classes/class.ilTestExporter.php b/components/ILIAS/Test/classes/class.ilTestExporter.php index 3cfc9ff2026d..85e741c91093 100755 --- a/components/ILIAS/Test/classes/class.ilTestExporter.php +++ b/components/ILIAS/Test/classes/class.ilTestExporter.php @@ -18,176 +18,70 @@ declare(strict_types=1); +use ILIAS\Export\ExportHandler\Factory as ExportHandler; +use ILIAS\Test\ExportImport\Types; use ILIAS\Test\TestDIC; -use ILIAS\TestQuestionPool\Questions\GeneralQuestionPropertiesRepository; -use ILIAS\Test\Logging\TestLogger; -use ILIAS\Test\ExportImport\Factory as ExportImportFactory; -use ILIAS\Test\ExportImport\Types as ExportImportTypes; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Bridge\XmlExporterBridge; -/** - * Used for container export with tests - * - * @author Stefan Meyer - * @version $Id$ - * @ingroup components\ILIASTest - */ class ilTestExporter extends ilXmlExporter { - private readonly ilLanguage $lng; - private readonly ExportImportFactory $export_factory; - private readonly TestLogger $logger; - private readonly ilTree $tree; - private readonly ilCtrl $ctrl; - private readonly ilComponentRepository $component_repository; - private readonly GeneralQuestionPropertiesRepository $questionrepository; + use XmlExporterBridge; - public function __construct() + public function init(): void { - global $DIC; - $this->lng = $DIC['lng']; $local_dic = TestDIC::dic(); - $this->export_factory = $local_dic['exportimport.factory']; - $this->logger = $local_dic['logging.logger']; - $this->questionrepository = $local_dic['question.general_properties.repository']; - $this->tree = $DIC['tree']; - $this->ctrl = $DIC['ilCtrl']; - $this->component_repository = $DIC['component.repository']; - parent::__construct(); + $this->export_handler = new ExportHandler(); + $this->state_holder = $local_dic['exportimport.state_holder']; + $this->exporter = $local_dic['exportimport.exporter']; + $this->logger = $local_dic['logging.logger']; } /** - * Initialisation + * Returns the final XML content for the test. + * + * This method is called after `getXmlExportTailDependencies()`. At this point the export writer and export + * directory are available, so the preprocessed export can be written to disk and returned as xml. */ - public function init(): void - { - } - public function getXmlRepresentation(string $a_entity, string $a_schema_version, string $id): string { - $parameters = $this->ctrl->getParameterArrayByClass(ilTestExportGUI::class); - $export_type = ExportImportTypes::XML; - if (!empty($parameters['export_results'])) { - $export_type = ExportImportTypes::XML_WITH_RESULTS; - $this->ctrl->clearParameterByClass(ilTestExportGUI::class, 'export_results'); - } - $tst = new ilObjTest((int) $id, false); - $tst->read(); - $zip = $this->export_factory->getExporter($tst, $export_type) - ->withExportDirInfo($this->getAbsoluteExportDirectory()) - ->write(); - - $this->logger->info(__METHOD__ . ': Created zip file ' . $zip); - return ''; - } - - public function getXmlExportHeadDependencies(string $entity, string $target_release, array $ids): array - { - if ($entity === 'tst') { - $mobs = []; - $files = []; - foreach ($ids as $id) { - $tst = new ilObjTest((int) $id, false); - $tst->read(); - - $intro_page_id = $tst->getMainSettings()->getIntroductionSettings()->getIntroductionPageId(); - if ($intro_page_id !== null) { - $mobs = array_merge($mobs, ilObjMediaObject::_getMobsOfObject('tst:pg', $intro_page_id)); - $files = array_merge($files, ilObjFile::_getFilesOfObject('tst:pg', $intro_page_id)); - } - - $concluding_remarks_page_id = $tst->getMainSettings()->getFinishingSettings()->getConcludingRemarksPageId(); - if ($concluding_remarks_page_id !== null) { - $mobs = array_merge($mobs, ilObjMediaObject::_getMobsOfObject('tst:pg', $concluding_remarks_page_id)); - $files = array_merge($files, ilObjFile::_getFilesOfObject('tst:pg', $concluding_remarks_page_id)); - } - } - - return [ - [ - 'component' => 'components/ILIAS/MediaObjects', - 'entity' => 'mob', - 'ids' => $mobs - ], - [ - 'component' => 'components/ILIAS/File', - 'entity' => 'file', - 'ids' => $files - ] - ]; + if ($a_entity !== 'tst') { + throw new InvalidArgumentException("Invalid entity for test export: {$a_entity}"); } - return parent::getXmlExportTailDependencies($entity, $target_release, $ids); + return $this->finalizeExport()->getContent(); } /** - * @param array ids - * @return array array of array with keys 'component', 'entity', 'ids' + * Collects export tail dependencies for the test. + * + * The export framework calls this method before `getXmlRepresentation()`. Therefore this method only prepares and + * processes the export in memory using the export state. The export state is created if it does not exist yet. */ public function getXmlExportTailDependencies(string $a_entity, string $a_target_release, array $a_ids): array { - if ($a_entity == 'tst') { - $deps = []; - - $tax_ids = $this->getDependingTaxonomyIds($a_ids); - - if (count($tax_ids)) { - $deps[] = [ - 'component' => 'components/ILIAS/Taxonomy', - 'entity' => 'tax', - 'ids' => $tax_ids - ]; - } - - $deps[] = [ - 'component' => 'components/ILIAS/ILIASObject', - 'entity' => 'common', - 'ids' => $a_ids - ]; - - - $md_ids = []; - foreach ($a_ids as $id) { - $md_ids[] = $id . ':0:tst'; - } - if ($md_ids !== []) { - $deps[] = [ - 'component' => 'components/ILIAS/MetaData', - 'entity' => 'md', - 'ids' => $md_ids - ]; - } - - return $deps; + if ($a_entity !== 'tst') { + throw new InvalidArgumentException("Invalid entity for test export: {$a_entity}"); } - return parent::getXmlExportTailDependencies($a_entity, $a_target_release, $a_ids); - } - - /** - * @param array $testObjIds - * @return array $taxIds - */ - private function getDependingTaxonomyIds(array $test_obj_ids): array - { - $tax_ids = []; - - foreach ($test_obj_ids as $test_obj_id) { - foreach (ilObjTaxonomy::getUsageOfObject($test_obj_id) as $tax_id) { - $tax_ids[$tax_id] = $tax_id; - } + // If the default export option was used, the state is not initialized yet. + if ($this->state_holder->exists() === false) { + $this->initExportState( + 'components/ILIAS/Test', + $a_target_release, + $a_entity, + $a_ids, + Types::XML->value + ); } - return $tax_ids; + return $this->processExport()->getDependencies(); } /** - * Returns schema versions that the component can export to. - * ILIAS chooses the first one, that has min/max constraints which - * fit to the target release. Please put the newest on top. - * @param string $a_entity - * @return array - */ + * Returns schema versions that the component can export to. ILIAS chooses the first one, that has min/max + * constraints which fit to the target release. + */ public function getValidSchemaVersions(string $a_entity): array { return [ diff --git a/components/ILIAS/Test/src/ExportImport/ExportOptions/XMLWithResultsOption.php b/components/ILIAS/Test/src/ExportImport/ExportOptions/XMLWithResultsOption.php new file mode 100644 index 000000000000..1e84c37de047 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/ExportOptions/XMLWithResultsOption.php @@ -0,0 +1,94 @@ +lng = $DIC->language(); + $this->state_holder = TestDIC::dic()['exportimport.state_holder']; + } + + public function getExportType(): string + { + return 'ZIP Results'; + } + + public function getExportOptionId(): string + { + return Types::XML_WITH_RESULTS->value; + } + + public function getSupportedRepositoryObjectTypes(): array + { + return ['tst']; + } + + public function getLabel(): string + { + $this->lng->loadLanguageModule('exp'); + $this->lng->loadLanguageModule('assessment'); + + return $this->lng->txt('exp_format_dropdown-xml') . ' (' . $this->lng->txt('ass_create_export_file_with_results') . ')'; + } + + public function onExportOptionSelected(ConsumerContext $context): void + { + $handler = new ExportHandlerLocator(); + $manager = $handler->manager()->handler(); + + $export_info = $manager->getExportInfoWithObject( + $context->exportObject(), + time(), + $handler->consumer()->exportConfig()->allExportConfigs() + ); + + // Prepare export state to bridge between export option and the xml exporter + $this->state_holder->create( + $export_info->getTarget(), + $handler->consumer()->exportConfig()->allExportConfigs(), + Types::XML_WITH_RESULTS->value + ); + + // Delegate the export to the manager which will call ilTestExporter + $manager->createExport( + 1, + $export_info, + '' + ); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/TestExporter.php b/components/ILIAS/Test/src/ExportImport/TestExporter.php new file mode 100644 index 000000000000..34ba3623c109 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/TestExporter.php @@ -0,0 +1,343 @@ +assertStep(ExportStep::INIT); + $state->setStep(ExportStep::PREPARE); + + $object_id = $this->extractObjectId($state); + if ($object_id === null) { + return; + } + + $collector = new TestCollector( + $this->participant_repository, + $this->results_repository, + $this->questions_repository, + $this->db, + $object_id + ); + $state->setCollector($collector); + + $transformations = $this->builder + ->withAdditionalPipes([ + new CollectUserIds(), + new CollectQuestionImages( + new UUIDFactory(), + $object_id + ), + new CollectResources($this->irss), + ]) + ->create(); + + $state->setTransformations($transformations); + } + + private function extractObjectId(ExportState $state): ?ObjectId + { + $target_ids = $state->target()->getObjectIds(); + + if (count($target_ids) === 0) { + $state->logger()->warning('No target object IDs found for test export'); + return null; + } + + if (count($target_ids) > 1) { + $state->logger()->warning( + 'Multiple target object IDs found for test export. Only the first one will be used.' + ); + } + + return $this->data_factory->objId(array_shift($target_ids)); + } + + /** + * @inheritDoc + */ + public function process(ExportState $state): void + { + $state->assertStep(ExportStep::PREPARE); + $state->setStep(ExportStep::PROCESS); + + $state->serializer()->group( + 'general', + fn() => $this->exportObject( + $state->collector(), + $state->transformations(), + $state->serializer(), + $state + ) + ); + $state->serializer()->group( + 'settings', + fn() => $this->exportSettings( + $state->collector(), + $state->transformations(), + $state->serializer(), + $state + ) + ); + $state->serializer()->group( + 'questions', + fn() => $this->exportQuestions( + $state->collector(), + $state->transformations(), + $state->serializer(), + $state + ) + ); + $state->serializer()->group( + 'skill_assignments', + fn() => $this->exportSkillAssignments( + $state->collector(), + $state->transformations(), + $state->serializer(), + ) + ); + + if ($state->getOption() === Types::XML_WITH_RESULTS->value) { + $this->processResults($state); + } + } + + private function processResults(ExportState $state): void + { + $state->serializer()->group( + 'participants', + fn() => $this->exportParticipants( + $state->collector(), + $state->transformations(), + $state->serializer() + ) + ); + $state->serializer()->group( + 'results', + fn() => $this->exportResults( + $state->collector(), + $state->transformations(), + $state->serializer() + ) + ); + } + + /** + * @inheritDoc + */ + public function write(ExportState $state): void + { + $state->assertStep(ExportStep::PROCESS); + $state->setStep(ExportStep::WRITE); + + $export_dir = $state->path()->getPathToComponentExpDirInContainer(); + $question_image_pipe = $state->transformations()->context(CollectQuestionImages::class); + $resource_pipe = $state->transformations()->context(CollectResources::class); + + foreach ($question_image_pipe->getFiles() as $file) { + if (file_exists($file['from'])) { + $state->writer()->writeFileByFilePath( + $file['from'], + "{$export_dir}/" . $file['to'] + ); + } else { + $state->logger()->warning('Question image file not found: ' . $file['from']); + } + } + + + $resource_dir = "{$export_dir}/resources"; + if (!file_exists($resource_dir)) { + mkdir($resource_dir, 0755, true); + } + + foreach ($resource_pipe->getResources() as $id => $resource) { + $file = "{$id}.{$resource->getCurrentRevision()->getInformation()->getSuffix()}"; + $state->writer()->writeFilesByResourceId( + $id, + "{$export_dir}/{$file}" + ); + + } + + $this->exportMappings( + $state->collector(), + $state->transformations(), + $state->serializer() + ); + } + + + private function exportObject( + TestCollector $collector, + Transformations $transformations, + Serializer $serializer, + ExportState $state + ): void { + $serializer->append('object', $transformations->normalize($collector->getObject())); + + $obj_id = $collector->getObjectId()->toInt(); + $state->addDependency('components/ILIAS/ILIASObject', 'common', [$obj_id]); + $state->addDependency('components/ILIAS/MetaData', 'qpl', ["{$obj_id}:0:qpl"]); + $state->addDependency( + 'components/ILIAS/Taxonomy', + 'tax', + $this->taxonomy->getUsageOfObject($obj_id) + ); + } + + private function exportSettings( + TestCollector $collector, + Transformations $transformations, + Serializer $serializer, + ExportState $state + ): void { + $test = $collector->getObject(); + $main_settings = $test->getMainSettings(); + + $serializer->append('main', $transformations->normalize($main_settings)); + $serializer->append('scoring', $transformations->normalize($test->getScoreSettings())); + $serializer->append('marks', $transformations->normalize($test->getMarkSchema())); + + if ($intro_page_id = $main_settings->getIntroductionSettings()->getIntroductionPageId()) { + $state->addDependency('components/ILIAS/COPage', 'pg', ["tst:{$intro_page_id}"]); + } + if ($concluding_page_id = $main_settings->getFinishingSettings()->getConcludingRemarksPageId()) { + $state->addDependency('components/ILIAS/COPage', 'pg', ["tst:{$concluding_page_id}"]); + } + } + + private function exportQuestions( + TestCollector $collector, + Transformations $transformations, + Serializer $serializer, + ExportState $state + ): void { + foreach ($collector->getQuestionObjects() as $question) { + $normalized = [ + ... $transformations->normalize($question), + 'feedback' => $transformations->normalize( + $collector->getFeedback($question) + ) + ]; + + if ($question instanceof assFormulaQuestion) { + $data = $collector->getUnitsAndCategories($question->getId()); + $normalized['formula_data'] = $transformations->normalize($data); + } + + $serializer->append('question', $normalized); + $state->addDependency('components/ILIAS/COPage', 'pg', ["qpl:{$question->getId()}"]); + } + } + + private function exportSkillAssignments( + TestCollector $collector, + Transformations $transformations, + Serializer $serializer, + ): void { + foreach ($collector->getSkillAssignments() as $assignment) { + $serializer->append('skill_assignment', $transformations->normalize($assignment)); + } + } + + private function exportParticipants( + TestCollector $collector, + Transformations $transformations, + Serializer $serializer + ): void { + foreach ($collector->getParticipants() as $participant) { + $serializer->append('participant', $transformations->normalize($participant)); + } + } + + private function exportResults( + TestCollector $collector, + Transformations $transformations, + Serializer $serializer + ): void { + foreach ($collector->getParticipantsIds() as $participant_id) { + $serializer->append( + 'results', + $transformations->normalize( + $collector->getResults($participant_id) + ) + ); + } + } + + private function exportMappings( + TestCollector $collector, + Transformations $transformations, + Serializer $serializer + ): void { + $serializer->startGroup('mappings'); + + $user_ids = $transformations->context(CollectUserIds::class)->getIds(); + $serializer->append('users', $collector->getUserMapping($user_ids)); + + $resources = $transformations->context(CollectResources::class)->getResources(); + $serializer->append( + 'resources', + array_map($transformations->normalize(...), $resources) + ); + + $serializer->endGroup('mappings'); + } +} diff --git a/components/ILIAS/Test/src/TestDIC.php b/components/ILIAS/Test/src/TestDIC.php index 1fcef8585ec3..35be08b991cd 100755 --- a/components/ILIAS/Test/src/TestDIC.php +++ b/components/ILIAS/Test/src/TestDIC.php @@ -21,6 +21,7 @@ namespace ILIAS\Test; use ILIAS\LegalDocuments\ConsumerToolbox\Setting; +use ILIAS\Test\ExportImport\TestExporter; use ILIAS\Test\Participants\ParticipantRepository; use ILIAS\Test\Results\Data\Repository as TestResultRepository; use ILIAS\Test\Scoring\Marks\MarkSchemaFactory; @@ -50,6 +51,8 @@ use ILIAS\Test\Results\Data\Factory as ResultsDataFactory; use ILIAS\Test\Results\Presentation\Factory as ResultsPresentationFactory; use ILIAS\Test\Results\Toplist\TestTopListRepository; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Bridge\StateHolder; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Builder; use ILIAS\TestQuestionPool\Questions\GeneralQuestionPropertiesRepository; use ILIAS\TestQuestionPool\RequestDataCollector as QPLRequestDataCollector; use ILIAS\Data\Factory as DataFactory; @@ -233,6 +236,27 @@ protected static function buildDIC(ILIASContainer $DIC): self $DIC['ilDB'] ); + $dic['exportimport.state_holder'] = static fn($c): StateHolder => + new StateHolder(); + + $dic['exportimport.builder'] = static fn($c): Builder => + new Builder( + $DIC, + $c + ); + + $dic['exportimport.exporter'] = static fn($c): TestExporter => + new TestExporter( + $c['exportimport.builder'], + new DataFactory(), + $DIC->database(), + $DIC->resourceStorage(), + $c['participant.repository'], + $c['results.data.repository'], + $c['questions.properties.repository'], + $DIC->taxonomy()->domain() + ); + $dic['questions.properties.repository'] = static fn($c): TestQuestionsRepository => new TestQuestionsDatabaseRepository( $DIC['ilDB'], diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/ExportState.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/ExportState.php index 1afb13e04b03..5b005ca56e1d 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/ExportState.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/ExportState.php @@ -51,7 +51,8 @@ class ExportState public function __construct( private ExportTarget $target, - private ExportConfig $config + private ExportConfig $config, + private string $option = '' ) { $this->step = ExportStep::INIT; } @@ -66,6 +67,11 @@ public function config(): ExportConfig return $this->config; } + public function getOption(): string + { + return $this->option; + } + public function getStep(): ExportStep { return $this->step; diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/StateHolder.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/StateHolder.php index 0c9ddb9d3855..fb5dd3dcac1c 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/StateHolder.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/StateHolder.php @@ -33,9 +33,12 @@ class StateHolder { private ?ExportState $export_state = null; - public function create(ExportTarget $target, ExportConfig $config): ExportState - { - $this->export_state = new ExportState($target, $config); + public function create( + ExportTarget $target, + ExportConfig $config, + string $option = '' + ): ExportState { + $this->export_state = new ExportState($target, $config, $option); return $this->export_state; } diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/XmlExporterBridge.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/XmlExporterBridge.php index 3c652352a049..f6767b577d61 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/XmlExporterBridge.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/XmlExporterBridge.php @@ -88,7 +88,8 @@ private function initExportState( string $component, string $target_release, string $type, - array $object_ids + array $object_ids, + string $option = '' ): ExportState { $target = $this->export_handler->target()->handler() ->withType($type) @@ -99,7 +100,8 @@ private function initExportState( return $this->state_holder->create( $target, - $this->export_handler->consumer()->exportConfig()->collection() + $this->export_handler->consumer()->exportConfig()->collection(), + $option ); } From 80b6ac95b27b4b499e7654f50c3db78a7fa00be5 Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Wed, 22 Apr 2026 11:31:27 +0200 Subject: [PATCH 18/43] feat(test): introduce skill threshold export --- .../class.ilTestSkillLevelThreshold.php | 37 ++++++++++++++++++- .../Test/src/ExportImport/TestCollector.php | 22 +++++++++++ .../Test/src/ExportImport/TestExporter.php | 19 +++++++++- ...ilAssQuestionSkillAssignmentNormalizer.php | 4 +- 4 files changed, 78 insertions(+), 4 deletions(-) diff --git a/components/ILIAS/Test/classes/class.ilTestSkillLevelThreshold.php b/components/ILIAS/Test/classes/class.ilTestSkillLevelThreshold.php index 70f22450ad38..26f40ff71821 100755 --- a/components/ILIAS/Test/classes/class.ilTestSkillLevelThreshold.php +++ b/components/ILIAS/Test/classes/class.ilTestSkillLevelThreshold.php @@ -17,6 +17,10 @@ *********************************************************************/ declare(strict_types=1); +use ILIAS\Refinery\Transformation; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Envelopes\Id; /** * @author Björn Heyser @@ -24,7 +28,7 @@ * * @package components\ILIAS/Test */ -class ilTestSkillLevelThreshold +class ilTestSkillLevelThreshold implements Normalizable { /** * @var ilDBInterface @@ -214,4 +218,35 @@ public function getThreshold(): ?int { return is_numeric($this->threshold) ? (int) $this->threshold : null; } + + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(fn(): array => [ + 'id' => $tt->normalize(new Id($this->getSkillLevelId(), 'skill_level')), + 'test_id' => $tt->normalize(new Id($this->getTestId(), 'test')), + 'skill_base_id' => $tt->normalize(new Id($this->getSkillBaseId(), 'skill_base')), + 'skill_tref_id' => $tt->normalize(new Id($this->getSkillTrefId(), 'skill_tref')), + 'threshold' => $this->getThreshold(), + ]); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = clone $this; + $clone->setSkillLevelId($tt->denormalize($normalized['id'], Id::class)->getId()); + $clone->setTestId($tt->denormalize($normalized['test_id'], Id::class)->getId()); + $clone->setSkillBaseId($tt->denormalize($normalized['skill_base_id'], Id::class)->getId()); + $clone->setSkillTrefId($tt->denormalize($normalized['skill_tref_id'], Id::class)->getId()); + $clone->setThreshold($normalized['threshold']); + return $clone; + }); + } } diff --git a/components/ILIAS/Test/src/ExportImport/TestCollector.php b/components/ILIAS/Test/src/ExportImport/TestCollector.php index dc80c6012540..7f187853def1 100644 --- a/components/ILIAS/Test/src/ExportImport/TestCollector.php +++ b/components/ILIAS/Test/src/ExportImport/TestCollector.php @@ -35,6 +35,8 @@ use ILIAS\TestQuestionPool\ExportImport\Export\CollectsQuestions; use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\DataCollector; use ilObjTest; +use ilTestSkillLevelThreshold; +use ilTestSkillLevelThresholdList; /** * Collector to aggregate data from the test object for export. @@ -147,6 +149,26 @@ public function getTestQuestionProperties(): array return $this->questions; } + /** + * @return list + */ + public function getSkillLevelThresholds(): array + { + $threshold_list = new ilTestSkillLevelThresholdList($this->database()); + $threshold_list->setTestId($this->getTestId()); + $threshold_list->loadFromDb(); + + $thresholds = []; + foreach ($this->getSkillAssignments() as $assignment) { + $thresholds += $threshold_list->getThesholdsOfBaseAndTrefId( + $assignment->getSkillBaseId(), + $assignment->getSkillTrefId() + ); + } + + return $thresholds; + } + /* Participants */ diff --git a/components/ILIAS/Test/src/ExportImport/TestExporter.php b/components/ILIAS/Test/src/ExportImport/TestExporter.php index 34ba3623c109..fb7288556fa3 100644 --- a/components/ILIAS/Test/src/ExportImport/TestExporter.php +++ b/components/ILIAS/Test/src/ExportImport/TestExporter.php @@ -25,7 +25,6 @@ use ILIAS\Data\Factory as DataFactory; use ILIAS\Data\ObjectId; use ILIAS\Data\UUID\Factory as UUIDFactory; -use ILIAS\Export\ExportHandler\I\Consumer\ExportWriter\HandlerInterface as ExportWriter; use ILIAS\ResourceStorage\Services as IRSS; use ILIAS\Taxonomy\DomainService as Taxonomy; use ILIAS\Test\ExportImport\Pipes\CollectUserIds; @@ -152,6 +151,14 @@ public function process(ExportState $state): void $state->serializer(), ) ); + $state->serializer()->group( + 'skill_thresholds', + fn() => $this->exportSkillLevelThresholds( + $state->collector(), + $state->transformations(), + $state->serializer(), + ) + ); if ($state->getOption() === Types::XML_WITH_RESULTS->value) { $this->processResults($state); @@ -297,6 +304,16 @@ private function exportSkillAssignments( } } + 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, diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/ilAssQuestionSkillAssignmentNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/ilAssQuestionSkillAssignmentNormalizer.php index 43bbd70f552c..0f63fdc77378 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/ilAssQuestionSkillAssignmentNormalizer.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/ilAssQuestionSkillAssignmentNormalizer.php @@ -57,8 +57,8 @@ public function normalize($value): array|float|bool|int|string|null $normalized = [ 'parent_id' => $this->tt->normalize(new Id($value->getParentObjId(), 'object')), 'question_id' => $this->tt->normalize(new Id($value->getQuestionId(), 'question')), - 'base_id' => $value->getSkillBaseId(), - 'tref_id' => $value->getSkillTrefId(), + '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(), From a27b330f3dbb3db197a780fdb76995a18a249bb1 Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Wed, 22 Apr 2026 13:07:25 +0200 Subject: [PATCH 19/43] fix(test): missing redirection leads to duplicate export --- .../src/ExportImport/ExportOptions/XMLWithResultsOption.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/components/ILIAS/Test/src/ExportImport/ExportOptions/XMLWithResultsOption.php b/components/ILIAS/Test/src/ExportImport/ExportOptions/XMLWithResultsOption.php index 1e84c37de047..0249ba8be06b 100644 --- a/components/ILIAS/Test/src/ExportImport/ExportOptions/XMLWithResultsOption.php +++ b/components/ILIAS/Test/src/ExportImport/ExportOptions/XMLWithResultsOption.php @@ -20,6 +20,7 @@ namespace ILIAS\Test\ExportImport\ExportOptions; +use ilExportGUI; use ILIAS\Export\ExportHandler\Consumer\ExportOption\BasicLegacyHandler as BasicLegacyExportOption; use ILIAS\Export\ExportHandler\Factory as ExportHandlerLocator; use ILIAS\Export\ExportHandler\I\Consumer\Context\HandlerInterface as ConsumerContext; @@ -90,5 +91,7 @@ public function onExportOptionSelected(ConsumerContext $context): void $export_info, '' ); + + $this->ctrl->redirectByClass(ilExportGUI::class, ilExportGUI::CMD_LIST_EXPORT_FILES); } } From e78bc08d864d04a489cfc9a34f3e299f3082a898 Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Wed, 22 Apr 2026 13:09:02 +0200 Subject: [PATCH 20/43] fix(test): incorrect resources export --- components/ILIAS/Test/src/ExportImport/TestExporter.php | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/components/ILIAS/Test/src/ExportImport/TestExporter.php b/components/ILIAS/Test/src/ExportImport/TestExporter.php index fb7288556fa3..0231ed628f37 100644 --- a/components/ILIAS/Test/src/ExportImport/TestExporter.php +++ b/components/ILIAS/Test/src/ExportImport/TestExporter.php @@ -208,17 +208,11 @@ public function write(ExportState $state): void } } - - $resource_dir = "{$export_dir}/resources"; - if (!file_exists($resource_dir)) { - mkdir($resource_dir, 0755, true); - } - foreach ($resource_pipe->getResources() as $id => $resource) { $file = "{$id}.{$resource->getCurrentRevision()->getInformation()->getSuffix()}"; $state->writer()->writeFilesByResourceId( $id, - "{$export_dir}/{$file}" + "{$export_dir}/resources/{$file}" ); } From d93cb4e6e1a07258d3d07948bef708465dcb690f Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Wed, 22 Apr 2026 13:16:54 +0200 Subject: [PATCH 21/43] fix(test): include question set config in export --- ...tRandomQuestionSetSourcePoolDefinition.php | 71 +++++++++++++++++- .../QuestionSetConfigNormalizer.php | 72 +++++++++++++++++++ .../Test/src/ExportImport/TestCollector.php | 50 +++++++++++++ .../Test/src/ExportImport/TestExporter.php | 16 +++++ 4 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 components/ILIAS/Test/src/ExportImport/Normalizer/QuestionSetConfigNormalizer.php diff --git a/components/ILIAS/Test/classes/class.ilTestRandomQuestionSetSourcePoolDefinition.php b/components/ILIAS/Test/classes/class.ilTestRandomQuestionSetSourcePoolDefinition.php index be517642ae82..d8d11efa7595 100755 --- a/components/ILIAS/Test/classes/class.ilTestRandomQuestionSetSourcePoolDefinition.php +++ b/components/ILIAS/Test/classes/class.ilTestRandomQuestionSetSourcePoolDefinition.php @@ -18,13 +18,18 @@ declare(strict_types=1); +use ILIAS\Refinery\Transformation; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Envelopes\Id; + /** * @author Björn Heyser * @version $Id$ * * @package Modules/Test */ -class ilTestRandomQuestionSetSourcePoolDefinition +class ilTestRandomQuestionSetSourcePoolDefinition implements Normalizable { private ?int $id = null; private ?int $pool_id = null; @@ -450,4 +455,68 @@ public function getPoolInfoLabel(ilLanguage $lng): string } // ----------------------------------------------------------------------------------------------------------------- + + /** + * @inheritDoc + */ + public function toNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function () use ($tt): array { + $normalized = [ + 'id' => $tt->normalize(new Id($this->getId(), static::class)), + 'pool_id' => $tt->normalize(new Id($this->getPoolId(), 'qpl')), + 'pool_title' => $this->getPoolTitle(), + 'pool_path' => $this->getPoolPath(), + 'quest_amount' => $this->getQuestionAmount(), + 'pool_quest_count' => $this->getPoolQuestionCount(), + 'position' => $this->getSequencePosition(), + 'type_filter' => $this->getTypeFilterAsTypeTags(), + 'lifecycle_filter' => $this->getLifecycleFilter(), + 'taxonomy_filter' => [], + ]; + + foreach ($this->getOriginalTaxonomyFilter() as $tax_id => $node_ids) { + $normalized['taxonomy_filter'][] = [ + 'tax_id' => $tt->normalize(new Id($tax_id, 'tax')), + 'node_ids' => array_map( + fn($node_id) => $tt->normalize(new Id($node_id, 'tax_node')), + $node_ids + ), + ]; + } + + return $normalized; + }); + } + + /** + * @inheritDoc + */ + public function fromNormalized(Transformations $tt): Transformation + { + return $tt->custom()->transformation(function (array $normalized) use ($tt): self { + $clone = clone $this; + $clone->setId($tt->denormalize($normalized['id'], Id::class)->getId()); + $clone->setPoolId($tt->denormalize($normalized['pool_id'], Id::class)->getId()); + $clone->setPoolTitle($tt->string($normalized['pool_title'])); + $clone->setPoolPath($tt->string($normalized['pool_path'])); + $clone->setQuestionAmount($tt->nullableInt($normalized['quest_amount'])); + $clone->setPoolQuestionCount($tt->nullableInt($normalized['pool_quest_count'])); + $clone->setSequencePosition($tt->int($normalized['position'])); + $clone->setTypeFilterFromTypeTags($normalized['type_filter']); + $clone->setLifecycleFilter($normalized['lifecycle_filter']); + + $taxonomy_filter = []; + foreach ($normalized['taxonomy_filter'] as $item) { + $tax_id = $tt->denormalize($item['tax_id'], Id::class)->getId(); + $taxonomy_filter[$tax_id] = array_map( + fn($node_id) => $tt->denormalize($node_id, Id::class)->getId(), + $item['node_ids'] + ); + } + $clone->setOriginalTaxonomyFilter($taxonomy_filter); + + return $clone; + }); + } } diff --git a/components/ILIAS/Test/src/ExportImport/Normalizer/QuestionSetConfigNormalizer.php b/components/ILIAS/Test/src/ExportImport/Normalizer/QuestionSetConfigNormalizer.php new file mode 100644 index 000000000000..e2ca52dad50d --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalizer/QuestionSetConfigNormalizer.php @@ -0,0 +1,72 @@ + + */ +#[Normalizes(ilTestQuestionSetConfig::class)] +class QuestionSetConfigNormalizer implements Normalizer +{ + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if ($value instanceof ilTestFixedQuestionSetConfig) { + return [ + 'type' => ilObjTest::QUESTION_SET_TYPE_FIXED, + ]; + } + + if ($value instanceof ilTestRandomQuestionSetConfig) { + return [ + 'type' => ilObjTest::QUESTION_SET_TYPE_RANDOM, + 'homogeneous' => $value->arePoolsWithHomogeneousScoredQuestionsRequired(), + 'amount_mode' => $value->getQuestionAmountConfigurationMode(), + 'amount' => $value->getQuestionAmountPerTest(), + 'sync_timestamp' => $value->getLastQuestionSyncTimestamp(), + ]; + } + + throw new NormalizingException('Invalid value', $value); + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): ilTestQuestionSetConfig + { + if (!in_array(ilTestQuestionSetConfig::class, class_parents($type))) { + throw new NormalizingException("Invalid type for ilTestQuestionSetConfig: {$type}"); + } + + //TODO: Implement denormalization + } +} diff --git a/components/ILIAS/Test/src/ExportImport/TestCollector.php b/components/ILIAS/Test/src/ExportImport/TestCollector.php index 7f187853def1..af4044e6aa64 100644 --- a/components/ILIAS/Test/src/ExportImport/TestCollector.php +++ b/components/ILIAS/Test/src/ExportImport/TestCollector.php @@ -21,12 +21,15 @@ namespace ILIAS\Test\ExportImport; use Generator; +use ilComponentRepository; use ilDBConstants; use ilDBInterface; use ILIAS\Data\ObjectId; +use ILIAS\Language\Language; use ILIAS\Test\ExportImport\Envelopes\ManualFeedback; use ILIAS\Test\ExportImport\Envelopes\Solution; use ILIAS\Test\ExportImport\Envelopes\WorkingTime; +use ILIAS\Test\Logging\TestLogger; use ILIAS\Test\Participants\ParticipantRepository; use ILIAS\Test\Questions\Properties\Properties; use ILIAS\Test\Questions\Properties\Repository as QuestionsRepository; @@ -34,9 +37,14 @@ use ILIAS\Test\Settings\GlobalSettings\UserIdentifiers; use ILIAS\TestQuestionPool\ExportImport\Export\CollectsQuestions; use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\DataCollector; +use ILIAS\TestQuestionPool\Questions\GeneralQuestionPropertiesRepository; use ilObjTest; +use ilTestQuestionSetConfigFactory; +use ilTestRandomQuestionSetSourcePoolDefinitionFactory; +use ilTestRandomQuestionSetSourcePoolDefinitionList; use ilTestSkillLevelThreshold; use ilTestSkillLevelThresholdList; +use ilTree; /** * Collector to aggregate data from the test object for export. @@ -55,7 +63,12 @@ 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 ) { } @@ -169,6 +182,43 @@ public function getSkillLevelThresholds(): array return $thresholds; } + /** + * Get the question set config and the source pool definitions. + * + * @return array{config: \ilTestQuestionSetConfig, definitions: list<\ilTestRandomQuestionSetSourcePoolDefinition>} + */ + public function getQuestionSetConfig(): array + { + $factory = new ilTestQuestionSetConfigFactory( + $this->tree, + $this->db, + $this->lng, + $this->logger, + $this->component_repository, + $this->getObject(), + $this->general_questions_repository + ); + + $config = $factory->getQuestionSetConfig(); + + $definition_factory = new ilTestRandomQuestionSetSourcePoolDefinitionFactory( + $this->db, + $this->getObject() + ); + + $definition_list = new ilTestRandomQuestionSetSourcePoolDefinitionList( + $this->db, + $this->getObject(), + $definition_factory + ); + $definition_list->loadDefinitions(); + + return [ + 'config' => $config, + 'definitions' => iterator_to_array($definition_list), + ]; + } + /* Participants */ diff --git a/components/ILIAS/Test/src/ExportImport/TestExporter.php b/components/ILIAS/Test/src/ExportImport/TestExporter.php index 0231ed628f37..3534fdfb99e3 100644 --- a/components/ILIAS/Test/src/ExportImport/TestExporter.php +++ b/components/ILIAS/Test/src/ExportImport/TestExporter.php @@ -21,13 +21,16 @@ namespace ILIAS\Test\ExportImport; use assFormulaQuestion; +use ilComponentRepository; use ilDBInterface; use ILIAS\Data\Factory as DataFactory; use ILIAS\Data\ObjectId; use ILIAS\Data\UUID\Factory as UUIDFactory; +use ILIAS\Language\Language; use ILIAS\ResourceStorage\Services as IRSS; use ILIAS\Taxonomy\DomainService as Taxonomy; use ILIAS\Test\ExportImport\Pipes\CollectUserIds; +use ILIAS\Test\Logging\TestLogger; use ILIAS\Test\Participants\ParticipantRepository; use ILIAS\Test\Questions\Properties\Repository as QuestionsRepository; use ILIAS\Test\Results\Data\Repository as ResultsRepository; @@ -39,6 +42,8 @@ use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Pipes\CollectResources; use ILIAS\TestQuestionPool\ExportImport\Pipes\CollectQuestionImages; +use ILIAS\TestQuestionPool\Questions\GeneralQuestionPropertiesRepository; +use ilTree; class TestExporter implements Exporter { @@ -46,10 +51,15 @@ public function __construct( private readonly Builder $builder, private readonly DataFactory $data_factory, private readonly ilDBInterface $db, + private readonly ilTree $tree, + private readonly Language $lng, + private readonly TestLogger $logger, + private readonly ilComponentRepository $component_repository, private readonly IRSS $irss, private readonly ParticipantRepository $participant_repository, private readonly ResultsRepository $results_repository, private readonly QuestionsRepository $questions_repository, + private readonly GeneralQuestionPropertiesRepository $general_questions_repository, private readonly Taxonomy $taxonomy ) { } @@ -71,7 +81,12 @@ public function prepare(ExportState $state): void $this->participant_repository, $this->results_repository, $this->questions_repository, + $this->general_questions_repository, $this->db, + $this->tree, + $this->lng, + $this->logger, + $this->component_repository, $object_id ); $state->setCollector($collector); @@ -255,6 +270,7 @@ private function exportSettings( $serializer->append('main', $transformations->normalize($main_settings)); $serializer->append('scoring', $transformations->normalize($test->getScoreSettings())); $serializer->append('marks', $transformations->normalize($test->getMarkSchema())); + $serializer->append('question_set_config', $transformations->normalize($collector->getQuestionSetConfig())); if ($intro_page_id = $main_settings->getIntroductionSettings()->getIntroductionPageId()) { $state->addDependency('components/ILIAS/COPage', 'pg', ["tst:{$intro_page_id}"]); From 643f139c2004ad822f516d7f52ef2f384bc7c841 Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Thu, 23 Apr 2026 09:40:06 +0200 Subject: [PATCH 22/43] refactor: remove request dependency in import stages --- .../classes/class.ilObjQuestionPoolGUI.php | 10 ++++++++-- .../Foundation/Contracts/ImportStage.php | 3 +-- .../Foundation/Importing/ImportStageRunner.php | 13 ++++++------- .../src/ExportImport/Import/CleanupStage.php | 3 +-- .../ExportImport/Import/DetectLegacyImportStage.php | 5 ++--- .../src/ExportImport/Import/PersistStage.php | 3 +-- .../ExportImport/Import/QuestionSelectionStage.php | 8 +++++--- .../ExportImport/Import/UploadValidationStage.php | 3 +-- 8 files changed, 25 insertions(+), 23 deletions(-) diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php b/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php index 065c7bf4f867..34f9fb787b75 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php @@ -131,7 +131,6 @@ 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']; parent::__construct('', $this->request_data_collector->getRefId(), true, false); @@ -1134,7 +1133,14 @@ private function buildImportStageRunner(): ImportStageRunner [ new UploadValidationStage($this->archives, $this->lng, 'components/ILIAS/TestQuestionPool'), new DetectLegacyImportStage(), - new QuestionSelectionStage($this->lng, $this->component_factory, $this->ui_factory, $form_action), + new QuestionSelectionStage( + $this->lng, + $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, diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/ImportStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/ImportStage.php index bdb78f8e391d..48e2b6fbd2ed 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/ImportStage.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Contracts/ImportStage.php @@ -22,7 +22,6 @@ use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportContext; use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\StageResult; -use Psr\Http\Message\ServerRequestInterface; /** * A single step in a multi-stage import workflow. Each stage is called by the ImportStageRunner and returns a @@ -52,5 +51,5 @@ public function getDescription(): ?string; * components to display. On form submission it should validate the input and return `StageResult::advance()` or * `StageResult::error()`. */ - public function process(ImportContext $context, ServerRequestInterface $request): StageResult; + public function process(ImportContext $context): StageResult; } diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportStageRunner.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportStageRunner.php index 3a4ba77dd938..6aa50d897985 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportStageRunner.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/ImportStageRunner.php @@ -24,7 +24,6 @@ use ILIAS\UI\Component\Listing\Workflow\Linear; use ILIAS\UI\Component\Listing\Workflow\Step; use ILIAS\UI\Factory as UIFactory; -use Psr\Http\Message\ServerRequestInterface; /** * Orchestrates the execution of a list of ImportStage instances. It delegates each request to the currently active @@ -45,7 +44,7 @@ public function __construct( * Run the import stage runner. It manages the state of the import process and delegates to the current stage. * It will return a StageResult that indicates the next action to take. */ - public function run(ServerRequestInterface $request): StageResult + public function run(): StageResult { $index = $this->session->getCurrentStageIndex(); $context = $this->session->getContext(); @@ -55,7 +54,7 @@ public function run(ServerRequestInterface $request): StageResult } $stage = $this->stages[$index]; - $result = $stage->process($context, $request); + $result = $stage->process($context); switch ($result->type) { case StageResultType::ADVANCE: @@ -71,7 +70,7 @@ public function run(ServerRequestInterface $request): StageResult case StageResultType::ERROR: $this->session->setContext($result->context); - $this->reset($request); + $this->reset(); return $result; case StageResultType::INTERACT: @@ -79,7 +78,7 @@ public function run(ServerRequestInterface $request): StageResult return $result; case StageResultType::COMPLETE: - $this->reset($request); + $this->reset(); return $result; } @@ -125,10 +124,10 @@ public function buildWorkflow(UIFactory $ui, string $title): Linear /** * Reset the import stage session. If a final stage is set, it will be processed. */ - public function reset(ServerRequestInterface $request): void + public function reset(): void { if ($this->final_stage) { - $this->final_stage->process($this->session->getContext(), $request); + $this->final_stage->process($this->session->getContext()); } $this->session->clear(); diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/CleanupStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/CleanupStage.php index fbd99c94b976..78bd35fd448e 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/CleanupStage.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/CleanupStage.php @@ -23,7 +23,6 @@ use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\ImportStage; use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportContext; use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\StageResult; -use Psr\Http\Message\ServerRequestInterface; /** * Final import stage that cleans up the temporary files and directories after successful import or @@ -46,7 +45,7 @@ public function getDescription(): ?string return null; } - public function process(ImportContext $context, ServerRequestInterface $request): StageResult + public function process(ImportContext $context): StageResult { $file_to_import = $context->get('file_to_import'); if ($file_to_import !== null) { diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/DetectLegacyImportStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/DetectLegacyImportStage.php index da921c6afd50..e0d657763446 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/DetectLegacyImportStage.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/DetectLegacyImportStage.php @@ -23,7 +23,6 @@ use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\ImportStage; use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportContext; use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\StageResult; -use Psr\Http\Message\ServerRequestInterface; /** * @deprecated This stage is only used for legacy imports and will be removed with further ILIAS versions. @@ -45,13 +44,13 @@ public function getDescription(): ?string return null; } - public function process(ImportContext $context, ServerRequestInterface $request): StageResult + public function process(ImportContext $context): StageResult { $import_base_dir = $context->get('import_base_dir'); $import_name = basename($import_base_dir); $xml_file = $import_base_dir . DIRECTORY_SEPARATOR . $import_name . '.xml'; - $qti_file = $import_base_dir . DIRECTORY_SEPARATOR . str_replace('_qpl_', '_qti_', $import_name) . '.xml'; + $qti_file = $import_base_dir . DIRECTORY_SEPARATOR . str_replace(['_qpl_', '_tst_'], '_qti_', $import_name) . '.xml'; if (!file_exists($qti_file) || !file_exists($xml_file)) { return StageResult::advance($context); diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/PersistStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/PersistStage.php index 83ca3be6e343..36a6069fedee 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/PersistStage.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/PersistStage.php @@ -27,7 +27,6 @@ use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\StageResult; use ILIAS\TestQuestionPool\RequestDataCollector; use ilImport; -use Psr\Http\Message\ServerRequestInterface; /** * Final stage of the question pool import process. Imports the question pool object and all its questions and other @@ -57,7 +56,7 @@ public function getDescription(): ?string return ''; } - public function process(ImportContext $context, ServerRequestInterface $request): StageResult + public function process(ImportContext $context): StageResult { $importer = new ilImport($this->request_data_collector->getRefId()); $importer->importObject( diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionSelectionStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionSelectionStage.php index e7fd2b79bb9c..32e3a0ad6499 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionSelectionStage.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionSelectionStage.php @@ -54,7 +54,9 @@ public function __construct( private readonly Language $lng, private readonly ilComponentFactory $component_factory, private readonly UIFactory $ui_factory, + private readonly ServerRequestInterface $request, private readonly string $form_action, + private readonly string $title, ) { } @@ -73,7 +75,7 @@ public function getDescription(): ?string return ''; } - public function process(ImportContext $context, ServerRequestInterface $request): StageResult + public function process(ImportContext $context): StageResult { if ($context->has('selectable_questions')) { $options = []; @@ -82,7 +84,7 @@ public function process(ImportContext $context, ServerRequestInterface $request) } $data = $this->buildSelectQuestionsForm($options) - ->withRequest($request) + ->withRequest($this->request) ->getData(); if (isset($data['selected_questions'])) { @@ -103,7 +105,7 @@ public function process(ImportContext $context, ServerRequestInterface $request) } $panel = $this->ui_factory->panel()->standard( - $this->lng->txt('import_qpl'), + $this->title, [ $this->ui_factory->legacy()->content($this->lng->txt('qpl_import_verify_found_questions')), $this->buildSelectQuestionsForm($options) diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/UploadValidationStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/UploadValidationStage.php index 28e602c7cd87..0e8641e8f2d1 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/UploadValidationStage.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/UploadValidationStage.php @@ -28,7 +28,6 @@ use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportContext; use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\StageResult; use ilManifestParser; -use Psr\Http\Message\ServerRequestInterface; /** * First stage of the question pool import pipeline. Receives the uploaded file path from the context, extracts ZIP @@ -60,7 +59,7 @@ public function getDescription(): ?string return ''; } - public function process(ImportContext $context, ServerRequestInterface $request): StageResult + public function process(ImportContext $context): StageResult { $file_to_import = $context->get('file_to_import'); if ( From 4457bc751c94f33c7669bf73412457715986d65d Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Fri, 24 Apr 2026 09:56:21 +0200 Subject: [PATCH 23/43] refactor(qpl): create question importer class --- .../class.ilTestQuestionPoolImporter.php | 2 +- .../Import/QuestionPoolImporter.php | 147 ++++++++++++ .../QuestionsImporter.php} | 223 ++++++------------ .../{ => Import}/SkillAssignmentsImporter.php | 2 +- ...ilAssQuestionSkillAssignmentNormalizer.php | 4 +- .../TestQuestionPool/src/QuestionPoolDIC.php | 15 +- 6 files changed, 241 insertions(+), 152 deletions(-) create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionPoolImporter.php rename components/ILIAS/TestQuestionPool/src/ExportImport/{QuestionPoolImporter.php => Import/QuestionsImporter.php} (52%) rename components/ILIAS/TestQuestionPool/src/ExportImport/{ => Import}/SkillAssignmentsImporter.php (98%) diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php index bb9be798b3db..e94a008c0d3e 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php @@ -22,7 +22,7 @@ use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportSessionRepository; use ILIAS\TestQuestionPool\ExportImport\Foundation\Serializing\SimpleXMLDeserializer; use ILIAS\TestQuestionPool\ExportImport\Import\DetectLegacyImportStage; -use ILIAS\TestQuestionPool\ExportImport\QuestionPoolImporter; +use ILIAS\TestQuestionPool\ExportImport\Import\QuestionPoolImporter; use ILIAS\TestQuestionPool\QuestionPoolDIC; class ilTestQuestionPoolImporter extends ilXmlImporter 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..c5e967d3940e --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionPoolImporter.php @@ -0,0 +1,147 @@ +data_factory->objId(0)); + $tt = $this->builder->withAdditionalPipes(append: [$id_mapping_pipe, $images_pipe])->create(); + + $selected_questions = QuestionSelectionStage::getSelectedQuestions($context); + + $deserializer->addHandler( + 'general', + function (array $objects) use ($tt, $mapping, $parent_id, &$context): void { + $new_pool_id = $this->importQuestionPool( + array_pop($objects), + $tt, + $mapping, + $parent_id + ); + $context = $context->with('pool_obj_id', $new_pool_id); + } + ); + + $deserializer->addHandler( + 'questions', + function (array $questions) use ($tt, $mapping, $selected_questions): void { + foreach ($questions as $question) { + $this->questions_importer->importQuestion( + $question, + $tt, + $mapping, + $selected_questions + ); + } + } + ); + + $deserializer->addHandler( + 'skill_assignments', + function (array $assignments) use ($tt, &$context): void { + $result = $this->skill_importer->import( + $assignments, + UploadValidationStage::getInstallId($context), + $tt, + ); + $context = $context->with('skill_assignments', $result); + } + ); + + $deserializer->process(); + + // Copy the question images from the temporary import directory to the question pool directory + $this->questions_importer->importQuestionImages($mapping, $context, $images_pipe); + + return $context; + } + + protected function importQuestionPool( + array $normalized, + Transformations $transformations, + ilImportMapping $mapping, + ReferenceId $parent_id + ): int { + $pool_object = $transformations->denormalize($normalized, ilObjQuestionPool::class); + $old_pool_id = $pool_object->getId(); + + $pool_object->setTitle('Imported'); //TODO: Remove after testing + $new_pool_id = $pool_object->create(true); + $pool_object->getObjectProperties()->storePropertyIsOnline( + $pool_object->getObjectProperties()->getPropertyIsOnline()->withOffline() + ); + $pool_object->saveToDb(); + + $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/QuestionPoolImporter.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionsImporter.php similarity index 52% rename from components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolImporter.php rename to components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionsImporter.php index ec98b3fd5dea..c11b5af840b3 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/QuestionPoolImporter.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionsImporter.php @@ -18,7 +18,7 @@ declare(strict_types=1); -namespace ILIAS\TestQuestionPool\ExportImport; +namespace ILIAS\TestQuestionPool\ExportImport\Import; use assFormulaQuestion; use assFormulaQuestionUnit; @@ -26,135 +26,29 @@ use assQuestion; use ilCtrl; use ilDBInterface; -use ILIAS\Data\ReferenceId; -use ILIAS\Data\ObjectId; -use ILIAS\Data\UUID\Factory; use ILIAS\Language\Language; use ILIAS\TestQuestionPool\ExportImport\Envelopes\QuestionImage; -use ILIAS\TestQuestionPool\ExportImport\Foundation\Builder; -use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Deserializer; use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportContext; use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Envelopes\Id; -use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Pipes\IdMappingPipe; use ILIAS\TestQuestionPool\ExportImport\Envelopes\Feedback; -use ILIAS\TestQuestionPool\ExportImport\Import\QuestionSelectionStage; -use ILIAS\TestQuestionPool\ExportImport\Import\UploadValidationStage; use ILIAS\TestQuestionPool\ExportImport\Pipes\CollectQuestionImages; use ILIAS\TestQuestionPool\Questions\Files\QuestionFiles; use ilImportMapping; -use ilObjQuestionPool; use ilUnitConfigurationRepository; -/** - * Orchestrates the import of a question pool. It uses the Builder to create a pipeline of transformations that are used - * to normalize the data provided by the deserializer. It imports the question pool object and its content (questions, - * skill assignments, etc.) into the database using repository classes and legacy active record models. - */ -class QuestionPoolImporter +class QuestionsImporter { - private const string COMPONENT = 'components/ILIAS/TestQuestionPool'; - public function __construct( - private readonly Builder $builder, + private readonly string $component, + private readonly string $parent_type, private readonly ilCtrl $ctrl, private readonly ilDBInterface $database, private readonly Language $language, - private readonly SkillAssignmentsImporter $skill_importer, ) { } - /** - * Import a question pool from a deserializer instance. It will import the question pool object, questions and skill - * assignments into the database. - */ - public function import( - Deserializer $deserializer, - ilImportMapping $mapping, - ReferenceId $parent_id, - ImportContext $context - ): ImportContext { - $id_mapping_pipe = new IdMappingPipe($mapping, self::COMPONENT); - $images_pipe = new CollectQuestionImages(new Factory(), new ObjectId(0)); - $tt = $this->builder->withAdditionalPipes(append: [$id_mapping_pipe, $images_pipe])->create(); - - $selected_questions = QuestionSelectionStage::getSelectedQuestions($context); - - $deserializer->addHandler( - 'general', - function (array $objects) use ($tt, $mapping, $parent_id, &$context): void { - $new_pool_id = $this->importQuestionPool( - array_pop($objects), - $tt, - $mapping, - $parent_id - ); - $context = $context->with('pool_obj_id', $new_pool_id); - } - ); - - $deserializer->addHandler( - 'questions', - function (array $questions) use ($tt, $mapping, $selected_questions): void { - foreach ($questions as $question) { - $this->importQuestion( - $question, - $tt, - $mapping, - $selected_questions - ); - } - } - ); - - $deserializer->addHandler( - 'skill_assignments', - function (array $assignments) use ($tt, &$context): void { - $result = $this->skill_importer->import( - $assignments, - UploadValidationStage::getInstallId($context), - $tt, - ); - $context = $context->with('skill_assignments', $result); - } - ); - - $deserializer->process(); - - // Copy the question images from the temporary import directory to the question pool directory - $this->importQuestionImages($mapping, $context, $images_pipe); - - return $context; - } - - protected function importQuestionPool( - array $normalized, - Transformations $transformations, - ilImportMapping $mapping, - ReferenceId $parent_id - ): int { - $pool_object = $transformations->denormalize($normalized, ilObjQuestionPool::class); - $old_pool_id = $pool_object->getId(); - - $pool_object->setTitle('Imported'); //TODO: Remove after testing - $new_pool_id = $pool_object->create(true); - $pool_object->getObjectProperties()->storePropertyIsOnline( - $pool_object->getObjectProperties()->getPropertyIsOnline()->withOffline() - ); - $pool_object->saveToDb(); - - $pool_object->createReference(); - $pool_object->putInTree($parent_id->toInt()); - $pool_object->setPermissions($parent_id->toInt()); - - $mapping->addMapping(self::COMPONENT, 'qpl', (string) $old_pool_id, (string) $new_pool_id); - $mapping->addMapping(self::COMPONENT, '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; - } - - protected function importQuestion( + public function importQuestion( array $normalized, Transformations $transformations, ilImportMapping $mapping, @@ -178,11 +72,7 @@ protected function importQuestion( // Create new question and store basic question properties $new_question_id = $question->createNewQuestion(false); - $mapping->addMapping(self::COMPONENT, 'question', (string) $old_question_id, (string) $new_question_id); - $mapping->addMapping(self::COMPONENT, 'question_assignment', (string) $new_question_id, (string) $question->getObjId()); - $mapping->addMapping('components/ILIAS/Taxonomy', 'tax_item', "qpl:quest:{$old_question_id}", (string) $new_question_id); - $mapping->addMapping('components/ILIAS/Taxonomy', 'tax_item_obj_id', "qpl:quest:{$old_question_id}", (string) $question->getObjId()); - $mapping->addMapping('components/ILIAS/COPage', 'pg', "qpl:{$old_question_id}", "qpl:{$new_question_id}"); + $this->storeQuestionMappings($mapping, $old_question_id, $new_question_id, $question->getObjId()); if ($question instanceof assFormulaQuestion) { $this->importFormulaQuestion($normalized, $question, $transformations, $mapping, ); @@ -195,7 +85,74 @@ protected function importQuestion( $this->importFeedback($feedback, $question); } - protected function importFeedback(Feedback $feedback, assQuestion $question): void + + public function importQuestionImages( + ilImportMapping $mapping, + ImportContext $context, + CollectQuestionImages $pipe, + ): void { + $import_dir = dirname($context->get('import_file')) . DIRECTORY_SEPARATOR . 'expDir_1'; + + $question_files = new QuestionFiles(); + foreach ($pipe->getEnvelopes() as $from_path => $envelope) { + $question_id = $mapping->getMapping($this->component, 'question', (string) $envelope->getQuestionId()); + if (!$question_id) { + continue; + } + + $base_dir = $envelope->getType() === QuestionImage::TYPE_ANSWER + ? $question_files->buildImagePath($question_id, $context->get('pool_obj_id')) + : $question_files->buildSolutionPath($question_id, $context->get('pool_obj_id')); + + if (!file_exists($base_dir)) { + mkdir($base_dir, 0755, true); + } + + copy($import_dir . DIRECTORY_SEPARATOR . $from_path, $base_dir . $envelope->getFilename()); + + //TODO: generate thumbnail + } + } + + 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', + "{$this->parent_type}:{$old_question_id}", + "{$this->parent_type}:{$new_question_id}" + ); + } + + private function importFeedback(Feedback $feedback, assQuestion $question): void { $question_id = $question->getId(); $question->feedbackOBJ->importGenericFeedback($question_id, false, $feedback->getGenericUncompleted()); @@ -211,7 +168,7 @@ protected function importFeedback(Feedback $feedback, assQuestion $question): vo } } - protected function importFormulaQuestion( + private function importFormulaQuestion( array $normalized, assFormulaQuestion $question, Transformations $transformations, @@ -226,7 +183,7 @@ protected function importFormulaQuestion( $old_category_id = $category->getId(); $repository->saveNewUnitCategory($category); - $mapping->addMapping(self::COMPONENT, 'unit_category', (string) $old_category_id, (string) $category->getId()); + $mapping->addMapping($this->component, 'unit_category', (string) $old_category_id, (string) $category->getId()); } // Ensure base units are imported first so they can be referenced by the units. The mapping pipe will ensure @@ -237,7 +194,7 @@ protected function importFormulaQuestion( $unit = new assFormulaQuestionUnit(); $repository->createNewUnit($unit); - $mapping->addMapping(self::COMPONENT, 'unit', (string) $old_unit_id, (string) $unit->getId()); + $mapping->addMapping($this->component, 'unit', (string) $old_unit_id, (string) $unit->getId()); $unit = $transformations->denormalize($normalized_unit, $unit); $repository->saveUnit($unit); @@ -256,30 +213,4 @@ protected function importFormulaQuestion( $question->addResult($result); } } - - protected function importQuestionImages( - ilImportMapping $mapping, - ImportContext $context, - CollectQuestionImages $pipe, - ): void { - $import_dir = dirname($context->get('import_file')) . DIRECTORY_SEPARATOR . 'expDir_1'; - - $question_files = new QuestionFiles(); - foreach ($pipe->getEnvelopes() as $from_path => $envelope) { - $question_id = $mapping->getMapping('components/ILIAS/TestQuestionPool', 'question', (string) $envelope->getQuestionId()); - if (!$question_id) { - continue; - } - - $base_dir = $envelope->getType() === QuestionImage::TYPE_ANSWER - ? $question_files->buildImagePath($question_id, $context->get('pool_obj_id')) - : $question_files->buildSolutionPath($question_id, $context->get('pool_obj_id')); - - if (!file_exists($base_dir)) { - mkdir($base_dir, 0755, true); - } - - copy($import_dir . DIRECTORY_SEPARATOR . $from_path, $base_dir . $envelope->getFilename()); - } - } } diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/SkillAssignmentsImporter.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/SkillAssignmentsImporter.php similarity index 98% rename from components/ILIAS/TestQuestionPool/src/ExportImport/SkillAssignmentsImporter.php rename to components/ILIAS/TestQuestionPool/src/ExportImport/Import/SkillAssignmentsImporter.php index a22364815c72..f0de70e409f8 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/SkillAssignmentsImporter.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/SkillAssignmentsImporter.php @@ -18,7 +18,7 @@ declare(strict_types=1); -namespace ILIAS\TestQuestionPool\ExportImport; +namespace ILIAS\TestQuestionPool\ExportImport\Import; use ilAssQuestionSkillAssignment; use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/ilAssQuestionSkillAssignmentNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/ilAssQuestionSkillAssignmentNormalizer.php index 0f63fdc77378..f8ea08567123 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/ilAssQuestionSkillAssignmentNormalizer.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/ilAssQuestionSkillAssignmentNormalizer.php @@ -104,8 +104,8 @@ public function denormalize(array|float|bool|int|string|null $value, string $typ $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->int($value['base_id'])); - $assignment->setSkillTrefId($this->tt->int($value['tref_id'])); + $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'])); diff --git a/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php b/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php index 95987154f8b0..bedf56db471c 100755 --- a/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php +++ b/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php @@ -25,8 +25,9 @@ use ILIAS\TestQuestionPool\ExportImport\Foundation\Builder; use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportSessionRepository; use ILIAS\TestQuestionPool\ExportImport\Export\QuestionPoolExporter; -use ILIAS\TestQuestionPool\ExportImport\QuestionPoolImporter; -use ILIAS\TestQuestionPool\ExportImport\SkillAssignmentsImporter; +use ILIAS\TestQuestionPool\ExportImport\Import\QuestionPoolImporter; +use ILIAS\TestQuestionPool\ExportImport\Import\QuestionsImporter; +use ILIAS\TestQuestionPool\ExportImport\Import\SkillAssignmentsImporter; use Pimple\Container as PimpleContainer; use ILIAS\DI\Container as ILIASContainer; use ILIAS\TestQuestionPool\Questions\SuggestedSolution\SuggestedSolutionsDatabaseRepository; @@ -98,12 +99,22 @@ protected static function buildDIC(ILIASContainer $DIC): self $DIC->skills()->usage(), (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() + ); $dic['exportimport.importer'] = static fn($c): QuestionPoolImporter => new QuestionPoolImporter( $c['exportimport.builder'], $DIC->ctrl(), $DIC->database(), $DIC->language(), + new DataFactory(), + $c['exportimport.questions_importer'], $c['exportimport.skill_assignments_importer'] ); From 44c345db547adc1ec208fb90d395f7e910a61320 Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Mon, 27 Apr 2026 11:20:03 +0200 Subject: [PATCH 24/43] refactor(qpl): replace string constants with defined constants in import stages --- .../classes/class.ilObjQuestionPoolGUI.php | 2 +- .../class.ilTestQuestionPoolImporter.php | 45 +-------------- ...class.ilTestQuestionPoolLegacyImporter.php | 8 ++- .../Serializing/SimpleXMLSerializer.php | 2 +- .../src/ExportImport/Import/CleanupStage.php | 4 +- .../Import/DetectLegacyImportStage.php | 11 ++-- .../src/ExportImport/Import/PersistStage.php | 4 +- .../Import/QuestionPoolImporter.php | 9 +++ .../Import/QuestionSelectionStage.php | 20 ++++--- .../ExportImport/Import/QuestionsImporter.php | 56 +++++++++++++++++-- .../Import/UploadValidationStage.php | 15 +++-- 11 files changed, 102 insertions(+), 74 deletions(-) diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php b/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php index 34f9fb787b75..d7db77d9d1ac 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php @@ -1099,7 +1099,7 @@ protected function importFile(string $file_to_import, string $path_to_uploaded_f { $this->import_session_repository->clear(); - $context = new ImportContext(['file_to_import' => $file_to_import]); + $context = new ImportContext([UploadValidationStage::FILE_TO_IMPORT => $file_to_import]); $this->import_session_repository->setContext($context); $this->import_session_repository->setCurrentStageIndex(0); diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php index e94a008c0d3e..2dee6dc85e14 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php @@ -83,53 +83,10 @@ public function finalProcessing(ilImportMapping $a_mapping): void return; } - $this->finalizeQuestionPages($a_mapping); + $this->importer->finalize($a_mapping); $this->finalizeTaxonomyUsage($a_mapping); } - /** - * Finalize the imported question pages by replacing the old question ids with the new question ids. - */ - private function finalizeQuestionPages(ilImportMapping $a_mapping): void - { - $page_mappings = $a_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 = $a_mapping->getMapping( - 'components/ILIAS/TestQuestionPool', - 'question_assignment', - $new_question_id - ); - if ($parent_obj_id !== null) { - $page->setParentId((int) $parent_obj_id); - } - - $page->updateFromXML(); - unset($page); - } - } - private function finalizeTaxonomyUsage(ilImportMapping $a_mapping): void { $qpl_mappings = $a_mapping->getMappingsOfEntity('components/ILIAS/TestQuestionPool', 'qpl'); diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolLegacyImporter.php b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolLegacyImporter.php index 921d9958f542..5924475c1875 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolLegacyImporter.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolLegacyImporter.php @@ -19,6 +19,8 @@ declare(strict_types=1); use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportSessionRepository; +use ILIAS\TestQuestionPool\ExportImport\Import\DetectLegacyImportStage; +use ILIAS\TestQuestionPool\ExportImport\Import\UploadValidationStage; use ILIAS\TestQuestionPool\QuestionPoolDIC; use ILIAS\TestQuestionPool\RequestDataCollector; @@ -55,8 +57,8 @@ public function importXmlRepresentation( $a_mapping->addMapping('components/ILIAS/TestQuestionPool', 'qpl', $a_id, (string) $this->pool_obj->getId()); $context = $this->session->getContext(); - $import_base_dir = $context->get('import_base_dir'); - $xml_file = $context->get('xml_file'); + $import_base_dir = $context->get(UploadValidationStage::IMPORT_BASE_DIR); + $xml_file = $context->get(DetectLegacyImportStage::LEGACY_XML_FILE); $context = $context->with('pool_obj_id', $this->pool_obj->getId()); $this->session->setContext($context); @@ -77,7 +79,7 @@ public function importXmlRepresentation( $qti_parser = new ilQTIParser( $import_base_dir, - $context->get('qti_file'), + $context->get(DetectLegacyImportStage::LEGACY_QTI_FILE), ilQTIParser::IL_MO_PARSE_QTI, $this->pool_obj->getId(), $context->get('selected_questions') diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/SimpleXMLSerializer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/SimpleXMLSerializer.php index 70c73271663c..86b27a4c5b57 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/SimpleXMLSerializer.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/SimpleXMLSerializer.php @@ -55,7 +55,7 @@ public function open(string $path): static * * @throws \LogicException if a document has already been started */ - private function createDocument(string $comment): void + public function createDocument(string $comment): void { if ($this->has_document) { throw new \LogicException('XML document already started'); diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/CleanupStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/CleanupStage.php index 78bd35fd448e..739e3f812fce 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/CleanupStage.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/CleanupStage.php @@ -47,7 +47,7 @@ public function getDescription(): ?string public function process(ImportContext $context): StageResult { - $file_to_import = $context->get('file_to_import'); + $file_to_import = $context->get(UploadValidationStage::FILE_TO_IMPORT); if ($file_to_import !== null) { $temp_dir = dirname($file_to_import); if (file_exists($temp_dir) && is_dir($temp_dir)) { @@ -55,7 +55,7 @@ public function process(ImportContext $context): StageResult } } - $import_base_dir = $context->get('import_base_dir'); + $import_base_dir = $context->get(UploadValidationStage::IMPORT_BASE_DIR); if (file_exists($import_base_dir) && is_dir($import_base_dir)) { $this->removeDirectory($import_base_dir); } diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/DetectLegacyImportStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/DetectLegacyImportStage.php index e0d657763446..1cb180a77c3e 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/DetectLegacyImportStage.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/DetectLegacyImportStage.php @@ -29,6 +29,9 @@ */ class DetectLegacyImportStage implements ImportStage { + public const string LEGACY_QTI_FILE = 'legacy_qti_file'; + public const string LEGACY_XML_FILE = 'legacy_xml_file'; + public function getIdentifier(): string { return 'detect_legacy_import'; @@ -46,7 +49,7 @@ public function getDescription(): ?string public function process(ImportContext $context): StageResult { - $import_base_dir = $context->get('import_base_dir'); + $import_base_dir = $context->get(UploadValidationStage::IMPORT_BASE_DIR); $import_name = basename($import_base_dir); $xml_file = $import_base_dir . DIRECTORY_SEPARATOR . $import_name . '.xml'; @@ -57,13 +60,13 @@ public function process(ImportContext $context): StageResult } return StageResult::advance( - $context->with('qti_file', $qti_file) - ->with('xml_file', $xml_file) + $context->with(self::LEGACY_QTI_FILE, $qti_file) + ->with(self::LEGACY_XML_FILE, $xml_file) ); } public static function isLegacyImport(ImportContext $context): bool { - return $context->has('qti_file') && $context->has('xml_file'); + return $context->has(self::LEGACY_QTI_FILE) && $context->has(self::LEGACY_XML_FILE); } } diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/PersistStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/PersistStage.php index 36a6069fedee..f3173b323d0f 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/PersistStage.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/PersistStage.php @@ -61,8 +61,8 @@ public function process(ImportContext $context): StageResult $importer = new ilImport($this->request_data_collector->getRefId()); $importer->importObject( null, - $context->get('file_to_import'), - basename($context->get('file_to_import')), + $context->get(UploadValidationStage::FILE_TO_IMPORT), + basename($context->get(UploadValidationStage::FILE_TO_IMPORT)), 'qpl', 'components/ILIAS/TestQuestionPool', true, diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionPoolImporter.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionPoolImporter.php index c5e967d3940e..00162cfeb235 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionPoolImporter.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionPoolImporter.php @@ -118,6 +118,15 @@ function (array $assignments) use ($tt, &$context): void { return $context; } + /** + * Finalize the import after all dependencies have been imported. + * It will replace the old question ids with the new question ids in the question pages. + */ + public function finalize(ilImportMapping $mapping): void + { + $this->questions_importer->finalizeQuestionPages($mapping); + } + protected function importQuestionPool( array $normalized, Transformations $transformations, diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionSelectionStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionSelectionStage.php index 32e3a0ad6499..9276e158ad88 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionSelectionStage.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionSelectionStage.php @@ -36,6 +36,9 @@ */ class QuestionSelectionStage implements ImportStage { + public const string SELECTED_QUESTIONS = 'selected_questions'; + private const string SELECTABLE_QUESTIONS = 'selectable_questions'; + private array $old_export_question_types = [ 'ORDERING QUESTION' => \ilQTIItem::QT_ORDERING, 'KPRIM CHOICE QUESTION' => \ilQTIItem::QT_KPRIM_CHOICE, @@ -88,11 +91,11 @@ public function process(ImportContext $context): StageResult ->getData(); if (isset($data['selected_questions'])) { - return StageResult::advance($context->with('selected_questions', $data['selected_questions'])); + return StageResult::advance($context->with(self::SELECTED_QUESTIONS, $data['selected_questions'])); } } - if (!$context->has('import_file')) { + if (!$context->has(UploadValidationStage::COMPONENT_IMPORT_FILE)) { return StageResult::error($context, $this->lng->txt('qpl_import_file_not_found')); } @@ -113,7 +116,7 @@ public function process(ImportContext $context): StageResult ); return StageResult::interact( - $context->with('selectable_questions', array_keys($options)), + $context->with(self::SELECTABLE_QUESTIONS, array_keys($options)), [$panel] ); } @@ -123,14 +126,17 @@ public function process(ImportContext $context): StageResult */ public static function getSelectedQuestions(ImportContext $context): array { - return array_map('intval', $context->get('selected_questions', [])); + return array_map('intval', $context->get(self::SELECTED_QUESTIONS, [])); } private function readQuestions(ImportContext $context): array { $options = []; - $deserializer = new SimpleXMLDeserializer()->open(file_get_contents($context->get('import_file'))); + $deserializer = new SimpleXMLDeserializer()->open( + file_get_contents($context->get(UploadValidationStage::COMPONENT_IMPORT_FILE)) + ); + $deserializer->addHandler('questions', function (array $questions) use (&$options): void { foreach ($questions as $question) { if (!isset($question['title']) || !isset($question['type'])) { @@ -153,8 +159,8 @@ private function readQuestions(ImportContext $context): array private function readQuestionsFromQTI(ImportContext $context): array { $parser = new \ilQTIParser( - $context->get('import_base_dir'), - $context->get('qti_file'), + $context->get(UploadValidationStage::IMPORT_BASE_DIR), + $context->get(DetectLegacyImportStage::LEGACY_QTI_FILE), \ilQTIParser::IL_MO_VERIFY_QTI, 0 ); diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionsImporter.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionsImporter.php index c11b5af840b3..8cb86d4176e1 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionsImporter.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionsImporter.php @@ -24,6 +24,7 @@ use assFormulaQuestionUnit; use assFormulaQuestionUnitCategory; use assQuestion; +use ilAssQuestionPage; use ilCtrl; use ilDBInterface; use ILIAS\Language\Language; @@ -53,7 +54,7 @@ public function importQuestion( Transformations $transformations, ilImportMapping $mapping, array $selected_questions - ): void { + ): ?assQuestion { $question_class = $normalized['type']; if (!class_exists($question_class)) { throw new \InvalidArgumentException("Question class {$question_class} does not exist"); @@ -63,7 +64,7 @@ public function importQuestion( $question = $transformations->denormalize($normalized, new $question_class()); $old_question_id = $question->getId(); if (!in_array($old_question_id, $selected_questions)) { - return; + return null; } // Initialize feedback object to prevent error when saving the question @@ -83,6 +84,8 @@ public function importQuestion( $feedback = $transformations->denormalize($normalized['feedback'], Feedback::class); $this->importFeedback($feedback, $question); + + return $question; } @@ -91,7 +94,7 @@ public function importQuestionImages( ImportContext $context, CollectQuestionImages $pipe, ): void { - $import_dir = dirname($context->get('import_file')) . DIRECTORY_SEPARATOR . 'expDir_1'; + $import_dir = dirname($context->get(UploadValidationStage::COMPONENT_IMPORT_FILE)) . DIRECTORY_SEPARATOR . 'expDir_1'; $question_files = new QuestionFiles(); foreach ($pipe->getEnvelopes() as $from_path => $envelope) { @@ -114,6 +117,49 @@ public function importQuestionImages( } } + /** + * 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, @@ -147,8 +193,8 @@ private function storeQuestionMappings( $mapping->addMapping( 'components/ILIAS/COPage', 'pg', - "{$this->parent_type}:{$old_question_id}", - "{$this->parent_type}:{$new_question_id}" + "qpl:{$old_question_id}", + "qpl:{$new_question_id}" ); } diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/UploadValidationStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/UploadValidationStage.php index 0e8641e8f2d1..ea73b375c871 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/UploadValidationStage.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/UploadValidationStage.php @@ -35,6 +35,11 @@ */ class UploadValidationStage implements ImportStage { + public const string FILE_TO_IMPORT = 'file_to_import'; + public const string COMPONENT_IMPORT_FILE = 'component_import_file'; + public const string IMPORT_BASE_DIR = 'import_base_dir'; + public const string INSTALL_ID = 'install_id'; + private const string IMPORT_TEMP_DIR = CLIENT_DATA_DIR . DIRECTORY_SEPARATOR . 'temp'; public function __construct( @@ -61,7 +66,7 @@ public function getDescription(): ?string public function process(ImportContext $context): StageResult { - $file_to_import = $context->get('file_to_import'); + $file_to_import = $context->get(self::FILE_TO_IMPORT); if ( $file_to_import === null || !is_file($file_to_import) @@ -88,14 +93,14 @@ public function process(ImportContext $context): StageResult } return StageResult::advance( - $context->with('import_file', $import_base_dir . DIRECTORY_SEPARATOR . $export_file['path']) - ->with('import_base_dir', $import_base_dir) - ->with('install_id', $manifest->getInstallId()) + $context->with(self::COMPONENT_IMPORT_FILE, $import_base_dir . DIRECTORY_SEPARATOR . $export_file['path']) + ->with(self::IMPORT_BASE_DIR, $import_base_dir) + ->with(self::INSTALL_ID, $manifest->getInstallId()) ); } public static function getInstallId(ImportContext $context): int { - return intval($context->get('install_id')); + return intval($context->get(self::INSTALL_ID)); } } From 2ef4be833e67aa56c36d4d48f25440c21080235c Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Tue, 28 Apr 2026 09:48:59 +0200 Subject: [PATCH 25/43] fix(test): normalizing bugs and test mapping --- .../ILIAS/Test/classes/class.ilTestSkillLevelThreshold.php | 2 +- .../src/ExportImport/Normalizer/ParticipantNormalizer.php | 2 +- .../Test/src/ExportImport/Normalizer/ilObjTestNormalizer.php | 2 +- .../Test/src/Settings/MainSettings/SettingsFinishing.php | 4 ++-- .../Test/src/Settings/MainSettings/SettingsIntroduction.php | 2 +- .../src/Settings/ScoreReporting/SettingsResultSummary.php | 2 +- .../Foundation/Serializing/SimpleXMLSerializer.php | 4 ++++ 7 files changed, 11 insertions(+), 7 deletions(-) diff --git a/components/ILIAS/Test/classes/class.ilTestSkillLevelThreshold.php b/components/ILIAS/Test/classes/class.ilTestSkillLevelThreshold.php index 26f40ff71821..a79f3e32a87f 100755 --- a/components/ILIAS/Test/classes/class.ilTestSkillLevelThreshold.php +++ b/components/ILIAS/Test/classes/class.ilTestSkillLevelThreshold.php @@ -227,7 +227,7 @@ public function toNormalized(Transformations $tt): Transformation { return $tt->custom()->transformation(fn(): array => [ 'id' => $tt->normalize(new Id($this->getSkillLevelId(), 'skill_level')), - 'test_id' => $tt->normalize(new Id($this->getTestId(), 'test')), + 'test_id' => $tt->normalize(new Id($this->getTestId(), 'tst')), 'skill_base_id' => $tt->normalize(new Id($this->getSkillBaseId(), 'skill_base')), 'skill_tref_id' => $tt->normalize(new Id($this->getSkillTrefId(), 'skill_tref')), 'threshold' => $this->getThreshold(), diff --git a/components/ILIAS/Test/src/ExportImport/Normalizer/ParticipantNormalizer.php b/components/ILIAS/Test/src/ExportImport/Normalizer/ParticipantNormalizer.php index 27d6dc082585..3927a248f290 100644 --- a/components/ILIAS/Test/src/ExportImport/Normalizer/ParticipantNormalizer.php +++ b/components/ILIAS/Test/src/ExportImport/Normalizer/ParticipantNormalizer.php @@ -51,7 +51,7 @@ public function normalize($value): array|float|bool|int|string|null return [ 'user_id' => $this->tt->normalize(new Id($value->getUserId(), 'user')), 'active_id' => $this->tt->normalize(new Id($value->getActiveId(), 'participant')), - 'test_id' => $this->tt->normalize(new Id($value->getTestId(), 'test')), + 'test_id' => $this->tt->normalize(new Id($value->getTestId(), 'tst')), 'anonymous_id' => $value->getAnonymousId(), 'firstname' => $value->getFirstname(), 'lastname' => $value->getLastname(), diff --git a/components/ILIAS/Test/src/ExportImport/Normalizer/ilObjTestNormalizer.php b/components/ILIAS/Test/src/ExportImport/Normalizer/ilObjTestNormalizer.php index 083dd5c228fa..35b05ae5d353 100644 --- a/components/ILIAS/Test/src/ExportImport/Normalizer/ilObjTestNormalizer.php +++ b/components/ILIAS/Test/src/ExportImport/Normalizer/ilObjTestNormalizer.php @@ -43,7 +43,7 @@ public function normalize($value): array|float|bool|int|string|null } $normalized = parent::normalize($value); - $normalized['test_id'] = $this->tt->normalize(new Id($value->getTestId(), 'test')); + $normalized['test_id'] = $this->tt->normalize(new Id($value->getTestId(), 'tst')); return $normalized; } diff --git a/components/ILIAS/Test/src/Settings/MainSettings/SettingsFinishing.php b/components/ILIAS/Test/src/Settings/MainSettings/SettingsFinishing.php index 3334bf32c421..9f7d6ded7c67 100755 --- a/components/ILIAS/Test/src/Settings/MainSettings/SettingsFinishing.php +++ b/components/ILIAS/Test/src/Settings/MainSettings/SettingsFinishing.php @@ -270,8 +270,8 @@ public static function fromExport(array $data): static return new self( (bool) $data['enable_examview'], (bool) $data['showfinalstatement'], - $data['concluding_remarks_page_id'], - RedirectionModes::from($data['redirection_mode']), + $data['concluding_remarks_page_id'] !== null ? (int) $data['concluding_remarks_page_id'] : null, + RedirectionModes::from((int) $data['redirection_mode']), $data['redirection_url'] ); } diff --git a/components/ILIAS/Test/src/Settings/MainSettings/SettingsIntroduction.php b/components/ILIAS/Test/src/Settings/MainSettings/SettingsIntroduction.php index 6ef0bded0d66..223c9ba82cc5 100755 --- a/components/ILIAS/Test/src/Settings/MainSettings/SettingsIntroduction.php +++ b/components/ILIAS/Test/src/Settings/MainSettings/SettingsIntroduction.php @@ -122,7 +122,7 @@ public static function fromExport(array $data): static { return new self( (bool) $data['intro_enabled'], - $data['introduction_page_id'], + $data['introduction_page_id'] !== null ? (int) $data['introduction_page_id'] : null, (bool) $data['conditions_checkbox_enabled'], ); } diff --git a/components/ILIAS/Test/src/Settings/ScoreReporting/SettingsResultSummary.php b/components/ILIAS/Test/src/Settings/ScoreReporting/SettingsResultSummary.php index be067e6d41a9..96357dd151d4 100755 --- a/components/ILIAS/Test/src/Settings/ScoreReporting/SettingsResultSummary.php +++ b/components/ILIAS/Test/src/Settings/ScoreReporting/SettingsResultSummary.php @@ -292,7 +292,7 @@ public function toExport(): array public static function fromExport(array $data): static { return (new self()) - ->withScoreReporting(ScoreReportingTypes::from($data['score_reporting'])) + ->withScoreReporting(ScoreReportingTypes::from((int) $data['score_reporting'])) ->withReportingDate($data['reporting_date'] ? new \DateTimeImmutable($data['reporting_date']) : null) ->withShowGradingStatusEnabled((bool) $data['show_grading_status']) ->withShowGradingMarkEnabled((bool) $data['show_grading_mark']) diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/SimpleXMLSerializer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/SimpleXMLSerializer.php index 86b27a4c5b57..8df8f8e8b15c 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/SimpleXMLSerializer.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/SimpleXMLSerializer.php @@ -107,6 +107,10 @@ public function group(string $name, callable $callback): void public function append(string $name, array $data): void { $this->writer->startElement($this->formatName($name)); + if (count($data) === 0) { + $this->writer->writeAttribute('type', 'empty-array'); + } + $this->appendRecursive($data); $this->writer->endElement(); } From c0a6a5684a9154d3f8569a40ee43a2cd7b0cd2c4 Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Tue, 28 Apr 2026 11:53:02 +0200 Subject: [PATCH 26/43] feat(test): introduce user import resolving --- .../src/ExportImport/Import/PersistStage.php | 87 +++++++++++++++++++ .../Import/UserImportResolver.php | 86 ++++++++++++++++++ .../Test/src/ExportImport/TestExporter.php | 24 +++-- .../GlobalSettings/UserIdentfiers.php | 11 +++ 4 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 components/ILIAS/Test/src/ExportImport/Import/PersistStage.php create mode 100644 components/ILIAS/Test/src/ExportImport/Import/UserImportResolver.php 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..a155eef0e21b --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Import/PersistStage.php @@ -0,0 +1,87 @@ +lng->txt('qpl_import_step_persist'); + } + + public function getDescription(): ?string + { + return ''; + } + + public function process(ImportContext $context): StageResult + { + $component_import_dir = dirname($context->get(UploadValidationStage::COMPONENT_IMPORT_FILE)); + $mappings_file = "{$component_import_dir}/mappings.xml"; + if (!file_exists($mappings_file) || !is_file($mappings_file)) { + return StageResult::error($context, $this->lng->txt('obj_import_file_error')); + } + + $deserializer = new SimpleXMLDeserializer()->open(file_get_contents($mappings_file)); + $deserializer->addHandler('mappings', function (array $mappings) use (&$context) { + $context = $context->with('mappings', $mappings); + }); + $deserializer->process(); + + $importer = new ilImport($this->requested_ref_id); + $importer->importObject( + null, + $context->get(UploadValidationStage::FILE_TO_IMPORT), + basename($context->get(UploadValidationStage::FILE_TO_IMPORT)), + 'tst', + 'components/ILIAS/Test', + true, + ); + + // Context is updated by the TestImporter so we need to reload it + return StageResult::complete($this->session->getContext()); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Import/UserImportResolver.php b/components/ILIAS/Test/src/ExportImport/Import/UserImportResolver.php new file mode 100644 index 000000000000..90e35fa8c3e0 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Import/UserImportResolver.php @@ -0,0 +1,86 @@ + $users + * @return array + */ + public function resolve(UserIdentifiers $criteria, array $users): array + { + $this->log->debug("Resolving user import for criteria {$criteria->value}"); + + if (!$this->db->tableColumnExists('usr_data', $criteria->value)) { + $this->log->error("User criteria field {$criteria->value} does not exist in usr_data table, using anonymous user ID for all users"); + + return array_fill_keys(array_keys($users), ANONYMOUS_USER_ID); + } + + $in_clause = $this->db->in( + $criteria->value, + array_values($users), + false, + $criteria->getColumnType() + ); + $query = $this->db->query("SELECT usr_id, {$criteria->value} AS identifier FROM usr_data WHERE {$in_clause}"); + + $db_mapping = []; + foreach ($this->db->fetchAll($query) as $row) { + $db_mapping[$row['identifier']] = $row['usr_id']; + } + + $mapping = []; + foreach ($users as $original_id => $identifier) { + if (isset($db_mapping[$identifier])) { + $this->log->debug("User identifier {$identifier} found, mapping user {$original_id} to {$db_mapping[$identifier]}"); + $mapping[$original_id] = $db_mapping[$identifier]; + } else { + $this->log->warning("User identifier {$identifier} not found for user {$original_id}, using anonymous user ID"); + $mapping[$original_id] = ANONYMOUS_USER_ID; + } + } + + return $mapping; + } + + /** + * @param array $user_mapping + */ + public function store(array $user_mapping, ilImportMapping $import_mapping): void + { + foreach ($user_mapping as $original_id => $user_id) { + $import_mapping->addMapping('tst', 'user', (string) $original_id, (string) $user_id); + } + } +} diff --git a/components/ILIAS/Test/src/ExportImport/TestExporter.php b/components/ILIAS/Test/src/ExportImport/TestExporter.php index 3534fdfb99e3..08292615aa93 100644 --- a/components/ILIAS/Test/src/ExportImport/TestExporter.php +++ b/components/ILIAS/Test/src/ExportImport/TestExporter.php @@ -26,6 +26,7 @@ use ILIAS\Data\Factory as DataFactory; use ILIAS\Data\ObjectId; use ILIAS\Data\UUID\Factory as UUIDFactory; +use ILIAS\Filesystem\Stream\Streams; use ILIAS\Language\Language; use ILIAS\ResourceStorage\Services as IRSS; use ILIAS\Taxonomy\DomainService as Taxonomy; @@ -41,6 +42,7 @@ use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Serializer; use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Pipes\CollectResources; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Serializing\SimpleXMLSerializer; use ILIAS\TestQuestionPool\ExportImport\Pipes\CollectQuestionImages; use ILIAS\TestQuestionPool\Questions\GeneralQuestionPropertiesRepository; use ilTree; @@ -232,10 +234,10 @@ public function write(ExportState $state): void } - $this->exportMappings( + $this->writeMappings( $state->collector(), $state->transformations(), - $state->serializer() + $state ); } @@ -250,7 +252,7 @@ private function exportObject( $obj_id = $collector->getObjectId()->toInt(); $state->addDependency('components/ILIAS/ILIASObject', 'common', [$obj_id]); - $state->addDependency('components/ILIAS/MetaData', 'qpl', ["{$obj_id}:0:qpl"]); + $state->addDependency('components/ILIAS/MetaData', 'tst', ["{$obj_id}:0:tst"]); $state->addDependency( 'components/ILIAS/Taxonomy', 'tax', @@ -286,12 +288,15 @@ private function exportQuestions( Serializer $serializer, ExportState $state ): void { + $question_properties = $collector->getTestQuestionProperties(); + foreach ($collector->getQuestionObjects() as $question) { $normalized = [ ... $transformations->normalize($question), 'feedback' => $transformations->normalize( $collector->getFeedback($question) - ) + ), + 'sequence' => $question_properties[$question->getId()]->getSequenceInformation()->getPlaceInSequence(), ]; if ($question instanceof assFormulaQuestion) { @@ -349,11 +354,13 @@ private function exportResults( } } - private function exportMappings( + private function writeMappings( TestCollector $collector, Transformations $transformations, - Serializer $serializer + ExportState $state ): void { + $serializer = new SimpleXMLSerializer()->open('memory'); + $serializer->createDocument('Test Export Mappings'); $serializer->startGroup('mappings'); $user_ids = $transformations->context(CollectUserIds::class)->getIds(); @@ -366,5 +373,10 @@ private function exportMappings( ); $serializer->endGroup('mappings'); + + $state->writer()->writeFileByStream( + Streams::ofString($serializer->write()), + "{$state->path()->getPathToComponentDirInContainer()}/mappings.xml" + ); } } 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, + }; + } } From 18e72dfbcf4cfb728bee56f1631be57ad9cc44d0 Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Wed, 29 Apr 2026 11:12:59 +0200 Subject: [PATCH 27/43] fix(test): add export for missing test result dependencies --- .../ExportImport/Envelopes/ManualFeedback.php | 6 +- .../ExportImport/Envelopes/QuestionResult.php | 88 +++++++++++++ .../src/ExportImport/Envelopes/Solution.php | 4 +- .../ExportImport/Envelopes/WorkingTime.php | 2 +- .../Normalizer/ilTestSequenceNormalizer.php | 123 ++++++++++++++++++ .../Test/src/ExportImport/TestCollector.php | 68 +++++++++- .../Test/src/ExportImport/TestExporter.php | 7 +- 7 files changed, 288 insertions(+), 10 deletions(-) create mode 100644 components/ILIAS/Test/src/ExportImport/Envelopes/QuestionResult.php create mode 100644 components/ILIAS/Test/src/ExportImport/Normalizer/ilTestSequenceNormalizer.php diff --git a/components/ILIAS/Test/src/ExportImport/Envelopes/ManualFeedback.php b/components/ILIAS/Test/src/ExportImport/Envelopes/ManualFeedback.php index 5531a916bd81..123c743a9032 100644 --- a/components/ILIAS/Test/src/ExportImport/Envelopes/ManualFeedback.php +++ b/components/ILIAS/Test/src/ExportImport/Envelopes/ManualFeedback.php @@ -59,13 +59,13 @@ public function toArray(Transformations $tt): array public static function fromArray(array $value, Transformations $tt): static { return new self( - $tt->denormalize($value['active_id'], Id::class)->getId(), - $tt->denormalize($value['question_id'], Id::class)->getId(), + $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)->getId(), + $tt->denormalize($value['finalized_by'], Id::class), ); } diff --git a/components/ILIAS/Test/src/ExportImport/Envelopes/QuestionResult.php b/components/ILIAS/Test/src/ExportImport/Envelopes/QuestionResult.php new file mode 100644 index 000000000000..dfe9877771ca --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Envelopes/QuestionResult.php @@ -0,0 +1,88 @@ + $tt->normalize($this->active_id), + 'question_id' => $tt->normalize($this->question_id), + 'attempt' => $this->attempt, + 'points' => $this->points, + 'answered' => $this->answered, + 'manual' => $this->manual, + 'step' => $this->step, + 'timestamp' => $this->timestamp, + ]; + } + + /** + * @inheritDoc + */ + public static function fromArray(array $value, Transformations $tt): static + { + return new self( + $tt->denormalize($value['active_id'], Id::class), + $tt->denormalize($value['question_id'], Id::class), + $tt->int($value['attempt']), + $tt->float($value['points']), + $tt->bool($value['answered']), + $tt->bool($value['manual']), + $tt->nullableInt($value['step']), + $tt->int($value['timestamp']), + ); + } + + public static function fromRow(array $row): static + { + return new self( + new Id($row['active_fi'], 'participant'), + new Id($row['question_fi'], 'question'), + (int) $row['pass'], + (float) $row['points'], + (bool) $row['answered'], + (bool) $row['manual'], + $row['step'] !== null ? (int) $row['step'] : null, + (int) $row['tstamp'], + ); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Envelopes/Solution.php b/components/ILIAS/Test/src/ExportImport/Envelopes/Solution.php index 2bdc8d75ba32..5ef1747f5ceb 100644 --- a/components/ILIAS/Test/src/ExportImport/Envelopes/Solution.php +++ b/components/ILIAS/Test/src/ExportImport/Envelopes/Solution.php @@ -65,8 +65,8 @@ public function toArray(Transformations $tt): array public static function fromArray(array $value, Transformations $tt): static { return new self( - $tt->denormalize($value['active_id'], Id::class)->getId(), - $tt->denormalize($value['question_id'], Id::class)->getId(), + $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']), diff --git a/components/ILIAS/Test/src/ExportImport/Envelopes/WorkingTime.php b/components/ILIAS/Test/src/ExportImport/Envelopes/WorkingTime.php index 17deb3dabbd3..97aeeeb4dc9b 100644 --- a/components/ILIAS/Test/src/ExportImport/Envelopes/WorkingTime.php +++ b/components/ILIAS/Test/src/ExportImport/Envelopes/WorkingTime.php @@ -53,7 +53,7 @@ public function toArray(Transformations $tt): array public static function fromArray(array $value, Transformations $tt): static { return new self( - $tt->denormalize($value['active_id'], Id::class)->getId(), + $tt->denormalize($value['active_id'], Id::class), $tt->int($value['attempt']), $tt->string($value['started']), $tt->string($value['finished']), diff --git a/components/ILIAS/Test/src/ExportImport/Normalizer/ilTestSequenceNormalizer.php b/components/ILIAS/Test/src/ExportImport/Normalizer/ilTestSequenceNormalizer.php new file mode 100644 index 000000000000..00762cb1e893 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalizer/ilTestSequenceNormalizer.php @@ -0,0 +1,123 @@ + + */ +#[Normalizes(ilTestSequence::class)] +class ilTestSequenceNormalizer implements Normalizer +{ + private readonly ilDBInterface $db; + private readonly GeneralQuestionPropertiesRepository $repository; + + public function __construct( + private readonly Transformations $tt, + Container $dic, + TestDIC $local_dic, + ) { + $this->db = $dic->database(); + $this->repository = $local_dic['question.general_properties.repository']; + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if (!$value instanceof ilTestSequence) { + throw new NormalizingException('Invalid value', $value); + } + + return [ + 'active_id' => $this->tt->normalize(new Id($value->getActiveId(), 'participant')), + 'attempt' => $value->getPass(), + 'sequence' => $value->sequencedata['sequence'], + 'postponed' => $this->normalizeQuestions($value->sequencedata['postponed'] ?? []), + 'hidden' => $this->normalizeQuestions($value->sequencedata['hidden'] ?? []), + 'ans_opt_confirmed' => $value->isAnsweringOptionalQuestionsConfirmed(), + 'optional_questions' => $this->normalizeQuestions($value->getOptionalQuestions()), + ]; + } + + private function normalizeQuestions(array $questions): array + { + return array_map( + fn(int $question_id) => $this->tt->normalize(new Id($question_id, 'question')), + $questions + ); + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): ilTestSequence + { + if ($type !== ilTestSequence::class) { + throw new NormalizingException("Invalid type for ilTestSequence: {$type}"); + } + + $active_id = $this->tt->denormalize($value['active_id'], Id::class)->getId(); + $attempt = $this->tt->int($value['attempt']); + + $sequence = new ilTestSequence($this->db, $active_id, $attempt, $this->repository); + + $sequence->setAnsweringOptionalQuestionsConfirmed($this->tt->bool($value['ans_opt_confirmed'])); + $sequence->sequencedata['sequence'] = $this->denormalizeSequence($value['sequence']); + $sequence->sequencedata['postponed'] = $this->denormalizeQuestions($value['postponed']); + $sequence->sequencedata['hidden'] = $this->denormalizeQuestions($value['hidden']); + + $optional = $this->denormalizeQuestions($value['optional_questions']); + foreach ($optional as $question_id) { + $sequence->setQuestionOptional($question_id); + } + + return $sequence; + } + + private function denormalizeSequence(array $normalized): array + { + $sequence = []; + foreach ($normalized as $key => $item) { + $sequence[$this->tt->int($key)] = $this->tt->int($item); + } + return $sequence; + } + + private function denormalizeQuestions(array $normalized): array + { + return array_map( + fn(mixed $item) => $this->tt->denormalize($item, Id::class)->getId(), + $normalized + ); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/TestCollector.php b/components/ILIAS/Test/src/ExportImport/TestCollector.php index af4044e6aa64..94add6193096 100644 --- a/components/ILIAS/Test/src/ExportImport/TestCollector.php +++ b/components/ILIAS/Test/src/ExportImport/TestCollector.php @@ -27,6 +27,7 @@ use ILIAS\Data\ObjectId; use ILIAS\Language\Language; use ILIAS\Test\ExportImport\Envelopes\ManualFeedback; +use ILIAS\Test\ExportImport\Envelopes\QuestionResult; use ILIAS\Test\ExportImport\Envelopes\Solution; use ILIAS\Test\ExportImport\Envelopes\WorkingTime; use ILIAS\Test\Logging\TestLogger; @@ -35,13 +36,16 @@ use ILIAS\Test\Questions\Properties\Repository as QuestionsRepository; use ILIAS\Test\Results\Data\Repository as ResultsRepository; use ILIAS\Test\Settings\GlobalSettings\UserIdentifiers; +use ILIAS\Test\TestManScoringDoneHelper; use ILIAS\TestQuestionPool\ExportImport\Export\CollectsQuestions; use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\DataCollector; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Envelopes\Id; use ILIAS\TestQuestionPool\Questions\GeneralQuestionPropertiesRepository; use ilObjTest; use ilTestQuestionSetConfigFactory; use ilTestRandomQuestionSetSourcePoolDefinitionFactory; use ilTestRandomQuestionSetSourcePoolDefinitionList; +use ilTestSequence; use ilTestSkillLevelThreshold; use ilTestSkillLevelThresholdList; use ilTree; @@ -53,6 +57,8 @@ class TestCollector implements DataCollector { use CollectsQuestions; + private readonly TestManScoringDoneHelper $manual_scoring; + /** @var array $questions */ private ?array $questions = null; private ?ilObjTest $test = null; @@ -71,6 +77,7 @@ public function __construct( private readonly ilComponentRepository $component_repository, private readonly ObjectId $object_id ) { + $this->manual_scoring = new TestManScoringDoneHelper($this->db); } private function database(): ilDBInterface @@ -122,7 +129,7 @@ public function getUserMapping(array $user_ids): array $mapping[$user_id] = $user_id; } } else { - $in_clause = $this->db->in('usr_id', $user_ids, false, 'integer'); + $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) { @@ -245,18 +252,42 @@ public function getParticipantsIds(): array 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); return [ - 'results' => $this->results_repository->getTestResult($participant_id), - 'attempts' => $this->results_repository->getTestAttemptResults($participant_id), + 'sequences' => $this->getSequences($participant_id, array_keys($attempt_results)), 'solutions' => $this->getSolutions($participant_id), + 'results' => $this->getQuestionResults($participant_id), + 'attempts' => $attempt_results, + 'test_result' => $this->results_repository->getTestResult($participant_id), 'working_times' => $this->getWorkingTimes($participant_id), 'manual_feedback' => $this->getManualFeedback($participant_id), + 'manual_scoring' => [ + 'active_id' => new Id($participant_id, 'participant'), + 'done' => $this->manual_scoring->isDone($participant_id), + ], ]; } @@ -277,6 +308,23 @@ public function getSolutions(int $participant_id): array ); } + /** + * @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 */ @@ -310,4 +358,18 @@ public function getManualFeedback(int $participant_id): array $this->db->fetchAll($query) ); } + + /** + * @param list $attempts + * @return list + */ + public function getSequences(int $participant_id, array $attempts): array + { + foreach ($attempts as $attempt) { + $test_sequence = new ilTestSequence($this->db, $participant_id, $attempt, $this->general_questions_repository); + $test_sequence->loadFromDb(); + $sequences[] = $test_sequence; + } + return $sequences; + } } diff --git a/components/ILIAS/Test/src/ExportImport/TestExporter.php b/components/ILIAS/Test/src/ExportImport/TestExporter.php index 08292615aa93..a6dba3fe9adf 100644 --- a/components/ILIAS/Test/src/ExportImport/TestExporter.php +++ b/components/ILIAS/Test/src/ExportImport/TestExporter.php @@ -334,8 +334,13 @@ private function exportParticipants( Transformations $transformations, Serializer $serializer ): void { + $additional_data = $collector->getAdditionalParticipantData($collector->getParticipantsIds()); + foreach ($collector->getParticipants() as $participant) { - $serializer->append('participant', $transformations->normalize($participant)); + $serializer->append('participant', [ + ...$transformations->normalize($participant), + ...$additional_data[$participant->getActiveId()], + ]); } } From 3f125efb215d1c699794e0de63d08f9f5aaaf142 Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Wed, 29 Apr 2026 12:13:30 +0200 Subject: [PATCH 28/43] fix(qpl): generate thumbnails for question images --- .../Import/QuestionPoolImporter.php | 7 +- .../ExportImport/Import/QuestionsImporter.php | 79 ++++++++++++++++--- .../TestQuestionPool/src/QuestionPoolDIC.php | 6 +- 3 files changed, 77 insertions(+), 15 deletions(-) diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionPoolImporter.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionPoolImporter.php index 00162cfeb235..3b9761cc446d 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionPoolImporter.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionPoolImporter.php @@ -113,7 +113,12 @@ function (array $assignments) use ($tt, &$context): void { $deserializer->process(); // Copy the question images from the temporary import directory to the question pool directory - $this->questions_importer->importQuestionImages($mapping, $context, $images_pipe); + $this->questions_importer->importQuestionImages( + $context->get('pool_obj_id'), + $mapping, + $context, + $images_pipe + ); return $context; } diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionsImporter.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionsImporter.php index 8cb86d4176e1..a3d84b6531a0 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionsImporter.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionsImporter.php @@ -27,6 +27,12 @@ use ilAssQuestionPage; use ilCtrl; use ilDBInterface; +use ILIAS\Filesystem\Filesystem; +use ILIAS\Filesystem\Filesystems; +use ILIAS\Filesystem\Stream\FileStream; +use ILIAS\Filesystem\Stream\Streams; +use ILIAS\Filesystem\Util\Convert\ImageOutputOptions; +use ILIAS\Filesystem\Util\Convert\Images; use ILIAS\Language\Language; use ILIAS\TestQuestionPool\ExportImport\Envelopes\QuestionImage; use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; @@ -34,19 +40,25 @@ use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Envelopes\Id; use ILIAS\TestQuestionPool\ExportImport\Envelopes\Feedback; use ILIAS\TestQuestionPool\ExportImport\Pipes\CollectQuestionImages; -use ILIAS\TestQuestionPool\Questions\Files\QuestionFiles; use ilImportMapping; use ilUnitConfigurationRepository; +use Psr\Log\LoggerInterface; class QuestionsImporter { + private readonly Filesystem $filesystem; + public function __construct( private readonly string $component, private readonly string $parent_type, private readonly ilCtrl $ctrl, private readonly ilDBInterface $database, private readonly Language $language, + private readonly LoggerInterface $log, + private readonly Images $image_converter, + Filesystems $filesystems, ) { + $this->filesystem = $filesystems->web(); } public function importQuestion( @@ -90,31 +102,72 @@ public function importQuestion( public function importQuestionImages( + int $parent_obj_id, ilImportMapping $mapping, ImportContext $context, CollectQuestionImages $pipe, ): void { - $import_dir = dirname($context->get(UploadValidationStage::COMPONENT_IMPORT_FILE)) . DIRECTORY_SEPARATOR . 'expDir_1'; + $import_dir = dirname($context->get(UploadValidationStage::COMPONENT_IMPORT_FILE)) . '/expDir_1'; + + foreach ($pipe->getEnvelopes() as $filename => $envelope) { + $source_path = $import_dir . DIRECTORY_SEPARATOR . $filename; + if (!file_exists($source_path)) { + $this->log->error("Imported image path does not exist: {$source_path}"); + continue; + } - $question_files = new QuestionFiles(); - foreach ($pipe->getEnvelopes() as $from_path => $envelope) { - $question_id = $mapping->getMapping($this->component, 'question', (string) $envelope->getQuestionId()); - if (!$question_id) { + $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; } - $base_dir = $envelope->getType() === QuestionImage::TYPE_ANSWER - ? $question_files->buildImagePath($question_id, $context->get('pool_obj_id')) - : $question_files->buildSolutionPath($question_id, $context->get('pool_obj_id')); + $input_stream = Streams::ofReattachableResource(fopen($source_path, 'rb')); + $this->filesystem->writeStream($image_path, $input_stream); + $this->log->debug("Imported question image: {$source_path} -> {$image_path}"); - if (!file_exists($base_dir)) { - mkdir($base_dir, 0755, true); + $thumbnail = $this->generateThumbnail($input_stream); + if (!$thumbnail) { + continue; } - copy($import_dir . DIRECTORY_SEPARATOR . $from_path, $base_dir . $envelope->getFilename()); + $thumbnail_path = "{$image_base_path}/thumb.{$envelope->getFilename()}"; + $this->filesystem->writeStream($thumbnail_path, $thumbnail); + $this->log->debug("Generated question image thumbnail: {$thumbnail_path}"); - //TODO: generate 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) { + $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(); } /** diff --git a/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php b/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php index bedf56db471c..9a23022146f8 100755 --- a/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php +++ b/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php @@ -28,6 +28,7 @@ use ILIAS\TestQuestionPool\ExportImport\Import\QuestionPoolImporter; use ILIAS\TestQuestionPool\ExportImport\Import\QuestionsImporter; use ILIAS\TestQuestionPool\ExportImport\Import\SkillAssignmentsImporter; +use ilLoggerFactory; use Pimple\Container as PimpleContainer; use ILIAS\DI\Container as ILIASContainer; use ILIAS\TestQuestionPool\Questions\SuggestedSolution\SuggestedSolutionsDatabaseRepository; @@ -105,7 +106,10 @@ protected static function buildDIC(ILIASContainer $DIC): self 'qpl', $DIC->ctrl(), $DIC->database(), - $DIC->language() + $DIC->language(), + ilLoggerFactory::getLogger('exp')->getLogger(), + $DIC->fileConverters()->images(), + $DIC->filesystem() ); $dic['exportimport.importer'] = static fn($c): QuestionPoolImporter => new QuestionPoolImporter( From 14a434948d8a185986933c10b58f49d26829743a Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Wed, 29 Apr 2026 12:14:39 +0200 Subject: [PATCH 29/43] fix(filesystem): preserve uri and mode in ReattachableStream across detach calls Stream::detach() nullifies uri and _mode, which made ReattachableStream unable to reopen the resource afterwards. Override detach() to store both values before the parent clears them, so assertStreamAttached() can reliably reattach. --- .../src/Stream/ReattachableStream.php | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/components/ILIAS/Filesystem/src/Stream/ReattachableStream.php b/components/ILIAS/Filesystem/src/Stream/ReattachableStream.php index 085e899b9456..78d83c7a520f 100644 --- a/components/ILIAS/Filesystem/src/Stream/ReattachableStream.php +++ b/components/ILIAS/Filesystem/src/Stream/ReattachableStream.php @@ -25,15 +25,23 @@ */ class ReattachableStream extends Stream { - /** - * Checks if the stream is attached to the wrapper. - * If not, the stream is reattached. - */ + private ?string $reattach_uri = null; + private ?string $reattach_mode = null; + + #[\Override] + public function detach() + { + $this->reattach_uri = $this->uri; + $this->reattach_mode = $this->_mode; + + return parent::detach(); + } + #[\Override] protected function assertStreamAttached(): void { if ($this->stream === null) { - $this->stream = fopen($this->uri, $this->_mode); + $this->stream = fopen($this->reattach_uri, $this->reattach_mode); } } } From 2fc8068bf969a15be2ab14f83170837267f348d7 Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Wed, 29 Apr 2026 14:49:49 +0200 Subject: [PATCH 30/43] feat(test): implement test importer class and integrate into xml import system --- .../ILIAS/Test/classes/class.ilObjTestGUI.php | 123 ++-- .../Test/classes/class.ilTestImporter.php | 343 ++--------- .../classes/class.ilTestLegacyImporter.php | 349 +++++++++++ .../src/ExportImport/Import/PersistStage.php | 7 +- .../src/ExportImport/Import/TestImporter.php | 578 ++++++++++++++++++ .../Test/src/ExportImport/TestExporter.php | 4 +- components/ILIAS/Test/src/TestDIC.php | 45 ++ .../Normalizer/ResourceNormalizer.php | 2 +- .../Normalizing/Pipes/CollectResources.php | 43 +- 9 files changed, 1124 insertions(+), 370 deletions(-) create mode 100755 components/ILIAS/Test/classes/class.ilTestLegacyImporter.php create mode 100644 components/ILIAS/Test/src/ExportImport/Import/TestImporter.php diff --git a/components/ILIAS/Test/classes/class.ilObjTestGUI.php b/components/ILIAS/Test/classes/class.ilObjTestGUI.php index 32579977450f..fee5a827561d 100755 --- a/components/ILIAS/Test/classes/class.ilObjTestGUI.php +++ b/components/ILIAS/Test/classes/class.ilObjTestGUI.php @@ -18,6 +18,7 @@ declare(strict_types=1); +use ILIAS\Test\ExportImport\Import\PersistStage; use ILIAS\Test\Logging\AdditionalInformationGenerator; use ILIAS\Skill\Service\SkillUsageService; use ILIAS\Test\Results\Data\Repository as TestResultRepository; @@ -61,6 +62,14 @@ use ILIAS\Test\Results\Toplist\TestTopListRepository; use ILIAS\Test\ExportImport\Factory as ExportImportFactory; use ILIAS\Test\ExportImport\DBRepository as ExportRepository; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportContext; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportSessionRepository; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportStageRunner; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\StageResultType; +use ILIAS\TestQuestionPool\ExportImport\Import\CleanupStage; +use ILIAS\TestQuestionPool\ExportImport\Import\DetectLegacyImportStage; +use ILIAS\TestQuestionPool\ExportImport\Import\QuestionSelectionStage; +use ILIAS\TestQuestionPool\ExportImport\Import\UploadValidationStage; use ILIAS\TestQuestionPool\Questions\GeneralQuestionPropertiesRepository; use ILIAS\TestQuestionPool\RequestDataCollector as QPLRequestDataCollector; use ILIAS\TestQuestionPool\Import\TestQuestionsImportTrait; @@ -180,6 +189,7 @@ 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 bool $create_question_mode; @@ -234,6 +244,7 @@ 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']; $ref_id = 0; if ($this->testrequest->hasRefId() && is_numeric($this->testrequest->getRefId())) { @@ -1362,81 +1373,76 @@ 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); + $this->import_session_repository->clear(); - $options = (new ILIAS\Filesystem\Util\Archive\UnzipOptions()) - ->withZipOutputPath($this->getImportTempDirectory()); + $context = new ImportContext([UploadValidationStage::FILE_TO_IMPORT => $file_to_import]); + $this->import_session_repository->setContext($context); + $this->import_session_repository->setCurrentStageIndex(0); - $unzip = $this->archives->unzip(Streams::ofResource(fopen($file_to_import, 'r')), $options); - $unzip->extract(); - - 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; - $form = $this->buildImportQuestionsSelectionForm( - 'importVerifiedFile', - $importdir, - $qtifile, - $file_to_import, - $path_to_uploaded_file_in_temp_dir - ); + case StageResultType::ADVANCE: + $this->ctrl->redirectByClass(self::class, 'processImport'); + break; - if ($form === null) { - return; + case StageResultType::ERROR: + $this->tpl->setOnScreenMessage('failure', $result->error_message, true); + break; + + case StageResultType::COMPLETE: + $this->tpl->setOnScreenMessage('success', $this->lng->txt('object_imported'), true); + $this->ctrl->setParameterByClass(ilObjTestGUI::class, 'ref_id', $result->context->get('test_ref_id')); + $this->ctrl->redirectByClass(self::class, self::SHOW_QUESTIONS_CMD); + break; } + } + + private function buildImportStageRunner(): ImportStageRunner + { + $form_action = $this->ctrl->getFormActionByClass(self::class, 'processImport'); - $panel = $this->ui_factory->panel()->standard( - $this->lng->txt('import_tst'), + return new ImportStageRunner( [ - $this->ui_factory->legacy()->content($this->lng->txt('qpl_import_verify_found_questions')), - $form - ] + new UploadValidationStage($this->archives, $this->lng, 'components/ILIAS/Test'), + new DetectLegacyImportStage(), + new QuestionSelectionStage( + $this->lng, + $this->component_factory, + $this->ui_factory, + $this->request, + $form_action, + $this->lng->txt('import_tst') + ), + new PersistStage($this->lng, $this->requested_ref_id, $this->import_session_repository), + ], + $this->import_session_repository, + new CleanupStage() ); - $this->tpl->setContent($this->ui_renderer->render($panel)); - $this->tpl->printToStdout(); - exit; } + /** * save object * @access public @@ -2362,6 +2368,7 @@ public function addLocatorItems(): void ); break; case "importFile": + case "processImport": case "cloneAll": case "importVerifiedFile": case "cancelImport": diff --git a/components/ILIAS/Test/classes/class.ilTestImporter.php b/components/ILIAS/Test/classes/class.ilTestImporter.php index 6c195c2836ca..7ee0c283a3f0 100755 --- a/components/ILIAS/Test/classes/class.ilTestImporter.php +++ b/components/ILIAS/Test/classes/class.ilTestImporter.php @@ -18,38 +18,38 @@ declare(strict_types=1); -use ILIAS\ResourceStorage\Services as ResourceStorage; -use ILIAS\TestQuestionPool\Import\TestQuestionsImportTrait; +use ILIAS\Data\ReferenceId; +use ILIAS\Test\ExportImport\Import\TestImporter; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportSessionRepository; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Serializing\SimpleXMLDeserializer; +use ILIAS\TestQuestionPool\ExportImport\Import\DetectLegacyImportStage; use ILIAS\Test\TestDIC; -use ILIAS\Test\Logging\TestLogger; -/** - * Importer class for files - * - * @author Stefan Meyer - * @version $Id$ - * @ingroup components\ILIASLearningModule - */ class ilTestImporter extends ilXmlImporter { - use TestQuestionsImportTrait; - /** - * @var array - */ - public static $finallyProcessedTestsRegistry = []; - - private readonly TestLogger $logger; - private readonly ilDBInterface $db; - private readonly ResourceStorage $irss; + protected readonly ImportSessionRepository $session; + protected readonly TestImporter $importer; + protected readonly ilTestLegacyImporter $legacy_importer; public function __construct() { - global $DIC; - $this->logger = TestDIC::dic()['logging.logger']; - $this->db = $DIC['ilDB']; - $this->irss = $DIC['resource_storage']; - parent::__construct(); + $this->legacy_importer = new ilTestLegacyImporter(); + + $local_dic = TestDIC::dic(); + $this->session = $local_dic['exportimport.session']; + $this->importer = $local_dic['exportimport.importer']; + } + + public function init(): void + { + /** @var ilCOPageImportConfig $co_config */ + $co_config = $this->imp->getConfig('components/ILIAS/COPage'); + $co_config->setUpdateIfExists(true); + + $this->legacy_importer->setImport($this->getImport()); + $this->legacy_importer->setImportDirectory($this->getImportDirectory()); + $this->legacy_importer->init(); } public function importXmlRepresentation( @@ -58,292 +58,35 @@ public function importXmlRepresentation( string $a_xml, ilImportMapping $a_mapping ): void { - $results_file_path = null; - if ($new_id = (int) $a_mapping->getMapping('components/ILIAS/Container', 'objs', $a_id)) { - // container content - $new_obj = ilObjectFactory::getInstanceByObjId((int) $new_id, false); - $new_obj->saveToDb(); - - [$importdir, $xmlfile, $qtifile] = $this->buildImportDirectoriesFromContainerImport( - $this->getImportDirectory() - ); - $selected_questions = []; - } else { - // single object - $new_id = (int) $a_mapping->getMapping('components/ILIAS/Test', 'tst', 'new_id'); - $new_obj = ilObjectFactory::getInstanceByObjId($new_id, false); - - $selected_questions = ilSession::get('tst_import_selected_questions') ?? []; - [$subdir, $importdir, $xmlfile, $qtifile] = $this->buildImportDirectoriesFromImportFile( - ilSession::get('path_to_import_file') - ); - $results_file_path = $this->buildResultsFilePath($importdir, $subdir); - ilSession::clear('tst_import_selected_questions'); - } - - $new_obj->loadFromDb(); - - if (!file_exists($xmlfile)) { - $this->logger->error(__METHOD__ . ': Cannot find xml definition: ' . $xmlfile); - return; - } - if (!file_exists($qtifile)) { - $this->logger->error(__METHOD__ . ': Cannot find xml definition: ' . $qtifile); + // Check if forward to legacy importer is needed + $context = $this->session->getContext(); + if (DetectLegacyImportStage::isLegacyImport($context)) { + $this->legacy_importer->setInstallId($this->getInstallId()); + $this->legacy_importer->setInstallUrl($this->getInstallUrl()); + $this->legacy_importer->setSchemaVersion($this->getSchemaVersion()); + $this->legacy_importer->setSkipEntities($this->getSkipEntities()); + $this->legacy_importer->importXmlRepresentation($a_entity, $a_id, $a_xml, $a_mapping); return; } - // start parsing of QTI files - $qti_parser = new ilQTIParser( - $importdir, - $qtifile, - ilQTIParser::IL_MO_PARSE_QTI, - $new_obj->getId(), - $selected_questions, - $a_mapping->getAllMappings() - ); - $qti_parser->setTestObject($new_obj); - $qti_parser->startParsing(); - $new_obj = $qti_parser->getTestObject(); - - // import page data - $question_page_parser = new ilQuestionPageParser( - $new_obj, - $xmlfile, - $importdir - ); - $question_page_parser->setQuestionMapping($qti_parser->getImportMapping()); - $question_page_parser->startParsing(); - - $a_mapping = $this->addTaxonomyAndQuestionsMapping($qti_parser->getQuestionIdMapping(), $new_obj->getId(), $a_mapping); - - if ($new_obj->isRandomTest()) { - $this->importRandomQuestionSetConfig($new_obj, $xmlfile, $a_mapping); - } - - if ($results_file_path !== null && file_exists($results_file_path)) { - $results = new ilTestResultsImportParser($results_file_path, $new_obj, $this->db, $this->logger, $this->irss); - $results->setQuestionIdMapping($a_mapping->getMappingsOfEntity('components/ILIAS/Test', 'quest')); - $results->setSrcPoolDefIdMapping($a_mapping->getMappingsOfEntity('components/ILIAS/Test', 'rnd_src_pool_def')); - $results->startParsing(); - } - - $new_obj->saveToDb(); // this creates test_fi - $new_obj->update(); // this saves ilObject data - - $this->importSkillLevelThresholds( + $result = $this->importer->import( + new SimpleXMLDeserializer()->open($a_xml), $a_mapping, - $this->importQuestionSkillAssignments($a_mapping, $new_obj, $xmlfile), - $new_obj, - $xmlfile - ); - - $a_mapping->addMapping("components/ILIAS/Test", "tst", (string) $a_id, (string) $new_obj->getId()); - $a_mapping->addMapping( - "components/ILIAS/MetaData", - "md", - $a_id . ":0:tst", - $new_obj->getId() . ":0:tst" + new ReferenceId($a_mapping->getTargetId()), + $context, ); - } - - public function addTaxonomyAndQuestionsMapping( - array $question_id_mapping, - int $new_obj_id, - ilImportMapping $mapping - ): ilImportMapping { - foreach ($question_id_mapping as $old_question_id => $new_question_id) { - $mapping->addMapping( - 'components/ILIAS/Taxonomy', - 'tax_item', - "tst:quest:{$old_question_id}", - (string) $new_question_id - ); - - $mapping->addMapping( - 'components/ILIAS/Taxonomy', - 'tax_item_obj_id', - "tst:quest:{$old_question_id}", - (string) $new_obj_id - ); - - $mapping->addMapping( - 'components/ILIAS/Test', - 'quest', - (string) $old_question_id, - (string) $new_question_id - ); - } - - return $mapping; + $this->session->setContext($result); } public function finalProcessing(ilImportMapping $a_mapping): void { - $maps = $a_mapping->getMappingsOfEntity("components/ILIAS/Test", "tst"); - - foreach ($maps as $old => $new) { - if ($old == "new_id" || (int) $old <= 0) { - continue; - } - - if (isset(self::$finallyProcessedTestsRegistry[$new])) { - continue; - } - - $test_obj = ilObjectFactory::getInstanceByObjId((int) $new, false); - if ($test_obj->isRandomTest()) { - $this->finalRandomTestTaxonomyProcessing($a_mapping, (string) $old, $new, $test_obj); - } - - self::$finallyProcessedTestsRegistry[$new] = true; - } - } - - protected function finalRandomTestTaxonomyProcessing( - ilImportMapping $mapping, - string $old_tst_obj_id, - string $new_tst_obj_id, - ilObjTest $test_obj - ): void { - $new_tax_ids = $mapping->getMapping( - 'components/ILIAS/Taxonomy', - 'tax_usage_of_obj', - $old_tst_obj_id - ); - - if ($new_tax_ids !== null) { - foreach (explode(':', $new_tax_ids) as $tax_id) { - ilObjTaxonomy::saveUsage((int) $tax_id, (int) $new_tst_obj_id); - } - } - - $src_pool_def_list = new ilTestRandomQuestionSetSourcePoolDefinitionList( - $this->db, - $test_obj, - new ilTestRandomQuestionSetSourcePoolDefinitionFactory( - $this->db, - $test_obj - ) - ); - - $src_pool_def_list->loadDefinitions(); - - foreach ($src_pool_def_list as $definition) { - $mapped_taxonomy_filter = $definition->getMappedTaxonomyFilter(); - if ($mapped_taxonomy_filter === []) { - continue; - } - - $definition->setMappedTaxonomyFilter( - $this->getNewMappedTaxonomyFilter( - $mapping, - $mapped_taxonomy_filter - ) - ); - $definition->saveToDb(); - } - } - - protected function getNewMappedTaxonomyFilter( - ilImportMapping $mapping, - array $mapped_filter - ): array { - $new_mapped_filter = []; - foreach ($mapped_filter as $tax_id => $tax_nodes) { - $new_tax_id = $mapping->getMapping( - 'components/ILIAS/Taxonomy', - 'tax', - (string) $tax_id - ); - - if ($new_tax_id === null) { - continue; - } - - $new_mapped_filter[$new_tax_id] = []; - - foreach ($tax_nodes as $tax_node_id) { - $new_tax_node_id = $mapping->getMapping( - 'components/ILIAS/Taxonomy', - 'tax_tree', - (string) $tax_node_id - ); - - if ($new_tax_node_id === null) { - continue; - } - - $new_mapped_filter[$new_tax_id][] = $new_tax_node_id; - } - } - - return $new_mapped_filter; - } - - public function importRandomQuestionSetConfig( - ilObjTest $test_obj, - ?string $xml_file, - \ilImportMapping $a_mapping - ): void { - $test_obj->questions = []; - $parser = new ilObjTestXMLParser($xml_file); - $parser->setTestOBJ($test_obj); - $parser->setImportMapping($a_mapping); - $parser->startParsing(); - } - - protected function importQuestionSkillAssignments( - ilImportMapping $mapping, - ilObjTest $test_obj, - ?string $xml_file - ): ilAssQuestionSkillAssignmentList { - $parser = new ilAssQuestionSkillAssignmentXmlParser($xml_file); - $parser->startParsing(); - - $importer = new ilAssQuestionSkillAssignmentImporter(); - $importer->setTargetParentObjId($test_obj->getId()); - $importer->setImportInstallationId((int) $this->getInstallId()); - $importer->setImportMappingRegistry($mapping); - $importer->setImportMappingComponent('components/ILIAS/Test'); - $importer->setImportAssignmentList($parser->getAssignmentList()); - - $importer->import(); - - if ($importer->getFailedImportAssignmentList()->assignmentsExist()) { - $qsaImportFails = new ilAssQuestionSkillAssignmentImportFails($test_obj->getId()); - $qsaImportFails->registerFailedImports($importer->getFailedImportAssignmentList()); - - $test_obj->getObjectProperties()->storePropertyIsOnline( - $test_obj->getObjectProperties()->getPropertyIsOnline()->withOffline() - ); + // Check if forward to legacy importer is needed + $context = $this->session->getContext(); + if (DetectLegacyImportStage::isLegacyImport($context)) { + $this->legacy_importer->finalProcessing($a_mapping); + return; } - 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); - } + $this->importer->finalize($a_mapping); } } diff --git a/components/ILIAS/Test/classes/class.ilTestLegacyImporter.php b/components/ILIAS/Test/classes/class.ilTestLegacyImporter.php new file mode 100755 index 000000000000..494752bc1602 --- /dev/null +++ b/components/ILIAS/Test/classes/class.ilTestLegacyImporter.php @@ -0,0 +1,349 @@ + + * @version $Id$ + * @ingroup components\ILIASLearningModule + */ +class ilTestLegacyImporter extends ilXmlImporter +{ + use TestQuestionsImportTrait; + /** + * @var array + */ + public static $finallyProcessedTestsRegistry = []; + + private readonly TestLogger $logger; + private readonly ilDBInterface $db; + private readonly ResourceStorage $irss; + + public function __construct() + { + global $DIC; + $this->logger = TestDIC::dic()['logging.logger']; + $this->db = $DIC['ilDB']; + $this->irss = $DIC['resource_storage']; + + parent::__construct(); + } + + public function importXmlRepresentation( + string $a_entity, + string $a_id, + 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); + 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( + $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" + ); + } + + public function addTaxonomyAndQuestionsMapping( + array $question_id_mapping, + int $new_obj_id, + ilImportMapping $mapping + ): ilImportMapping { + foreach ($question_id_mapping as $old_question_id => $new_question_id) { + $mapping->addMapping( + 'components/ILIAS/Taxonomy', + 'tax_item', + "tst:quest:{$old_question_id}", + (string) $new_question_id + ); + + $mapping->addMapping( + 'components/ILIAS/Taxonomy', + 'tax_item_obj_id', + "tst:quest:{$old_question_id}", + (string) $new_obj_id + ); + + $mapping->addMapping( + 'components/ILIAS/Test', + 'quest', + (string) $old_question_id, + (string) $new_question_id + ); + } + + return $mapping; + } + + public function finalProcessing(ilImportMapping $a_mapping): void + { + $maps = $a_mapping->getMappingsOfEntity("components/ILIAS/Test", "tst"); + + foreach ($maps as $old => $new) { + if ($old == "new_id" || (int) $old <= 0) { + continue; + } + + if (isset(self::$finallyProcessedTestsRegistry[$new])) { + continue; + } + + $test_obj = ilObjectFactory::getInstanceByObjId((int) $new, false); + if ($test_obj->isRandomTest()) { + $this->finalRandomTestTaxonomyProcessing($a_mapping, (string) $old, $new, $test_obj); + } + + self::$finallyProcessedTestsRegistry[$new] = true; + } + } + + protected function finalRandomTestTaxonomyProcessing( + ilImportMapping $mapping, + string $old_tst_obj_id, + string $new_tst_obj_id, + ilObjTest $test_obj + ): void { + $new_tax_ids = $mapping->getMapping( + 'components/ILIAS/Taxonomy', + 'tax_usage_of_obj', + $old_tst_obj_id + ); + + if ($new_tax_ids !== null) { + foreach (explode(':', $new_tax_ids) as $tax_id) { + ilObjTaxonomy::saveUsage((int) $tax_id, (int) $new_tst_obj_id); + } + } + + $src_pool_def_list = new ilTestRandomQuestionSetSourcePoolDefinitionList( + $this->db, + $test_obj, + new ilTestRandomQuestionSetSourcePoolDefinitionFactory( + $this->db, + $test_obj + ) + ); + + $src_pool_def_list->loadDefinitions(); + + foreach ($src_pool_def_list as $definition) { + $mapped_taxonomy_filter = $definition->getMappedTaxonomyFilter(); + if ($mapped_taxonomy_filter === []) { + continue; + } + + $definition->setMappedTaxonomyFilter( + $this->getNewMappedTaxonomyFilter( + $mapping, + $mapped_taxonomy_filter + ) + ); + $definition->saveToDb(); + } + } + + protected function getNewMappedTaxonomyFilter( + ilImportMapping $mapping, + array $mapped_filter + ): array { + $new_mapped_filter = []; + foreach ($mapped_filter as $tax_id => $tax_nodes) { + $new_tax_id = $mapping->getMapping( + 'components/ILIAS/Taxonomy', + 'tax', + (string) $tax_id + ); + + if ($new_tax_id === null) { + continue; + } + + $new_mapped_filter[$new_tax_id] = []; + + foreach ($tax_nodes as $tax_node_id) { + $new_tax_node_id = $mapping->getMapping( + 'components/ILIAS/Taxonomy', + 'tax_tree', + (string) $tax_node_id + ); + + if ($new_tax_node_id === null) { + continue; + } + + $new_mapped_filter[$new_tax_id][] = $new_tax_node_id; + } + } + + return $new_mapped_filter; + } + + public function importRandomQuestionSetConfig( + ilObjTest $test_obj, + ?string $xml_file, + \ilImportMapping $a_mapping + ): void { + $test_obj->questions = []; + $parser = new ilObjTestXMLParser($xml_file); + $parser->setTestOBJ($test_obj); + $parser->setImportMapping($a_mapping); + $parser->startParsing(); + } + + protected function importQuestionSkillAssignments( + ilImportMapping $mapping, + ilObjTest $test_obj, + ?string $xml_file + ): ilAssQuestionSkillAssignmentList { + $parser = new ilAssQuestionSkillAssignmentXmlParser($xml_file); + $parser->startParsing(); + + $importer = new ilAssQuestionSkillAssignmentImporter(); + $importer->setTargetParentObjId($test_obj->getId()); + $importer->setImportInstallationId((int) $this->getInstallId()); + $importer->setImportMappingRegistry($mapping); + $importer->setImportMappingComponent('components/ILIAS/Test'); + $importer->setImportAssignmentList($parser->getAssignmentList()); + + $importer->import(); + + if ($importer->getFailedImportAssignmentList()->assignmentsExist()) { + $qsaImportFails = new ilAssQuestionSkillAssignmentImportFails($test_obj->getId()); + $qsaImportFails->registerFailedImports($importer->getFailedImportAssignmentList()); + + $test_obj->getObjectProperties()->storePropertyIsOnline( + $test_obj->getObjectProperties()->getPropertyIsOnline()->withOffline() + ); + } + + return $importer->getSuccessImportAssignmentList(); + } + + protected function importSkillLevelThresholds( + ilImportMapping $mapping, + ilAssQuestionSkillAssignmentList $assignment_list, + ilObjTest $test_obj, + ?string $xml_file + ): void { + $parser = new ilTestSkillLevelThresholdXmlParser($xml_file); + $parser->initSkillLevelThresholdImportList(); + $parser->startParsing(); + + $importer = new ilTestSkillLevelThresholdImporter($this->db); + $importer->setTargetTestId($test_obj->getTestId()); + $importer->setImportInstallationId((int) $this->getInstallId()); + $importer->setImportMappingRegistry($mapping); + $importer->setImportedQuestionSkillAssignmentList($assignment_list); + $importer->setImportThresholdList($parser->getSkillLevelThresholdImportList()); + $importer->import(); + + if ($importer->getFailedThresholdImportSkillList()->skillsExist()) { + $sltImportFails = new ilTestSkillLevelThresholdImportFails($test_obj->getId()); + $sltImportFails->registerFailedImports($importer->getFailedThresholdImportSkillList()); + + $test_obj->setOfflineStatus(true); + } + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Import/PersistStage.php b/components/ILIAS/Test/src/ExportImport/Import/PersistStage.php index a155eef0e21b..23f3b2f7f3c1 100644 --- a/components/ILIAS/Test/src/ExportImport/Import/PersistStage.php +++ b/components/ILIAS/Test/src/ExportImport/Import/PersistStage.php @@ -30,8 +30,9 @@ use ilImport; /** - * Final stage of the test import process. Imports the test object and all its dependencies using `ilImport`. It will - * delegate the import to the `ilTestImporter` class. + * Final stage of the test import process. Imports the head dependencies (user and resource mappings) and then + * imports the test object and all its dependencies using `ilImport`. It will delegate the import to the + * `ilTestImporter` class. */ class PersistStage implements ImportStage { @@ -71,6 +72,8 @@ public function process(ImportContext $context): StageResult }); $deserializer->process(); + $this->session->setContext($context); + $importer = new ilImport($this->requested_ref_id); $importer->importObject( null, 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..2e16ba56cd50 --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Import/TestImporter.php @@ -0,0 +1,578 @@ +irss); + $id_mapping_pipe = new IdMappingPipe($mapping, 'components/ILIAS/Test'); + $question_images_pipe = new CollectQuestionImages(new UUIDFactory(), $this->data_factory->objId(0)); + + $tt = $this->builder->withAdditionalPipes(append: [$id_mapping_pipe, $question_images_pipe, $resource_pipe])->create(); + + /** @var ilObjTest|null $test_object */ + $test_object = null; + + $deserializer->addHandler( + 'general', + function (array $objects) use ($tt, $mapping, $parent_id, &$test_object): void { + $test_object = $this->importTest( + array_pop($objects), + $tt, + $mapping, + $parent_id + ); + } + ); + + $deserializer->addHandler( + 'settings', + function (array $settings) use ($tt, $mapping, &$test_object): void { + $this->importSettings( + $settings, + $tt, + $mapping, + $test_object + ); + } + ); + + $deserializer->addHandler( + 'questions', + function (array $normalized) use ($tt, $mapping, $context, &$test_object): void { + $this->importQuestions( + $normalized, + $tt, + $mapping, + $context, + $test_object + ); + } + ); + + $deserializer->addHandler( + 'skill_assignments', + function (array $assignments) use ($tt, &$context): void { + $result = $this->skill_importer->import( + $assignments, + UploadValidationStage::getInstallId($context), + $tt, + ); + $context = $context->with('skill_assignments', $result); + } + ); + + $deserializer->addHandler( + 'participants', + function (array $participants) use ($tt, $mapping): void { + $this->importParticipants( + $participants, + $tt, + $mapping, + ); + } + ); + + $deserializer->addHandler( + 'results', + function (array $results) use ($tt): void { + $this->importGroupedResults( + $results, + $tt, + ); + } + ); + + $this->log->info('Importing users and resources mappings...'); + $this->importMappings($mapping, $resource_pipe, $context); + $this->log->info('...Finished importing users and resources mappings'); + + $this->log->info('Importing test export file...'); + $deserializer->process(); + $this->log->info('...Finished importing test export file'); + + $this->log->info('Importing question images...'); + $this->questions_importer->importQuestionImages( + $test_object->getId(), + $mapping, + $context, + $question_images_pipe + ); + $this->log->info('...Finished importing question images'); + + $this->log->info("Finished importing test {$test_object->getTestId()} (Test ID), {$test_object->getId()} (Object ID)"); + return $context->with('test_obj_id', $test_object->getId())->with('test_ref_id', $test_object->getRefId()); + } + + /** + * Finalize the import after all dependencies have been imported. + * It will replace the old question ids with the new question ids in the test pages. + */ + public function finalize(ilImportMapping $mapping): void + { + $this->log->info('Finalizing test import...'); + $this->questions_importer->finalizeQuestionPages($mapping); + $this->log->info('...Finished finalizing test'); + } + + + private function importMappings( + ilImportMapping $mapping, + CollectResources $resource_pipe, + ImportContext $context + ): void { + $mappings = $context->get('mappings'); + if (count($mappings) < 2) { + throw new RuntimeException('Invalid mappings: Expected at least 2 mappings, got ' . count($mappings)); + } + [$user_mapping, $resource_mapping] = $mappings; + + $this->log->info('Importing user mappings...'); + $user_resolver = new UserImportResolver($this->database, $this->log); + $imported_users = $user_resolver->resolve( + UserIdentifiers::from($user_mapping['identifier']), + $user_mapping['mapping'] + ); + $user_resolver->store($imported_users, $mapping); + $this->log->info('...Finished importing user mappings'); + + $this->log->info('Importing resources and storing mappings...'); + $import_dir = dirname($context->get(UploadValidationStage::COMPONENT_IMPORT_FILE)) . '/expDir_1'; + foreach ($resource_mapping as $resource) { + $clean_id = str_replace(['-', '_'], '', $resource['id']); + $resource_path = "$import_dir/resources/{$clean_id}.{$resource['suffix']}"; + if (!file_exists($resource_path)) { + $this->log->error("Imported resource path does not exist: {$resource_path}, skipping"); + continue; + } + + $new_id = $this->irss->manage()->stream( + Streams::ofResource(fopen($resource_path, 'rb')), + new assFileUploadStakeholder(), + $resource['title'] + ); + $resource_pipe->storeMapping($resource['id'], $new_id); + $this->log->debug("Imported resource: {$resource_path} -> {$new_id->serialize()}"); + } + $this->log->info('...Finished importing resources and storing mappings'); + } + + private function importTest( + array $normalized, + Transformations $tt, + ilImportMapping $mapping, + ReferenceId $parent_id + ): ilObjTest { + $test_object = $tt->denormalize($normalized, ilObjTest::class); + $old_obj_id = $test_object->getId(); + $old_test_id = $test_object->getTestId(); + + $test_object->setTestId(-1); + $test_object->setTitle("{$test_object->getTitle()} (Imported)"); //TODO: Remove after testing + $new_obj_id = $test_object->create(); + $test_object->saveToDb(true); + $this->log->debug("Created new test object: {$old_test_id} -> {$test_object->getTestId()} (Test ID), {$old_obj_id} -> {$new_obj_id} (Object ID)"); + + $test_object->createReference(); + $test_object->putInTree($parent_id->toInt()); + $test_object->setPermissions($parent_id->toInt()); + $this->log->debug("Stored test object in tree: {$parent_id->toInt()} (Parent Ref) -> {$test_object->getRefId()} (Test Ref)"); + + $mapping->addMapping('components/ILIAS/Test', 'tst', (string) $old_test_id, (string) $test_object->getTestId()); + $mapping->addMapping('components/ILIAS/Test', 'object', (string) $old_obj_id, (string) $new_obj_id); + $mapping->addMapping('components/ILIAS/MetaData', 'md', "{$old_obj_id}:0:tst", "{$new_obj_id}:0:tst"); + + return $test_object; + } + + private function importSettings( + array $list, + Transformations $tt, + ilImportMapping $mapping, + ilObjTest $test_object + ): void { + $settings_id = $test_object->getMainSettings()->getId(); + + $main_settings = $tt->denormalize($list[0], MainSettings::class)->withId($settings_id); + $scoring_settings = $tt->denormalize($list[1], ScoreSettings::class)->withId($settings_id); + $mark_schema = $tt->denormalize($list[2], MarkSchema::class)->withTestId($test_object->getTestId()); + + if ($intro_page_id = $main_settings->getIntroductionSettings()->getIntroductionPageId()) { + $new_page_id = $this->createPage($intro_page_id, $test_object->getId(), $mapping); + $main_settings = $main_settings->withIntroductionSettings( + $main_settings->getIntroductionSettings()->withIntroductionPageId($new_page_id) + ); + $this->log->debug("Imported introduction page: {$intro_page_id} -> {$new_page_id}"); + } + + if ($concluding_page_id = $main_settings->getFinishingSettings()->getConcludingRemarksPageId()) { + $new_page_id = $this->createPage($concluding_page_id, $test_object->getId(), $mapping); + $main_settings = $main_settings->withFinishingSettings( + $main_settings->getFinishingSettings()->withConcludingRemarksPageId($new_page_id) + ); + $this->log->debug("Imported concluding remarks page: {$concluding_page_id} -> {$new_page_id}"); + } + + $test_object->getMainSettingsRepository()->store($main_settings); + $test_object->getScoreSettingsRepository()->store($scoring_settings); + $this->marks_repository->storeMarkSchema($mark_schema); + $this->log->debug("Imported test settings and mark schema: {$settings_id} (Settings ID)"); + } + + private function createPage(int $imported_page_id, int $parent_id, ilImportMapping $mapping): int + { + $page = new ilTestPage(); + $page->setParentId($parent_id); + $page->createPageWithNextId(); + + $mapping->addMapping( + 'components/ILIAS/COPage', + 'pg', + "tst:{$imported_page_id}", + "tst:{$page->getId()}" + ); + + return $page->getId(); + } + + private function importQuestions( + array $list, + Transformations $tt, + ilImportMapping $mapping, + ImportContext $context, + ilObjTest $test_object + ): void { + $selected_questions = QuestionSelectionStage::getSelectedQuestions($context); + + foreach ($list as $normalized) { + $question = $this->questions_importer->importQuestion($normalized, $tt, $mapping, $selected_questions); + + if ($question) { + $sequence = $tt->int($normalized['sequence']); + $test_object->questions[$sequence] = $question->getId(); + $this->log->debug("Stored question {$question->getId()} at sequence {$sequence} in test"); + } + } + + $test_object->saveQuestionsToDb(); + $this->log->debug('Saved test questions to database'); + } + + private function importParticipants(array $list, Transformations $tt, ilImportMapping $mapping): void + { + foreach ($list as $normalized) { + $old_active_id = $tt->denormalize($normalized['active_id'], Id::class)->getId(); + $new_active_id = $this->database->nextId('tst_active'); + $mapping->addMapping('components/ILIAS/Test', 'participant', (string) $old_active_id, (string) $new_active_id); + $this->log->debug("Stored participant/test session mapping: {$old_active_id} -> {$new_active_id}"); + + // TestID, UserID and ActiveID will be replaced by the mapping pipe + $participant = $tt->denormalize($normalized, Participant::class); + + $this->database->insert( + 'tst_active', + [ + 'active_id' => [ilDBConstants::T_INTEGER, $new_active_id], + 'user_fi' => [ilDBConstants::T_INTEGER, $participant->getUserId()], + 'test_fi' => [ilDBConstants::T_INTEGER, $participant->getTestId()], + 'anonymous_id' => [ilDBConstants::T_TEXT, $participant->getAnonymousId()], + 'tries' => [ilDBConstants::T_INTEGER, $participant->getAttempts()], + 'submitted' => [ilDBConstants::T_INTEGER, $participant->getSubmitted() ? 1 : 0], + 'last_finished_pass' => [ilDBConstants::T_INTEGER, $participant->getLastFinishedAttempt()], + 'last_started_pass' => [ilDBConstants::T_INTEGER, $participant->getLastStartedAttempt()], + 'importname' => [ilDBConstants::T_TEXT, "{$participant->getFirstname()} {$participant->getLastname()}"], + 'tstamp' => [ilDBConstants::T_INTEGER, time()], + 'submittimestamp' => [ilDBConstants::T_TIMESTAMP, $tt->nullableString($normalized['submittimestamp'])], + 'lastindex' => [ilDBConstants::T_INTEGER, $tt->nullableInt($normalized['lastindex'])], + 'objective_container' => [ilDBConstants::T_INTEGER, $tt->nullableInt($normalized['objective_container'])], + 'start_lock' => [ilDBConstants::T_TEXT, $tt->nullableString($normalized['start_lock'])], + ] + ); + $this->log->debug("Stored test session in database: {$new_active_id} (Active ID)"); + } + } + + /* + Results + */ + + private function importGroupedResults(array $list, Transformations $tt): void + { + foreach ($list as $set) { + foreach ($set as $name => $data) { + match($name) { + 'sequences' => $this->importTestSequences($data, $tt), + 'solutions' => $this->importSolutions($data, $tt), + 'results' => $this->importQuestionResults($data, $tt), + 'attempts' => $this->importAttemptResults($data, $tt), + 'test_result' => $this->importTestResult($data, $tt), + 'working_times' => $this->importWorkingTimes($data, $tt), + 'manual_feedback' => $this->importManualFeedback($data, $tt), + 'manual_scoring' => $this->importManualScoring($data, $tt), + default => $this->log->warning("Invalid result type: {$name}"), + }; + } + } + } + + private function importTestSequences(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // ActiveID and QuestionIDs will be replaced by the mapping pipe + $sequence = $tt->denormalize($normalized, ilTestSequence::class); + $sequence->saveToDb(); + $this->log->debug("Stored test sequence in database: {$sequence->getActiveId()} (Active ID), {$sequence->getPass()} (Pass)"); + } + } + + private function importSolutions(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // ActiveID and QuestionID will be replaced by the mapping pipe + $solution = $tt->denormalize($normalized, Solution::class); + + $next_id = $this->database->nextId('tst_solutions'); + $this->database->insert( + 'tst_solutions', + [ + 'solution_id' => [ilDBConstants::T_INTEGER, $next_id], + 'active_fi' => [ilDBConstants::T_INTEGER, $solution->active_id->getId()], + 'question_fi' => [ilDBConstants::T_INTEGER, $solution->question_id->getId()], + 'pass' => [ilDBConstants::T_INTEGER, $solution->attempt], + 'value1' => [ilDBConstants::T_TEXT, $solution->value1 !== null ? (string) $solution->value1 : null], + 'value2' => [ilDBConstants::T_TEXT, $solution->value2], + 'points' => [ilDBConstants::T_FLOAT, $solution->points], + 'step' => [ilDBConstants::T_INTEGER, $solution->step], + 'authorized' => [ilDBConstants::T_INTEGER, $solution->authorized ? 1 : 0], + 'tstamp' => [ilDBConstants::T_INTEGER, time()], + ] + ); + $this->log->debug("Stored solution in database: {$next_id}"); + } + } + + private function importQuestionResults(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // ActiveID and QuestionID will be replaced by the mapping pipe + $result = $tt->denormalize($normalized, QuestionResult::class); + + $next_id = $this->database->nextId('tst_test_result'); + $this->database->insert( + 'tst_test_result', + [ + 'test_result_id' => [ilDBConstants::T_INTEGER, $next_id], + 'active_fi' => [ilDBConstants::T_INTEGER, $result->active_id->getId()], + 'question_fi' => [ilDBConstants::T_INTEGER, $result->question_id->getId()], + 'pass' => [ilDBConstants::T_INTEGER, $result->attempt], + 'points' => [ilDBConstants::T_FLOAT, $result->points], + 'manual' => [ilDBConstants::T_INTEGER, $result->manual ? 1 : 0], + 'tstamp' => [ilDBConstants::T_INTEGER, time()], + 'answered' => [ilDBConstants::T_INTEGER, $result->answered ? 1 : 0], + 'step' => [ilDBConstants::T_INTEGER, $result->step], + ] + ); + $this->log->debug("Stored question result in database: {$next_id}"); + } + } + private function importAttemptResults(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // ActiveID will be replaced by the mapping pipe + $attempt = $tt->denormalize($normalized, AttemptResult::class); + + $this->database->insert( + 'tst_pass_result', + [ + 'active_fi' => [ilDBConstants::T_INTEGER, $attempt->getActiveId()], + 'pass' => [ilDBConstants::T_INTEGER, $attempt->getAttempt()], + 'maxpoints' => [ilDBConstants::T_FLOAT, $attempt->getMaxPoints()], + 'points' => [ilDBConstants::T_FLOAT, $attempt->getReachedPoints()], + 'questioncount' => [ilDBConstants::T_INTEGER, $attempt->getQuestionCount()], + 'answeredquestions' => [ilDBConstants::T_INTEGER, $attempt->getAnsweredQuestions()], + 'workingtime' => [ilDBConstants::T_INTEGER, $attempt->getWorkingTime()], + 'exam_id' => [ilDBConstants::T_TEXT, $attempt->getExamId()], + 'finalized_by' => [ilDBConstants::T_TEXT, $attempt->getFinalizedBy()], + 'tstamp' => [ilDBConstants::T_INTEGER, time()], + ] + ); + $this->log->debug("Stored attempt result in database: {$attempt->getActiveId()} (Active ID), {$attempt->getAttempt()} (Pass)"); + } + } + + private function importTestResult(?array $normalized, Transformations $tt): void + { + if ($normalized === null) { + $this->log->warning("Missing test result, skipping"); + return; + } + + // ActiveID will be replaced by the mapping pipe + $result = $tt->denormalize($normalized, ParticipantResult::class); + + $this->database->insert( + 'tst_result_cache', + [ + 'active_fi' => [ilDBConstants::T_INTEGER, $result->getActiveId()], + 'pass' => [ilDBConstants::T_INTEGER, $result->getAttempt()], + 'max_points' => [ilDBConstants::T_FLOAT, $result->getMaxPoints()], + 'reached_points' => [ilDBConstants::T_FLOAT, $result->getReachedPoints()], + 'mark_short' => [ilDBConstants::T_TEXT, $result->getMark()->getShortName()], + 'mark_official' => [ilDBConstants::T_TEXT, $result->getMark()->getOfficialName()], + 'passed' => [ilDBConstants::T_INTEGER, $result->isPassed() ? 1 : 0], + 'failed' => [ilDBConstants::T_INTEGER, $result->isFailed() ? 1 : 0], + 'tstamp' => [ilDBConstants::T_INTEGER, time()], + ] + ); + $this->log->debug("Stored test result in database: {$result->getActiveId()} (Active ID), {$result->getAttempt()} (Pass)"); + } + + private function importWorkingTimes(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // ActiveID will be replaced by the mapping pipe + $working_time = $tt->denormalize($normalized, WorkingTime::class); + + $next_id = $this->database->nextId('tst_times'); + $this->database->insert( + 'tst_times', + [ + 'times_id' => [ilDBConstants::T_INTEGER, $next_id], + 'active_fi' => [ilDBConstants::T_INTEGER, $working_time->active_id->getId()], + 'pass' => [ilDBConstants::T_INTEGER, $working_time->attempt], + 'started' => [ilDBConstants::T_TIMESTAMP, $working_time->started], + 'finished' => [ilDBConstants::T_TIMESTAMP, $working_time->finished], + 'tstamp' => [ilDBConstants::T_INTEGER, time()], + ] + ); + $this->log->debug("Stored working time in database: {$next_id}"); + } + } + + private function importManualFeedback(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // ActiveID, QuestionID and UserID will be replaced by the mapping pipe + $manual_feedback = $tt->denormalize($normalized, ManualFeedback::class); + + $next_id = $this->database->nextId('tst_manual_fb'); + $this->database->insert( + 'tst_manual_fb', + [ + 'manual_feedback_id' => [ilDBConstants::T_INTEGER, $next_id], + 'active_fi' => [ilDBConstants::T_INTEGER, $manual_feedback->active_id->getId()], + 'question_fi' => [ilDBConstants::T_INTEGER, $manual_feedback->question_id->getId()], + 'pass' => [ilDBConstants::T_INTEGER, $manual_feedback->attempt], + 'feedback' => [ilDBConstants::T_TEXT, $manual_feedback->feedback], + 'finalized_evaluation' => [ilDBConstants::T_INTEGER, $manual_feedback->finalized_evaluation ? 1 : 0], + 'finalized_timestamp' => [ilDBConstants::T_INTEGER, $manual_feedback->finalized_timestamp], + 'finalized_by_usr_id' => [ilDBConstants::T_INTEGER, $manual_feedback->finalized_by->getId()], + 'tstamp' => [ilDBConstants::T_INTEGER, time()], + ] + ); + $this->log->debug("Stored manual feedback in database: {$next_id}"); + } + } + + private function importManualScoring( + array $normalized, + Transformations $tt, + ): void { + // ActiveID will be replaced by the mapping pipe + $active_id = $tt->denormalize($normalized['active_id'], Id::class)->getId(); + + new TestManScoringDoneHelper()->setDone($active_id, $tt->bool($normalized['done'])); + $this->log->debug("Stored manual scoring in database: {$normalized['active_id']} (Active ID)"); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/TestExporter.php b/components/ILIAS/Test/src/ExportImport/TestExporter.php index a6dba3fe9adf..e44d3e4e3aec 100644 --- a/components/ILIAS/Test/src/ExportImport/TestExporter.php +++ b/components/ILIAS/Test/src/ExportImport/TestExporter.php @@ -226,7 +226,9 @@ public function write(ExportState $state): void } foreach ($resource_pipe->getResources() as $id => $resource) { - $file = "{$id}.{$resource->getCurrentRevision()->getInformation()->getSuffix()}"; + $clean_id = str_replace(['-', '_'], '', $id); + $file = "{$clean_id}.{$resource->getCurrentRevision()->getInformation()->getSuffix()}"; + $state->writer()->writeFilesByResourceId( $id, "{$export_dir}/resources/{$file}" diff --git a/components/ILIAS/Test/src/TestDIC.php b/components/ILIAS/Test/src/TestDIC.php index 35be08b991cd..a1f5cc00abb4 100755 --- a/components/ILIAS/Test/src/TestDIC.php +++ b/components/ILIAS/Test/src/TestDIC.php @@ -53,10 +53,15 @@ use ILIAS\Test\Results\Toplist\TestTopListRepository; use ILIAS\TestQuestionPool\ExportImport\Foundation\Bridge\StateHolder; use ILIAS\TestQuestionPool\ExportImport\Foundation\Builder; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportSessionRepository; +use ILIAS\TestQuestionPool\ExportImport\Import\QuestionsImporter; +use ILIAS\TestQuestionPool\ExportImport\Import\SkillAssignmentsImporter; +use ILIAS\Test\ExportImport\Import\TestImporter; use ILIAS\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 @@ -239,6 +244,9 @@ protected static function buildDIC(ILIASContainer $DIC): self $dic['exportimport.state_holder'] = static fn($c): StateHolder => new StateHolder(); + $dic['exportimport.session'] = static fn($c): ImportSessionRepository => + new ImportSessionRepository('test'); + $dic['exportimport.builder'] = static fn($c): Builder => new Builder( $DIC, @@ -250,13 +258,50 @@ protected static function buildDIC(ILIASContainer $DIC): self $c['exportimport.builder'], new DataFactory(), $DIC->database(), + $DIC->repositoryTree(), + $DIC->language(), + $c['logging.logger'], + $DIC['component.repository'], $DIC->resourceStorage(), $c['participant.repository'], $c['results.data.repository'], $c['questions.properties.repository'], + $c['question.general_properties.repository'], $DIC->taxonomy()->domain() ); + $dic['exportimport.skill_assignments_importer'] = static fn($c): SkillAssignmentsImporter => + new SkillAssignmentsImporter( + $DIC->skills()->internal()->repo()->getTreeRepo(), + $DIC->skills()->usage(), + (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(), + ilLoggerFactory::getLogger('exp')->getLogger(), + $DIC->fileConverters()->images(), + $DIC->filesystem() + ); + $dic['exportimport.importer'] = static fn($c): TestImporter => + new TestImporter( + $c['exportimport.builder'], + $DIC->ctrl(), + $DIC->database(), + ilLoggerFactory::getLogger('exp')->getLogger(), + $DIC->language(), + $DIC->resourceStorage(), + new DataFactory(), + $c['exportimport.questions_importer'], + $c['exportimport.skill_assignments_importer'], + $c['marks.repository'] + ); + $dic['questions.properties.repository'] = static fn($c): TestQuestionsRepository => new TestQuestionsDatabaseRepository( $DIC['ilDB'], diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/ResourceNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/ResourceNormalizer.php index 64e7e3dc2c89..2e8452a92dbb 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/ResourceNormalizer.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/ResourceNormalizer.php @@ -37,7 +37,7 @@ #[Normalizes(ResourceIdentification::class, StorableResource::class)] class ResourceNormalizer implements Normalizer { - private const string KEY_TYPE = '_type'; + private const string KEY_TYPE = 'type'; private const string TYPE_RID = 'rid'; private const string TYPE_RESOURCE = 'resource'; diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/CollectResources.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/CollectResources.php index ea9c3b4b4c8b..8c3c4a76ba74 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/CollectResources.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/CollectResources.php @@ -25,9 +25,11 @@ use ILIAS\ResourceStorage\Services as IRSS; use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Pipe; use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Pipes\NormalizeCarry; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Pipes\DenormalizeCarry; /** - * Pipe that collects all resources by their identification during normalization. + * Pipe that collects all resources by their identification during normalization. During denormalization, it will replace + * the resource ids with the mapped new resource ids. */ class CollectResources implements Pipe { @@ -36,11 +38,35 @@ class CollectResources implements Pipe */ private array $resources = []; + /** + * @var array $import_mapping + */ + private array $import_mapping = []; + public function __construct( private readonly IRSS $irss ) { } + /** + * Get all resources collected during normalization. + * + * @return array + */ + public function getResources(): array + { + return $this->resources; + } + + /** + * Store a mapping of an old resource id to a new resource id. + * This is used to replace the old resource ids with the new resource ids during denormalization. + */ + public function storeMapping(string $old_id, ResourceIdentification $new_id): void + { + $this->import_mapping[$old_id] = $new_id; + } + /** * @inheritDoc */ @@ -50,6 +76,12 @@ public function handle(mixed $passable, \Closure $next): mixed $this->handleNormalization($passable->value); } + if ($passable instanceof DenormalizeCarry && $passable->expected === ResourceIdentification::class) { + $passable->setResult( + $this->replaceRid($passable->result()) + ); + } + return $next($passable); } @@ -60,13 +92,8 @@ private function handleNormalization(ResourceIdentification $rid): void $this->resources[$rid->serialize()] = $resource; } - /** - * Get all resources collected during normalization. - * - * @return array - */ - public function getResources(): array + private function replaceRid(ResourceIdentification $rid): ResourceIdentification { - return $this->resources; + return $this->import_mapping[$rid->serialize()] ?? $rid; } } From d9501296ab74f03961e75805dc2e896f7b22b081 Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Wed, 29 Apr 2026 16:12:42 +0200 Subject: [PATCH 31/43] feat(test): add missing participant relations export/import --- .../Envelopes/AdditionalWorkingTime.php | 72 +++++++++++++++++++ .../src/ExportImport/Import/TestImporter.php | 58 +++++++++++++-- .../Normalizer/ParticipantNormalizer.php | 8 ++- .../Test/src/ExportImport/TestCollector.php | 23 +++++- .../Test/src/ExportImport/TestExporter.php | 30 ++++++-- 5 files changed, 178 insertions(+), 13 deletions(-) create mode 100644 components/ILIAS/Test/src/ExportImport/Envelopes/AdditionalWorkingTime.php diff --git a/components/ILIAS/Test/src/ExportImport/Envelopes/AdditionalWorkingTime.php b/components/ILIAS/Test/src/ExportImport/Envelopes/AdditionalWorkingTime.php new file mode 100644 index 000000000000..bb55f0f092fe --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Envelopes/AdditionalWorkingTime.php @@ -0,0 +1,72 @@ + $tt->normalize($this->user_id), + 'test_id' => $tt->normalize($this->test_id), + 'time' => $this->time, + 'timestamp' => $this->timestamp, + ]; + } + + /** + * @inheritDoc + */ + public static function fromArray(array $value, Transformations $tt): static + { + return new self( + $tt->denormalize($value['user_id'], Id::class), + $tt->denormalize($value['test_id'], Id::class), + $tt->int($value['time']), + $tt->int($value['timestamp']), + ); + } + + public static function fromRow(array $row): static + { + return new self( + new Id($row['user_fi'], 'user'), + new Id($row['test_fi'], 'tst'), + (int) $row['additionaltime'], + (int) $row['tstamp'], + ); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Import/TestImporter.php b/components/ILIAS/Test/src/ExportImport/Import/TestImporter.php index 2e16ba56cd50..784927cd67ef 100644 --- a/components/ILIAS/Test/src/ExportImport/Import/TestImporter.php +++ b/components/ILIAS/Test/src/ExportImport/Import/TestImporter.php @@ -29,6 +29,7 @@ use ILIAS\Filesystem\Stream\Streams; use ILIAS\Language\Language; use ILIAS\ResourceStorage\Services as IRSS; +use ILIAS\Test\ExportImport\Envelopes\AdditionalWorkingTime; use ILIAS\Test\ExportImport\Envelopes\ManualFeedback; use ILIAS\Test\ExportImport\Envelopes\QuestionResult; use ILIAS\Test\ExportImport\Envelopes\Solution; @@ -174,6 +175,16 @@ function (array $results) use ($tt): void { } ); + $deserializer->addHandler( + 'additional_working_times', + function (array $times) use ($tt): void { + $this->importAdditionalWorkingTimes( + $times, + $tt, + ); + } + ); + $this->log->info('Importing users and resources mappings...'); $this->importMappings($mapping, $resource_pipe, $context); $this->log->info('...Finished importing users and resources mappings'); @@ -352,6 +363,11 @@ private function importQuestions( private function importParticipants(array $list, Transformations $tt, ilImportMapping $mapping): void { foreach ($list as $normalized) { + if ($normalized['active_id'] === null) { + $this->importInvitedParticipant($normalized, $tt); + continue; + } + $old_active_id = $tt->denormalize($normalized['active_id'], Id::class)->getId(); $new_active_id = $this->database->nextId('tst_active'); $mapping->addMapping('components/ILIAS/Test', 'participant', (string) $old_active_id, (string) $new_active_id); @@ -383,6 +399,21 @@ private function importParticipants(array $list, Transformations $tt, ilImportMa } } + private function importInvitedParticipant(array $normalized, Transformations $tt): void + { + // TestID and UserID will be replaced by the mapping pipe + $participant = $tt->denormalize($normalized, Participant::class); + + $this->database->insert('tst_invited_user', [ + 'test_fi' => [ilDBConstants::T_INTEGER, $participant->getTestId()], + 'user_fi' => [ilDBConstants::T_INTEGER, $participant->getUserId()], + 'ip_range_from' => [ilDBConstants::T_TEXT, $participant->getClientIpFrom()], + 'ip_range_to' => [ilDBConstants::T_TEXT, $participant->getClientIpTo()], + 'tstamp' => [ilDBConstants::T_INTEGER, $participant->getInvitationDate()], + ]); + $this->log->debug("Stored invited participant in database: {$participant->getUserId()} (User ID), {$participant->getTestId()} (Test ID)"); + } + /* Results */ @@ -565,14 +596,31 @@ private function importManualFeedback(array $list, Transformations $tt): void } } - private function importManualScoring( - array $normalized, - Transformations $tt, - ): void { + private function importManualScoring(array $normalized, Transformations $tt): void + { // ActiveID will be replaced by the mapping pipe $active_id = $tt->denormalize($normalized['active_id'], Id::class)->getId(); new TestManScoringDoneHelper()->setDone($active_id, $tt->bool($normalized['done'])); - $this->log->debug("Stored manual scoring in database: {$normalized['active_id']} (Active ID)"); + $this->log->debug("Stored manual scoring in database: {$active_id} (Active ID)"); + } + + private function importAdditionalWorkingTimes(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // UserID and TestID will be replaced by the mapping pipe + $time = $tt->denormalize($normalized, AdditionalWorkingTime::class); + + $this->database->insert( + 'tst_addtime', + [ + 'additionaltime' => [ilDBConstants::T_INTEGER, $time->time], + 'user_fi' => [ilDBConstants::T_INTEGER, $time->user_id->getId()], + 'test_fi' => [ilDBConstants::T_INTEGER, $time->test_id->getId()], + 'tstamp' => [ilDBConstants::T_TIMESTAMP, $time->timestamp], + ] + ); + $this->log->debug("Stored additional working time in database: {$time->user_id->getId()} (User ID)"); + } } } diff --git a/components/ILIAS/Test/src/ExportImport/Normalizer/ParticipantNormalizer.php b/components/ILIAS/Test/src/ExportImport/Normalizer/ParticipantNormalizer.php index 3927a248f290..c3e11a9f982b 100644 --- a/components/ILIAS/Test/src/ExportImport/Normalizer/ParticipantNormalizer.php +++ b/components/ILIAS/Test/src/ExportImport/Normalizer/ParticipantNormalizer.php @@ -50,7 +50,9 @@ public function normalize($value): array|float|bool|int|string|null return [ 'user_id' => $this->tt->normalize(new Id($value->getUserId(), 'user')), - 'active_id' => $this->tt->normalize(new Id($value->getActiveId(), 'participant')), + '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(), @@ -84,7 +86,9 @@ public function denormalize(array|float|bool|int|string|null $value, string $typ return new Participant( $this->tt->denormalize($value['user_id'], Id::class)->getId(), - $this->tt->denormalize($value['active_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']), diff --git a/components/ILIAS/Test/src/ExportImport/TestCollector.php b/components/ILIAS/Test/src/ExportImport/TestCollector.php index 94add6193096..3d59f2c50d30 100644 --- a/components/ILIAS/Test/src/ExportImport/TestCollector.php +++ b/components/ILIAS/Test/src/ExportImport/TestCollector.php @@ -26,6 +26,7 @@ use ilDBInterface; use ILIAS\Data\ObjectId; use ILIAS\Language\Language; +use ILIAS\Test\ExportImport\Envelopes\AdditionalWorkingTime; use ILIAS\Test\ExportImport\Envelopes\ManualFeedback; use ILIAS\Test\ExportImport\Envelopes\QuestionResult; use ILIAS\Test\ExportImport\Envelopes\Solution; @@ -246,7 +247,9 @@ public function getParticipantsIds(): array if ($this->participants === null) { $this->participants = []; foreach ($this->getParticipants() as $participant) { - $this->participants[] = $participant->getActiveId(); + if ($participant->getActiveId() !== null) { + $this->participants[] = $participant->getActiveId(); + } } } return $this->participants; @@ -372,4 +375,22 @@ public function getSequences(int $participant_id, array $attempts): array } 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) + ); + } } diff --git a/components/ILIAS/Test/src/ExportImport/TestExporter.php b/components/ILIAS/Test/src/ExportImport/TestExporter.php index e44d3e4e3aec..53fdc761ce93 100644 --- a/components/ILIAS/Test/src/ExportImport/TestExporter.php +++ b/components/ILIAS/Test/src/ExportImport/TestExporter.php @@ -160,6 +160,14 @@ public function process(ExportState $state): void $state ) ); + $state->serializer()->group( + 'additional_working_times', + fn() => $this->exportAdditionalWorkingTimes( + $state->collector(), + $state->transformations(), + $state->serializer(), + ) + ); $state->serializer()->group( 'skill_assignments', fn() => $this->exportSkillAssignments( @@ -339,10 +347,12 @@ private function exportParticipants( $additional_data = $collector->getAdditionalParticipantData($collector->getParticipantsIds()); foreach ($collector->getParticipants() as $participant) { - $serializer->append('participant', [ - ...$transformations->normalize($participant), - ...$additional_data[$participant->getActiveId()], - ]); + $normalized = $transformations->normalize($participant); + if ($participant->getActiveId() !== null) { + $normalized = array_merge($normalized, $additional_data[$participant->getActiveId()]); + } + + $serializer->append('participant', $normalized); } } @@ -353,7 +363,7 @@ private function exportResults( ): void { foreach ($collector->getParticipantsIds() as $participant_id) { $serializer->append( - 'results', + 'set', $transformations->normalize( $collector->getResults($participant_id) ) @@ -361,6 +371,16 @@ private function exportResults( } } + 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, From 3b57c9e541cfb12e8cfda9bbd8d04f3153f5d741 Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Wed, 29 Apr 2026 16:50:55 +0200 Subject: [PATCH 32/43] refactor(qpl): introduce export/import logging --- .../src/ExportImport/Import/TestImporter.php | 4 +- .../Test/src/ExportImport/TestExporter.php | 5 ++- components/ILIAS/Test/src/TestDIC.php | 10 +++-- .../Foundation/Bridge/XmlExporterBridge.php | 20 ++++++++- .../Normalizing/Pipes/CollectResources.php | 13 +++++- .../Normalizing/Pipes/IdMappingPipe.php | 6 ++- .../Import/QuestionPoolImporter.php | 4 +- .../src/ExportImport/LoggingProvider.php | 41 +++++++++++++++++++ .../TestQuestionPool/src/QuestionPoolDIC.php | 6 ++- 9 files changed, 97 insertions(+), 12 deletions(-) create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/LoggingProvider.php diff --git a/components/ILIAS/Test/src/ExportImport/Import/TestImporter.php b/components/ILIAS/Test/src/ExportImport/Import/TestImporter.php index 784927cd67ef..b837d9b7e6e1 100644 --- a/components/ILIAS/Test/src/ExportImport/Import/TestImporter.php +++ b/components/ILIAS/Test/src/ExportImport/Import/TestImporter.php @@ -96,8 +96,8 @@ public function import( ReferenceId $parent_id, ImportContext $context ): ImportContext { - $resource_pipe = new CollectResources($this->irss); - $id_mapping_pipe = new IdMappingPipe($mapping, 'components/ILIAS/Test'); + $resource_pipe = new CollectResources($this->irss, $this->log); + $id_mapping_pipe = new IdMappingPipe($mapping, 'components/ILIAS/Test', $this->log); $question_images_pipe = new CollectQuestionImages(new UUIDFactory(), $this->data_factory->objId(0)); $tt = $this->builder->withAdditionalPipes(append: [$id_mapping_pipe, $question_images_pipe, $resource_pipe])->create(); diff --git a/components/ILIAS/Test/src/ExportImport/TestExporter.php b/components/ILIAS/Test/src/ExportImport/TestExporter.php index 53fdc761ce93..7e4c519ce955 100644 --- a/components/ILIAS/Test/src/ExportImport/TestExporter.php +++ b/components/ILIAS/Test/src/ExportImport/TestExporter.php @@ -100,7 +100,10 @@ public function prepare(ExportState $state): void new UUIDFactory(), $object_id ), - new CollectResources($this->irss), + new CollectResources( + $this->irss, + $this->logger + ), ]) ->create(); diff --git a/components/ILIAS/Test/src/TestDIC.php b/components/ILIAS/Test/src/TestDIC.php index a1f5cc00abb4..ce60c933344d 100755 --- a/components/ILIAS/Test/src/TestDIC.php +++ b/components/ILIAS/Test/src/TestDIC.php @@ -57,6 +57,7 @@ use ILIAS\TestQuestionPool\ExportImport\Import\QuestionsImporter; use ILIAS\TestQuestionPool\ExportImport\Import\SkillAssignmentsImporter; use ILIAS\Test\ExportImport\Import\TestImporter; +use ILIAS\TestQuestionPool\ExportImport\LoggingProvider; use ILIAS\TestQuestionPool\Questions\GeneralQuestionPropertiesRepository; use ILIAS\TestQuestionPool\RequestDataCollector as QPLRequestDataCollector; use ILIAS\Data\Factory as DataFactory; @@ -241,11 +242,14 @@ protected static function buildDIC(ILIASContainer $DIC): self $DIC['ilDB'] ); + $dic['exportimport.logging'] = static fn($c): LoggingProvider => + new LoggingProvider(); + $dic['exportimport.state_holder'] = static fn($c): StateHolder => new StateHolder(); $dic['exportimport.session'] = static fn($c): ImportSessionRepository => - new ImportSessionRepository('test'); + new ImportSessionRepository('tst'); $dic['exportimport.builder'] = static fn($c): Builder => new Builder( @@ -284,7 +288,7 @@ protected static function buildDIC(ILIASContainer $DIC): self $DIC->ctrl(), $DIC->database(), $DIC->language(), - ilLoggerFactory::getLogger('exp')->getLogger(), + $c['exportimport.logging'](), $DIC->fileConverters()->images(), $DIC->filesystem() ); @@ -293,7 +297,7 @@ protected static function buildDIC(ILIASContainer $DIC): self $c['exportimport.builder'], $DIC->ctrl(), $DIC->database(), - ilLoggerFactory::getLogger('exp')->getLogger(), + $c['exportimport.logging'](), $DIC->language(), $DIC->resourceStorage(), new DataFactory(), diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/XmlExporterBridge.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/XmlExporterBridge.php index f6767b577d61..546b7c0f0828 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/XmlExporterBridge.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/XmlExporterBridge.php @@ -52,13 +52,17 @@ private function processExport(): ExportState $state = $this->state_holder->get(); if ($state->getStep()->value < ExportStep::PREPARE->value) { + $this->logger->debug("Preparing export for component {$state->target()->getComponent()}..."); $state->setLogger($this->logger); $this->exporter->prepare($state); + $this->logger->debug("...Finished preparing export"); } if ($state->getStep()->value < ExportStep::PROCESS->value) { + $this->logger->debug("Processing export for component {$state->target()->getComponent()}..."); $state->setSerializer(new SimpleXMLSerializer()->open('memory')); $this->exporter->process($state); + $this->logger->debug("...Finished processing export"); } $this->state_holder->set($state); @@ -77,7 +81,9 @@ private function finalizeExport(): ExportState $state->setPathInfo($this->createPathInfo()); $state->setWriter($this->exp->getExportWriter()); + $this->logger->debug("Writing export for component {$state->target()->getComponent()}..."); $this->exporter->write($state); + $this->logger->debug("...Finished writing export"); } $this->state_holder->set($state); @@ -98,11 +104,23 @@ private function initExportState( ->withClassname(static::class) ->withComponent($component); - return $this->state_holder->create( + $state = $this->state_holder->create( $target, $this->export_handler->consumer()->exportConfig()->collection(), $option ); + + $this->logger->debug(sprintf( + "Export state created for component %s with release %s, type %s, class %s, object ids %s, option %s", + $target->getComponent(), + $target->getTargetRelease(), + $target->getType(), + $target->getClassname(), + implode(', ', $object_ids), + $option + )); + + return $state; } private function createPathInfo(): ExportPath diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/CollectResources.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/CollectResources.php index 8c3c4a76ba74..87529d81b575 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/CollectResources.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/CollectResources.php @@ -26,6 +26,7 @@ use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Pipe; use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Pipes\NormalizeCarry; use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Pipes\DenormalizeCarry; +use Psr\Log\LoggerInterface; /** * Pipe that collects all resources by their identification during normalization. During denormalization, it will replace @@ -44,7 +45,8 @@ class CollectResources implements Pipe private array $import_mapping = []; public function __construct( - private readonly IRSS $irss + private readonly IRSS $irss, + private readonly LoggerInterface $log ) { } @@ -94,6 +96,13 @@ private function handleNormalization(ResourceIdentification $rid): void private function replaceRid(ResourceIdentification $rid): ResourceIdentification { - return $this->import_mapping[$rid->serialize()] ?? $rid; + $id = $rid->serialize(); + if (isset($this->import_mapping[$id])) { + $this->log->debug("Replaced resource id {$id} with {$this->import_mapping[$id]->serialize()}"); + return $this->import_mapping[$id]; + } else { + $this->log->warning("Unresolved resource id {$id}"); + return $rid; + } } } diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/IdMappingPipe.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/IdMappingPipe.php index 138c4d6533bc..5645ed580eef 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/IdMappingPipe.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Pipes/IdMappingPipe.php @@ -24,6 +24,7 @@ use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Envelopes\Id; use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\NormalizingException; use ilImportMapping; +use Psr\Log\LoggerInterface; /** * Resolves imported object references by mapping exported ids to their newly created local ids via `ilImportMapping`. @@ -39,7 +40,8 @@ class IdMappingPipe implements Pipe public function __construct( private readonly ilImportMapping $mapping, - private readonly string $component + private readonly string $component, + private readonly LoggerInterface $log ) { } @@ -61,8 +63,10 @@ public function handle(mixed $passable, \Closure $next): mixed } $passable->setResult(new Id($new_id, $envelope->getObject())); + $this->log->debug("Replaced id {$envelope->getObject()}:{$envelope->getId()} with {$new_id}"); } else { $this->unresolved[] = $envelope; + $this->log->warning("Unresolved id {$envelope->getObject()}:{$envelope->getId()}"); } return $next($passable); diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionPoolImporter.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionPoolImporter.php index 3b9761cc446d..0be8d25cd088 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionPoolImporter.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionPoolImporter.php @@ -36,6 +36,7 @@ use ILIAS\TestQuestionPool\ExportImport\Pipes\CollectQuestionImages; use ilImportMapping; use ilObjQuestionPool; +use Psr\Log\LoggerInterface; /** * Orchestrates the import of a question pool. It uses the Builder to create a pipeline of transformations that are used @@ -49,6 +50,7 @@ public function __construct( private readonly ilCtrl $ctrl, private readonly ilDBInterface $database, private readonly Language $language, + private readonly LoggerInterface $log, private readonly DataFactory $data_factory, private readonly QuestionsImporter $questions_importer, private readonly SkillAssignmentsImporter $skill_importer, @@ -65,7 +67,7 @@ public function import( ReferenceId $parent_id, ImportContext $context ): ImportContext { - $id_mapping_pipe = new IdMappingPipe($mapping, 'components/ILIAS/TestQuestionPool'); + $id_mapping_pipe = new IdMappingPipe($mapping, 'components/ILIAS/TestQuestionPool', $this->log); $images_pipe = new CollectQuestionImages(new Factory(), $this->data_factory->objId(0)); $tt = $this->builder->withAdditionalPipes(append: [$id_mapping_pipe, $images_pipe])->create(); diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/LoggingProvider.php b/components/ILIAS/TestQuestionPool/src/ExportImport/LoggingProvider.php new file mode 100644 index 000000000000..98817b30cfc7 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/LoggingProvider.php @@ -0,0 +1,41 @@ +getLogger(); + } + + public function __invoke() { + return $this->getLogger(); + } +} \ No newline at end of file diff --git a/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php b/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php index 9a23022146f8..7240f58a9597 100755 --- a/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php +++ b/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php @@ -28,6 +28,7 @@ use ILIAS\TestQuestionPool\ExportImport\Import\QuestionPoolImporter; use ILIAS\TestQuestionPool\ExportImport\Import\QuestionsImporter; use ILIAS\TestQuestionPool\ExportImport\Import\SkillAssignmentsImporter; +use ILIAS\TestQuestionPool\ExportImport\LoggingProvider; use ilLoggerFactory; use Pimple\Container as PimpleContainer; use ILIAS\DI\Container as ILIASContainer; @@ -76,6 +77,8 @@ protected static function buildDIC(ILIASContainer $DIC): self $dic['global_test_settings'] = static fn($c): GlobalTestSettings => (new GlobalTestSettingsRepository($DIC['ilSetting'], new \ilSetting('assessment')))->getGlobalSettings(); + $dic['exportimport.logging'] = static fn($c): LoggingProvider => + new LoggingProvider(); $dic['exportimport.builder'] = static fn($c): Builder => new Builder( $DIC, @@ -107,7 +110,7 @@ protected static function buildDIC(ILIASContainer $DIC): self $DIC->ctrl(), $DIC->database(), $DIC->language(), - ilLoggerFactory::getLogger('exp')->getLogger(), + $c['exportimport.logging'](), $DIC->fileConverters()->images(), $DIC->filesystem() ); @@ -117,6 +120,7 @@ protected static function buildDIC(ILIASContainer $DIC): self $DIC->ctrl(), $DIC->database(), $DIC->language(), + $c['exportimport.logging'](), new DataFactory(), $c['exportimport.questions_importer'], $c['exportimport.skill_assignments_importer'] From 3ca3a2fd2ae100738e201e17ec78dc438f0a0f09 Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Thu, 30 Apr 2026 11:28:09 +0200 Subject: [PATCH 33/43] fix(foundation): improve legacy normalizer resolving --- .../src/ExportImport/Foundation/Builder.php | 67 +++++++++++++++++-- .../Attributes/NormalizesLegacy.php | 12 ++-- .../Setup/NormalizerArtifactObjective.php | 4 +- 3 files changed, 74 insertions(+), 9 deletions(-) diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Builder.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Builder.php index ddab7b63f94c..07cf40ee86a4 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Builder.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Builder.php @@ -110,8 +110,6 @@ public function create(): TransformationsContract } if ($this->legacy_version !== null) { - // TODO: Implement semver comparison here? Use ILIAS\Data\Version class? - $registry = $this->buildRegistry($object, $this->legacy_version); $pipeline->pipe(new NormalizingPipe($registry)); $pipeline->pipe(new DenormalizingPipe($registry)); @@ -142,19 +140,80 @@ private function buildRegistry(Transformations $object, string $version = Normal $registry = new Registry(); foreach ($type_map as $type => $normalizer_classes) { - if (!isset($normalizer_classes[$version]) || $registry->hasNormalizer($type)) { + if ($registry->hasNormalizer($type)) { + continue; + } + + $resolved_key = $this->resolveVersionKey($version, $normalizer_classes); + if (!isset($normalizer_classes[$resolved_key])) { continue; } $registry->registerNormalizer( $type, - fn() => $this->createInstance($normalizer_classes[$version], $object) + fn() => $this->createInstance($normalizer_classes[$resolved_key], $object) ); } return $registry; } + /** + * Resolve the best matching version key from the available normalizer versions. + * + * Priority: exact match > nearest concrete version before requested > wildcard (major.*) > default. + * Uses version_compare for comparing ILIAS versions (major.minor, optional suffixes like alpha). + * + * @param array $available_versions + */ + private function resolveVersionKey(string $requested_version, array $available_versions): string + { + if (isset($available_versions[$requested_version])) { + return $requested_version; + } + + if ($requested_version === NormalizerArtifactObjective::DEFAULT_KEY) { + return $requested_version; + } + + $requested_major = strstr($requested_version, '.', true) ?: $requested_version; + + $best_concrete = null; + foreach (array_keys($available_versions) as $version) { + if ($version === NormalizerArtifactObjective::DEFAULT_KEY + || $this->isWildcardVersion($version)) { + continue; + } + + $version_major = strstr($version, '.', true) ?: $version; + if ($version_major !== $requested_major) { + continue; + } + + if (version_compare($version, $requested_version, '<=') + && ($best_concrete === null || version_compare($version, $best_concrete, '>'))) { + $best_concrete = $version; + } + } + + if ($best_concrete !== null) { + return $best_concrete; + } + + foreach (["{$requested_major}.*", $requested_major] as $wildcard) { + if (isset($available_versions[$wildcard])) { + return $wildcard; + } + } + + return NormalizerArtifactObjective::DEFAULT_KEY; + } + + private function isWildcardVersion(string $version): bool + { + return !str_contains($version, '.') || str_ends_with($version, '.*'); + } + /* Factory & Autowiring */ diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Attributes/NormalizesLegacy.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Attributes/NormalizesLegacy.php index c1ddb635de5f..f7343aa82a4e 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Attributes/NormalizesLegacy.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Attributes/NormalizesLegacy.php @@ -29,16 +29,20 @@ #[Attribute(Attribute::TARGET_CLASS)] final readonly class NormalizesLegacy { + /** @var list */ + public array $versions; + + /** @var list */ + public array $types; + /** * @param class-string ...$types */ public function __construct( - public string $version, + string|array $version, string ...$types ) { + $this->versions = is_array($version) ? $version : [$version]; $this->types = $types; } - - /** @var list */ - public array $types; } diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Setup/NormalizerArtifactObjective.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Setup/NormalizerArtifactObjective.php index 25055088ffb5..22c220a0ca28 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Setup/NormalizerArtifactObjective.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Setup/NormalizerArtifactObjective.php @@ -70,7 +70,9 @@ public function build(): Artifact foreach ($attrs as $attr) { $instance = $attr->newInstance(); foreach ($instance->types as $type) { - $type_map[$type][$instance->version] = $class_name; + foreach ($instance->versions as $version) { + $type_map[$type][$version] = $class_name; + } } } } From 45f180e4127c0e64f0dedc766361792522a6bbb6 Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Mon, 4 May 2026 09:18:58 +0200 Subject: [PATCH 34/43] refactor(foundation): introduce XMLFileDeserializer --- .../Test/classes/class.ilTestImporter.php | 4 +- .../src/ExportImport/Import/PersistStage.php | 4 +- .../class.ilTestQuestionPoolImporter.php | 4 +- ...erializer.php => XMLDeserializerTrait.php} | 53 ++------------ .../Serializing/XMLFileDeserializer.php | 72 +++++++++++++++++++ .../Serializing/XMLMemoryDeserializer.php | 64 +++++++++++++++++ .../Import/QuestionSelectionStage.php | 6 +- 7 files changed, 149 insertions(+), 58 deletions(-) rename components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/{SimpleXMLDeserializer.php => XMLDeserializerTrait.php} (73%) create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/XMLFileDeserializer.php create mode 100644 components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/XMLMemoryDeserializer.php diff --git a/components/ILIAS/Test/classes/class.ilTestImporter.php b/components/ILIAS/Test/classes/class.ilTestImporter.php index 7ee0c283a3f0..cbbc2732d66b 100755 --- a/components/ILIAS/Test/classes/class.ilTestImporter.php +++ b/components/ILIAS/Test/classes/class.ilTestImporter.php @@ -21,7 +21,7 @@ use ILIAS\Data\ReferenceId; use ILIAS\Test\ExportImport\Import\TestImporter; use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportSessionRepository; -use ILIAS\TestQuestionPool\ExportImport\Foundation\Serializing\SimpleXMLDeserializer; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Serializing\XMLMemoryDeserializer; use ILIAS\TestQuestionPool\ExportImport\Import\DetectLegacyImportStage; use ILIAS\Test\TestDIC; @@ -70,7 +70,7 @@ public function importXmlRepresentation( } $result = $this->importer->import( - new SimpleXMLDeserializer()->open($a_xml), + new XMLMemoryDeserializer()->open($a_xml), $a_mapping, new ReferenceId($a_mapping->getTargetId()), $context, diff --git a/components/ILIAS/Test/src/ExportImport/Import/PersistStage.php b/components/ILIAS/Test/src/ExportImport/Import/PersistStage.php index 23f3b2f7f3c1..fd054b7a89b7 100644 --- a/components/ILIAS/Test/src/ExportImport/Import/PersistStage.php +++ b/components/ILIAS/Test/src/ExportImport/Import/PersistStage.php @@ -25,7 +25,7 @@ use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportContext; use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportSessionRepository; use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\StageResult; -use ILIAS\TestQuestionPool\ExportImport\Foundation\Serializing\SimpleXMLDeserializer; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Serializing\XMLFileDeserializer; use ILIAS\TestQuestionPool\ExportImport\Import\UploadValidationStage; use ilImport; @@ -66,7 +66,7 @@ public function process(ImportContext $context): StageResult return StageResult::error($context, $this->lng->txt('obj_import_file_error')); } - $deserializer = new SimpleXMLDeserializer()->open(file_get_contents($mappings_file)); + $deserializer = new XMLFileDeserializer()->open($mappings_file); $deserializer->addHandler('mappings', function (array $mappings) use (&$context) { $context = $context->with('mappings', $mappings); }); diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php index 2dee6dc85e14..1c6a2a528a4d 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolImporter.php @@ -20,7 +20,7 @@ use ILIAS\Data\ReferenceId; use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportSessionRepository; -use ILIAS\TestQuestionPool\ExportImport\Foundation\Serializing\SimpleXMLDeserializer; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Serializing\XMLMemoryDeserializer; use ILIAS\TestQuestionPool\ExportImport\Import\DetectLegacyImportStage; use ILIAS\TestQuestionPool\ExportImport\Import\QuestionPoolImporter; use ILIAS\TestQuestionPool\QuestionPoolDIC; @@ -66,7 +66,7 @@ public function importXmlRepresentation( } $result = $this->importer->import( - new SimpleXMLDeserializer()->open($a_xml), + new XMLMemoryDeserializer()->open($a_xml), $a_mapping, new ReferenceId($a_mapping->getTargetId()), $context, diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/SimpleXMLDeserializer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/XMLDeserializerTrait.php similarity index 73% rename from components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/SimpleXMLDeserializer.php rename to components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/XMLDeserializerTrait.php index 7e93b5eac22b..e4ab81b7bff1 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/SimpleXMLDeserializer.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/XMLDeserializerTrait.php @@ -20,48 +20,21 @@ namespace ILIAS\TestQuestionPool\ExportImport\Foundation\Serializing; -use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Deserializer; - /** - * Simple XML deserializer that reads an XML string in chunks and invokes registered handlers per group. + * Shared XMLWriter-based parsing logic used by both in-memory and file-based XML serializers. */ -class SimpleXMLDeserializer implements Deserializer +trait XMLDeserializerTrait { - private string $xml = ''; - - /** @var array $handler */ + /** @var array */ private array $handler = []; - /** - * @inheritDoc - */ - public function open(string $path): static - { - $clone = clone $this; - $clone->xml = $path; - return $clone; - } - - /** - * @inheritDoc - */ public function addHandler(string $group, callable $handler): void { $this->handler[$group] = $handler; } - /** - * @inheritDoc - */ - public function process(): void + private function processReader(\XMLReader $reader): void { - $reader = new \XMLReader(); - $xml = $this->prepareXmlInput($this->xml); - - if (!$reader->XML($xml, null, LIBXML_NONET)) { - throw new \RuntimeException('Unable to read XML input.'); - } - while ($reader->read()) { $node_name = $this->kebabToSnake($reader->name); @@ -85,7 +58,6 @@ private function readGroup(\XMLReader $reader): array return []; } - // Track the current group boundary so we can stop exactly at its closing tag $group_depth = $reader->depth; $group_name = $reader->name; $group_data = []; @@ -103,7 +75,6 @@ private function readGroup(\XMLReader $reader): array $reader->nodeType !== \XMLReader::ELEMENT || $reader->depth !== $group_depth + 1 ) { - // Only direct children belong to this group entry list continue; } @@ -117,7 +88,6 @@ private function readElementValue(\XMLReader $reader): mixed { $is_marked_empty_array = $this->isMarkedEmptyArray($reader); - // Keep empty elements as empty strings to preserve the legacy XML shape if ($reader->isEmptyElement) { if ($is_marked_empty_array) { return []; @@ -143,17 +113,14 @@ private function readElementValue(\XMLReader $reader): mixed $reader->nodeType === \XMLReader::ELEMENT && $reader->depth === $element_depth + 1 ) { - // Direct child elements are deserialized recursively $child_key = $this->resolveElementKey($reader); $child_value = $this->readElementValue($reader); if ($child_key === null) { - // Unkeyed nodes are appended as list entries $children[] = $child_value; continue; } - // Repeated keys are normalized to list values via appendValue() $this->appendValue($children, $child_key, $child_value); continue; } @@ -170,13 +137,11 @@ private function readElementValue(\XMLReader $reader): mixed true ) ) { - // Keep raw text content and decode scalar tokens after traversal $text_content .= $reader->value; } } if ($children !== []) { - // Structured child data has precedence over accumulated text content return $children; } @@ -194,24 +159,20 @@ private function isMarkedEmptyArray(\XMLReader $reader): bool private function resolveElementKey(\XMLReader $reader): ?string { - // Regular element names map directly to associative keys if ($reader->name !== 'item') { return $this->kebabToSnake($reader->name); } - // nodes are list-like unless they define an explicit key attribute $raw_key = $reader->getAttribute('key'); if ($raw_key === null || $raw_key === '') { return null; } - // Normalize kebab-case keys to snake_case for downstream consumers return $this->kebabToSnake($raw_key); } /** * @param array $target - * @param mixed $value */ private function appendValue(array &$target, string $key, mixed $value): void { @@ -237,10 +198,4 @@ private function kebabToSnake(string $name): string { return str_replace('-', '_', $name); } - - private function prepareXmlInput(string $xml): string - { - $xml = preg_replace('/^\s*<\?xml[^>]*\?>\s*/i', '', trim($xml)) ?? trim($xml); - return "{$xml}"; - } } diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/XMLFileDeserializer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/XMLFileDeserializer.php new file mode 100644 index 000000000000..90e7ad6e52e2 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/XMLFileDeserializer.php @@ -0,0 +1,72 @@ +file_path = $path; + return $clone; + } + + /** + * @inheritDoc + */ + public function process(): void + { + if ($this->file_path === '') { + throw new \RuntimeException( + 'No file has been opened. Call open() before process().' + ); + } + + $reader = new \XMLReader(); + + if (!$reader->open($this->file_path, null, LIBXML_NONET)) { + throw new \RuntimeException( + "Unable to open XML file '{$this->file_path}'." + ); + } + + $this->processReader($reader); + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/XMLMemoryDeserializer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/XMLMemoryDeserializer.php new file mode 100644 index 000000000000..395d0d2a5b87 --- /dev/null +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Serializing/XMLMemoryDeserializer.php @@ -0,0 +1,64 @@ +xml = $path; + return $clone; + } + + /** + * @inheritDoc + */ + public function process(): void + { + $reader = new \XMLReader(); + $xml = $this->prepareXmlInput($this->xml); + + if (!$reader->XML($xml, null, LIBXML_NONET)) { + throw new \RuntimeException('Unable to read XML input.'); + } + + $this->processReader($reader); + } + + private function prepareXmlInput(string $xml): string + { + $xml = preg_replace('/^\s*<\?xml[^>]*\?>\s*/i', '', trim($xml)) ?? trim($xml); + return "{$xml}"; + } +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionSelectionStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionSelectionStage.php index 9276e158ad88..bb890f483b9f 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionSelectionStage.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionSelectionStage.php @@ -25,7 +25,7 @@ use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\ImportStage; use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportContext; use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\StageResult; -use ILIAS\TestQuestionPool\ExportImport\Foundation\Serializing\SimpleXMLDeserializer; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Serializing\XMLFileDeserializer; use ILIAS\UI\Component\Input\Container\Form\Form; use ILIAS\UI\Factory as UIFactory; use Psr\Http\Message\ServerRequestInterface; @@ -133,8 +133,8 @@ private function readQuestions(ImportContext $context): array { $options = []; - $deserializer = new SimpleXMLDeserializer()->open( - file_get_contents($context->get(UploadValidationStage::COMPONENT_IMPORT_FILE)) + $deserializer = new XMLFileDeserializer()->open( + $context->get(UploadValidationStage::COMPONENT_IMPORT_FILE) ); $deserializer->addHandler('questions', function (array $questions) use (&$options): void { From 943d6231fbd0398e22e1861c20a585c0e7fa36ff Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Mon, 4 May 2026 12:15:10 +0200 Subject: [PATCH 35/43] feat(test): implement skill level thresholds import --- .../class.ilTestSkillLevelThreshold.php | 37 +----- .../Import/SkillLevelThresholdsImporter.php | 119 ++++++++++++++++++ .../src/ExportImport/Import/TestImporter.php | 23 +++- .../ilTestSkillLevelThresholdNormalizer.php | 83 ++++++++++++ .../Test/src/ExportImport/TestExporter.php | 4 +- components/ILIAS/Test/src/TestDIC.php | 13 +- .../Import/QuestionPoolImporter.php | 3 +- .../Import/SkillAssignmentsImporter.php | 18 ++- .../TestQuestionPool/src/QuestionPoolDIC.php | 1 + 9 files changed, 252 insertions(+), 49 deletions(-) create mode 100644 components/ILIAS/Test/src/ExportImport/Import/SkillLevelThresholdsImporter.php create mode 100644 components/ILIAS/Test/src/ExportImport/Normalizer/ilTestSkillLevelThresholdNormalizer.php diff --git a/components/ILIAS/Test/classes/class.ilTestSkillLevelThreshold.php b/components/ILIAS/Test/classes/class.ilTestSkillLevelThreshold.php index a79f3e32a87f..70f22450ad38 100755 --- a/components/ILIAS/Test/classes/class.ilTestSkillLevelThreshold.php +++ b/components/ILIAS/Test/classes/class.ilTestSkillLevelThreshold.php @@ -17,10 +17,6 @@ *********************************************************************/ declare(strict_types=1); -use ILIAS\Refinery\Transformation; -use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizable; -use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; -use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Envelopes\Id; /** * @author Björn Heyser @@ -28,7 +24,7 @@ * * @package components\ILIAS/Test */ -class ilTestSkillLevelThreshold implements Normalizable +class ilTestSkillLevelThreshold { /** * @var ilDBInterface @@ -218,35 +214,4 @@ public function getThreshold(): ?int { return is_numeric($this->threshold) ? (int) $this->threshold : null; } - - - /** - * @inheritDoc - */ - public function toNormalized(Transformations $tt): Transformation - { - return $tt->custom()->transformation(fn(): array => [ - 'id' => $tt->normalize(new Id($this->getSkillLevelId(), 'skill_level')), - 'test_id' => $tt->normalize(new Id($this->getTestId(), 'tst')), - 'skill_base_id' => $tt->normalize(new Id($this->getSkillBaseId(), 'skill_base')), - 'skill_tref_id' => $tt->normalize(new Id($this->getSkillTrefId(), 'skill_tref')), - 'threshold' => $this->getThreshold(), - ]); - } - - /** - * @inheritDoc - */ - public function fromNormalized(Transformations $tt): Transformation - { - return $tt->custom()->transformation(function (array $normalized) use ($tt): self { - $clone = clone $this; - $clone->setSkillLevelId($tt->denormalize($normalized['id'], Id::class)->getId()); - $clone->setTestId($tt->denormalize($normalized['test_id'], Id::class)->getId()); - $clone->setSkillBaseId($tt->denormalize($normalized['skill_base_id'], Id::class)->getId()); - $clone->setSkillTrefId($tt->denormalize($normalized['skill_tref_id'], Id::class)->getId()); - $clone->setThreshold($normalized['threshold']); - return $clone; - }); - } } 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..0c9cf976ba0c --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Import/SkillLevelThresholdsImporter.php @@ -0,0 +1,119 @@ +> $normalized_thresholds + * @return array{failed: list, success: list} + */ + public function import( + array $normalized_thresholds, + int $import_install_id, + Transformations $transformations, + ilImportMapping $mapping, + ): array { + $result = ['failed' => [], 'success' => []]; + $threshold_list = new ilTestSkillLevelThresholdList($this->db); + + foreach ($normalized_thresholds as $item) { + // TestID and Skill BaseID/TRefID will be replaced by the mapping pipe + $threshold = $transformations->denormalize($item, ilTestSkillLevelThreshold::class); + + $local_level_id = $this->getLevelIdMapping($import_install_id, $threshold->getSkillLevelId()); + if ($local_level_id === null) { + $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; + } + + protected function getLevelIdMapping(int $import_install_id, int $import_level_id): ?int + { + if ($import_install_id === $this->local_install_id) { + return $import_level_id; + } + + $result = $this->skill_repo->getCommonSkillIdForImportId($import_install_id, $import_level_id); + $most_new_level_data = current($result); + if (!is_array($most_new_level_data)) { + return null; + } + + return $most_new_level_data['level_id']; + } + + /** + * @return ImportResultData + */ + private function buildResultData(ilTestSkillLevelThreshold $threshold): array + { + return [ + 'skill_base_id' => $threshold->getSkillBaseId() ?? 0, + 'skill_tref_id' => $threshold->getSkillTrefId() ?? 0, + 'skill_level_id' => $threshold->getSkillLevelId() ?? 0, + 'threshold' => $threshold->getThreshold() ?? 0, + ]; + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Import/TestImporter.php b/components/ILIAS/Test/src/ExportImport/Import/TestImporter.php index b837d9b7e6e1..077e3d8ea2af 100644 --- a/components/ILIAS/Test/src/ExportImport/Import/TestImporter.php +++ b/components/ILIAS/Test/src/ExportImport/Import/TestImporter.php @@ -21,13 +21,12 @@ namespace ILIAS\Test\ExportImport\Import; use assFileUploadStakeholder; -use ilCtrl; +use ilDBConstants; use ilDBInterface; use ILIAS\Data\Factory as DataFactory; use ILIAS\Data\ReferenceId; use ILIAS\Data\UUID\Factory as UUIDFactory; use ILIAS\Filesystem\Stream\Streams; -use ILIAS\Language\Language; use ILIAS\ResourceStorage\Services as IRSS; use ILIAS\Test\ExportImport\Envelopes\AdditionalWorkingTime; use ILIAS\Test\ExportImport\Envelopes\ManualFeedback; @@ -58,7 +57,6 @@ use ilImportMapping; use ilObjTest; use ilTestPage; -use ilDBConstants; use ilTestSequence; use Psr\Log\LoggerInterface; use RuntimeException; @@ -73,14 +71,13 @@ class TestImporter { public function __construct( private readonly Builder $builder, - private readonly ilCtrl $ctrl, private readonly ilDBInterface $database, private readonly LoggerInterface $log, - private readonly Language $language, private readonly IRSS $irss, private readonly DataFactory $data_factory, private readonly QuestionsImporter $questions_importer, private readonly SkillAssignmentsImporter $skill_importer, + private readonly SkillLevelThresholdsImporter $skill_thresholds_importer, private readonly MarksRepository $marks_repository, ) { } @@ -144,16 +141,30 @@ function (array $normalized) use ($tt, $mapping, $context, &$test_object): void $deserializer->addHandler( 'skill_assignments', - function (array $assignments) use ($tt, &$context): void { + function (array $assignments) use ($tt, $mapping, &$context): void { $result = $this->skill_importer->import( $assignments, UploadValidationStage::getInstallId($context), $tt, + $mapping, ); $context = $context->with('skill_assignments', $result); } ); + $deserializer->addHandler( + 'skill_thresholds', + function (array $thresholds) use ($tt, $mapping, &$context): void { + $result = $this->skill_thresholds_importer->import( + $thresholds, + UploadValidationStage::getInstallId($context), + $tt, + $mapping, + ); + $context = $context->with('skill_thresholds', $result); + } + ); + $deserializer->addHandler( 'participants', function (array $participants) use ($tt, $mapping): void { diff --git a/components/ILIAS/Test/src/ExportImport/Normalizer/ilTestSkillLevelThresholdNormalizer.php b/components/ILIAS/Test/src/ExportImport/Normalizer/ilTestSkillLevelThresholdNormalizer.php new file mode 100644 index 000000000000..e788922778ee --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Normalizer/ilTestSkillLevelThresholdNormalizer.php @@ -0,0 +1,83 @@ + + */ +#[Normalizes(ilTestSkillLevelThreshold::class)] +class ilTestSkillLevelThresholdNormalizer implements Normalizer +{ + private readonly ilDBInterface $db; + + public function __construct( + private readonly Transformations $tt, + Container $dic + ) { + $this->db = $dic->database(); + } + + /** + * @inheritDoc + */ + public function normalize($value): array|float|bool|int|string|null + { + if (!$value instanceof ilTestSkillLevelThreshold) { + throw new NormalizingException('Invalid value', $value); + } + + return [ + 'id' => $this->tt->normalize(new Id($value->getSkillLevelId(), 'skill_level')), + 'test_id' => $this->tt->normalize(new Id($value->getTestId(), 'tst')), + 'skill_base_id' => $this->tt->normalize(new Id($value->getSkillBaseId(), 'skill_base')), + 'skill_tref_id' => $this->tt->normalize(new Id($value->getSkillTrefId(), 'skill_tref')), + 'threshold' => $value->getThreshold(), + ]; + } + + /** + * @inheritDoc + */ + public function denormalize(array|float|bool|int|string|null $value, string $type): ilTestSkillLevelThreshold + { + if ($type !== ilTestSkillLevelThreshold::class) { + throw new NormalizingException("Invalid type for ilTestSkillLevelThreshold: {$type}"); + } + + $threshold = new ilTestSkillLevelThreshold($this->db); + $threshold->setSkillLevelId($this->tt->denormalize($value['id'], Id::class)->getId()); + $threshold->setTestId($this->tt->denormalize($value['test_id'], Id::class)->getId()); + $threshold->setSkillBaseId($this->tt->denormalize($value['skill_base_id'], Id::class)->getId()); + $threshold->setSkillTrefId($this->tt->denormalize($value['skill_tref_id'], Id::class)->getId()); + $threshold->setThreshold($this->tt->int($value['threshold'])); + + return $threshold; + } +} diff --git a/components/ILIAS/Test/src/ExportImport/TestExporter.php b/components/ILIAS/Test/src/ExportImport/TestExporter.php index 7e4c519ce955..59d2c5e228b0 100644 --- a/components/ILIAS/Test/src/ExportImport/TestExporter.php +++ b/components/ILIAS/Test/src/ExportImport/TestExporter.php @@ -101,7 +101,7 @@ public function prepare(ExportState $state): void $object_id ), new CollectResources( - $this->irss, + $this->irss, $this->logger ), ]) @@ -309,7 +309,7 @@ private function exportQuestions( 'feedback' => $transformations->normalize( $collector->getFeedback($question) ), - 'sequence' => $question_properties[$question->getId()]->getSequenceInformation()->getPlaceInSequence(), + 'sequence' => $question_properties[$question->getId()]->getSequenceInformation()?->getPlaceInSequence(), ]; if ($question instanceof assFormulaQuestion) { diff --git a/components/ILIAS/Test/src/TestDIC.php b/components/ILIAS/Test/src/TestDIC.php index ce60c933344d..f70861b60b82 100755 --- a/components/ILIAS/Test/src/TestDIC.php +++ b/components/ILIAS/Test/src/TestDIC.php @@ -57,6 +57,7 @@ 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; @@ -278,6 +279,15 @@ protected static function buildDIC(ILIASContainer $DIC): self new SkillAssignmentsImporter( $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( + $DIC->database(), + $DIC->skills()->internal()->repo()->getTreeRepo(), + 'components/ILIAS/Test', (int) $DIC->settings()->get('inst_id', '0') ); @@ -295,14 +305,13 @@ protected static function buildDIC(ILIASContainer $DIC): self $dic['exportimport.importer'] = static fn($c): TestImporter => new TestImporter( $c['exportimport.builder'], - $DIC->ctrl(), $DIC->database(), $c['exportimport.logging'](), - $DIC->language(), $DIC->resourceStorage(), new DataFactory(), $c['exportimport.questions_importer'], $c['exportimport.skill_assignments_importer'], + $c['exportimport.skill_level_thresholds_importer'], $c['marks.repository'] ); diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionPoolImporter.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionPoolImporter.php index 0be8d25cd088..8c9ef8e678ce 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionPoolImporter.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionPoolImporter.php @@ -102,11 +102,12 @@ function (array $questions) use ($tt, $mapping, $selected_questions): void { $deserializer->addHandler( 'skill_assignments', - function (array $assignments) use ($tt, &$context): void { + function (array $assignments) use ($tt, $mapping, &$context): void { $result = $this->skill_importer->import( $assignments, UploadValidationStage::getInstallId($context), $tt, + $mapping, ); $context = $context->with('skill_assignments', $result); } diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/SkillAssignmentsImporter.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/SkillAssignmentsImporter.php index f0de70e409f8..04eec3458349 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/SkillAssignmentsImporter.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/SkillAssignmentsImporter.php @@ -23,6 +23,7 @@ use ilAssQuestionSkillAssignment; use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\Skill\Service\SkillUsageService; +use ilImportMapping; use ilSkillTreeRepository; /** @@ -36,6 +37,7 @@ class SkillAssignmentsImporter public function __construct( private readonly ilSkillTreeRepository $skill_repo, private readonly SkillUsageService $skill_usage_service, + private readonly string $component, private readonly int $local_install_id ) { } @@ -51,10 +53,12 @@ public function import( array $normalized_assignments, int $import_install_id, Transformations $transformations, + ilImportMapping $mapping, ): array { $result = ['failed' => [], 'success' => []]; foreach ($normalized_assignments as $item) { + // ParentObjID and QuestionID will be replaced by the mapping pipe $assignment = $transformations->denormalize($item, ilAssQuestionSkillAssignment::class); $skill_data = $this->getSkillIdMapping( @@ -67,8 +71,18 @@ public function import( continue; } - // Map imported skill ids to local skill ids. Question id and object id are already replaced in the id - // mapping pipe. + $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']); diff --git a/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php b/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php index 7240f58a9597..cb89f529776d 100755 --- a/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php +++ b/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php @@ -101,6 +101,7 @@ protected static function buildDIC(ILIASContainer $DIC): self new SkillAssignmentsImporter( $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 => From 303dcf9491b71120338d4c9f232a21524667ae0e Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Mon, 4 May 2026 15:54:16 +0200 Subject: [PATCH 36/43] feat(test): introduce random question set config import --- .../Envelopes/QuestionSetConfig.php | 79 ++++++++ .../src/ExportImport/Import/TestImporter.php | 96 +++++++++- .../QuestionSetConfigNormalizer.php | 173 ++++++++++++++++-- .../Test/src/ExportImport/TestCollector.php | 42 ++++- .../Test/src/ExportImport/TestExporter.php | 20 +- 5 files changed, 386 insertions(+), 24 deletions(-) create mode 100644 components/ILIAS/Test/src/ExportImport/Envelopes/QuestionSetConfig.php diff --git a/components/ILIAS/Test/src/ExportImport/Envelopes/QuestionSetConfig.php b/components/ILIAS/Test/src/ExportImport/Envelopes/QuestionSetConfig.php new file mode 100644 index 000000000000..6e1a75dececb --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Envelopes/QuestionSetConfig.php @@ -0,0 +1,79 @@ + */ + private array $definitions = [], + /** @var array> */ + private array $staging_pools = [], + ) { + } + + public function getConfig(): ilTestQuestionSetConfig + { + return $this->config; + } + + public function isRandom(): bool + { + return $this->config instanceof ilTestRandomQuestionSetConfig; + } + + /** + * @return list + */ + public function getDefinitions(): array + { + return $this->definitions; + } + + /** + * @param list $definitions + */ + public function setDefinitions(array $definitions): void + { + $this->definitions = $definitions; + } + + /** + * @return array> + */ + public function getStagingPools(): array + { + return $this->staging_pools; + } + + /** + * @param list $questions + */ + public function addStagingPoolQuestions(int $pool_id, array $questions): void + { + $this->staging_pools[$pool_id] = $questions; + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Import/TestImporter.php b/components/ILIAS/Test/src/ExportImport/Import/TestImporter.php index 077e3d8ea2af..649084021c3e 100644 --- a/components/ILIAS/Test/src/ExportImport/Import/TestImporter.php +++ b/components/ILIAS/Test/src/ExportImport/Import/TestImporter.php @@ -31,6 +31,7 @@ use ILIAS\Test\ExportImport\Envelopes\AdditionalWorkingTime; use ILIAS\Test\ExportImport\Envelopes\ManualFeedback; use ILIAS\Test\ExportImport\Envelopes\QuestionResult; +use ILIAS\Test\ExportImport\Envelopes\QuestionSetConfig; use ILIAS\Test\ExportImport\Envelopes\Solution; use ILIAS\Test\ExportImport\Envelopes\WorkingTime; use ILIAS\Test\Participants\Participant; @@ -57,6 +58,8 @@ use ilImportMapping; use ilObjTest; use ilTestPage; +use ilTestRandomQuestionSetSourcePoolDefinition; +use ilTestRandomQuestionSetStagingPoolQuestion; use ilTestSequence; use Psr\Log\LoggerInterface; use RuntimeException; @@ -139,6 +142,18 @@ function (array $normalized) use ($tt, $mapping, $context, &$test_object): void } ); + $deserializer->addHandler( + 'question_set_config', + function (array $normalized) use ($tt, $mapping, $context, &$test_object): void { + $this->importQuestionSetConfig( + reset($normalized), + $tt, + $mapping, + $test_object + ); + } + ); + $deserializer->addHandler( 'skill_assignments', function (array $assignments) use ($tt, $mapping, &$context): void { @@ -360,7 +375,7 @@ private function importQuestions( foreach ($list as $normalized) { $question = $this->questions_importer->importQuestion($normalized, $tt, $mapping, $selected_questions); - if ($question) { + if ($question && $normalized['sequence'] !== null) { $sequence = $tt->int($normalized['sequence']); $test_object->questions[$sequence] = $question->getId(); $this->log->debug("Stored question {$question->getId()} at sequence {$sequence} in test"); @@ -371,6 +386,85 @@ private function importQuestions( $this->log->debug('Saved test questions to database'); } + private function importQuestionSetConfig( + array $normalized, + Transformations $tt, + ilImportMapping $mapping, + ilObjTest $test_object + ): void { + $config = $tt->denormalize($normalized, QuestionSetConfig::class); + if (!$config->isRandom()) { + return; + } + + $config->getConfig()->saveToDb(); + $this->log->debug("Imported random question set config for test {$test_object->getTestId()}"); + + foreach ($config->getStagingPools() as $pool_id => $questions) { + $this->importRandomQuestionStagingPool($pool_id, $questions, $mapping, $test_object); + } + + foreach ($config->getDefinitions() as $definition) { + $this->importSourcePoolDefinition($definition, $mapping); + } + } + + private function importRandomQuestionStagingPool( + int $old_pool_id, + array $questions, + ilImportMapping $mapping, + ilObjTest $test_object + ): void { + $new_pool_id = $this->database->nextId('object_data'); + $mapping->addMapping( + 'components/ILIAS/Test', + 'pool', + (string) $old_pool_id, + (string) $new_pool_id + ); + $this->log->debug("Imported random question staging pool: {$old_pool_id} -> {$new_pool_id}"); + + // QuestionID was mapped during question set config denormalization + foreach ($questions as $question_id) { + $question = new ilTestRandomQuestionSetStagingPoolQuestion($this->database); + $question->setTestId($test_object->getTestId()); + $question->setPoolId($new_pool_id); + $question->setQuestionId($question_id); + $question->saveQuestionStaging(); + $this->log->debug("Imported random question staging question: {$question_id}"); + } + } + + private function importSourcePoolDefinition( + ilTestRandomQuestionSetSourcePoolDefinition $definition, + ilImportMapping $mapping, + ): void { + // New PoolID was not available during denormalization, so we have to map it here + $old_pool_id = $definition->getPoolId(); + $new_pool_id = (int) $mapping->getMapping('components/ILIAS/Test', 'pool', (string) $old_pool_id); + $definition->setPoolId($new_pool_id); + + if ($old_pool_id !== $new_pool_id) { + $ref_ids = $this->data_factory->objId($new_pool_id)->toReferenceIds(); + if (count($ref_ids) > 0) { + $definition->setPoolRefId(current($ref_ids)->toInt()); + $this->log->debug("Derived source pool definition from Object ID: {$old_pool_id} -> {$definition->getPoolRefId()}"); + } + } + + $old_definition_id = $definition->getId(); + $definition->setId(0); + $definition->saveToDb(); + $this->log->debug("Imported source pool definition: {$old_definition_id} -> {$definition->getId()}"); + + $mapping->addMapping( + 'components/ILIAS/Test', + 'rnd_src_pool_def', + (string) $old_definition_id, + (string) $definition->getId() + ); + } + private function importParticipants(array $list, Transformations $tt, ilImportMapping $mapping): void { foreach ($list as $normalized) { diff --git a/components/ILIAS/Test/src/ExportImport/Normalizer/QuestionSetConfigNormalizer.php b/components/ILIAS/Test/src/ExportImport/Normalizer/QuestionSetConfigNormalizer.php index e2ca52dad50d..f91a2aff589d 100644 --- a/components/ILIAS/Test/src/ExportImport/Normalizer/QuestionSetConfigNormalizer.php +++ b/components/ILIAS/Test/src/ExportImport/Normalizer/QuestionSetConfigNormalizer.php @@ -20,53 +20,200 @@ namespace ILIAS\Test\ExportImport\Normalizer; +use ILIAS\DI\Container; +use ILIAS\Test\ExportImport\Envelopes\QuestionSetConfig; +use ILIAS\Test\TestDIC; use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Normalizer; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Attributes\Normalizes; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\Envelopes\Id; use ILIAS\TestQuestionPool\ExportImport\Foundation\Normalizing\NormalizingException; use ilObjTest; +use ilTestException; use ilTestFixedQuestionSetConfig; use ilTestRandomQuestionSetConfig; use ilTestQuestionSetConfig; +use ilTestRandomQuestionSetSourcePoolDefinition; +use ReflectionClass; /** - * @implements Normalizer + * @implements Normalizer */ -#[Normalizes(ilTestQuestionSetConfig::class)] +#[Normalizes(QuestionSetConfig::class)] class QuestionSetConfigNormalizer implements Normalizer { + public function __construct( + private readonly Transformations $tt, + private readonly Container $dic, + private readonly TestDIC $test_dic, + ) { + } + /** * @inheritDoc */ public function normalize($value): array|float|bool|int|string|null { - if ($value instanceof ilTestFixedQuestionSetConfig) { + 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 ($value instanceof ilTestRandomQuestionSetConfig) { + if ($config instanceof ilTestRandomQuestionSetConfig) { return [ 'type' => ilObjTest::QUESTION_SET_TYPE_RANDOM, - 'homogeneous' => $value->arePoolsWithHomogeneousScoredQuestionsRequired(), - 'amount_mode' => $value->getQuestionAmountConfigurationMode(), - 'amount' => $value->getQuestionAmountPerTest(), - 'sync_timestamp' => $value->getLastQuestionSyncTimestamp(), + 'homogeneous' => $config->arePoolsWithHomogeneousScoredQuestionsRequired(), + 'amount_mode' => $config->getQuestionAmountConfigurationMode(), + 'amount' => $config->getQuestionAmountPerTest(), + 'sync_timestamp' => $config->getLastQuestionSyncTimestamp(), + ]; + } + + throw new NormalizingException('Invalid value', $config); + } + + private function normalizeStagingPools(array $staging_pools): array + { + $normalized = []; + foreach ($staging_pools as $pool_id => $questions) { + $normalized[] = [ + 'pool_id' => $this->tt->normalize(new Id($pool_id, 'pool')), + 'questions' => array_map( + fn($question) => $this->tt->normalize( + new Id($question, 'question') + ), + $questions + ), ]; } + return $normalized; + } + + private function normalizeTestObj(ilTestQuestionSetConfig $config): array + { + $reflection = new ReflectionClass($config); + $property = $reflection->getProperty('test_obj'); + $property->setAccessible(true); + $test_obj = $property->getValue($config); + + if (!$test_obj instanceof ilObjTest) { + throw new NormalizingException('Invalid test object', $test_obj); + } - throw new NormalizingException('Invalid value', $value); + return [ + 'obj_id' => $this->tt->normalize(new Id($test_obj->getId(), 'object')), + 'test_id' => $this->tt->normalize(new Id($test_obj->getTestId(), 'tst')), + ]; } /** * @inheritDoc */ - public function denormalize(array|float|bool|int|string|null $value, string $type): ilTestQuestionSetConfig + public function denormalize(array|float|bool|int|string|null $value, string $type): mixed + { + if ($type !== QuestionSetConfig::class) { + throw new NormalizingException("Invalid type for QuestionSetConfig: {$type}"); + } + + $test_obj = $this->denormalizeTestObj($value['test_obj']); + $config = $this->denormalizeQuestionSetConfig($value['config'], $test_obj); + + if (!$config instanceof ilTestRandomQuestionSetConfig) { + return new QuestionSetConfig($config); + } + + $definitions = array_map( + fn($definition) => $this->denormalizeSourcePoolDefinition($definition, $test_obj), + $value['definitions'] + ); + $staging_pools = $this->denormalizeStagingPools($value['staging_pools']); + + return new QuestionSetConfig($config, $definitions, $staging_pools); + } + + private function denormalizeQuestionSetConfig(array $normalized, ilObjTest $test_obj): ilTestQuestionSetConfig + { + $class = $normalized['type'] === ilObjTest::QUESTION_SET_TYPE_RANDOM + ? ilTestRandomQuestionSetConfig::class + : ilTestFixedQuestionSetConfig::class; + + $config = new $class( + $this->dic->repositoryTree(), + $this->dic->database(), + $this->dic->language(), + $this->test_dic['logging.logger'], + $this->dic['component.repository'], + $test_obj, + $this->test_dic['question.general_properties.repository'] + ); + + if ($config instanceof ilTestFixedQuestionSetConfig) { + return $config; + } + + $amount_mode = $this->tt->string($normalized['amount_mode']); + if (!$config->isValidQuestionAmountConfigurationMode($amount_mode)) { + throw new ilTestException("Invalid random test question set config amount mode given: {$amount_mode}"); + } + + $config->setQuestionAmountConfigurationMode($amount_mode); + $config->setQuestionAmountPerTest($this->tt->nullableInt($normalized['amount'])); + $config->setPoolsWithHomogeneousScoredQuestionsRequired($this->tt->nullableBool($normalized['homogeneous'])); + $config->setLastQuestionSyncTimestamp($this->tt->nullableInt($normalized['sync_timestamp'])); + + return $config; + } + + private function denormalizeSourcePoolDefinition(array $normalized, ilObjTest $test_obj): ilTestRandomQuestionSetSourcePoolDefinition + { + $definition = new ilTestRandomQuestionSetSourcePoolDefinition( + $this->dic->database(), + $test_obj + ); + + return $this->tt->denormalize($normalized, $definition); + } + + private function denormalizeStagingPools(array $normalized): array { - if (!in_array(ilTestQuestionSetConfig::class, class_parents($type))) { - throw new NormalizingException("Invalid type for ilTestQuestionSetConfig: {$type}"); + $staging_pools = []; + foreach ($normalized as $staging_pool) { + $pool_id = $this->tt->denormalize($staging_pool['pool_id'], Id::class)->getId(); + + $staging_pools[$pool_id] = array_map( + fn($question) => $this->tt->denormalize($question, Id::class)->getId(), + $staging_pool['questions'] + ); } + return $staging_pools; + } + + private function denormalizeTestObj(array $normalized): ilObjTest + { + $test_obj = new ilObjTest(0, false); + $test_obj->setTestId($this->tt->denormalize($normalized['test_id'], Id::class)->getId()); + $test_obj->setId($this->tt->denormalize($normalized['obj_id'], Id::class)->getId()); - //TODO: Implement denormalization + return $test_obj; } } diff --git a/components/ILIAS/Test/src/ExportImport/TestCollector.php b/components/ILIAS/Test/src/ExportImport/TestCollector.php index 3d59f2c50d30..e41250590b03 100644 --- a/components/ILIAS/Test/src/ExportImport/TestCollector.php +++ b/components/ILIAS/Test/src/ExportImport/TestCollector.php @@ -29,6 +29,7 @@ use ILIAS\Test\ExportImport\Envelopes\AdditionalWorkingTime; use ILIAS\Test\ExportImport\Envelopes\ManualFeedback; use ILIAS\Test\ExportImport\Envelopes\QuestionResult; +use ILIAS\Test\ExportImport\Envelopes\QuestionSetConfig; use ILIAS\Test\ExportImport\Envelopes\Solution; use ILIAS\Test\ExportImport\Envelopes\WorkingTime; use ILIAS\Test\Logging\TestLogger; @@ -44,8 +45,10 @@ use ILIAS\TestQuestionPool\Questions\GeneralQuestionPropertiesRepository; use ilObjTest; use ilTestQuestionSetConfigFactory; +use ilTestRandomQuestionSetConfig; use ilTestRandomQuestionSetSourcePoolDefinitionFactory; use ilTestRandomQuestionSetSourcePoolDefinitionList; +use ilTestRandomQuestionSetStagingPoolQuestionList; use ilTestSequence; use ilTestSkillLevelThreshold; use ilTestSkillLevelThresholdList; @@ -191,11 +194,10 @@ public function getSkillLevelThresholds(): array } /** - * Get the question set config and the source pool definitions. - * - * @return array{config: \ilTestQuestionSetConfig, definitions: list<\ilTestRandomQuestionSetSourcePoolDefinition>} + * Get the question set config. If it is a random question set config, also return the source pool sages and + * definitions. */ - public function getQuestionSetConfig(): array + public function getQuestionSetConfig(): QuestionSetConfig { $factory = new ilTestQuestionSetConfigFactory( $this->tree, @@ -207,7 +209,10 @@ public function getQuestionSetConfig(): array $this->general_questions_repository ); - $config = $factory->getQuestionSetConfig(); + $config = new QuestionSetConfig($factory->getQuestionSetConfig()); + if (!$config->isRandom()) { + return $config; + } $definition_factory = new ilTestRandomQuestionSetSourcePoolDefinitionFactory( $this->db, @@ -219,12 +224,31 @@ public function getQuestionSetConfig(): array $this->getObject(), $definition_factory ); + $definition_list->loadDefinitions(); + $config->setDefinitions(iterator_to_array($definition_list)); - return [ - 'config' => $config, - 'definitions' => 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(); } /* diff --git a/components/ILIAS/Test/src/ExportImport/TestExporter.php b/components/ILIAS/Test/src/ExportImport/TestExporter.php index 59d2c5e228b0..553e0c9b37c3 100644 --- a/components/ILIAS/Test/src/ExportImport/TestExporter.php +++ b/components/ILIAS/Test/src/ExportImport/TestExporter.php @@ -163,6 +163,14 @@ public function process(ExportState $state): void $state ) ); + $state->serializer()->group( + 'question_set_config', + fn() => $this->exportQuestionSetConfig( + $state->collector(), + $state->transformations(), + $state->serializer(), + ) + ); $state->serializer()->group( 'additional_working_times', fn() => $this->exportAdditionalWorkingTimes( @@ -285,7 +293,6 @@ private function exportSettings( $serializer->append('main', $transformations->normalize($main_settings)); $serializer->append('scoring', $transformations->normalize($test->getScoreSettings())); $serializer->append('marks', $transformations->normalize($test->getMarkSchema())); - $serializer->append('question_set_config', $transformations->normalize($collector->getQuestionSetConfig())); if ($intro_page_id = $main_settings->getIntroductionSettings()->getIntroductionPageId()) { $state->addDependency('components/ILIAS/COPage', 'pg', ["tst:{$intro_page_id}"]); @@ -322,6 +329,17 @@ private function exportQuestions( } } + 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, From 61085fdd0198f702b2bfc7d5d51330ece7020e95 Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Thu, 7 May 2026 09:45:11 +0200 Subject: [PATCH 37/43] fix(qpl): remove todo annotations in normalizing steps --- .../classes/class.ilAssKprimChoiceAnswer.php | 2 -- .../Normalizing/Normalizer/IlObjectNormalizer.php | 11 ++--------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilAssKprimChoiceAnswer.php b/components/ILIAS/TestQuestionPool/classes/class.ilAssKprimChoiceAnswer.php index e2a0842316e6..5f4fbda39bc0 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilAssKprimChoiceAnswer.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilAssKprimChoiceAnswer.php @@ -171,8 +171,6 @@ public function fromNormalized(Transformations $tt): Transformation $clone->answertext = $tt->nullableString($normalized['answertext']); $clone->imageFile = $tt->denormalize($normalized['image'], QuestionImage::class)?->getFilename(); $clone->correctness = $tt->int($normalized['correctness']); - - //TODO: imageFSDir, imageWebDir, thumbPrefix ? return $clone; }); } diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/IlObjectNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/IlObjectNormalizer.php index 50aadff251f6..727963aa1378 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/IlObjectNormalizer.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Normalizing/Normalizer/IlObjectNormalizer.php @@ -57,11 +57,8 @@ public function normalize($value): array|float|bool|int|string|null throw new NormalizingException('Invalid value', $value); } - // TODO: Icon - // TODO: Tile Image - // TODO: Translations - // TODO: Container Settings - + // 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(), @@ -84,8 +81,6 @@ private function normalizeProperties(Properties $properties): array 'title_and_icon_visibility' => $this->normalizeProperty($properties->getPropertyTitleAndIconVisibility()), 'header_action_visibility' => $this->normalizeProperty($properties->getPropertyHeaderActionVisibility()), 'info_tab_visibility' => $this->normalizeProperty($properties->getPropertyInfoTabVisibility()), - // 'tile_image' => $properties->getPropertyTileImage(), - // 'icon' => $this->normalizeProperty($properties->getPropertyIcon()), 'translations' => $this->normalizeTranslations($properties->getPropertyTranslations()), ]; } @@ -148,8 +143,6 @@ public function denormalize(array|float|bool|int|string|null $value, string $typ $object->setOwner($this->tt->int($value['owner'])); $object->setImportId($this->tt->string($value['import_id'])); - // TODO: Properties - return $object; } } From d50aeae46b2d9f52f2e293c888958c8096f0de78 Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Thu, 7 May 2026 11:00:57 +0200 Subject: [PATCH 38/43] refactor(test): improve random test import and split importer class --- .../Test/classes/class.ilTestImporter.php | 23 ++ ...tRandomQuestionSetSourcePoolDefinition.php | 21 ++ .../Import/RandomTestConfigImporter.php | 186 +++++++++++ .../src/ExportImport/Import/TestImporter.php | 301 +----------------- .../Import/TestResultsImporter.php | 258 +++++++++++++++ components/ILIAS/Test/src/TestDIC.php | 15 + 6 files changed, 512 insertions(+), 292 deletions(-) create mode 100644 components/ILIAS/Test/src/ExportImport/Import/RandomTestConfigImporter.php create mode 100644 components/ILIAS/Test/src/ExportImport/Import/TestResultsImporter.php diff --git a/components/ILIAS/Test/classes/class.ilTestImporter.php b/components/ILIAS/Test/classes/class.ilTestImporter.php index cbbc2732d66b..7922e637ec52 100755 --- a/components/ILIAS/Test/classes/class.ilTestImporter.php +++ b/components/ILIAS/Test/classes/class.ilTestImporter.php @@ -88,5 +88,28 @@ public function finalProcessing(ilImportMapping $a_mapping): void } $this->importer->finalize($a_mapping); + $this->finalizeTaxonomyUsage($a_mapping); + } + + private function finalizeTaxonomyUsage(ilImportMapping $a_mapping): void + { + $tst_mappings = $a_mapping->getMappingsOfEntity('components/ILIAS/Test', 'tst'); + + foreach ($tst_mappings as $old => $new) { + if ($old !== 'new_id' && (int) $old > 0) { + $new_tax_ids = $a_mapping->getMapping( + 'components/ILIAS/Taxonomy', + 'tax_usage_of_obj', + (string) $old + ); + + if ($new_tax_ids !== null) { + $tax_ids = explode(':', $new_tax_ids); + foreach ($tax_ids as $tid) { + ilObjTaxonomy::saveUsage((int) $tid, (int) $new); + } + } + } + } } } diff --git a/components/ILIAS/Test/classes/class.ilTestRandomQuestionSetSourcePoolDefinition.php b/components/ILIAS/Test/classes/class.ilTestRandomQuestionSetSourcePoolDefinition.php index d8d11efa7595..c8f81179befa 100755 --- a/components/ILIAS/Test/classes/class.ilTestRandomQuestionSetSourcePoolDefinition.php +++ b/components/ILIAS/Test/classes/class.ilTestRandomQuestionSetSourcePoolDefinition.php @@ -473,6 +473,7 @@ public function toNormalized(Transformations $tt): Transformation 'type_filter' => $this->getTypeFilterAsTypeTags(), 'lifecycle_filter' => $this->getLifecycleFilter(), 'taxonomy_filter' => [], + 'mapped_taxonomy_filter' => [], ]; foreach ($this->getOriginalTaxonomyFilter() as $tax_id => $node_ids) { @@ -485,6 +486,16 @@ public function toNormalized(Transformations $tt): Transformation ]; } + foreach ($this->getMappedTaxonomyFilter() as $tax_id => $node_ids) { + $normalized['mapped_taxonomy_filter'][] = [ + 'tax_id' => $tt->normalize(new Id($tax_id, 'mapped_tax')), + 'node_ids' => array_map( + fn($node_id) => $tt->normalize(new Id($node_id, 'mapped_tax_node')), + $node_ids + ), + ]; + } + return $normalized; }); } @@ -516,6 +527,16 @@ public function fromNormalized(Transformations $tt): Transformation } $clone->setOriginalTaxonomyFilter($taxonomy_filter); + $mapped_taxonomy_filter = []; + foreach (($normalized['mapped_taxonomy_filter'] ?? []) as $item) { + $tax_id = $tt->denormalize($item['tax_id'], Id::class)->getId(); + $mapped_taxonomy_filter[$tax_id] = array_map( + fn($node_id) => $tt->denormalize($node_id, Id::class)->getId(), + $item['node_ids'] + ); + } + $clone->setMappedTaxonomyFilter($mapped_taxonomy_filter); + return $clone; }); } diff --git a/components/ILIAS/Test/src/ExportImport/Import/RandomTestConfigImporter.php b/components/ILIAS/Test/src/ExportImport/Import/RandomTestConfigImporter.php new file mode 100644 index 000000000000..f9726a6454ca --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Import/RandomTestConfigImporter.php @@ -0,0 +1,186 @@ +isRandom()) { + throw new \InvalidArgumentException('Expected random question set config'); + } + + $config->getConfig()->saveToDb(); + $this->log->debug("Imported random question set config for test {$test_object->getTestId()}"); + + foreach ($config->getStagingPools() as $pool_id => $questions) { + $this->importRandomQuestionStagingPool($pool_id, $questions, $mapping, $test_object); + } + + foreach ($config->getDefinitions() as $definition) { + $this->importSourcePoolDefinition($definition, $mapping); + } + } + + private function importRandomQuestionStagingPool( + int $old_pool_id, + array $questions, + ilImportMapping $mapping, + ilObjTest $test_object + ): void { + $new_pool_id = $this->database->nextId('object_data'); + $mapping->addMapping( + 'components/ILIAS/Test', + 'pool', + (string) $old_pool_id, + (string) $new_pool_id + ); + $this->log->debug("Imported random question staging pool: {$old_pool_id} -> {$new_pool_id}"); + + // QuestionID was mapped during question set config denormalization + foreach ($questions as $question_id) { + $question = new ilTestRandomQuestionSetStagingPoolQuestion($this->database); + $question->setTestId($test_object->getTestId()); + $question->setPoolId($new_pool_id); + $question->setQuestionId($question_id); + $question->saveQuestionStaging(); + $this->log->debug("Imported random question staging question: {$question_id}"); + } + } + + private function importSourcePoolDefinition( + ilTestRandomQuestionSetSourcePoolDefinition $definition, + ilImportMapping $mapping, + ): void { + // New PoolID was not available during denormalization, so we have to map it here + $old_pool_id = $definition->getPoolId(); + $new_pool_id = (int) $mapping->getMapping('components/ILIAS/Test', 'pool', (string) $old_pool_id); + $definition->setPoolId($new_pool_id); + + if ($old_pool_id !== $new_pool_id) { + $ref_ids = $this->data_factory->objId($new_pool_id)->toReferenceIds(); + if (count($ref_ids) > 0) { + $definition->setPoolRefId(current($ref_ids)->toInt()); + $this->log->debug("Derived source pool definition from Object ID: {$old_pool_id} -> {$definition->getPoolRefId()}"); + } + } + + $old_definition_id = $definition->getId(); + $definition->setId(0); + $definition->saveToDb(); + $this->log->debug("Imported source pool definition: {$old_definition_id} -> {$definition->getId()}"); + + $mapping->addMapping( + 'components/ILIAS/Test', + 'rnd_src_pool_def', + (string) $old_definition_id, + (string) $definition->getId() + ); + } + + /** + * Remap taxonomy IDs in the mapped_taxonomy_filter of all imported source pool definitions. + * Taxonomy mappings are only available after the Taxonomy component has finished its import, so this must run + * during finalProcessing(). + */ + public function finalizeTaxonomyFilters(ilImportMapping $mapping): void + { + $tst_mappings = $mapping->getMappingsOfEntity('components/ILIAS/Test', 'tst'); + + foreach ($tst_mappings as $old_test_id => $new_test_id) { + if ($old_test_id === 'new_id' || (int) $old_test_id <= 0) { + continue; + } + + $test_obj = new ilObjTest(0, false); + $test_obj->setTestId((int) $new_test_id); + + $definition_list = new ilTestRandomQuestionSetSourcePoolDefinitionList( + $this->database, + $test_obj, + new ilTestRandomQuestionSetSourcePoolDefinitionFactory($this->database, $test_obj) + ); + $definition_list->loadDefinitions(); + + foreach ($definition_list as $definition) { + $mapped_filter = $definition->getMappedTaxonomyFilter(); + if ($mapped_filter === []) { + continue; + } + + $new_filter = $this->remapTaxonomyFilter($mapping, $mapped_filter); + $definition->setMappedTaxonomyFilter($new_filter); + $definition->saveToDb(); + $this->log->debug("Remapped taxonomy filter for definition {$definition->getId()}"); + } + } + } + + private function remapTaxonomyFilter(ilImportMapping $mapping, array $filter): array + { + $remapped = []; + foreach ($filter as $tax_id => $node_ids) { + $new_tax_id = (int) $mapping->getMapping('components/ILIAS/Taxonomy', 'tax', (string) $tax_id); + if ($new_tax_id <= 0) { + continue; + } + + $remapped[$new_tax_id] = []; + foreach ($node_ids as $node_id) { + $new_node_id = (int) $mapping->getMapping('components/ILIAS/Taxonomy', 'tax_tree', (string) $node_id); + if ($new_node_id > 0) { + $remapped[$new_tax_id][] = $new_node_id; + } + } + } + + return $remapped; + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Import/TestImporter.php b/components/ILIAS/Test/src/ExportImport/Import/TestImporter.php index 649084021c3e..c737b428b1f7 100644 --- a/components/ILIAS/Test/src/ExportImport/Import/TestImporter.php +++ b/components/ILIAS/Test/src/ExportImport/Import/TestImporter.php @@ -28,21 +28,13 @@ use ILIAS\Data\UUID\Factory as UUIDFactory; use ILIAS\Filesystem\Stream\Streams; use ILIAS\ResourceStorage\Services as IRSS; -use ILIAS\Test\ExportImport\Envelopes\AdditionalWorkingTime; -use ILIAS\Test\ExportImport\Envelopes\ManualFeedback; -use ILIAS\Test\ExportImport\Envelopes\QuestionResult; use ILIAS\Test\ExportImport\Envelopes\QuestionSetConfig; -use ILIAS\Test\ExportImport\Envelopes\Solution; -use ILIAS\Test\ExportImport\Envelopes\WorkingTime; use ILIAS\Test\Participants\Participant; -use ILIAS\Test\Results\Data\AttemptResult; -use ILIAS\Test\Results\Data\ParticipantResult; use ILIAS\Test\Scoring\Marks\MarkSchema; use ILIAS\Test\Scoring\Marks\MarksRepository; use ILIAS\Test\Settings\GlobalSettings\UserIdentifiers; use ILIAS\Test\Settings\MainSettings\MainSettings; use ILIAS\Test\Settings\ScoreReporting\ScoreSettings; -use ILIAS\Test\TestManScoringDoneHelper; use ILIAS\TestQuestionPool\ExportImport\Foundation\Builder; use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Deserializer; use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; @@ -58,9 +50,6 @@ use ilImportMapping; use ilObjTest; use ilTestPage; -use ilTestRandomQuestionSetSourcePoolDefinition; -use ilTestRandomQuestionSetStagingPoolQuestion; -use ilTestSequence; use Psr\Log\LoggerInterface; use RuntimeException; @@ -79,6 +68,8 @@ public function __construct( private readonly IRSS $irss, private readonly DataFactory $data_factory, private readonly QuestionsImporter $questions_importer, + private readonly RandomTestConfigImporter $random_test_config_importer, + private readonly TestResultsImporter $test_results_importer, private readonly SkillAssignmentsImporter $skill_importer, private readonly SkillLevelThresholdsImporter $skill_thresholds_importer, private readonly MarksRepository $marks_repository, @@ -194,7 +185,7 @@ function (array $participants) use ($tt, $mapping): void { $deserializer->addHandler( 'results', function (array $results) use ($tt): void { - $this->importGroupedResults( + $this->test_results_importer->import( $results, $tt, ); @@ -204,7 +195,7 @@ function (array $results) use ($tt): void { $deserializer->addHandler( 'additional_working_times', function (array $times) use ($tt): void { - $this->importAdditionalWorkingTimes( + $this->test_results_importer->importAdditionalWorkingTimes( $times, $tt, ); @@ -234,12 +225,14 @@ function (array $times) use ($tt): void { /** * 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. + * It will replace the old question ids with the new question ids in the test pages and remap taxonomy IDs in random + * question set source pool definitions. */ public function finalize(ilImportMapping $mapping): void { $this->log->info('Finalizing test import...'); $this->questions_importer->finalizeQuestionPages($mapping); + $this->random_test_config_importer->finalizeTaxonomyFilters($mapping); $this->log->info('...Finished finalizing test'); } @@ -393,78 +386,12 @@ private function importQuestionSetConfig( ilObjTest $test_object ): void { $config = $tt->denormalize($normalized, QuestionSetConfig::class); - if (!$config->isRandom()) { - return; - } - - $config->getConfig()->saveToDb(); - $this->log->debug("Imported random question set config for test {$test_object->getTestId()}"); - - foreach ($config->getStagingPools() as $pool_id => $questions) { - $this->importRandomQuestionStagingPool($pool_id, $questions, $mapping, $test_object); - } - - foreach ($config->getDefinitions() as $definition) { - $this->importSourcePoolDefinition($definition, $mapping); - } - } - private function importRandomQuestionStagingPool( - int $old_pool_id, - array $questions, - ilImportMapping $mapping, - ilObjTest $test_object - ): void { - $new_pool_id = $this->database->nextId('object_data'); - $mapping->addMapping( - 'components/ILIAS/Test', - 'pool', - (string) $old_pool_id, - (string) $new_pool_id - ); - $this->log->debug("Imported random question staging pool: {$old_pool_id} -> {$new_pool_id}"); - - // QuestionID was mapped during question set config denormalization - foreach ($questions as $question_id) { - $question = new ilTestRandomQuestionSetStagingPoolQuestion($this->database); - $question->setTestId($test_object->getTestId()); - $question->setPoolId($new_pool_id); - $question->setQuestionId($question_id); - $question->saveQuestionStaging(); - $this->log->debug("Imported random question staging question: {$question_id}"); + if ($config->isRandom()) { + $this->random_test_config_importer->import($config, $mapping, $test_object); } } - private function importSourcePoolDefinition( - ilTestRandomQuestionSetSourcePoolDefinition $definition, - ilImportMapping $mapping, - ): void { - // New PoolID was not available during denormalization, so we have to map it here - $old_pool_id = $definition->getPoolId(); - $new_pool_id = (int) $mapping->getMapping('components/ILIAS/Test', 'pool', (string) $old_pool_id); - $definition->setPoolId($new_pool_id); - - if ($old_pool_id !== $new_pool_id) { - $ref_ids = $this->data_factory->objId($new_pool_id)->toReferenceIds(); - if (count($ref_ids) > 0) { - $definition->setPoolRefId(current($ref_ids)->toInt()); - $this->log->debug("Derived source pool definition from Object ID: {$old_pool_id} -> {$definition->getPoolRefId()}"); - } - } - - $old_definition_id = $definition->getId(); - $definition->setId(0); - $definition->saveToDb(); - $this->log->debug("Imported source pool definition: {$old_definition_id} -> {$definition->getId()}"); - - $mapping->addMapping( - 'components/ILIAS/Test', - 'rnd_src_pool_def', - (string) $old_definition_id, - (string) $definition->getId() - ); - } - private function importParticipants(array $list, Transformations $tt, ilImportMapping $mapping): void { foreach ($list as $normalized) { @@ -518,214 +445,4 @@ private function importInvitedParticipant(array $normalized, Transformations $tt ]); $this->log->debug("Stored invited participant in database: {$participant->getUserId()} (User ID), {$participant->getTestId()} (Test ID)"); } - - /* - Results - */ - - private function importGroupedResults(array $list, Transformations $tt): void - { - foreach ($list as $set) { - foreach ($set as $name => $data) { - match($name) { - 'sequences' => $this->importTestSequences($data, $tt), - 'solutions' => $this->importSolutions($data, $tt), - 'results' => $this->importQuestionResults($data, $tt), - 'attempts' => $this->importAttemptResults($data, $tt), - 'test_result' => $this->importTestResult($data, $tt), - 'working_times' => $this->importWorkingTimes($data, $tt), - 'manual_feedback' => $this->importManualFeedback($data, $tt), - 'manual_scoring' => $this->importManualScoring($data, $tt), - default => $this->log->warning("Invalid result type: {$name}"), - }; - } - } - } - - private function importTestSequences(array $list, Transformations $tt): void - { - foreach ($list as $normalized) { - // ActiveID and QuestionIDs will be replaced by the mapping pipe - $sequence = $tt->denormalize($normalized, ilTestSequence::class); - $sequence->saveToDb(); - $this->log->debug("Stored test sequence in database: {$sequence->getActiveId()} (Active ID), {$sequence->getPass()} (Pass)"); - } - } - - private function importSolutions(array $list, Transformations $tt): void - { - foreach ($list as $normalized) { - // ActiveID and QuestionID will be replaced by the mapping pipe - $solution = $tt->denormalize($normalized, Solution::class); - - $next_id = $this->database->nextId('tst_solutions'); - $this->database->insert( - 'tst_solutions', - [ - 'solution_id' => [ilDBConstants::T_INTEGER, $next_id], - 'active_fi' => [ilDBConstants::T_INTEGER, $solution->active_id->getId()], - 'question_fi' => [ilDBConstants::T_INTEGER, $solution->question_id->getId()], - 'pass' => [ilDBConstants::T_INTEGER, $solution->attempt], - 'value1' => [ilDBConstants::T_TEXT, $solution->value1 !== null ? (string) $solution->value1 : null], - 'value2' => [ilDBConstants::T_TEXT, $solution->value2], - 'points' => [ilDBConstants::T_FLOAT, $solution->points], - 'step' => [ilDBConstants::T_INTEGER, $solution->step], - 'authorized' => [ilDBConstants::T_INTEGER, $solution->authorized ? 1 : 0], - 'tstamp' => [ilDBConstants::T_INTEGER, time()], - ] - ); - $this->log->debug("Stored solution in database: {$next_id}"); - } - } - - private function importQuestionResults(array $list, Transformations $tt): void - { - foreach ($list as $normalized) { - // ActiveID and QuestionID will be replaced by the mapping pipe - $result = $tt->denormalize($normalized, QuestionResult::class); - - $next_id = $this->database->nextId('tst_test_result'); - $this->database->insert( - 'tst_test_result', - [ - 'test_result_id' => [ilDBConstants::T_INTEGER, $next_id], - 'active_fi' => [ilDBConstants::T_INTEGER, $result->active_id->getId()], - 'question_fi' => [ilDBConstants::T_INTEGER, $result->question_id->getId()], - 'pass' => [ilDBConstants::T_INTEGER, $result->attempt], - 'points' => [ilDBConstants::T_FLOAT, $result->points], - 'manual' => [ilDBConstants::T_INTEGER, $result->manual ? 1 : 0], - 'tstamp' => [ilDBConstants::T_INTEGER, time()], - 'answered' => [ilDBConstants::T_INTEGER, $result->answered ? 1 : 0], - 'step' => [ilDBConstants::T_INTEGER, $result->step], - ] - ); - $this->log->debug("Stored question result in database: {$next_id}"); - } - } - private function importAttemptResults(array $list, Transformations $tt): void - { - foreach ($list as $normalized) { - // ActiveID will be replaced by the mapping pipe - $attempt = $tt->denormalize($normalized, AttemptResult::class); - - $this->database->insert( - 'tst_pass_result', - [ - 'active_fi' => [ilDBConstants::T_INTEGER, $attempt->getActiveId()], - 'pass' => [ilDBConstants::T_INTEGER, $attempt->getAttempt()], - 'maxpoints' => [ilDBConstants::T_FLOAT, $attempt->getMaxPoints()], - 'points' => [ilDBConstants::T_FLOAT, $attempt->getReachedPoints()], - 'questioncount' => [ilDBConstants::T_INTEGER, $attempt->getQuestionCount()], - 'answeredquestions' => [ilDBConstants::T_INTEGER, $attempt->getAnsweredQuestions()], - 'workingtime' => [ilDBConstants::T_INTEGER, $attempt->getWorkingTime()], - 'exam_id' => [ilDBConstants::T_TEXT, $attempt->getExamId()], - 'finalized_by' => [ilDBConstants::T_TEXT, $attempt->getFinalizedBy()], - 'tstamp' => [ilDBConstants::T_INTEGER, time()], - ] - ); - $this->log->debug("Stored attempt result in database: {$attempt->getActiveId()} (Active ID), {$attempt->getAttempt()} (Pass)"); - } - } - - private function importTestResult(?array $normalized, Transformations $tt): void - { - if ($normalized === null) { - $this->log->warning("Missing test result, skipping"); - return; - } - - // ActiveID will be replaced by the mapping pipe - $result = $tt->denormalize($normalized, ParticipantResult::class); - - $this->database->insert( - 'tst_result_cache', - [ - 'active_fi' => [ilDBConstants::T_INTEGER, $result->getActiveId()], - 'pass' => [ilDBConstants::T_INTEGER, $result->getAttempt()], - 'max_points' => [ilDBConstants::T_FLOAT, $result->getMaxPoints()], - 'reached_points' => [ilDBConstants::T_FLOAT, $result->getReachedPoints()], - 'mark_short' => [ilDBConstants::T_TEXT, $result->getMark()->getShortName()], - 'mark_official' => [ilDBConstants::T_TEXT, $result->getMark()->getOfficialName()], - 'passed' => [ilDBConstants::T_INTEGER, $result->isPassed() ? 1 : 0], - 'failed' => [ilDBConstants::T_INTEGER, $result->isFailed() ? 1 : 0], - 'tstamp' => [ilDBConstants::T_INTEGER, time()], - ] - ); - $this->log->debug("Stored test result in database: {$result->getActiveId()} (Active ID), {$result->getAttempt()} (Pass)"); - } - - private function importWorkingTimes(array $list, Transformations $tt): void - { - foreach ($list as $normalized) { - // ActiveID will be replaced by the mapping pipe - $working_time = $tt->denormalize($normalized, WorkingTime::class); - - $next_id = $this->database->nextId('tst_times'); - $this->database->insert( - 'tst_times', - [ - 'times_id' => [ilDBConstants::T_INTEGER, $next_id], - 'active_fi' => [ilDBConstants::T_INTEGER, $working_time->active_id->getId()], - 'pass' => [ilDBConstants::T_INTEGER, $working_time->attempt], - 'started' => [ilDBConstants::T_TIMESTAMP, $working_time->started], - 'finished' => [ilDBConstants::T_TIMESTAMP, $working_time->finished], - 'tstamp' => [ilDBConstants::T_INTEGER, time()], - ] - ); - $this->log->debug("Stored working time in database: {$next_id}"); - } - } - - private function importManualFeedback(array $list, Transformations $tt): void - { - foreach ($list as $normalized) { - // ActiveID, QuestionID and UserID will be replaced by the mapping pipe - $manual_feedback = $tt->denormalize($normalized, ManualFeedback::class); - - $next_id = $this->database->nextId('tst_manual_fb'); - $this->database->insert( - 'tst_manual_fb', - [ - 'manual_feedback_id' => [ilDBConstants::T_INTEGER, $next_id], - 'active_fi' => [ilDBConstants::T_INTEGER, $manual_feedback->active_id->getId()], - 'question_fi' => [ilDBConstants::T_INTEGER, $manual_feedback->question_id->getId()], - 'pass' => [ilDBConstants::T_INTEGER, $manual_feedback->attempt], - 'feedback' => [ilDBConstants::T_TEXT, $manual_feedback->feedback], - 'finalized_evaluation' => [ilDBConstants::T_INTEGER, $manual_feedback->finalized_evaluation ? 1 : 0], - 'finalized_timestamp' => [ilDBConstants::T_INTEGER, $manual_feedback->finalized_timestamp], - 'finalized_by_usr_id' => [ilDBConstants::T_INTEGER, $manual_feedback->finalized_by->getId()], - 'tstamp' => [ilDBConstants::T_INTEGER, time()], - ] - ); - $this->log->debug("Stored manual feedback in database: {$next_id}"); - } - } - - private function importManualScoring(array $normalized, Transformations $tt): void - { - // ActiveID will be replaced by the mapping pipe - $active_id = $tt->denormalize($normalized['active_id'], Id::class)->getId(); - - new TestManScoringDoneHelper()->setDone($active_id, $tt->bool($normalized['done'])); - $this->log->debug("Stored manual scoring in database: {$active_id} (Active ID)"); - } - - private function importAdditionalWorkingTimes(array $list, Transformations $tt): void - { - foreach ($list as $normalized) { - // UserID and TestID will be replaced by the mapping pipe - $time = $tt->denormalize($normalized, AdditionalWorkingTime::class); - - $this->database->insert( - 'tst_addtime', - [ - 'additionaltime' => [ilDBConstants::T_INTEGER, $time->time], - 'user_fi' => [ilDBConstants::T_INTEGER, $time->user_id->getId()], - 'test_fi' => [ilDBConstants::T_INTEGER, $time->test_id->getId()], - 'tstamp' => [ilDBConstants::T_TIMESTAMP, $time->timestamp], - ] - ); - $this->log->debug("Stored additional working time in database: {$time->user_id->getId()} (User ID)"); - } - } } 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..ffceef4aa72f --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Import/TestResultsImporter.php @@ -0,0 +1,258 @@ + $data) { + match($name) { + 'sequences' => $this->importTestSequences($data, $tt), + 'solutions' => $this->importSolutions($data, $tt), + 'results' => $this->importQuestionResults($data, $tt), + 'attempts' => $this->importAttemptResults($data, $tt), + 'test_result' => $this->importTestResult($data, $tt), + 'working_times' => $this->importWorkingTimes($data, $tt), + 'manual_feedback' => $this->importManualFeedback($data, $tt), + 'manual_scoring' => $this->importManualScoring($data, $tt), + default => $this->log->warning("Invalid result type: {$name}"), + }; + } + } + } + + public function importTestSequences(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // ActiveID and QuestionIDs will be replaced by the mapping pipe + $sequence = $tt->denormalize($normalized, ilTestSequence::class); + $sequence->saveToDb(); + $this->log->debug("Stored test sequence in database: {$sequence->getActiveId()} (Active ID), {$sequence->getPass()} (Pass)"); + } + } + + public function importSolutions(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // ActiveID and QuestionID will be replaced by the mapping pipe + $solution = $tt->denormalize($normalized, Solution::class); + + $next_id = $this->database->nextId('tst_solutions'); + $this->database->insert( + 'tst_solutions', + [ + 'solution_id' => [ilDBConstants::T_INTEGER, $next_id], + 'active_fi' => [ilDBConstants::T_INTEGER, $solution->active_id->getId()], + 'question_fi' => [ilDBConstants::T_INTEGER, $solution->question_id->getId()], + 'pass' => [ilDBConstants::T_INTEGER, $solution->attempt], + 'value1' => [ilDBConstants::T_TEXT, $solution->value1 !== null ? (string) $solution->value1 : null], + 'value2' => [ilDBConstants::T_TEXT, $solution->value2], + 'points' => [ilDBConstants::T_FLOAT, $solution->points], + 'step' => [ilDBConstants::T_INTEGER, $solution->step], + 'authorized' => [ilDBConstants::T_INTEGER, $solution->authorized ? 1 : 0], + 'tstamp' => [ilDBConstants::T_INTEGER, time()], + ] + ); + $this->log->debug("Stored solution in database: {$next_id}"); + } + } + + public function importQuestionResults(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // ActiveID and QuestionID will be replaced by the mapping pipe + $result = $tt->denormalize($normalized, QuestionResult::class); + + $next_id = $this->database->nextId('tst_test_result'); + $this->database->insert( + 'tst_test_result', + [ + 'test_result_id' => [ilDBConstants::T_INTEGER, $next_id], + 'active_fi' => [ilDBConstants::T_INTEGER, $result->active_id->getId()], + 'question_fi' => [ilDBConstants::T_INTEGER, $result->question_id->getId()], + 'pass' => [ilDBConstants::T_INTEGER, $result->attempt], + 'points' => [ilDBConstants::T_FLOAT, $result->points], + 'manual' => [ilDBConstants::T_INTEGER, $result->manual ? 1 : 0], + 'tstamp' => [ilDBConstants::T_INTEGER, time()], + 'answered' => [ilDBConstants::T_INTEGER, $result->answered ? 1 : 0], + 'step' => [ilDBConstants::T_INTEGER, $result->step], + ] + ); + $this->log->debug("Stored question result in database: {$next_id}"); + } + } + public function importAttemptResults(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // ActiveID will be replaced by the mapping pipe + $attempt = $tt->denormalize($normalized, AttemptResult::class); + + $this->database->insert( + 'tst_pass_result', + [ + 'active_fi' => [ilDBConstants::T_INTEGER, $attempt->getActiveId()], + 'pass' => [ilDBConstants::T_INTEGER, $attempt->getAttempt()], + 'maxpoints' => [ilDBConstants::T_FLOAT, $attempt->getMaxPoints()], + 'points' => [ilDBConstants::T_FLOAT, $attempt->getReachedPoints()], + 'questioncount' => [ilDBConstants::T_INTEGER, $attempt->getQuestionCount()], + 'answeredquestions' => [ilDBConstants::T_INTEGER, $attempt->getAnsweredQuestions()], + 'workingtime' => [ilDBConstants::T_INTEGER, $attempt->getWorkingTime()], + 'exam_id' => [ilDBConstants::T_TEXT, $attempt->getExamId()], + 'finalized_by' => [ilDBConstants::T_TEXT, $attempt->getFinalizedBy()], + 'tstamp' => [ilDBConstants::T_INTEGER, time()], + ] + ); + $this->log->debug("Stored attempt result in database: {$attempt->getActiveId()} (Active ID), {$attempt->getAttempt()} (Pass)"); + } + } + + public function importTestResult(?array $normalized, Transformations $tt): void + { + if ($normalized === null) { + $this->log->warning("Missing test result, skipping"); + return; + } + + // ActiveID will be replaced by the mapping pipe + $result = $tt->denormalize($normalized, ParticipantResult::class); + + $this->database->insert( + 'tst_result_cache', + [ + 'active_fi' => [ilDBConstants::T_INTEGER, $result->getActiveId()], + 'pass' => [ilDBConstants::T_INTEGER, $result->getAttempt()], + 'max_points' => [ilDBConstants::T_FLOAT, $result->getMaxPoints()], + 'reached_points' => [ilDBConstants::T_FLOAT, $result->getReachedPoints()], + 'mark_short' => [ilDBConstants::T_TEXT, $result->getMark()->getShortName()], + 'mark_official' => [ilDBConstants::T_TEXT, $result->getMark()->getOfficialName()], + 'passed' => [ilDBConstants::T_INTEGER, $result->isPassed() ? 1 : 0], + 'failed' => [ilDBConstants::T_INTEGER, $result->isFailed() ? 1 : 0], + 'tstamp' => [ilDBConstants::T_INTEGER, time()], + ] + ); + $this->log->debug("Stored test result in database: {$result->getActiveId()} (Active ID), {$result->getAttempt()} (Pass)"); + } + + public function importWorkingTimes(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // ActiveID will be replaced by the mapping pipe + $working_time = $tt->denormalize($normalized, WorkingTime::class); + + $next_id = $this->database->nextId('tst_times'); + $this->database->insert( + 'tst_times', + [ + 'times_id' => [ilDBConstants::T_INTEGER, $next_id], + 'active_fi' => [ilDBConstants::T_INTEGER, $working_time->active_id->getId()], + 'pass' => [ilDBConstants::T_INTEGER, $working_time->attempt], + 'started' => [ilDBConstants::T_TIMESTAMP, $working_time->started], + 'finished' => [ilDBConstants::T_TIMESTAMP, $working_time->finished], + 'tstamp' => [ilDBConstants::T_INTEGER, time()], + ] + ); + $this->log->debug("Stored working time in database: {$next_id}"); + } + } + + public function importManualFeedback(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // ActiveID, QuestionID and UserID will be replaced by the mapping pipe + $manual_feedback = $tt->denormalize($normalized, ManualFeedback::class); + + $next_id = $this->database->nextId('tst_manual_fb'); + $this->database->insert( + 'tst_manual_fb', + [ + 'manual_feedback_id' => [ilDBConstants::T_INTEGER, $next_id], + 'active_fi' => [ilDBConstants::T_INTEGER, $manual_feedback->active_id->getId()], + 'question_fi' => [ilDBConstants::T_INTEGER, $manual_feedback->question_id->getId()], + 'pass' => [ilDBConstants::T_INTEGER, $manual_feedback->attempt], + 'feedback' => [ilDBConstants::T_TEXT, $manual_feedback->feedback], + 'finalized_evaluation' => [ilDBConstants::T_INTEGER, $manual_feedback->finalized_evaluation ? 1 : 0], + 'finalized_timestamp' => [ilDBConstants::T_INTEGER, $manual_feedback->finalized_timestamp], + 'finalized_by_usr_id' => [ilDBConstants::T_INTEGER, $manual_feedback->finalized_by->getId()], + 'tstamp' => [ilDBConstants::T_INTEGER, time()], + ] + ); + $this->log->debug("Stored manual feedback in database: {$next_id}"); + } + } + + public function importManualScoring(array $normalized, Transformations $tt): void + { + // ActiveID will be replaced by the mapping pipe + $active_id = $tt->denormalize($normalized['active_id'], Id::class)->getId(); + + new TestManScoringDoneHelper()->setDone($active_id, $tt->bool($normalized['done'])); + $this->log->debug("Stored manual scoring in database: {$active_id} (Active ID)"); + } + + public function importAdditionalWorkingTimes(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // UserID and TestID will be replaced by the mapping pipe + $time = $tt->denormalize($normalized, AdditionalWorkingTime::class); + + $this->database->insert( + 'tst_addtime', + [ + 'additionaltime' => [ilDBConstants::T_INTEGER, $time->time], + 'user_fi' => [ilDBConstants::T_INTEGER, $time->user_id->getId()], + 'test_fi' => [ilDBConstants::T_INTEGER, $time->test_id->getId()], + 'tstamp' => [ilDBConstants::T_TIMESTAMP, $time->timestamp], + ] + ); + $this->log->debug("Stored additional working time in database: {$time->user_id->getId()} (User ID)"); + } + } +} diff --git a/components/ILIAS/Test/src/TestDIC.php b/components/ILIAS/Test/src/TestDIC.php index f70861b60b82..75da2f9ad128 100755 --- a/components/ILIAS/Test/src/TestDIC.php +++ b/components/ILIAS/Test/src/TestDIC.php @@ -21,6 +21,8 @@ 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; @@ -302,6 +304,17 @@ protected static function buildDIC(ILIASContainer $DIC): self $DIC->fileConverters()->images(), $DIC->filesystem() ); + $dic['exportimport.test_results_importer'] = static fn($c): TestResultsImporter => + new TestResultsImporter( + $DIC->database(), + $c['exportimport.logging']() + ); + $dic['exportimport.random_test_config_importer'] = static fn($c): RandomTestConfigImporter => + new RandomTestConfigImporter( + $DIC->database(), + $c['exportimport.logging'](), + new DataFactory() + ); $dic['exportimport.importer'] = static fn($c): TestImporter => new TestImporter( $c['exportimport.builder'], @@ -310,6 +323,8 @@ protected static function buildDIC(ILIASContainer $DIC): self $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'] From 962276eeba9848e5371f07fa547384b1ad325cbc Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Thu, 7 May 2026 11:23:30 +0200 Subject: [PATCH 39/43] fix(test): support random test question reference export/import --- ...tRandomQuestionSetSourcePoolDefinition.php | 2 +- .../Envelopes/RandomTestQuestion.php | 80 +++++++++++++++++++ .../Import/TestResultsImporter.php | 25 ++++++ .../Test/src/ExportImport/TestCollector.php | 26 +++++- 4 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 components/ILIAS/Test/src/ExportImport/Envelopes/RandomTestQuestion.php diff --git a/components/ILIAS/Test/classes/class.ilTestRandomQuestionSetSourcePoolDefinition.php b/components/ILIAS/Test/classes/class.ilTestRandomQuestionSetSourcePoolDefinition.php index c8f81179befa..3f52b21035d6 100755 --- a/components/ILIAS/Test/classes/class.ilTestRandomQuestionSetSourcePoolDefinition.php +++ b/components/ILIAS/Test/classes/class.ilTestRandomQuestionSetSourcePoolDefinition.php @@ -463,7 +463,7 @@ public function toNormalized(Transformations $tt): Transformation { return $tt->custom()->transformation(function () use ($tt): array { $normalized = [ - 'id' => $tt->normalize(new Id($this->getId(), static::class)), + 'id' => $tt->normalize(new Id($this->getId(), 'rnd_src_pool_def')), 'pool_id' => $tt->normalize(new Id($this->getPoolId(), 'qpl')), 'pool_title' => $this->getPoolTitle(), 'pool_path' => $this->getPoolPath(), diff --git a/components/ILIAS/Test/src/ExportImport/Envelopes/RandomTestQuestion.php b/components/ILIAS/Test/src/ExportImport/Envelopes/RandomTestQuestion.php new file mode 100644 index 000000000000..e759fa26f9ed --- /dev/null +++ b/components/ILIAS/Test/src/ExportImport/Envelopes/RandomTestQuestion.php @@ -0,0 +1,80 @@ + $tt->normalize($this->active_id), + 'question_id' => $tt->normalize($this->question_id), + 'sequence' => $this->sequence, + 'pass' => $this->pass, + 'timestamp' => $this->timestamp, + 'src_pool_def_id' => $tt->normalize($this->src_pool_def_id), + ]; + } + + /** + * @inheritDoc + */ + public static function fromArray(array $value, Transformations $tt): static + { + return new self( + $tt->denormalize($value['active_id'], Id::class), + $tt->denormalize($value['question_id'], Id::class), + $tt->int($value['sequence']), + $tt->int($value['pass']), + $tt->int($value['timestamp']), + $tt->denormalize($value['src_pool_def_id'], Id::class), + ); + } + + public static function fromRow(array $row): static + { + return new self( + new Id($row['active_fi'], 'participant'), + new Id($row['question_fi'], 'question'), + (int) $row['sequence'], + (int) $row['pass'], + (int) $row['tstamp'], + new Id($row['src_pool_def_fi'], 'rnd_src_pool_def'), + ); + } +} diff --git a/components/ILIAS/Test/src/ExportImport/Import/TestResultsImporter.php b/components/ILIAS/Test/src/ExportImport/Import/TestResultsImporter.php index ffceef4aa72f..e01733bd9ca9 100644 --- a/components/ILIAS/Test/src/ExportImport/Import/TestResultsImporter.php +++ b/components/ILIAS/Test/src/ExportImport/Import/TestResultsImporter.php @@ -25,6 +25,7 @@ use ILIAS\Test\ExportImport\Envelopes\AdditionalWorkingTime; use ILIAS\Test\ExportImport\Envelopes\ManualFeedback; use ILIAS\Test\ExportImport\Envelopes\QuestionResult; +use ILIAS\Test\ExportImport\Envelopes\RandomTestQuestion; use ILIAS\Test\ExportImport\Envelopes\Solution; use ILIAS\Test\ExportImport\Envelopes\WorkingTime; use ILIAS\Test\Results\Data\AttemptResult; @@ -63,6 +64,7 @@ public function import(array $list, Transformations $tt): void '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}"), }; } @@ -255,4 +257,27 @@ public function importAdditionalWorkingTimes(array $list, Transformations $tt): $this->log->debug("Stored additional working time in database: {$time->user_id->getId()} (User ID)"); } } + + public function importRandomTestQuestions(array $list, Transformations $tt): void + { + foreach ($list as $normalized) { + // ActiveID, QuestionID and SourcePoolDefinitionID will be replaced by the mapping pipe + $question = $tt->denormalize($normalized, RandomTestQuestion::class); + + $next_id = $this->database->nextId('tst_test_rnd_qst'); + $this->database->insert( + 'tst_test_rnd_qst', + [ + 'test_random_question_id' => [ilDBConstants::T_INTEGER, $next_id], + 'active_fi' => [ilDBConstants::T_INTEGER, $question->active_id->getId()], + 'question_fi' => [ilDBConstants::T_INTEGER, $question->question_id->getId()], + 'sequence' => [ilDBConstants::T_INTEGER, $question->sequence], + 'pass' => [ilDBConstants::T_INTEGER, $question->pass], + 'tstamp' => [ilDBConstants::T_INTEGER, time()], + 'src_pool_def_fi' => [ilDBConstants::T_INTEGER, $question->src_pool_def_id->getId()], + ] + ); + $this->log->debug("Stored random test question in database: {$next_id}"); + } + } } diff --git a/components/ILIAS/Test/src/ExportImport/TestCollector.php b/components/ILIAS/Test/src/ExportImport/TestCollector.php index e41250590b03..538d194e3b6f 100644 --- a/components/ILIAS/Test/src/ExportImport/TestCollector.php +++ b/components/ILIAS/Test/src/ExportImport/TestCollector.php @@ -30,6 +30,7 @@ use ILIAS\Test\ExportImport\Envelopes\ManualFeedback; use ILIAS\Test\ExportImport\Envelopes\QuestionResult; use ILIAS\Test\ExportImport\Envelopes\QuestionSetConfig; +use ILIAS\Test\ExportImport\Envelopes\RandomTestQuestion; use ILIAS\Test\ExportImport\Envelopes\Solution; use ILIAS\Test\ExportImport\Envelopes\WorkingTime; use ILIAS\Test\Logging\TestLogger; @@ -45,7 +46,6 @@ use ILIAS\TestQuestionPool\Questions\GeneralQuestionPropertiesRepository; use ilObjTest; use ilTestQuestionSetConfigFactory; -use ilTestRandomQuestionSetConfig; use ilTestRandomQuestionSetSourcePoolDefinitionFactory; use ilTestRandomQuestionSetSourcePoolDefinitionList; use ilTestRandomQuestionSetStagingPoolQuestionList; @@ -303,7 +303,7 @@ public function getAdditionalParticipantData(array $participant_ids): array public function getResults(int $participant_id): array { $attempt_results = $this->results_repository->getTestAttemptResults($participant_id); - return [ + $set = [ 'sequences' => $this->getSequences($participant_id, array_keys($attempt_results)), 'solutions' => $this->getSolutions($participant_id), 'results' => $this->getQuestionResults($participant_id), @@ -316,6 +316,11 @@ public function getResults(int $participant_id): array 'done' => $this->manual_scoring->isDone($participant_id), ], ]; + + if ($this->getObject()->isRandomTest()) { + $set['questions'] = $this->getRandomTestQuestions($participant_id); + } + return $set; } /** @@ -417,4 +422,21 @@ public function getAdditionalWorkingTimes(): array $this->db->fetchAll($query) ); } + + /** + * @return list + */ + public function getRandomTestQuestions(int $participant_id): array + { + $query = $this->db->queryF( + "SELECT * FROM tst_test_rnd_qst WHERE active_fi = %s", + [ilDBConstants::T_INTEGER], + [$participant_id] + ); + + return array_map( + fn(array $row): RandomTestQuestion => RandomTestQuestion::fromRow($row), + $this->db->fetchAll($query) + ); + } } From de931707da0a7a746c136cc697b682258cb50a6e Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Fri, 8 May 2026 13:59:34 +0200 Subject: [PATCH 40/43] feat(test): support legacy test import and handle redirect after import completed --- .../ILIAS/Test/classes/class.ilObjTestGUI.php | 126 +++++------------- .../classes/class.ilTestLegacyImporter.php | 89 ++++++------- .../src/ExportImport/Import/PersistStage.php | 34 +++-- .../src/ExportImport/Import/CleanupStage.php | 4 +- 4 files changed, 94 insertions(+), 159 deletions(-) diff --git a/components/ILIAS/Test/classes/class.ilObjTestGUI.php b/components/ILIAS/Test/classes/class.ilObjTestGUI.php index fee5a827561d..fc20db4840f4 100755 --- a/components/ILIAS/Test/classes/class.ilObjTestGUI.php +++ b/components/ILIAS/Test/classes/class.ilObjTestGUI.php @@ -1412,13 +1412,42 @@ public function processImportObject(): void break; case StageResultType::COMPLETE: - $this->tpl->setOnScreenMessage('success', $this->lng->txt('object_imported'), true); - $this->ctrl->setParameterByClass(ilObjTestGUI::class, 'ref_id', $result->context->get('test_ref_id')); - $this->ctrl->redirectByClass(self::class, self::SHOW_QUESTIONS_CMD); + $this->afterImportCompleted($result->context); break; } } + 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 ($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( + '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'); @@ -1484,97 +1513,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 */ diff --git a/components/ILIAS/Test/classes/class.ilTestLegacyImporter.php b/components/ILIAS/Test/classes/class.ilTestLegacyImporter.php index 494752bc1602..8a44bafdc327 100755 --- a/components/ILIAS/Test/classes/class.ilTestLegacyImporter.php +++ b/components/ILIAS/Test/classes/class.ilTestLegacyImporter.php @@ -19,17 +19,15 @@ declare(strict_types=1); use ILIAS\ResourceStorage\Services as ResourceStorage; +use ILIAS\Test\RequestDataCollector; +use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportSessionRepository; +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\Test\TestDIC; use ILIAS\Test\Logging\TestLogger; -/** - * Importer class for files - * - * @author Stefan Meyer - * @version $Id$ - * @ingroup components\ILIASLearningModule - */ class ilTestLegacyImporter extends ilXmlImporter { use TestQuestionsImportTrait; @@ -41,15 +39,20 @@ class ilTestLegacyImporter extends ilXmlImporter private readonly TestLogger $logger; private readonly ilDBInterface $db; private readonly ResourceStorage $irss; + private readonly ImportSessionRepository $session; + private readonly RequestDataCollector $request_data_collector; public function __construct() { global $DIC; - $this->logger = TestDIC::dic()['logging.logger']; + parent::__construct(); + + $local_dic = TestDIC::dic(); + $this->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']; - - parent::__construct(); } public function importXmlRepresentation( @@ -58,47 +61,28 @@ 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 = new ilObjTest(0, true); + $new_obj->setTitle('dummy'); + $new_obj->setDescription('test import'); + $new_obj->create(true); + $new_obj->createReference(); + $new_obj->putInTree($this->request_data_collector->getRefId()); + $new_obj->setPermissions($this->request_data_collector->getRefId()); + $new_obj->saveToDb(); - $new_obj->loadFromDb(); + $a_mapping->addMapping('components/ILIAS/Test', 'tst', 'new_id', (string) $new_obj->getId()); - 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); - return; - } + $context = $this->session->getContext(); + $import_base_dir = $context->get(UploadValidationStage::IMPORT_BASE_DIR); + $xml_file = $context->get(DetectLegacyImportStage::LEGACY_XML_FILE); // start parsing of QTI files $qti_parser = new ilQTIParser( - $importdir, - $qtifile, + $import_base_dir, + $context->get(DetectLegacyImportStage::LEGACY_QTI_FILE), ilQTIParser::IL_MO_PARSE_QTI, $new_obj->getId(), - $selected_questions, + $context->get('selected_questions'), $a_mapping->getAllMappings() ); $qti_parser->setTestObject($new_obj); @@ -108,8 +92,8 @@ public function importXmlRepresentation( // import page data $question_page_parser = new ilQuestionPageParser( $new_obj, - $xmlfile, - $importdir + $xml_file, + $import_base_dir ); $question_page_parser->setQuestionMapping($qti_parser->getImportMapping()); $question_page_parser->startParsing(); @@ -117,10 +101,11 @@ public function importXmlRepresentation( $a_mapping = $this->addTaxonomyAndQuestionsMapping($qti_parser->getQuestionIdMapping(), $new_obj->getId(), $a_mapping); if ($new_obj->isRandomTest()) { - $this->importRandomQuestionSetConfig($new_obj, $xmlfile, $a_mapping); + $this->importRandomQuestionSetConfig($new_obj, $xml_file, $a_mapping); } - if ($results_file_path !== null && file_exists($results_file_path)) { + $results_file_path = str_replace('__tst', '__results', $xml_file); + if (file_exists($results_file_path)) { $results = new ilTestResultsImportParser($results_file_path, $new_obj, $this->db, $this->logger, $this->irss); $results->setQuestionIdMapping($a_mapping->getMappingsOfEntity('components/ILIAS/Test', 'quest')); $results->setSrcPoolDefIdMapping($a_mapping->getMappingsOfEntity('components/ILIAS/Test', 'rnd_src_pool_def')); @@ -132,18 +117,20 @@ public function importXmlRepresentation( $this->importSkillLevelThresholds( $a_mapping, - $this->importQuestionSkillAssignments($a_mapping, $new_obj, $xmlfile), + $this->importQuestionSkillAssignments($a_mapping, $new_obj, $xml_file), $new_obj, - $xmlfile + $xml_file ); - $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" ); + + $context = $context->with('test_obj_id', $new_obj->getId())->with('test_ref_id', $new_obj->getRefId()); + $this->session->setContext($context); } public function addTaxonomyAndQuestionsMapping( diff --git a/components/ILIAS/Test/src/ExportImport/Import/PersistStage.php b/components/ILIAS/Test/src/ExportImport/Import/PersistStage.php index fd054b7a89b7..b0dad1fac42a 100644 --- a/components/ILIAS/Test/src/ExportImport/Import/PersistStage.php +++ b/components/ILIAS/Test/src/ExportImport/Import/PersistStage.php @@ -26,6 +26,7 @@ use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportSessionRepository; use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\StageResult; use ILIAS\TestQuestionPool\ExportImport\Foundation\Serializing\XMLFileDeserializer; +use ILIAS\TestQuestionPool\ExportImport\Import\DetectLegacyImportStage; use ILIAS\TestQuestionPool\ExportImport\Import\UploadValidationStage; use ilImport; @@ -60,20 +61,12 @@ public function getDescription(): ?string public function process(ImportContext $context): StageResult { - $component_import_dir = dirname($context->get(UploadValidationStage::COMPONENT_IMPORT_FILE)); - $mappings_file = "{$component_import_dir}/mappings.xml"; - if (!file_exists($mappings_file) || !is_file($mappings_file)) { - return StageResult::error($context, $this->lng->txt('obj_import_file_error')); + if(!DetectLegacyImportStage::isLegacyImport($context)) { + if($result = $this->importMappingsFile($context)) { + return $result; + } } - $deserializer = new XMLFileDeserializer()->open($mappings_file); - $deserializer->addHandler('mappings', function (array $mappings) use (&$context) { - $context = $context->with('mappings', $mappings); - }); - $deserializer->process(); - - $this->session->setContext($context); - $importer = new ilImport($this->requested_ref_id); $importer->importObject( null, @@ -87,4 +80,21 @@ public function process(ImportContext $context): StageResult // Context is updated by the TestImporter so we need to reload it return StageResult::complete($this->session->getContext()); } + + private function importMappingsFile(ImportContext $context): ?StageResult { + $component_import_dir = dirname($context->get(UploadValidationStage::COMPONENT_IMPORT_FILE)); + $mappings_file = "{$component_import_dir}/mappings.xml"; + if (!file_exists($mappings_file) || !is_file($mappings_file)) { + return StageResult::error($context, $this->lng->txt('obj_import_file_error')); + } + + $deserializer = new XMLFileDeserializer()->open($mappings_file); + $deserializer->addHandler('mappings', function (array $mappings) use (&$context) { + $context = $context->with('mappings', $mappings); + }); + $deserializer->process(); + + $this->session->setContext($context); + return null; + } } diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/CleanupStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/CleanupStage.php index 739e3f812fce..9b926ec14f5a 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/CleanupStage.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/CleanupStage.php @@ -50,13 +50,13 @@ public function process(ImportContext $context): StageResult $file_to_import = $context->get(UploadValidationStage::FILE_TO_IMPORT); if ($file_to_import !== null) { $temp_dir = dirname($file_to_import); - if (file_exists($temp_dir) && is_dir($temp_dir)) { + if ($temp_dir && file_exists($temp_dir) && is_dir($temp_dir)) { $this->removeDirectory($temp_dir); } } $import_base_dir = $context->get(UploadValidationStage::IMPORT_BASE_DIR); - if (file_exists($import_base_dir) && is_dir($import_base_dir)) { + if ($import_base_dir && file_exists($import_base_dir) && is_dir($import_base_dir)) { $this->removeDirectory($import_base_dir); } From c757fe8a8f76d0deaa517394edcb58090ecb838f Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Wed, 13 May 2026 09:31:55 +0200 Subject: [PATCH 41/43] refactor(test/qpl): integrate logging into import/export processes for better traceability --- .../ILIAS/Test/classes/class.ilObjTestGUI.php | 22 +++++++++++++++---- .../Test/classes/class.ilTestExporter.php | 2 +- .../src/ExportImport/Import/PersistStage.php | 12 +++++++--- .../Import/SkillLevelThresholdsImporter.php | 7 +++++- .../Test/src/ExportImport/TestExporter.php | 14 +++++++++++- components/ILIAS/Test/src/TestDIC.php | 2 ++ .../classes/class.ilObjQuestionPoolGUI.php | 21 ++++++++++++++---- .../class.ilTestQuestionPoolExporter.php | 3 +-- .../Export/QuestionPoolExporter.php | 12 ++++++++++ .../src/ExportImport/Import/CleanupStage.php | 12 ++++++++++ .../Import/DetectLegacyImportStage.php | 8 +++++++ .../Import/QuestionPoolImporter.php | 13 +++++++---- .../Import/QuestionSelectionStage.php | 10 +++++++++ .../ExportImport/Import/QuestionsImporter.php | 9 ++++++++ .../Import/SkillAssignmentsImporter.php | 5 +++++ .../Import/UploadValidationStage.php | 11 +++++++++- .../TestQuestionPool/src/QuestionPoolDIC.php | 5 +---- 17 files changed, 143 insertions(+), 25 deletions(-) diff --git a/components/ILIAS/Test/classes/class.ilObjTestGUI.php b/components/ILIAS/Test/classes/class.ilObjTestGUI.php index fc20db4840f4..c341b9c32e68 100755 --- a/components/ILIAS/Test/classes/class.ilObjTestGUI.php +++ b/components/ILIAS/Test/classes/class.ilObjTestGUI.php @@ -90,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 @@ -190,6 +191,7 @@ class ilObjTestGUI extends ilObjectGUI implements ilCtrlBaseClassInterface, ilDe protected GUIFactory $gui_factory; protected SkillUsageService $skill_usage_service; protected ImportSessionRepository $import_session_repository; + protected LoggerInterface $import_logger; protected bool $create_question_mode; @@ -245,6 +247,7 @@ public function __construct() $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())) { @@ -1454,20 +1457,31 @@ private function buildImportStageRunner(): ImportStageRunner return new ImportStageRunner( [ - new UploadValidationStage($this->archives, $this->lng, 'components/ILIAS/Test'), - new DetectLegacyImportStage(), + 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->requested_ref_id, $this->import_session_repository), + new PersistStage( + $this->lng, + $this->import_logger, + $this->requested_ref_id, + $this->import_session_repository + ), ], $this->import_session_repository, - new CleanupStage() + new CleanupStage($this->import_logger) ); } diff --git a/components/ILIAS/Test/classes/class.ilTestExporter.php b/components/ILIAS/Test/classes/class.ilTestExporter.php index 85e741c91093..27ef39a891c4 100755 --- a/components/ILIAS/Test/classes/class.ilTestExporter.php +++ b/components/ILIAS/Test/classes/class.ilTestExporter.php @@ -34,7 +34,7 @@ public function init(): void $this->export_handler = new ExportHandler(); $this->state_holder = $local_dic['exportimport.state_holder']; $this->exporter = $local_dic['exportimport.exporter']; - $this->logger = $local_dic['logging.logger']; + $this->logger = $local_dic['exportimport.logging'](); } /** diff --git a/components/ILIAS/Test/src/ExportImport/Import/PersistStage.php b/components/ILIAS/Test/src/ExportImport/Import/PersistStage.php index b0dad1fac42a..14b68aba884f 100644 --- a/components/ILIAS/Test/src/ExportImport/Import/PersistStage.php +++ b/components/ILIAS/Test/src/ExportImport/Import/PersistStage.php @@ -29,6 +29,7 @@ use ILIAS\TestQuestionPool\ExportImport\Import\DetectLegacyImportStage; use ILIAS\TestQuestionPool\ExportImport\Import\UploadValidationStage; use ilImport; +use Psr\Log\LoggerInterface; /** * Final stage of the test import process. Imports the head dependencies (user and resource mappings) and then @@ -39,6 +40,7 @@ class PersistStage implements ImportStage { public function __construct( private readonly Language $lng, + private readonly LoggerInterface $log, private readonly int $requested_ref_id, private readonly ImportSessionRepository $session ) { @@ -61,8 +63,8 @@ public function getDescription(): ?string public function process(ImportContext $context): StageResult { - if(!DetectLegacyImportStage::isLegacyImport($context)) { - if($result = $this->importMappingsFile($context)) { + if (!DetectLegacyImportStage::isLegacyImport($context)) { + if ($result = $this->importMappingsFile($context)) { return $result; } } @@ -81,10 +83,12 @@ public function process(ImportContext $context): StageResult return StageResult::complete($this->session->getContext()); } - private function importMappingsFile(ImportContext $context): ?StageResult { + private function importMappingsFile(ImportContext $context): ?StageResult + { $component_import_dir = dirname($context->get(UploadValidationStage::COMPONENT_IMPORT_FILE)); $mappings_file = "{$component_import_dir}/mappings.xml"; if (!file_exists($mappings_file) || !is_file($mappings_file)) { + $this->log->error("Mappings file not found: {$mappings_file}"); return StageResult::error($context, $this->lng->txt('obj_import_file_error')); } @@ -92,7 +96,9 @@ private function importMappingsFile(ImportContext $context): ?StageResult { $deserializer->addHandler('mappings', function (array $mappings) use (&$context) { $context = $context->with('mappings', $mappings); }); + $deserializer->process(); + $this->log->info("Processed mappings file: {$mappings_file}"); $this->session->setContext($context); return null; diff --git a/components/ILIAS/Test/src/ExportImport/Import/SkillLevelThresholdsImporter.php b/components/ILIAS/Test/src/ExportImport/Import/SkillLevelThresholdsImporter.php index 0c9cf976ba0c..697e0f32e202 100644 --- a/components/ILIAS/Test/src/ExportImport/Import/SkillLevelThresholdsImporter.php +++ b/components/ILIAS/Test/src/ExportImport/Import/SkillLevelThresholdsImporter.php @@ -20,13 +20,13 @@ namespace ILIAS\Test\ExportImport\Import; -use ilBasicSkill; use ilDBInterface; use ilImportMapping; use ilSkillTreeRepository; use ilTestSkillLevelThreshold; use ilTestSkillLevelThresholdList; use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\Transformations; +use Psr\Log\LoggerInterface; /** * Imports skill level thresholds from normalized data. It maps imported skill level ids to local skill level ids using @@ -40,6 +40,7 @@ class SkillLevelThresholdsImporter { public function __construct( + private readonly LoggerInterface $log, private readonly ilDBInterface $db, private readonly ilSkillTreeRepository $skill_repo, private readonly string $component, @@ -69,9 +70,11 @@ public function import( $local_level_id = $this->getLevelIdMapping($import_install_id, $threshold->getSkillLevelId()); if ($local_level_id === null) { + $this->log->warning("Failed to find skill level id mapping for threshold: {$threshold->getSkillLevelId()}"); $result['failed'][] = $this->buildResultData($threshold); continue; } + $this->log->debug("Found skill level id mapping for threshold: {$threshold->getSkillLevelId()} -> {$local_level_id}"); $mapping->addMapping( $this->component, @@ -86,6 +89,8 @@ public function import( } $threshold_list->saveToDb(); + $this->log->debug("Saved skill level thresholds"); + return $result; } diff --git a/components/ILIAS/Test/src/ExportImport/TestExporter.php b/components/ILIAS/Test/src/ExportImport/TestExporter.php index 553e0c9b37c3..8df3d140781f 100644 --- a/components/ILIAS/Test/src/ExportImport/TestExporter.php +++ b/components/ILIAS/Test/src/ExportImport/TestExporter.php @@ -71,6 +71,7 @@ public function __construct( */ public function prepare(ExportState $state): void { + $state->logger()->info('Preparing test export (1/3)...'); $state->assertStep(ExportStep::INIT); $state->setStep(ExportStep::PREPARE); @@ -108,6 +109,7 @@ public function prepare(ExportState $state): void ->create(); $state->setTransformations($transformations); + $state->logger()->info('...Finished preparing test export (1/3)'); } private function extractObjectId(ExportState $state): ?ObjectId @@ -133,6 +135,7 @@ private function extractObjectId(ExportState $state): ?ObjectId */ public function process(ExportState $state): void { + $state->logger()->info('Processing test export (2/3)...'); $state->assertStep(ExportStep::PREPARE); $state->setStep(ExportStep::PROCESS); @@ -197,8 +200,12 @@ public function process(ExportState $state): void ); if ($state->getOption() === Types::XML_WITH_RESULTS->value) { + $state->logger()->info('Processing test results export ...'); $this->processResults($state); + $state->logger()->info('...Finished processing test results export'); } + + $state->logger()->info('...Finished processing test export (2/3)'); } private function processResults(ExportState $state): void @@ -226,6 +233,7 @@ private function processResults(ExportState $state): void */ public function write(ExportState $state): void { + $state->logger()->info('Writing test export (3/3)...'); $state->assertStep(ExportStep::PROCESS); $state->setStep(ExportStep::WRITE); @@ -239,6 +247,7 @@ public function write(ExportState $state): void $file['from'], "{$export_dir}/" . $file['to'] ); + $state->logger()->debug("Copied question image {$file['from']} to {$export_dir}/{$file['to']}"); } else { $state->logger()->warning('Question image file not found: ' . $file['from']); } @@ -252,7 +261,7 @@ public function write(ExportState $state): void $id, "{$export_dir}/resources/{$file}" ); - + $state->logger()->debug("Copied resource {$id} to {$export_dir}/resources/{$file}"); } $this->writeMappings( @@ -260,6 +269,9 @@ public function write(ExportState $state): void $state->transformations(), $state ); + $state->logger()->debug('Stored test export mappings'); + + $state->logger()->info('...Finished writing test export (3/3)'); } diff --git a/components/ILIAS/Test/src/TestDIC.php b/components/ILIAS/Test/src/TestDIC.php index 75da2f9ad128..b87ad3516f36 100755 --- a/components/ILIAS/Test/src/TestDIC.php +++ b/components/ILIAS/Test/src/TestDIC.php @@ -279,6 +279,7 @@ protected static function buildDIC(ILIASContainer $DIC): self $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', @@ -287,6 +288,7 @@ protected static function buildDIC(ILIASContainer $DIC): self $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', diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php b/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php index d7db77d9d1ac..a9667788bd03 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilObjQuestionPoolGUI.php @@ -47,6 +47,7 @@ use ILIAS\FileUpload\MimeType; use ILIAS\UI\Component\Modal\RoundTrip as RoundTripModal; use ILIAS\Style\Content\Service as ContentStyle; +use Psr\Log\LoggerInterface; /** * Class ilObjQuestionPoolGUI @@ -105,6 +106,7 @@ class ilObjQuestionPoolGUI extends ilObjectGUI implements ilCtrlBaseClassInterfa protected GeneralQuestionPropertiesRepository $questionrepository; protected GlobalTestSettings $global_test_settings; protected ImportSessionRepository $import_session_repository; + protected LoggerInterface $import_logger; public function __construct() { @@ -132,6 +134,7 @@ public function __construct() $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); @@ -1131,20 +1134,30 @@ private function buildImportStageRunner(): ImportStageRunner return new ImportStageRunner( [ - new UploadValidationStage($this->archives, $this->lng, 'components/ILIAS/TestQuestionPool'), - new DetectLegacyImportStage(), + 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), + new PersistStage( + $this->lng, + $this->request_data_collector, + $this->import_session_repository + ), ], $this->import_session_repository, - new CleanupStage() + new CleanupStage($this->import_logger) ); } diff --git a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolExporter.php b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolExporter.php index ba4e24df7c65..54e4513695f7 100755 --- a/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolExporter.php +++ b/components/ILIAS/TestQuestionPool/classes/class.ilTestQuestionPoolExporter.php @@ -26,13 +26,12 @@ class ilTestQuestionPoolExporter extends ilXmlExporter public function init(): void { - global $DIC; $local_dic = QuestionPoolDIC::dic(); $this->export_handler = new ExportHandler(); $this->state_holder = $local_dic['exportimport.state_holder']; $this->exporter = $local_dic['exportimport.exporter']; - $this->logger = $DIC->logger()->qpl()->getLogger(); + $this->logger = $local_dic['exportimport.logging'](); } /** diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Export/QuestionPoolExporter.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Export/QuestionPoolExporter.php index b4d72eab3b86..f472d0a43f81 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Export/QuestionPoolExporter.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Export/QuestionPoolExporter.php @@ -56,11 +56,13 @@ public function __construct( */ public function prepare(ExportState $state): void { + $state->logger()->info('Preparing question pool export (1/3)...'); $state->assertStep(ExportStep::INIT); $state->setStep(ExportStep::PREPARE); $pool_id = $this->extractObjectId($state); if ($pool_id === null) { + $state->logger()->warning('No question pool object ID found for export'); return; } @@ -80,6 +82,8 @@ public function prepare(ExportState $state): void ->withAdditionalPipes([$question_image_pipe]) ->create(); $state->setTransformations($transformations); + + $state->logger()->info('...Finished preparing question pool export (1/3)'); } /** @@ -88,6 +92,7 @@ public function prepare(ExportState $state): void */ public function process(ExportState $state): void { + $state->logger()->info('Processing question pool export (2/3)...'); $state->assertStep(ExportStep::PREPARE); $state->setStep(ExportStep::PROCESS); @@ -117,6 +122,8 @@ public function process(ExportState $state): void $state->serializer(), ) ); + + $state->logger()->info('...Finished processing question pool export (2/3)'); } /** @@ -124,18 +131,23 @@ public function process(ExportState $state): void */ public function write(ExportState $state): void { + $state->logger()->info('Writing question pool export (3/3)...'); $state->assertStep(ExportStep::PROCESS); $state->setStep(ExportStep::WRITE); $export_dir = $state->path()->getPathToComponentExpDirInContainer(); $question_image_pipe = $state->transformations()->context(CollectQuestionImages::class); + $state->logger()->debug("Copying question images to export directory {$export_dir}"); foreach ($question_image_pipe->getFiles() as $file) { $state->writer()->writeFileByFilePath( $file['from'], "{$export_dir}/" . $file['to'] ); + $state->logger()->debug("Copied question image {$file['from']} to {$export_dir}/{$file['to']}"); } + + $state->logger()->info('...Finished writing question pool export (3/3)'); } diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/CleanupStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/CleanupStage.php index 9b926ec14f5a..f13f3548247c 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/CleanupStage.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/CleanupStage.php @@ -23,6 +23,7 @@ use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\ImportStage; use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportContext; use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\StageResult; +use Psr\Log\LoggerInterface; /** * Final import stage that cleans up the temporary files and directories after successful import or @@ -30,6 +31,11 @@ */ class CleanupStage implements ImportStage { + public function __construct( + private readonly LoggerInterface $log, + ) { + } + public function getIdentifier(): string { return 'cleanup'; @@ -52,12 +58,18 @@ public function process(ImportContext $context): StageResult $temp_dir = dirname($file_to_import); if ($temp_dir && file_exists($temp_dir) && is_dir($temp_dir)) { $this->removeDirectory($temp_dir); + $this->log->info("Removed temporary import directory: {$temp_dir}"); + } else { + $this->log->warning("Temporary import directory does not exist: {$temp_dir}"); } } $import_base_dir = $context->get(UploadValidationStage::IMPORT_BASE_DIR); if ($import_base_dir && file_exists($import_base_dir) && is_dir($import_base_dir)) { $this->removeDirectory($import_base_dir); + $this->log->info("Removed import target base directory: {$import_base_dir}"); + } else { + $this->log->warning("Import target base directory does not exist: {$import_base_dir}"); } return StageResult::complete($context); diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/DetectLegacyImportStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/DetectLegacyImportStage.php index 1cb180a77c3e..cb331f69b8d6 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/DetectLegacyImportStage.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/DetectLegacyImportStage.php @@ -23,6 +23,7 @@ use ILIAS\TestQuestionPool\ExportImport\Foundation\Contracts\ImportStage; use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportContext; use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\StageResult; +use Psr\Log\LoggerInterface; /** * @deprecated This stage is only used for legacy imports and will be removed with further ILIAS versions. @@ -32,6 +33,11 @@ class DetectLegacyImportStage implements ImportStage public const string LEGACY_QTI_FILE = 'legacy_qti_file'; public const string LEGACY_XML_FILE = 'legacy_xml_file'; + public function __construct( + private readonly LoggerInterface $log, + ) { + } + public function getIdentifier(): string { return 'detect_legacy_import'; @@ -56,9 +62,11 @@ public function process(ImportContext $context): StageResult $qti_file = $import_base_dir . DIRECTORY_SEPARATOR . str_replace(['_qpl_', '_tst_'], '_qti_', $import_name) . '.xml'; if (!file_exists($qti_file) || !file_exists($xml_file)) { + $this->log->debug("No legacy import files found for {$import_name}"); return StageResult::advance($context); } + $this->log->info("Detected legacy import files for {$import_name}"); return StageResult::advance( $context->with(self::LEGACY_QTI_FILE, $qti_file) ->with(self::LEGACY_XML_FILE, $xml_file) diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionPoolImporter.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionPoolImporter.php index 8c9ef8e678ce..dbcfa852510b 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionPoolImporter.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionPoolImporter.php @@ -47,9 +47,6 @@ class QuestionPoolImporter { public function __construct( private readonly Builder $builder, - private readonly ilCtrl $ctrl, - private readonly ilDBInterface $database, - private readonly Language $language, private readonly LoggerInterface $log, private readonly DataFactory $data_factory, private readonly QuestionsImporter $questions_importer, @@ -113,16 +110,20 @@ function (array $assignments) use ($tt, $mapping, &$context): void { } ); + $this->log->info('Importing question pool export file...'); $deserializer->process(); + $this->log->info('...Finished importing question pool export file'); - // Copy the question images from the temporary import directory to the question pool directory + $this->log->info('Importing question images...'); $this->questions_importer->importQuestionImages( $context->get('pool_obj_id'), $mapping, $context, $images_pipe ); + $this->log->info('...Finished importing question images'); + $this->log->info("Finished importing question pool {$context->get('pool_obj_id')} (Object ID)"); return $context; } @@ -132,7 +133,9 @@ function (array $assignments) use ($tt, $mapping, &$context): void { */ public function finalize(ilImportMapping $mapping): void { + $this->log->info('Finalizing question pool import...'); $this->questions_importer->finalizeQuestionPages($mapping); + $this->log->info('...Finished finalizing question pool'); } protected function importQuestionPool( @@ -150,10 +153,12 @@ protected function importQuestionPool( $pool_object->getObjectProperties()->getPropertyIsOnline()->withOffline() ); $pool_object->saveToDb(); + $this->log->debug("Created new pool object: {$old_pool_id} -> {$new_pool_id}"); $pool_object->createReference(); $pool_object->putInTree($parent_id->toInt()); $pool_object->setPermissions($parent_id->toInt()); + $this->log->debug("Stored pool object in tree: {$parent_id->toInt()} (Parent Ref) -> {$pool_object->getRefId()} (Pool Ref)"); $mapping->addMapping('components/ILIAS/TestQuestionPool', 'qpl', (string) $old_pool_id, (string) $new_pool_id); $mapping->addMapping('components/ILIAS/TestQuestionPool', 'object', (string) $old_pool_id, (string) $new_pool_id); diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionSelectionStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionSelectionStage.php index bb890f483b9f..7bffa9f3a7b1 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionSelectionStage.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionSelectionStage.php @@ -29,6 +29,7 @@ use ILIAS\UI\Component\Input\Container\Form\Form; use ILIAS\UI\Factory as UIFactory; use Psr\Http\Message\ServerRequestInterface; +use Psr\Log\LoggerInterface; /** * Second stage of the question pool import process. It displays the list of questions found in the imported file and @@ -55,6 +56,7 @@ class QuestionSelectionStage implements ImportStage 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, @@ -96,6 +98,7 @@ public function process(ImportContext $context): StageResult } if (!$context->has(UploadValidationStage::COMPONENT_IMPORT_FILE)) { + $this->log->error("No component import file found in context"); return StageResult::error($context, $this->lng->txt('qpl_import_file_not_found')); } @@ -104,6 +107,7 @@ public function process(ImportContext $context): StageResult : $this->readQuestions($context); if ($options === []) { + $this->log->error("No questions found in import file"); return StageResult::error($context, $this->lng->txt('qpl_import_no_items')); } @@ -150,6 +154,9 @@ private function readQuestions(ImportContext $context): array }); $deserializer->process(); + $count = count($options); + $this->log->info("Found {$count} questions in import file"); + return $options; } @@ -171,6 +178,9 @@ private function readQuestionsFromQTI(ImportContext $context): array $options[$item['ident']] = "{$item['title']} ({$this->getLabelForQuestionType($item['type'])})"; } + $count = count($options); + $this->log->info("Found {$count} questions in legacy import file"); + return $options; } diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionsImporter.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionsImporter.php index a3d84b6531a0..238daf17c287 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionsImporter.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/QuestionsImporter.php @@ -76,6 +76,7 @@ public function importQuestion( $question = $transformations->denormalize($normalized, new $question_class()); $old_question_id = $question->getId(); if (!in_array($old_question_id, $selected_questions)) { + $this->log->debug("Skipping question import for ID {$old_question_id} (not selected)"); return null; } @@ -85,6 +86,7 @@ public function importQuestion( // Create new question and store basic question properties $new_question_id = $question->createNewQuestion(false); + $this->log->debug("Created new question: {$old_question_id} -> {$new_question_id}"); $this->storeQuestionMappings($mapping, $old_question_id, $new_question_id, $question->getObjId()); if ($question instanceof assFormulaQuestion) { @@ -93,6 +95,7 @@ public function importQuestion( // Save question-specific properties $question->saveToDb(); + $this->log->debug("Imported question {$new_question_id} (type: {$question->getQuestionType()})"); $feedback = $transformations->denormalize($normalized['feedback'], Feedback::class); $this->importFeedback($feedback, $question); @@ -187,6 +190,7 @@ public function finalizeQuestionPages(ilImportMapping $mapping): void continue; } $new_question_id = $new_matches[1]; + $this->log->debug("Finalizing question page: {$old_question_id} -> {$new_question_id}"); $page = new ilAssQuestionPage((int) $new_question_id); $xml = preg_replace( @@ -209,6 +213,7 @@ public function finalizeQuestionPages(ilImportMapping $mapping): void } $page->updateFromXML(); + $this->log->debug("Updated question page: {$page->getId()}"); unset($page); } } @@ -265,6 +270,8 @@ private function importFeedback(Feedback $feedback, assQuestion $question): void $specific_feedback['feedback'] ); } + + $this->log->debug("Imported feedback for question: {$question_id}"); } private function importFormulaQuestion( @@ -282,6 +289,7 @@ private function importFormulaQuestion( $old_category_id = $category->getId(); $repository->saveNewUnitCategory($category); + $this->log->debug("Imported formula question unit category: {$old_category_id} -> {$category->getId()}"); $mapping->addMapping($this->component, 'unit_category', (string) $old_category_id, (string) $category->getId()); } @@ -297,6 +305,7 @@ private function importFormulaQuestion( $unit = $transformations->denormalize($normalized_unit, $unit); $repository->saveUnit($unit); + $this->log->debug("Imported formula question unit: {$old_unit_id} -> {$unit->getId()}"); } // The question object is denormalized again to ensure the new unit ids are set in the variables and results. diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/SkillAssignmentsImporter.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/SkillAssignmentsImporter.php index 04eec3458349..47ccd6d6831e 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/SkillAssignmentsImporter.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/SkillAssignmentsImporter.php @@ -25,6 +25,7 @@ use ILIAS\Skill\Service\SkillUsageService; use ilImportMapping; use ilSkillTreeRepository; +use Psr\Log\LoggerInterface; /** * Imports skill assignments from normalized data. It will map imported skill using the source installation id. If a @@ -35,6 +36,7 @@ class SkillAssignmentsImporter { public function __construct( + private readonly LoggerInterface $log, private readonly ilSkillTreeRepository $skill_repo, private readonly SkillUsageService $skill_usage_service, private readonly string $component, @@ -67,6 +69,7 @@ public function import( $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; } @@ -85,6 +88,7 @@ public function import( ); $assignment->setSkillBaseId($skill_data['skill_id']); $assignment->setSkillTrefId($skill_data['tref_id']); + $this->log->debug("Found skill assignment: {$assignment->getSkillBaseId()}/{$assignment->getSkillTrefId()} -> {$skill_data['skill_id']}/{$skill_data['tref_id']}"); $assignment->initSolutionComparisonExpressionList(); foreach ($assignment->getSolutionComparisonExpressionList()->get() as $expression) { @@ -94,6 +98,7 @@ public function import( $assignment->saveToDb(); $assignment->saveComparisonExpressions(); + $this->log->debug("Saved skill assignment: {$assignment->getSkillBaseId()}/{$assignment->getSkillTrefId()}"); $this->skill_usage_service->addUsage( $assignment->getParentObjId(), diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/UploadValidationStage.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/UploadValidationStage.php index ea73b375c871..4a26199c8a5c 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Import/UploadValidationStage.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Import/UploadValidationStage.php @@ -28,6 +28,7 @@ use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\ImportContext; use ILIAS\TestQuestionPool\ExportImport\Foundation\Importing\StageResult; use ilManifestParser; +use Psr\Log\LoggerInterface; /** * First stage of the question pool import pipeline. Receives the uploaded file path from the context, extracts ZIP @@ -45,6 +46,7 @@ class UploadValidationStage implements ImportStage public function __construct( private readonly Archives $archives, private readonly Language $lng, + private readonly LoggerInterface $log, private readonly string $component ) { } @@ -72,6 +74,7 @@ public function process(ImportContext $context): StageResult || !is_file($file_to_import) || !str_ends_with(strtolower($file_to_import), '.zip') ) { + $this->log->error("Invalid import file: {$file_to_import}"); return StageResult::error($context, $this->lng->txt('obj_import_file_error')); } @@ -81,6 +84,7 @@ public function process(ImportContext $context): StageResult $options = (new UnzipOptions())->withZipOutputPath(self::IMPORT_TEMP_DIR); $unzip = $this->archives->unzip(Streams::ofResource(fopen($file_to_import, 'r')), $options); $unzip->extract(); + $this->log->info("Extracted import file: {$file_to_import} -> {$import_base_dir}"); $manifest = new ilManifestParser($import_base_dir . DIRECTORY_SEPARATOR . 'manifest.xml'); $export_file = array_find( @@ -89,11 +93,16 @@ public function process(ImportContext $context): StageResult ); if ($export_file === null) { + $this->log->error("No export file found for component: {$this->component}"); return StageResult::error($context, $this->lng->txt('obj_import_file_error')); } + $component_import_file = $import_base_dir . DIRECTORY_SEPARATOR . $export_file['path']; + $this->log->info("Found export file for {$this->component}: -> {$component_import_file}"); + $this->log->info("Found valid export file from installation: {$manifest->getInstallId()}"); + return StageResult::advance( - $context->with(self::COMPONENT_IMPORT_FILE, $import_base_dir . DIRECTORY_SEPARATOR . $export_file['path']) + $context->with(self::COMPONENT_IMPORT_FILE, $component_import_file) ->with(self::IMPORT_BASE_DIR, $import_base_dir) ->with(self::INSTALL_ID, $manifest->getInstallId()) ); diff --git a/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php b/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php index cb89f529776d..18dcd71d7c64 100755 --- a/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php +++ b/components/ILIAS/TestQuestionPool/src/QuestionPoolDIC.php @@ -29,7 +29,6 @@ use ILIAS\TestQuestionPool\ExportImport\Import\QuestionsImporter; use ILIAS\TestQuestionPool\ExportImport\Import\SkillAssignmentsImporter; use ILIAS\TestQuestionPool\ExportImport\LoggingProvider; -use ilLoggerFactory; use Pimple\Container as PimpleContainer; use ILIAS\DI\Container as ILIASContainer; use ILIAS\TestQuestionPool\Questions\SuggestedSolution\SuggestedSolutionsDatabaseRepository; @@ -99,6 +98,7 @@ protected static function buildDIC(ILIASContainer $DIC): self 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', @@ -118,9 +118,6 @@ protected static function buildDIC(ILIASContainer $DIC): self $dic['exportimport.importer'] = static fn($c): QuestionPoolImporter => new QuestionPoolImporter( $c['exportimport.builder'], - $DIC->ctrl(), - $DIC->database(), - $DIC->language(), $c['exportimport.logging'](), new DataFactory(), $c['exportimport.questions_importer'], From d2faea47ea17488d297226257dbd6248b05f895b Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Wed, 13 May 2026 09:53:05 +0200 Subject: [PATCH 42/43] style: fix coding style --- .../Foundation/Bridge/XmlExporterBridge.php | 12 ++++++------ .../Foundation/Importing/StageResultType.php | 2 +- .../src/ExportImport/LoggingProvider.php | 8 +++++--- .../Normalizer/SuggestedSolutionNormalizer.php | 2 +- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/XmlExporterBridge.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/XmlExporterBridge.php index 546b7c0f0828..2ce4297e0741 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/XmlExporterBridge.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Bridge/XmlExporterBridge.php @@ -111,12 +111,12 @@ private function initExportState( ); $this->logger->debug(sprintf( - "Export state created for component %s with release %s, type %s, class %s, object ids %s, option %s", - $target->getComponent(), - $target->getTargetRelease(), - $target->getType(), - $target->getClassname(), - implode(', ', $object_ids), + "Export state created for component %s with release %s, type %s, class %s, object ids %s, option %s", + $target->getComponent(), + $target->getTargetRelease(), + $target->getType(), + $target->getClassname(), + implode(', ', $object_ids), $option )); diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/StageResultType.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/StageResultType.php index 55e42ea50fa3..26d418ec7ed3 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/StageResultType.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Foundation/Importing/StageResultType.php @@ -25,7 +25,7 @@ */ enum StageResultType { - /** + /** * The stage has encountered an error and the workflow should display an error message. */ case ERROR; diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/LoggingProvider.php b/components/ILIAS/TestQuestionPool/src/ExportImport/LoggingProvider.php index 98817b30cfc7..e0b39c6bd0dc 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/LoggingProvider.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/LoggingProvider.php @@ -30,12 +30,14 @@ class LoggingProvider { private const string COMPONENT_ID = 'exp'; - public function getLogger(?string $component_id = null): LoggerInterface { + public function getLogger(?string $component_id = null): LoggerInterface + { $component_id = $component_id ?? self::COMPONENT_ID; return ilLoggerFactory::getLogger($component_id)->getLogger(); } - public function __invoke() { + public function __invoke() + { return $this->getLogger(); } -} \ No newline at end of file +} diff --git a/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/SuggestedSolutionNormalizer.php b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/SuggestedSolutionNormalizer.php index 5cefef06f6aa..3f962e121309 100644 --- a/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/SuggestedSolutionNormalizer.php +++ b/components/ILIAS/TestQuestionPool/src/ExportImport/Normalizer/SuggestedSolutionNormalizer.php @@ -73,7 +73,7 @@ public function normalize($value): array|float|bool|int|string|null $normalized['file'] = $this->tt->normalize( new QuestionImage($value->getFilename(), $value->getQuestionId(), QuestionImage::TYPE_SOLUTION) ); - + } return $normalized; From 3251bd9d3a73f682391dd3c4cfcfa75d8113667e Mon Sep 17 00:00:00 2001 From: Lukas Eichenauer Date: Wed, 13 May 2026 10:16:50 +0200 Subject: [PATCH 43/43] refactor: remove legacy test classes --- .../Test/classes/class.ilTestResultsToXML.php | 300 ------------------ .../ExportFixedQuestionSetTest.php | 60 ---- .../ExportRandomQuestionSetTest.php | 59 ---- .../Test/tests/ilTestResultsToXMLTest.php | 57 ---- .../tests/ilQuestionpoolExportTest.php | 48 --- 5 files changed, 524 deletions(-) delete mode 100755 components/ILIAS/Test/classes/class.ilTestResultsToXML.php delete mode 100755 components/ILIAS/Test/tests/ExportImport/ExportFixedQuestionSetTest.php delete mode 100755 components/ILIAS/Test/tests/ExportImport/ExportRandomQuestionSetTest.php delete mode 100755 components/ILIAS/Test/tests/ilTestResultsToXMLTest.php delete mode 100644 components/ILIAS/TestQuestionPool/tests/ilQuestionpoolExportTest.php diff --git a/components/ILIAS/Test/classes/class.ilTestResultsToXML.php b/components/ILIAS/Test/classes/class.ilTestResultsToXML.php deleted file mode 100755 index bc3a6d67888d..000000000000 --- a/components/ILIAS/Test/classes/class.ilTestResultsToXML.php +++ /dev/null @@ -1,300 +0,0 @@ -include_random_test_questions_enabled; - } - - public function setIncludeRandomTestQuestionsEnabled(bool $include_random_test_questions_enabled): void - { - $this->include_random_test_questions_enabled = $include_random_test_questions_enabled; - } - - protected function exportActiveIDs(): void - { - $user_criteria = (new ilSetting('assessment'))->get('user_criteria', 'usr_id'); - - $query = $this->test_obj->getAnonymity() - ? 'SELECT * FROM tst_active WHERE test_fi = %s' - : "SELECT tst_active.*, usr_data.{$user_criteria} FROM tst_active, usr_data WHERE tst_active.test_fi = %s AND tst_active.user_fi = usr_data.usr_id"; - $result = $this->db->queryF($query, [ilDBConstants::T_INTEGER], [$this->test_obj->getTestId()]); - - $test_participant_list = new ilTestParticipantList($this->test_obj, $this->user, $this->lng, $this->db); - $test_participant_list->initializeFromDbRows($this->test_obj->getTestParticipants()); - - $this->xmlStartTag('tst_active', null); - while ($row = $this->db->fetchAssoc($result)) { - $this->active_ids[] = $row['active_id']; - $participant = $test_participant_list->getParticipantByActiveId($row['active_id']); - - $attrs = [ - 'active_id' => $row['active_id'], - 'user_fi' => $this->test_obj->getAnonymity() ? '' : ($row['user_fi'] ?? ''), - 'fullname' => $participant ? $test_participant_list->buildFullname($participant) : '', - 'anonymous_id' => $row['anonymous_id'] ?? '', - 'test_fi' => $row['test_fi'], - 'lastindex' => $row['lastindex'] ?? '', - 'tries' => $row['tries'] ?? '', - 'last_started_pass' => $row['last_started_pass'] ?? '', - 'last_finished_pass' => $row['last_finished_pass'] ?? '', - 'submitted' => $row['submitted'] ?? '', - 'submittimestamp' => $row['submittimestamp'] ?? '', - 'tstamp' => $row['tstamp'] ?? '' - ]; - - if (!$this->test_obj->getAnonymity()) { - $attrs['user_criteria'] = $user_criteria; - $attrs[$user_criteria] = $row[$user_criteria]; - } - - $this->xmlElement('row', $attrs); - } - $this->xmlEndTag('tst_active'); - } - - protected function exportPassResult(): void - { - $query = 'SELECT * FROM tst_pass_result WHERE ' . $this->db->in('active_fi', $this->active_ids, false, 'integer') . ' ORDER BY active_fi, pass'; - $result = $this->db->query($query); - $this->xmlStartTag('tst_pass_result', null); - while ($row = $this->db->fetchAssoc($result)) { - $attrs = [ - 'active_fi' => $row['active_fi'], - 'pass' => $row['pass'] ?? '', - 'points' => $row['points'] ?? '', - 'maxpoints' => $row['maxpoints'] ?? '', - 'questioncount' => $row['questioncount'] ?? '', - 'answeredquestions' => $row['answeredquestions'] ?? '', - 'workingtime' => $row['workingtime'] ?? '', - 'tstamp' => $row['tstamp'] ?? '' - ]; - $this->xmlElement('row', $attrs); - } - $this->xmlEndTag('tst_pass_result'); - } - - protected function exportResultCache(): void - { - $query = 'SELECT * FROM tst_result_cache WHERE ' . $this->db->in('active_fi', $this->active_ids, false, 'integer') . ' ORDER BY active_fi'; - $result = $this->db->query($query); - $this->xmlStartTag('tst_result_cache', null); - while ($row = $this->db->fetchAssoc($result)) { - $attrs = [ - 'active_fi' => $row['active_fi'], - 'pass' => $row['pass'], - 'max_points' => $row['max_points'], - 'reached_points' => $row['reached_points'], - 'mark_short' => $row['mark_short'], - 'mark_official' => $row['mark_official'], - 'passed' => $row['passed'], - 'failed' => $row['failed'], - 'tstamp' => $row['tstamp'] - ]; - $this->xmlElement('row', $attrs); - } - $this->xmlEndTag('tst_result_cache'); - } - - protected function exportTestSequence(): void - { - $query = 'SELECT * FROM tst_sequence WHERE ' . $this->db->in('active_fi', $this->active_ids, false, 'integer') . ' ORDER BY active_fi, pass'; - $result = $this->db->query($query); - $this->xmlStartTag('tst_sequence', null); - while ($row = $this->db->fetchAssoc($result)) { - $attrs = [ - 'active_fi' => $row['active_fi'], - 'pass' => $row['pass'] ?? '', - 'sequence' => $row['sequence'] ?? '', - 'postponed' => $row['postponed'] ?? '', - 'hidden' => $row['hidden'] ?? '', - 'tstamp' => $row['tstamp'] ?? '' - ]; - $this->xmlElement('row', $attrs); - } - $this->xmlEndTag('tst_sequence'); - } - - protected function exportTestSolutions(): void - { - $query = 'SELECT * FROM tst_solutions WHERE ' . $this->db->in('active_fi', $this->active_ids, false, 'integer') . ' ORDER BY solution_id'; - $result = $this->db->query($query); - $this->xmlStartTag('tst_solutions', null); - while ($row = $this->db->fetchAssoc($result)) { - $attrs = [ - 'solution_id' => $row['solution_id'], - 'active_fi' => $row['active_fi'], - 'question_fi' => $row['question_fi'], - 'points' => $row['points'] ?? '', - 'pass' => $row['pass'] ?? '', - 'value1' => $row['value1'] ?? '', - 'value2' => $row['value2'] ?? '', - 'tstamp' => $row['tstamp'] ?? '' - ]; - $this->xmlElement('row', $attrs); - } - $this->xmlEndTag('tst_solutions'); - } - - protected function exportRandomTestQuestions(): void - { - $result = $this->db->query(" - SELECT * FROM tst_test_rnd_qst - WHERE {$this->db->in('active_fi', $this->active_ids, false, 'integer')} - ORDER BY test_random_question_id - "); - - $this->xmlStartTag('tst_test_rnd_qst', null); - while ($row = $this->db->fetchAssoc($result)) { - $attrs = []; - - foreach ($row as $field => $value) { - $attrs[$field] = $value; - } - - $this->xmlElement('row', $attrs); - } - $this->xmlEndTag('tst_test_rnd_qst'); - } - - - protected function exportTestResults(): void - { - $query = 'SELECT * FROM tst_test_result WHERE ' . $this->db->in('active_fi', $this->active_ids, false, 'integer') . ' ORDER BY active_fi'; - $result = $this->db->query($query); - $this->xmlStartTag('tst_test_result', null); - while ($row = $this->db->fetchAssoc($result)) { - $active_fi = $row['active_fi']; - $pass = $row['pass'] ?? ''; - $attrs = [ - 'test_result_id' => $row['test_result_id'], - 'active_fi' => $active_fi, - 'question_fi' => $row['question_fi'], - 'points' => $row['points'] ?? '', - 'pass' => $pass, - 'manual' => $row['manual'] ?? '', - 'tstamp' => $row['tstamp'] ?? '' - ]; - - if (($question = assQuestion::instantiateQuestion($row['question_fi'])) instanceof assFileUpload) { - $this->exportParticipantUploadedFiles($question->getUploadedFiles($active_fi, $pass)); - } - - $this->xmlElement('row', $attrs); - } - $this->xmlEndTag('tst_test_result'); - } - - /** - * @param array{value1: string, value2: string} $uploaded_files - */ - protected function exportParticipantUploadedFiles(array $uploaded_files): void - { - foreach ($uploaded_files as $uploaded_file) { - if ($uploaded_file['value2'] !== 'rid') { - continue; - } - - $rid_string = $uploaded_file['value1']; - $rid = $this->irss->manage()->find($rid_string); - if ($rid === null) { - continue; - } - - $target_dir = "$this->objects_export_directory/resources/$rid_string"; - ilFileUtils::makeDirParents($target_dir); - file_put_contents( - "$target_dir/{$this->irss->manage()->getCurrentRevision($rid)->getTitle()}", - $this->irss->consume()->stream($rid)->getStream(), - ); - } - } - - protected function exportTestTimes(): void - { - $query = 'SELECT * FROM tst_times WHERE ' . $this->db->in('active_fi', $this->active_ids, false, 'integer') . ' ORDER BY active_fi'; - $result = $this->db->query($query); - $this->xmlStartTag('tst_times', null); - while ($row = $this->db->fetchAssoc($result)) { - $attrs = [ - 'times_id' => $row['times_id'], - 'active_fi' => $row['active_fi'], - 'started' => $row['started'], - 'finished' => $row['finished'], - 'pass' => $row['pass'], - 'tstamp' => $row['tstamp'] - ]; - $this->xmlElement('row', $attrs); - } - $this->xmlEndTag('tst_times'); - } - - public function getXML(): void - { - $this->active_ids = []; - $this->xmlHeader(); - $attrs = ['version' => '4.1.0']; - $this->xmlStartTag('results', $attrs); - $this->exportActiveIDs(); - - if ($this->isIncludeRandomTestQuestionsEnabled()) { - $this->exportRandomTestQuestions(); - } - - $this->exportPassResult(); - $this->exportResultCache(); - $this->exportTestSequence(); - $this->exportTestSolutions(); - $this->exportTestResults(); - $this->exportTestTimes(); - $this->xmlEndTag('results'); - } - - public function xmlDumpMem(bool $format = true): string - { - $this->getXML(); - return parent::xmlDumpMem($format); - } - - public function xmlDumpFile(string $file, bool $format = true): void - { - $this->getXML(); - parent::xmlDumpFile($file, $format); - } -} diff --git a/components/ILIAS/Test/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/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/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); - } -}