From 0d7cba556e6fd536e87b8a83882a10b78ad44250 Mon Sep 17 00:00:00 2001 From: Matheus Zych Date: Thu, 26 Feb 2026 11:54:08 +0100 Subject: [PATCH 1/2] Test: Prevent manual scoring of unfinished attempts See: https://mantis.ilias.de/view.php?id=45782 Manual scoring by question allowed opening the scoring UI for attempts that were still in progress. `ScoringByQuestionTableBinder` now skips unfinished attempts when composing table rows. `TestScoringByQuestionGUI` verifies the attempt is finished via `getAttemptOverviewFor` before calling `getAnswerDetail`; otherwise an exception is thrown. --- .../Manual/ScoringByQuestionTableBinder.php | 4 +++ .../Manual/class.TestScoringByQuestionGUI.php | 32 ++++++++++++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/components/ILIAS/Test/src/Scoring/Manual/ScoringByQuestionTableBinder.php b/components/ILIAS/Test/src/Scoring/Manual/ScoringByQuestionTableBinder.php index d4ff3f0a1615..935b08fa4655 100644 --- a/components/ILIAS/Test/src/Scoring/Manual/ScoringByQuestionTableBinder.php +++ b/components/ILIAS/Test/src/Scoring/Manual/ScoringByQuestionTableBinder.php @@ -154,6 +154,10 @@ function ( array $c, \ilTestEvaluationPassData $pd ) use ($question_id, $active_id, $filtered_participants, $complete_feedback): array { + if (!$pd->getStatusOfAttempt()->isFinished()) { + return $c; + } + $question_result = $pd->getAnsweredQuestionByQuestionId($question_id); $feedback_data = $complete_feedback[$active_id][$pd->getPass()][$question_id] ?? []; if ($this->isFilteredAttempt($pd, $question_result, $feedback_data)) { diff --git a/components/ILIAS/Test/src/Scoring/Manual/class.TestScoringByQuestionGUI.php b/components/ILIAS/Test/src/Scoring/Manual/class.TestScoringByQuestionGUI.php index abfceafbc3c1..b2dec336942f 100755 --- a/components/ILIAS/Test/src/Scoring/Manual/class.TestScoringByQuestionGUI.php +++ b/components/ILIAS/Test/src/Scoring/Manual/class.TestScoringByQuestionGUI.php @@ -123,7 +123,14 @@ protected function showManScoringByQuestionParticipantsTable( if ($this->testrequest->strVal($this->action_parameter_token->getName()) === ScoringByQuestionTable::ACTION_SCORING) { $affected_rows = $this->testrequest->raw($this->row_id_token->getName()); - $this->getAnswerDetail($question_id, $affected_rows[0]); + [$active_id, $attempt] = explode('_', $affected_rows[0]); + + if ($this->isAttemptFinished((int) $active_id, (int) $attempt)) { + $this->getAnswerDetail($question_id, $affected_rows[0]); + return; + } + + $this->http->close(); } $content = [ @@ -158,8 +165,12 @@ protected function saveManScoringByQuestion(): void $active_id = $this->testrequest->getActiveId(); $question_id = $this->testrequest->getQuestionId(); $attempt = $this->testrequest->getPassId(); - if ($active_id === 0 || $question_id === 0 - || !$this->test_access->checkScoreParticipantsAccessForActiveId($active_id, $this->object->getTestId())) { + if ( + $active_id === 0 + || $question_id === 0 + || !$this->test_access->checkScoreParticipantsAccessForActiveId($active_id, $this->object->getTestId()) + || !$this->isAttemptFinished($active_id, $attempt) + ) { $this->tpl->setOnScreenMessage('info', $this->lng->txt('cannot_edit_test'), true); $this->ctrl->redirectByClass(\ilObjTestGUI::class); } @@ -270,7 +281,10 @@ protected function getAnswerDetail(int $question_id, string $row_id): void $this->refinery->kindlyTo()->int() )->transform($row_info_array); - if (!$this->getTestAccess()->checkScoreParticipantsAccessForActiveId($active_id, $this->object->getTestId())) { + if ( + !$this->getTestAccess()->checkScoreParticipantsAccessForActiveId($active_id, $this->object->getTestId()) + || !$this->isAttemptFinished($active_id, $attempt) + ) { $this->http->close(); } @@ -488,6 +502,16 @@ function (TestQuestionProperties $v): StandardLink { return $dropdown; } + private function isAttemptFinished(int $active_id, int $attempt): bool + { + $pass_data = $this->object + ->getCompleteEvaluationData() + ->getParticipant($active_id) + ?->getPass($attempt); + + return $pass_data !== null && $pass_data->getStatusOfAttempt()->isFinished(); + } + private function buildQuestionTitleWithPoints(TestQuestionProperties $test_question_properties): string { $question_properties = $test_question_properties->getGeneralQuestionProperties(); From 20916211f27b4f98e72f5336a9353265342e97e9 Mon Sep 17 00:00:00 2001 From: Matheus Zych Date: Fri, 22 May 2026 13:47:42 +0200 Subject: [PATCH 2/2] Test: Add participant table attempt view modes See: https://mantis.ilias.de/view.php?id=45782 The test participants listing always summarized attempt overview data for scored attempts only. `ParticipantTable` now renders a UI mode control (scored attempts vs last attempts), keeps the choice in the `view_mode` request parameter via `RequestDataCollector::getViewMode()`, and passes `ilCtrl` into the table for proper link targets. `Results\Data\Factory::addAttemptOverviewInformationToParticipants` accepts a flag to target the last started pass when building overviews. English and German assessment language entries cover the new labels. --- .../classes/class.ilTestParticipantsGUI.php | 1 + .../src/Participants/ParticipantTable.php | 52 ++++++++++++++++--- .../ILIAS/Test/src/RequestDataCollector.php | 7 ++- .../ILIAS/Test/src/Results/Data/Factory.php | 7 +-- lang/ilias_de.lang | 2 + lang/ilias_en.lang | 2 + 6 files changed, 59 insertions(+), 12 deletions(-) diff --git a/components/ILIAS/Test/classes/class.ilTestParticipantsGUI.php b/components/ILIAS/Test/classes/class.ilTestParticipantsGUI.php index cc1269ae3651..196fd84f4625 100755 --- a/components/ILIAS/Test/classes/class.ilTestParticipantsGUI.php +++ b/components/ILIAS/Test/classes/class.ilTestParticipantsGUI.php @@ -181,6 +181,7 @@ private function getParticipantTable(): ParticipantTable $this->ui_factory, $this->ui_service, $this->lng, + $this->ctrl, $this->test_access, $this->testrequest, $this->participant_access_filter, diff --git a/components/ILIAS/Test/src/Participants/ParticipantTable.php b/components/ILIAS/Test/src/Participants/ParticipantTable.php index 2d46d95ec854..2bef899e6f1c 100644 --- a/components/ILIAS/Test/src/Participants/ParticipantTable.php +++ b/components/ILIAS/Test/src/Participants/ParticipantTable.php @@ -32,6 +32,7 @@ use ILIAS\UI\Component\Input\Field\Factory as FieldFactory; use ILIAS\UI\Component\Table\DataRetrieval; use ILIAS\UI\Component\Table\DataRowBuilder; +use ILIAS\UI\Component\ViewControl\Mode; use ILIAS\UI\Factory as UIFactory; use ILIAS\UI\URLBuilder; use Psr\Http\Message\ServerRequestInterface; @@ -39,12 +40,18 @@ class ParticipantTable implements DataRetrieval { private const ID = 'pt'; + public const VIEW_MODE_PARAMETER = 'view_mode'; + public const VIEW_MODE_SCORED_ATTEMPTS = 'scored_attempts'; + public const VIEW_MODE_LAST_ATTEMPTS = 'last_attempts'; + private ?iterable $records = null; + private string $view_mode; public function __construct( private readonly UIFactory $ui_factory, private readonly \ilUIService $ui_service, private readonly Language $lng, + private readonly \ilCtrl $ctrl, private readonly \ilTestAccess $test_access, private readonly RequestDataCollector $test_request, private readonly \ilTestParticipantAccessFilterFactory $participant_access_filter, @@ -55,6 +62,10 @@ public function __construct( private readonly \ilObjTest $test_object, private readonly ParticipantTableActions $table_actions ) { + $view_mode = $this->test_request->getViewMode(); + $this->view_mode = in_array($view_mode, [self::VIEW_MODE_SCORED_ATTEMPTS, self::VIEW_MODE_LAST_ATTEMPTS]) + ? $view_mode + : self::VIEW_MODE_LAST_ATTEMPTS; } public function execute(URLBuilder $url_builder): ?Modal @@ -74,11 +85,37 @@ public function getComponents(URLBuilder $url_builder, string $filter_url): arra ); return [ + $this->getModeViewControlComponent(), $filter, $table->withActions($this->table_actions->getEnabledActions(...$this->acquireParameters($url_builder))) ]; } + private function getModeViewControlComponent(): Mode + { + $target_class = \ilTestParticipantsGUI::class; + $this->ctrl->setParameterByClass($target_class, self::VIEW_MODE_PARAMETER, 'scored_attempts'); + $scored_attempts_target = $this->ctrl->getLinkTargetByClass($target_class); + $this->ctrl->setParameterByClass($target_class, self::VIEW_MODE_PARAMETER, 'last_attempts'); + $last_attempts_target = $this->ctrl->getLinkTargetByClass($target_class); + $this->ctrl->clearParametersByClass($target_class); + + $scored_attempts_label = $this->lng->txt('tst_participant_scored_attempts'); + $last_attempts_label = $this->lng->txt('tst_participant_last_attempts'); + + return $this->ui_factory->viewControl()->mode( + [ + $scored_attempts_label => $scored_attempts_target, + $last_attempts_label => $last_attempts_target + ], + $this->lng->txt('participant_view_control_aria') + )->withActive( + $this->view_mode === self::VIEW_MODE_LAST_ATTEMPTS + ? $last_attempts_label + : $scored_attempts_label + ); + } + public function getTotalRowCount(?array $filter_data, ?array $additional_parameters): ?int { return $this->repository->countParticipants($this->test_object->getTestId(), $filter_data); @@ -442,7 +479,7 @@ private function loadRecords(?array $filter, Order $order): iterable ) ); - $this->records = array_filter( + $records = array_filter( $records, fn(Participant $participant) => in_array( $participant->getUserId(), @@ -450,7 +487,12 @@ private function loadRecords(?array $filter, Order $order): iterable ) ); - return $this->records; + return $this->records = $this->results_data_factory->addAttemptOverviewInformationToParticipants( + $this->results_presentation_settings, + $this->test_object, + $records, + $this->view_mode === self::VIEW_MODE_LAST_ATTEMPTS + ); } /** @@ -480,11 +522,7 @@ private function getViewControlledRecords(?array $filter_data, Range $range, Ord return $this->limitRecords( $this->orderRecords( $this->filterRecords( - $this->results_data_factory->addAttemptOverviewInformationToParticipants( - $this->results_presentation_settings, - $this->test_object, - $this->loadRecords($filter_data, $order) - ), + $this->loadRecords($filter_data, $order), $filter_data ), $order diff --git a/components/ILIAS/Test/src/RequestDataCollector.php b/components/ILIAS/Test/src/RequestDataCollector.php index 454e70194012..6aea5e2afb2e 100755 --- a/components/ILIAS/Test/src/RequestDataCollector.php +++ b/components/ILIAS/Test/src/RequestDataCollector.php @@ -26,8 +26,6 @@ use ILIAS\Repository\BaseGUIRequest; use Psr\Http\Message\ServerRequestInterface; -use function array_map; - class RequestDataCollector { use BaseGUIRequest; @@ -97,6 +95,11 @@ public function getPassId(): int return $this->int('pass_id'); } + public function getViewMode(): string + { + return $this->str('view_mode'); + } + public function retrieveBoolFromPost(string $key): ?bool { if (!$this->http->wrapper()->post()->has($key)) { diff --git a/components/ILIAS/Test/src/Results/Data/Factory.php b/components/ILIAS/Test/src/Results/Data/Factory.php index a0d9df74637f..5a6278a7fd3c 100644 --- a/components/ILIAS/Test/src/Results/Data/Factory.php +++ b/components/ILIAS/Test/src/Results/Data/Factory.php @@ -137,10 +137,11 @@ public function getAttemptOverviewFor( public function addAttemptOverviewInformationToParticipants( ResultPresentationSettings $settings, \ilObjTest $test_obj, - array $participants + array $participants, + bool $last_attempt = false ): array { return array_map( - function (Participant $v) use ($settings, $test_obj, $participants): Participant { + function (Participant $v) use ($settings, $test_obj, $last_attempt): Participant { if ($v->getActiveId() === null) { return $v; } @@ -149,7 +150,7 @@ function (Participant $v) use ($settings, $test_obj, $participants): Participant $settings, $test_obj, $v->getActiveId(), - null + $last_attempt ? $v->getLastStartedAttempt() : null ); if ($scored_attempt !== null diff --git a/lang/ilias_de.lang b/lang/ilias_de.lang index 4c5d50705860..10536381bd24 100644 --- a/lang/ilias_de.lang +++ b/lang/ilias_de.lang @@ -1693,6 +1693,8 @@ assessment#:#tst_optional_questions_confirmation_non_fixed_test#:#Fragen zu b assessment#:#tst_out_of_time_message#:#Sie haben die maximal erlaubte Bearbeitungszeit des Tests erreicht! assessment#:#tst_participant#:#Teilnehmer assessment#:#tst_participant_fullname_pattern#:#%2$s, %1$s +assessment#:#tst_participant_last_attempts#:#Letzte Versuche +assessment#:#tst_participant_scored_attempts#:#Bewertete Versuche assessment#:#tst_participant_status#:#Teilnehmerstatus assessment#:#tst_participating_users#:#Teilnehmende Benutzer assessment#:#tst_pass_best_pass#:#Besten Durchlauf des Tests bewerten diff --git a/lang/ilias_en.lang b/lang/ilias_en.lang index 31906bcf08ad..b5e308a3c7c5 100755 --- a/lang/ilias_en.lang +++ b/lang/ilias_en.lang @@ -1693,6 +1693,8 @@ assessment#:#tst_optional_questions_confirmation_non_fixed_test#:#Question re assessment#:#tst_out_of_time_message#:#You have reached the maximum allowed processing time of the test! assessment#:#tst_participant#:#Participant assessment#:#tst_participant_fullname_pattern#:#%2$s, %1$s +assessment#:#tst_participant_last_attempts#:#Last Attempts +assessment#:#tst_participant_scored_attempts#:#Scored Attempts assessment#:#tst_participant_status#:#Participant Status assessment#:#tst_participating_users#:#Participating Users assessment#:#tst_pass_best_pass#:#Score the Best Attempt