ILIAS  trunk Revision v12.0_alpha-377-g3641b37b9db
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' => $this->test_object->buildName($record->getUserId(), $record->getFirstname(), $record->getLastname()),
112 'login' => $record->getLogin(),
113 'matriculation' => $record->getMatriculation(),
114 'total_time_on_task' => $record->getAttemptOverviewInformation()?->getHumanReadableTotalTimeOnTask() ?? '',
115 'status_of_attempt' => $this->lng->txt($status_of_attempt->value),
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((string) $record->getUserId(), $row),
164 $record
165 );
166 }
167 }
168
169 private function acquireParameters($url_builder): array
170 {
171 return $url_builder->acquireParameters(
172 [self::ID],
173 ParticipantTableActions::ROW_ID_PARAMETER,
174 ParticipantTableActions::ACTION_PARAMETER,
175 ParticipantTableActions::ACTION_TYPE_PARAMETER
176 );
177 }
178
182 private function getPostLoadFilters(): array
183 {
184 return [
185 'solution' => fn(string $value, Participant $record) =>
186 $value === 'true' ? $record->hasAnsweredQuestionsForScoredAttempt() : !$record->hasAnsweredQuestionsForScoredAttempt(),
187 'status_of_attempt' => fn(string $value, Participant $record) =>
188 ($value === StatusOfAttempt::NOT_YET_STARTED->value && $record->getAttemptOverviewInformation()?->getStatusOfAttempt() === null) ||
189 $value === $record->getAttemptOverviewInformation()?->getStatusOfAttempt()->value,
190 'test_passed' => fn(string $value, Participant $record) => $value === 'true'
191 ? $record->getAttemptOverviewInformation()?->hasPassingMark() === true
192 : $record->getAttemptOverviewInformation()?->hasPassingMark() !== true,
193 'scoring_finalized' => fn(string $value, Participant $record) => $value === 'true'
194 ? $record->isScoringFinalized() == true
195 : $record->isScoringFinalized() === false
196 ];
197 }
198
202 private function getPostLoadOrderFields(): array
203 {
204 $processing_time = $this->test_object->getProcessingTimeInSeconds();
205 $reset_time_on_new_attempt = $this->test_object->getResetProcessingTime();
206
207 return [
208 'attempt_started_at' => static fn(Participant $a, Participant $b) => $a->getFirstAccess() <=> $b->getFirstAccess(),
209 'total_duration' => static fn(
212 ) => $a->getTotalDuration($processing_time) <=> $b->getTotalDuration($processing_time),
213 'remaining_duration' => static fn(
216 ) => $a->getRemainingDuration($processing_time, $reset_time_on_new_attempt)
217 <=> $b->getRemainingDuration($processing_time, $reset_time_on_new_attempt),
218 'last_access' => static fn(Participant $a, Participant $b) => $a->getLastAccess() <=> $b->getLastAccess(),
219 'status_of_attempt' => static fn(
222 ) => $a->getAttemptOverviewInformation()?->getStatusOfAttempt()
223 <=> $b->getAttemptOverviewInformation()?->getStatusOfAttempt(),
224 'reached_points' => static fn(
227 ) => $a->getAttemptOverviewInformation()?->getReachedPoints()
228 <=> $b->getAttemptOverviewInformation()?->getReachedPoints(),
229 'nr_of_answered_questions' => static fn(
232 ) => $a->getAttemptOverviewInformation()?->getNrOfAnsweredQuestions()
233 <=> $b->getAttemptOverviewInformation()?->getNrOfAnsweredQuestions(),
234 'percent_of_available_points' => static fn(
237 ) => $a->getAttemptOverviewInformation()?->getReachedPointsInPercent()
238 <=> $b->getAttemptOverviewInformation()?->getReachedPointsInPercent(),
239 'test_passed' => static fn(
242 ) => $a->getAttemptOverviewInformation()?->hasPassingMark()
243 <=> $b->getAttemptOverviewInformation()?->hasPassingMark(),
244 'mark' => static fn(
247 ) => $a->getAttemptOverviewInformation()?->getMark() <=> $b->getAttemptOverviewInformation()?->getMark(),
248 'matriculation' => static fn(
251 ) => $a->getMatriculation() <=> $b->getMatriculation(),
252 'id_of_attempt' => static fn(
255 ) => $a->getAttemptOverviewInformation()?->getExamId() <=> $b->getAttemptOverviewInformation()?->getExamId(),
256 'total_time_on_task' => static fn(
259 ) => $a->getAttemptOverviewInformation()?->getTotalTimeOnTask() <=> $b->getAttemptOverviewInformation()?->getTotalTimeOnTask()
260 ];
261 }
262
263 private function getFilterComponent(string $action, ServerRequestInterface $request): FilterComponent
264 {
265 $filter_inputs = [];
266 $is_input_initially_rendered = [];
267 $field_factory = $this->ui_factory->input()->field();
268
269 foreach ($this->getFilterFields($field_factory) as $filter_id => $filter) {
270 [$filter_inputs[$filter_id], $is_input_initially_rendered[$filter_id]] = $filter;
271 }
272
273 return $this->ui_service->filter()->standard(
274 "participant_filter_{$this->test_request->getRefId()}",
275 $action,
276 $filter_inputs,
277 $is_input_initially_rendered,
278 true,
279 true
280 );
281 }
282
288 private function getFilterFields(FieldFactory $field_factory): array
289 {
290 $yes_no_all_options = [
291 'true' => $this->lng->txt('yes'),
292 'false' => $this->lng->txt('no')
293 ];
294
295 $solution_options = [
296 'false' => $this->lng->txt('without_solution'),
297 'true' => $this->lng->txt('with_solution')
298 ];
299
300 $status_of_attempt_options = [
301 StatusOfAttempt::NOT_YET_STARTED->value => $this->lng->txt(StatusOfAttempt::NOT_YET_STARTED->value),
302 StatusOfAttempt::RUNNING->value => $this->lng->txt(StatusOfAttempt::RUNNING->value),
303 StatusOfAttempt::FINISHED_BY_UNKNOWN->value => $this->lng->txt(StatusOfAttempt::FINISHED_BY_UNKNOWN->value),
304 StatusOfAttempt::FINISHED_BY_ADMINISTRATOR->value => $this->lng->txt(StatusOfAttempt::FINISHED_BY_ADMINISTRATOR->value),
305 StatusOfAttempt::FINISHED_BY_CRONJOB->value => $this->lng->txt(StatusOfAttempt::FINISHED_BY_CRONJOB->value),
306 StatusOfAttempt::FINISHED_BY_DURATION->value => $this->lng->txt(StatusOfAttempt::FINISHED_BY_DURATION->value),
307 StatusOfAttempt::FINISHED_BY_PARTICIPANT->value => $this->lng->txt(StatusOfAttempt::FINISHED_BY_PARTICIPANT->value),
308 ];
309
310 $filters = [
311 'name' => [$field_factory->text($this->lng->txt('name')), true],
312 'login' => [$field_factory->text($this->lng->txt('login')), true],
313 'ip_range' => [$field_factory->text($this->lng->txt('client_ip_range')), true],
314 'solution' => [$field_factory->select($this->lng->txt('solutions'), $solution_options), true],
315 ];
316
317 if ($this->test_object->getEnableProcessingTime()) {
318 $filters['extra_time'] = [$field_factory->select($this->lng->txt('extratime'), $yes_no_all_options), true];
319 }
320
321 $filters['status_of_attempt'] = [
322 $field_factory->select($this->lng->txt('status_of_attempt'), $status_of_attempt_options),
323 true
324 ];
325
326 $filters['test_passed'] = [
327 $field_factory->select($this->lng->txt('tst_passed'), $yes_no_all_options),
328 true
329 ];
330
331 if ($this->scoring_enabled) {
332 $filters['scoring_finalized'] = [
333 $field_factory->select($this->lng->txt('finalized_evaluation'), $yes_no_all_options),
334 true
335 ];
336 }
337
338 return $filters;
339 }
340
341 private function getTableComponent(ServerRequestInterface $request, ?array $filter)
342 {
343 return $this->ui_factory
344 ->table()
345 ->data(
346 $this,
347 $this->lng->txt('list_of_participants'),
348 $this->getColumns(),
349 )
350 ->withId(self::ID)
351 ->withRequest($request)
352 ->withFilter($filter);
353 }
354
358 private function getColumns(): array
359 {
360 $column_factory = $this->ui_factory->table()->column();
361
362 $columns = [
363 'name' => $column_factory->text($this->lng->txt('name'))
364 ->withIsSortable(!$this->test_object->getAnonymity())
365 ];
366 if (!$this->test_object->getAnonymity()) {
367 $columns['login'] = $column_factory->text($this->lng->txt('login'))->withIsSortable(true);
368 }
369
370 $columns += [
371 'matriculation' => $column_factory->text($this->lng->txt('matriculation'))
372 ->withIsOptional(true, false)
373 ->withIsSortable(true),
374 'ip_range' => $column_factory->text($this->lng->txt('client_ip_range'))
375 ->withIsOptional(true, false)
376 ->withIsSortable(true),
377 'attempt_started_at' => $column_factory->date(
378 $this->lng->txt('tst_attempt_started'),
379 $this->current_user->getDateTimeFormat()
380 )->withIsSortable(true),
381 'total_time_on_task' => $column_factory->text($this->lng->txt('working_time'))
382 ->withIsOptional(true, false),
383 'total_attempts' => $column_factory->number($this->lng->txt('total_attempts'))
384 ->withIsOptional(true, false)
385 ->withIsSortable(true),
386 ];
387
388 $columns['status_of_attempt'] = $column_factory->text($this->lng->txt('status_of_attempt'))
389 ->withIsSortable(true);
390
391 if ($this->test_object->getEnableProcessingTime()) {
392 $columns['remaining_duration'] = $column_factory->text($this->lng->txt('remaining_duration'))
393 ->withIsOptional(true);
394 $columns['total_duration'] = $column_factory->text($this->lng->txt('total_duration'))
395 ->withIsOptional(true, false);
396 $columns['extra_time'] = $column_factory->text($this->lng->txt('extratime'))
397 ->withIsOptional(true, false);
398 }
399
400 if ($this->test_object->getMainSettings()->getTestBehaviourSettings()->getExamIdInTestAttemptEnabled()) {
401 $columns['id_of_attempt'] = $column_factory->text($this->lng->txt('exam_id_of_attempt'))
402 ->withIsOptional(true, false)
403 ->withIsSortable(true);
404 }
405
406 if ($this->test_access->checkParticipantsResultsAccess()) {
407 $columns['reached_points'] = $column_factory->text($this->lng->txt('tst_reached_points'))
408 ->withIsSortable(true);
409 $columns['nr_of_answered_questions'] = $column_factory->text($this->lng->txt('tst_answered_questions'))
410 ->withIsOptional(true, false)
411 ->withIsSortable(true);
412 $columns['percent_of_available_points'] = $column_factory->number($this->lng->txt('tst_percent_solved'))
413 ->withUnit('%')
414 ->withIsOptional(true, false)
415 ->withIsSortable(true);
416 $columns['test_passed'] = $column_factory->boolean(
417 $this->lng->txt('tst_passed'),
418 $this->ui_factory->symbol()->icon()->custom(
419 'assets/images/standard/icon_checked.svg',
420 $this->lng->txt('yes'),
421 'small'
422 ),
423 $this->ui_factory->symbol()->icon()->custom(
424 'assets/images/standard/icon_unchecked.svg',
425 $this->lng->txt('no'),
426 'small'
427 )
428 )->withIsSortable(true)
429 ->withOrderingLabels(
430 "{$this->lng->txt('tst_passed')}, {$this->lng->txt('yes')} {$this->lng->txt('order_option_first')}",
431 "{$this->lng->txt('tst_passed')}, {$this->lng->txt('no')} {$this->lng->txt('order_option_first')}"
432 );
433 $columns['mark'] = $column_factory->text($this->lng->txt('tst_mark'))
434 ->withIsOptional(true, false)
435 ->withIsSortable(true);
436 }
437 if ($this->scoring_enabled) {
438 $columns['scoring_finalized'] = $column_factory->boolean(
439 $this->lng->txt('finalized_evaluation'),
440 $this->ui_factory->symbol()->icon()->custom(
441 'assets/images/standard/icon_checked.svg',
442 $this->lng->txt('yes'),
443 'small'
444 ),
445 $this->ui_factory->symbol()->icon()->custom(
446 'assets/images/standard/icon_unchecked.svg',
447 $this->lng->txt('no'),
448 'small'
449 )
450 )->withIsOptional(true, false)
451 ->withIsSortable(true);
452 }
453
454 $columns['last_access'] = $column_factory->date(
455 $this->lng->txt('last_access'),
456 $this->current_user->getDateTimeFormat()
457 );
458
459 return $columns;
460 }
461
462 private function loadRecords(?array $filter, Order $order): iterable
463 {
464 if ($this->records !== null) {
465 return $this->records;
466 }
467
468 $records = iterator_to_array(
469 $this->repository->getParticipants(
470 $this->test_object->getTestId(),
471 $filter,
472 null,
473 $order
474 )
475 );
476
477 $this->records = array_filter(
478 $records,
479 fn(Participant $participant) => in_array(
480 $participant->getUserId(),
481 $this->buildAccessFilteredParticipantsList($records)
482 )
483 );
484
485 return $this->records;
486 }
487
493 private function buildAccessFilteredParticipantsList(array $records): array
494 {
495 $manage_access_filter = $this->participant_access_filter
496 ->getManageParticipantsUserFilter($this->test_object->getRefId());
497 $access_results_access_filter = $this->participant_access_filter
498 ->getAccessResultsUserFilter($this->test_object->getRefId());
499 $participant_ids = array_map(
500 fn(Participant $participant) => $participant->getUserId(),
501 $records
502 );
503 return $manage_access_filter($participant_ids) + $access_results_access_filter($participant_ids);
504 }
505
506
510 private function getViewControlledRecords(?array $filter_data, Range $range, Order $order): iterable
511 {
512 return $this->limitRecords(
513 $this->orderRecords(
514 $this->filterRecords(
515 $this->results_data_factory->addAttemptOverviewInformationToParticipants(
516 $this->results_presentation_settings,
517 $this->test_object,
518 $this->loadRecords($filter_data, $order)
519 ),
520 $filter_data
521 ),
522 $order
523 ),
524 $range
525 );
526 }
527
528 private function filterRecords(iterable $records, ?array $filter_data): iterable
529 {
530 foreach ($records as $record) {
531 if ($this->matchFilter($record, $filter_data)) {
532 yield $record;
533 }
534 }
535 }
536
537 private function matchFilter(Participant $record, ?array $filter): bool
538 {
539 if ($filter === null) {
540 return true;
541 }
542
543 $post_load_filters = $this->getPostLoadFilters();
544 $allow = true;
545
546 foreach ($filter as $key => $value) {
547 if (!$value) {
548 continue;
549 }
550
551 $post_load_filter = $post_load_filters[$key] ?? fn() => true;
552 $allow = $allow && $post_load_filter($value, $record);
553 }
554
555 return $allow;
556 }
557
558 private function orderRecords(iterable $records, Order $order): array
559 {
560 $post_load_order_fields = $this->getPostLoadOrderFields();
561 $records = iterator_to_array($records);
562
563 uasort($records, static function (Participant $a, Participant $b) use ($order, $post_load_order_fields) {
564 foreach ($order->get() as $subject => $direction) {
565 $post_load_order_field = $post_load_order_fields[$subject] ?? fn() => 0;
566 $index = $post_load_order_field($a, $b);
567
568 if ($index !== 0) {
569 return $direction === 'DESC' ? $index * -1 : $index;
570 }
571 }
572
573 return 0;
574 });
575
576 return $records;
577 }
578
579 private function limitRecords(array $records, Range $range): array
580 {
581 return array_slice($records, $range->getStart(), $range->getLength());
582 }
583}
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:31