ILIAS  trunk Revision v12.0_alpha-1540-g00f839d5fa1
ParticipantTable.php
Go to the documentation of this file.
1<?php
2
19declare(strict_types=1);
20
22
23use ILIAS\Test\Results\Data\Factory as ResultsDataFactory;
24use ILIAS\Test\Results\Presentation\Settings as ResultsPresentationSettings;
35use ILIAS\UI\Factory as UIFactory;
37use Psr\Http\Message\ServerRequestInterface;
38
40{
41 private const ID = 'pt';
42 private ?iterable $records = null;
43 private bool $scoring_enabled = false;
44
45 public function __construct(
46 private readonly UIFactory $ui_factory,
47 private readonly \ilUIService $ui_service,
48 private readonly Language $lng,
49 private readonly \ilTestAccess $test_access,
50 private readonly RequestDataCollector $test_request,
51 private readonly \ilTestParticipantAccessFilterFactory $participant_access_filter,
52 private readonly ParticipantRepository $repository,
53 private readonly ResultsDataFactory $results_data_factory,
54 private readonly ResultsPresentationSettings $results_presentation_settings,
55 private readonly \ilObjUser $current_user,
56 private readonly \ilObjTest $test_object,
57 private readonly ParticipantTableActions $table_actions
58 ) {
59 $this->scoring_enabled = $this->test_object->getGlobalSettings()->isManualScoringEnabled();
60 }
61
62 public function execute(URLBuilder $url_builder): ?Modal
63 {
64 return $this->table_actions->execute(...$this->acquireParameters($url_builder));
65 }
66
70 public function getComponents(URLBuilder $url_builder, string $filter_url): array
71 {
72 $filter = $this->getFilterComponent($filter_url, $this->test_request->getRequest());
73 $table = $this->getTableComponent(
74 $this->test_request->getRequest(),
75 $this->ui_service->filter()->getData($filter)
76 );
77
78 return [
79 $filter,
80 $table->withActions($this->table_actions->getEnabledActions(...$this->acquireParameters($url_builder)))
81 ];
82 }
83
84 public function getTotalRowCount(
85 mixed $additional_viewcontrol_data,
86 mixed $filter_data,
87 mixed $additional_parameters
88 ): ?int {
89 return $this->repository->countParticipants($this->test_object->getTestId(), $filter_data);
90 }
91
92 public function getRows(
93 DataRowBuilder $row_builder,
94 array $visible_column_ids,
96 Order $order,
97 mixed $additional_viewcontrol_data,
98 mixed $filter_data,
99 mixed $additional_parameters
100 ): \Generator {
101 $processing_time = $this->test_object->getProcessingTimeInSeconds();
102 $reset_time_on_new_attempt = $this->test_object->getResetProcessingTime();
103
104 $current_user_timezone = new \DateTimeZone($this->current_user->getTimeZone());
105
107 foreach ($this->getViewControlledRecords($filter_data, $range, $order) as $record) {
108 $total_duration = $record->getTotalDuration($processing_time);
109 $status_of_attempt = $record->getAttemptOverviewInformation()?->getStatusOfAttempt() ?? StatusOfAttempt::NOT_YET_STARTED;
110 $row = [
111 'name' => $record->getDisplayName($this->lng, $this->test_object->getAnonymity()),
112 'login' => $record->getLogin(),
113 'matriculation' => $record->getMatriculation(),
114 'total_time_on_task' => $record->getAttemptOverviewInformation()?->getHumanReadableTotalTimeOnTask() ?? '',
115 'status_of_attempt' => $status_of_attempt->getTranslation($this->lng),
116 'id_of_attempt' => $record->getAttemptOverviewInformation()?->getExamId(),
117 'ip_range' => $record->getClientIpTo() !== '' || $record->getClientIpFrom() !== ''
118 ? sprintf('%s - %s', $record->getClientIpFrom(), $record->getClientIpTo())
119 : '',
120 'total_attempts' => $record->getAttemptOverviewInformation()?->getNrOfAttempts() ?? 0,
121 'extra_time' => $record->getExtraTime() > 0 ? sprintf('%d min', $record->getExtraTime()) : '',
122 'total_duration' => $total_duration > 0 ? sprintf('%d min', $total_duration / 60) : '',
123 'remaining_duration' => sprintf('%d min', $record->getRemainingDuration($processing_time, $reset_time_on_new_attempt) / 60),
124 ];
125
126 if ($this->scoring_enabled) {
127 $row['scoring_finalized'] = $record->isScoringFinalized();
128 }
129
130 $first_access = $record->getAttemptOverviewInformation()?->getStartedDate();
131 if ($first_access !== null) {
132 $row['attempt_started_at'] = $first_access->setTimezone($current_user_timezone);
133 }
134
135 $last_access = $record->getLastAccess();
136 if ($last_access !== null) {
137 $row['last_access'] = $last_access->setTimezone($current_user_timezone);
138 }
139 if ($record->getActiveId() !== null
140 && $this->test_access->checkResultsAccessForActiveId(
141 $record->getActiveId(),
142 $this->test_object->getTestId()
143 ) || $record === null && $this->test_access->checkParticipantsResultsAccess()) {
144 $row['reached_points'] = sprintf(
145 $this->lng->txt('tst_reached_points_of_max'),
146 $record->getAttemptOverviewInformation()?->getReachedPoints(),
147 $record->getAttemptOverviewInformation()?->getAvailablePoints()
148 );
149 $row['nr_of_answered_questions'] = sprintf(
150 $this->lng->txt('tst_answered_questions_of_total'),
151 $record->getAttemptOverviewInformation()?->getNrOfAnsweredQuestions(),
152 $record->getAttemptOverviewInformation()?->getNrOfTotalQuestions()
153 );
154 $row['percent_of_available_points'] = $record->getAttemptOverviewInformation()?->getReachedPointsInPercent();
155 }
156
157 if ($status_of_attempt->isFinished()) {
158 $row['test_passed'] = $record->getAttemptOverviewInformation()?->hasPassingMark() ?? false;
159 $row['mark'] = $record->getAttemptOverviewInformation()?->getMark();
160 }
161
162 yield $this->table_actions->onDataRow(
163 $row_builder->buildDataRow(
164 "{$record->getUserId()}_{$record->getActiveId()}",
165 $row
166 ),
167 $record
168 );
169 }
170 }
171
172 private function acquireParameters($url_builder): array
173 {
174 return $url_builder->acquireParameters(
175 [self::ID],
176 ParticipantTableActions::ROW_ID_PARAMETER,
177 ParticipantTableActions::ACTION_PARAMETER,
178 ParticipantTableActions::ACTION_TYPE_PARAMETER
179 );
180 }
181
185 private function getPostLoadFilters(): array
186 {
187 return [
188 'solution' => fn(string $value, Participant $record) =>
189 $value === 'true' ? $record->hasAnsweredQuestionsForScoredAttempt() : !$record->hasAnsweredQuestionsForScoredAttempt(),
190 'status_of_attempt' => fn(string $value, Participant $record) =>
191 ($value === StatusOfAttempt::NOT_YET_STARTED->value && $record->getAttemptOverviewInformation()?->getStatusOfAttempt() === null) ||
192 $value === $record->getAttemptOverviewInformation()?->getStatusOfAttempt()->value,
193 'test_passed' => fn(string $value, Participant $record) => $value === 'true'
194 ? $record->getAttemptOverviewInformation()?->hasPassingMark() === true
195 : $record->getAttemptOverviewInformation()?->hasPassingMark() !== true,
196 'scoring_finalized' => fn(string $value, Participant $record) => $value === 'true'
197 ? $record->isScoringFinalized() == true
198 : $record->isScoringFinalized() === false
199 ];
200 }
201
205 private function getPostLoadOrderFields(): array
206 {
207 $processing_time = $this->test_object->getProcessingTimeInSeconds();
208 $reset_time_on_new_attempt = $this->test_object->getResetProcessingTime();
209
210 return [
211 'attempt_started_at' => static fn(Participant $a, Participant $b) => $a->getFirstAccess() <=> $b->getFirstAccess(),
212 'total_duration' => static fn(
215 ) => $a->getTotalDuration($processing_time) <=> $b->getTotalDuration($processing_time),
216 'remaining_duration' => static fn(
219 ) => $a->getRemainingDuration($processing_time, $reset_time_on_new_attempt)
220 <=> $b->getRemainingDuration($processing_time, $reset_time_on_new_attempt),
221 'last_access' => static fn(Participant $a, Participant $b) => $a->getLastAccess() <=> $b->getLastAccess(),
222 'status_of_attempt' => fn(
225 ) => strcmp(
226 $a->getAttemptOverviewInformation()?->getStatusOfAttempt()?->getTranslation($this->lng) ?? StatusOfAttempt::NOT_YET_STARTED->getTranslation($this->lng),
227 $b->getAttemptOverviewInformation()?->getStatusOfAttempt()?->getTranslation($this->lng) ?? StatusOfAttempt::NOT_YET_STARTED->getTranslation($this->lng)
228 ),
229 'reached_points' => static fn(
232 ) => $a->getAttemptOverviewInformation()?->getReachedPoints()
233 <=> $b->getAttemptOverviewInformation()?->getReachedPoints(),
234 'nr_of_answered_questions' => static fn(
237 ) => $a->getAttemptOverviewInformation()?->getNrOfAnsweredQuestions()
238 <=> $b->getAttemptOverviewInformation()?->getNrOfAnsweredQuestions(),
239 'percent_of_available_points' => static fn(
242 ) => $a->getAttemptOverviewInformation()?->getReachedPointsInPercent()
243 <=> $b->getAttemptOverviewInformation()?->getReachedPointsInPercent(),
244 'test_passed' => static fn(
247 ) => $a->getAttemptOverviewInformation()?->hasPassingMark()
248 <=> $b->getAttemptOverviewInformation()?->hasPassingMark(),
249 'mark' => static fn(
252 ) => $a->getAttemptOverviewInformation()?->getMark() <=> $b->getAttemptOverviewInformation()?->getMark(),
253 'matriculation' => static fn(
256 ) => $a->getMatriculation() <=> $b->getMatriculation(),
257 'id_of_attempt' => static fn(
260 ) => $a->getAttemptOverviewInformation()?->getExamId() <=> $b->getAttemptOverviewInformation()?->getExamId(),
261 'total_time_on_task' => static fn(
264 ) => $a->getAttemptOverviewInformation()?->getTotalTimeOnTask() <=> $b->getAttemptOverviewInformation()?->getTotalTimeOnTask()
265 ];
266 }
267
268 private function getFilterComponent(string $action, ServerRequestInterface $request): FilterComponent
269 {
270 $filter_inputs = [];
271 $is_input_initially_rendered = [];
272 $field_factory = $this->ui_factory->input()->field();
273
274 foreach ($this->getFilterFields($field_factory) as $filter_id => $filter) {
275 [$filter_inputs[$filter_id], $is_input_initially_rendered[$filter_id]] = $filter;
276 }
277
278 return $this->ui_service->filter()->standard(
279 "participant_filter_{$this->test_request->getRefId()}",
280 $action,
281 $filter_inputs,
282 $is_input_initially_rendered,
283 true,
284 true
285 );
286 }
287
293 private function getFilterFields(FieldFactory $field_factory): array
294 {
295 $yes_no_all_options = [
296 'true' => $this->lng->txt('yes'),
297 'false' => $this->lng->txt('no')
298 ];
299
300 $solution_options = [
301 'false' => $this->lng->txt('without_solution'),
302 'true' => $this->lng->txt('with_solution')
303 ];
304
305 $status_of_attempt_options = [
306 StatusOfAttempt::NOT_YET_STARTED->value => $this->lng->txt(StatusOfAttempt::NOT_YET_STARTED->value),
307 StatusOfAttempt::RUNNING->value => $this->lng->txt(StatusOfAttempt::RUNNING->value),
308 StatusOfAttempt::FINISHED_BY_UNKNOWN->value => $this->lng->txt(StatusOfAttempt::FINISHED_BY_UNKNOWN->value),
309 StatusOfAttempt::FINISHED_BY_ADMINISTRATOR->value => $this->lng->txt(StatusOfAttempt::FINISHED_BY_ADMINISTRATOR->value),
310 StatusOfAttempt::FINISHED_BY_CRONJOB->value => $this->lng->txt(StatusOfAttempt::FINISHED_BY_CRONJOB->value),
311 StatusOfAttempt::FINISHED_BY_DURATION->value => $this->lng->txt(StatusOfAttempt::FINISHED_BY_DURATION->value),
312 StatusOfAttempt::FINISHED_BY_PARTICIPANT->value => $this->lng->txt(StatusOfAttempt::FINISHED_BY_PARTICIPANT->value),
313 ];
314
315 $filters = [
316 'name' => [$field_factory->text($this->lng->txt('name')), true],
317 'login' => [$field_factory->text($this->lng->txt('login')), true],
318 'ip_range' => [$field_factory->text($this->lng->txt('client_ip_range')), true],
319 'solution' => [$field_factory->select($this->lng->txt('solutions'), $solution_options), true],
320 ];
321
322 if ($this->test_object->getEnableProcessingTime()) {
323 $filters['extra_time'] = [$field_factory->select($this->lng->txt('extratime'), $yes_no_all_options), true];
324 }
325
326 $filters['status_of_attempt'] = [
327 $field_factory->select($this->lng->txt('status_of_attempt'), $status_of_attempt_options),
328 true
329 ];
330
331 $filters['test_passed'] = [
332 $field_factory->select($this->lng->txt('tst_passed'), $yes_no_all_options),
333 true
334 ];
335
336 if ($this->scoring_enabled) {
337 $filters['scoring_finalized'] = [
338 $field_factory->select($this->lng->txt('finalized_evaluation'), $yes_no_all_options),
339 true
340 ];
341 }
342
343 return $filters;
344 }
345
346 private function getTableComponent(ServerRequestInterface $request, ?array $filter)
347 {
348 return $this->ui_factory
349 ->table()
350 ->data(
351 $this,
352 $this->lng->txt('list_of_participants'),
353 $this->getColumns(),
354 )
355 ->withId(self::ID)
356 ->withRequest($request)
357 ->withFilter($filter);
358 }
359
363 private function getColumns(): array
364 {
365 $column_factory = $this->ui_factory->table()->column();
366
367 $columns = [
368 'name' => $column_factory->text($this->lng->txt('name'))
369 ->withIsSortable(!$this->test_object->getAnonymity())
370 ];
371
372 if (!$this->test_object->getAnonymity()) {
373 $columns += [
374 'login' => $column_factory->text($this->lng->txt('login'))->withIsSortable(true),
375 'matriculation' => $column_factory->text($this->lng->txt('matriculation'))
376 ->withIsOptional(true, false)
377 ->withIsSortable(true)
378 ];
379 }
380
381 $columns += [
382 'ip_range' => $column_factory->text($this->lng->txt('client_ip_range'))
383 ->withIsOptional(true, false)
384 ->withIsSortable(true),
385 'attempt_started_at' => $column_factory->date(
386 $this->lng->txt('tst_attempt_started'),
387 $this->current_user->getDateTimeFormat()
388 )->withIsSortable(true),
389 'total_time_on_task' => $column_factory->text($this->lng->txt('working_time'))
390 ->withIsOptional(true, false),
391 'total_attempts' => $column_factory->number($this->lng->txt('total_attempts'))
392 ->withIsOptional(true, false)
393 ->withIsSortable(true),
394 ];
395
396 $columns['status_of_attempt'] = $column_factory->text($this->lng->txt('status_of_attempt'))
397 ->withIsSortable(true);
398
399 if ($this->test_object->getEnableProcessingTime()) {
400 $columns['remaining_duration'] = $column_factory->text($this->lng->txt('remaining_duration'))
401 ->withIsOptional(true);
402 $columns['total_duration'] = $column_factory->text($this->lng->txt('total_duration'))
403 ->withIsOptional(true, false);
404 $columns['extra_time'] = $column_factory->text($this->lng->txt('extratime'))
405 ->withIsOptional(true, false);
406 }
407
408 if ($this->test_object->getMainSettings()->getTestBehaviourSettings()->getExamIdInTestAttemptEnabled()) {
409 $columns['id_of_attempt'] = $column_factory->text($this->lng->txt('exam_id_of_attempt'))
410 ->withIsOptional(true, false)
411 ->withIsSortable(true);
412 }
413
414 if ($this->test_access->checkParticipantsResultsAccess()) {
415 $columns['reached_points'] = $column_factory->text($this->lng->txt('tst_reached_points'))
416 ->withIsSortable(true)
417 ->withOrderingLabels(...$column_factory->number($this->lng->txt('tst_reached_points'))->getOrderingLabels());
418 $columns['nr_of_answered_questions'] = $column_factory->text($this->lng->txt('tst_answered_questions'))
419 ->withIsOptional(true, false)
420 ->withIsSortable(true)
421 ->withOrderingLabels(...$column_factory->number($this->lng->txt('tst_answered_questions'))->getOrderingLabels());
422 $columns['percent_of_available_points'] = $column_factory->number($this->lng->txt('tst_percent_solved'))
423 ->withUnit('%')
424 ->withIsOptional(true, false)
425 ->withIsSortable(true);
426 $columns['test_passed'] = $column_factory->boolean(
427 $this->lng->txt('tst_passed'),
428 $this->ui_factory->symbol()->icon()->custom(
429 'assets/images/standard/icon_checked.svg',
430 $this->lng->txt('yes'),
431 'small'
432 ),
433 $this->ui_factory->symbol()->icon()->custom(
434 'assets/images/standard/icon_unchecked.svg',
435 $this->lng->txt('no'),
436 'small'
437 )
438 )->withIsSortable(true)
439 ->withOrderingLabels(
440 "{$this->lng->txt('tst_passed')}, {$this->lng->txt('no')} {$this->lng->txt('order_option_first')}",
441 "{$this->lng->txt('tst_passed')}, {$this->lng->txt('yes')} {$this->lng->txt('order_option_first')}"
442 );
443 $columns['mark'] = $column_factory->text($this->lng->txt('tst_mark'))
444 ->withIsOptional(true, false)
445 ->withIsSortable(true);
446 }
447 if ($this->scoring_enabled) {
448 $columns['scoring_finalized'] = $column_factory->boolean(
449 $this->lng->txt('finalized_evaluation'),
450 $this->ui_factory->symbol()->icon()->custom(
451 'assets/images/standard/icon_checked.svg',
452 $this->lng->txt('yes'),
453 'small'
454 ),
455 $this->ui_factory->symbol()->icon()->custom(
456 'assets/images/standard/icon_unchecked.svg',
457 $this->lng->txt('no'),
458 'small'
459 )
460 )->withIsOptional(true, false)
461 ->withIsSortable(true);
462 }
463
464 $columns['last_access'] = $column_factory->date(
465 $this->lng->txt('last_access'),
466 $this->current_user->getDateTimeFormat()
467 );
468
469 return $columns;
470 }
471
472 private function loadRecords(?array $filter, Order $order): iterable
473 {
474 if ($this->records !== null) {
475 return $this->records;
476 }
477
478 $records = iterator_to_array(
479 $this->repository->getParticipants(
480 $this->test_object->getTestId(),
481 $filter,
482 null,
483 $order
484 )
485 );
486
487 $this->records = array_filter(
488 $records,
489 fn(Participant $participant) => in_array(
490 $participant->getUserId(),
491 $this->buildAccessFilteredParticipantsList($records)
492 )
493 );
494
495 return $this->records;
496 }
497
503 private function buildAccessFilteredParticipantsList(array $records): array
504 {
505 $manage_access_filter = $this->participant_access_filter
506 ->getManageParticipantsUserFilter($this->test_object->getRefId());
507 $access_results_access_filter = $this->participant_access_filter
508 ->getAccessResultsUserFilter($this->test_object->getRefId());
509 $participant_ids = array_map(
510 fn(Participant $participant) => $participant->getUserId(),
511 $records
512 );
513 return $manage_access_filter($participant_ids) + $access_results_access_filter($participant_ids);
514 }
515
516
520 private function getViewControlledRecords(?array $filter_data, Range $range, Order $order): iterable
521 {
522 return $this->limitRecords(
523 $this->orderRecords(
524 $this->filterRecords(
525 $this->results_data_factory->addAttemptOverviewInformationToParticipants(
526 $this->results_presentation_settings,
527 $this->test_object,
528 $this->loadRecords($filter_data, $order)
529 ),
530 $filter_data
531 ),
532 $order
533 ),
534 $range
535 );
536 }
537
538 private function filterRecords(iterable $records, ?array $filter_data): iterable
539 {
540 foreach ($records as $record) {
541 if ($this->matchFilter($record, $filter_data)) {
542 yield $record;
543 }
544 }
545 }
546
547 private function matchFilter(Participant $record, ?array $filter): bool
548 {
549 if ($filter === null) {
550 return true;
551 }
552
553 $post_load_filters = $this->getPostLoadFilters();
554 $allow = true;
555
556 foreach ($filter as $key => $value) {
557 if (!$value) {
558 continue;
559 }
560
561 $post_load_filter = $post_load_filters[$key] ?? fn() => true;
562 $allow = $allow && $post_load_filter($value, $record);
563 }
564
565 return $allow;
566 }
567
568 private function orderRecords(iterable $records, Order $order): array
569 {
570 $post_load_order_fields = $this->getPostLoadOrderFields();
571 $records = iterator_to_array($records);
572
573 uasort($records, static function (Participant $a, Participant $b) use ($order, $post_load_order_fields) {
574 foreach ($order->get() as $subject => $direction) {
575 $post_load_order_field = $post_load_order_fields[$subject] ?? fn() => 0;
576 $index = $post_load_order_field($a, $b);
577
578 if ($index !== 0) {
579 return $direction === 'DESC' ? $index * -1 : $index;
580 }
581 }
582
583 return 0;
584 });
585
586 return $records;
587 }
588
589 private function limitRecords(array $records, Range $range): array
590 {
591 return array_slice($records, $range->getStart(), $range->getLength());
592 }
593}
Builds a Color from either hex- or rgb values.
Definition: Factory.php:31
Both the subject and the direction need to be specified when expressing an order.
Definition: Order.php:29
A simple class to express a naive range of whole positive numbers.
Definition: Range.php:29
getTotalRowCount(mixed $additional_viewcontrol_data, mixed $filter_data, mixed $additional_parameters)
Mainly for the purpose of pagination-support, it is important to know about the total number of recor...
getFilterFields(FieldFactory $field_factory)
getComponents(URLBuilder $url_builder, string $filter_url)
filterRecords(iterable $records, ?array $filter_data)
getFilterComponent(string $action, ServerRequestInterface $request)
orderRecords(iterable $records, Order $order)
getViewControlledRecords(?array $filter_data, Range $range, Order $order)
__construct(private readonly UIFactory $ui_factory, private readonly \ilUIService $ui_service, private readonly Language $lng, private readonly \ilTestAccess $test_access, private readonly RequestDataCollector $test_request, private readonly \ilTestParticipantAccessFilterFactory $participant_access_filter, private readonly ParticipantRepository $repository, private readonly ResultsDataFactory $results_data_factory, private readonly ResultsPresentationSettings $results_presentation_settings, private readonly \ilObjUser $current_user, private readonly \ilObjTest $test_object, private readonly ParticipantTableActions $table_actions)
loadRecords(?array $filter, Order $order)
matchFilter(Participant $record, ?array $filter)
limitRecords(array $records, Range $range)
getTableComponent(ServerRequestInterface $request, ?array $filter)
return true
User class.
Filter service.
return['delivery_method'=> 'php',]
This file is part of ILIAS, a powerful learning management system published by ILIAS open source e-Le...
This describes a standard filter.
Definition: Standard.php:27
This is what a factory for input fields looks like.
Definition: Factory.php:31
This describes commonalities between the different modals.
Definition: Modal.php:35
buildDataRow(string $id, array $record)
This file is part of ILIAS, a powerful learning management system published by ILIAS open source e-Le...
Definition: Participant.php:21
$a
thx to https://mlocati.github.io/php-cs-fixer-configurator for the examples
global $lng
Definition: privfeed.php:26