ILIAS  trunk Revision v11.0_alpha-3011-gc6b235a2e85
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(?array $filter_data, ?array $additional_parameters): ?int
85 {
86 return $this->repository->countParticipants($this->test_object->getTestId(), $filter_data);
87 }
88
89 public function getRows(
90 DataRowBuilder $row_builder,
91 array $visible_column_ids,
93 Order $order,
94 ?array $filter_data,
95 ?array $additional_parameters
96 ): \Generator {
97 $processing_time = $this->test_object->getProcessingTimeInSeconds();
98 $reset_time_on_new_attempt = $this->test_object->getResetProcessingTime();
99
100 $current_user_timezone = new \DateTimeZone($this->current_user->getTimeZone());
101
103 foreach ($this->getViewControlledRecords($filter_data, $range, $order) as $record) {
104 $total_duration = $record->getTotalDuration($processing_time);
105 $status_of_attempt = $record->getAttemptOverviewInformation()?->getStatusOfAttempt() ?? StatusOfAttempt::NOT_YET_STARTED;
106 $row = [
107 'name' => $this->test_object->buildName($record->getUserId(), $record->getFirstname(), $record->getLastname()),
108 'login' => $record->getLogin(),
109 'matriculation' => $record->getMatriculation(),
110 'total_time_on_task' => $record->getAttemptOverviewInformation()?->getHumanReadableTotalTimeOnTask() ?? '',
111 'status_of_attempt' => $this->lng->txt($status_of_attempt->value),
112 'id_of_attempt' => $record->getAttemptOverviewInformation()?->getExamId(),
113 'ip_range' => $record->getClientIpTo() !== '' || $record->getClientIpFrom() !== ''
114 ? sprintf('%s - %s', $record->getClientIpFrom(), $record->getClientIpTo())
115 : '',
116 'total_attempts' => $record->getAttemptOverviewInformation()?->getNrOfAttempts() ?? 0,
117 'extra_time' => $record->getExtraTime() > 0 ? sprintf('%d min', $record->getExtraTime()) : '',
118 'total_duration' => $total_duration > 0 ? sprintf('%d min', $total_duration / 60) : '',
119 'remaining_duration' => sprintf('%d min', $record->getRemainingDuration($processing_time, $reset_time_on_new_attempt) / 60),
120 ];
121
122 if ($this->scoring_enabled) {
123 $row['scoring_finalized'] = $record->isScoringFinalized();
124 }
125
126 $first_access = $record->getAttemptOverviewInformation()?->getStartedDate();
127 if ($first_access !== null) {
128 $row['attempt_started_at'] = $first_access->setTimezone($current_user_timezone);
129 }
130
131 $last_access = $record->getLastAccess();
132 if ($last_access !== null) {
133 $row['last_access'] = $last_access->setTimezone($current_user_timezone);
134 }
135 if ($record->getActiveId() !== null
136 && $this->test_access->checkResultsAccessForActiveId(
137 $record->getActiveId(),
138 $this->test_object->getTestId()
139 ) || $record === null && $this->test_access->checkParticipantsResultsAccess()) {
140 $row['reached_points'] = sprintf(
141 $this->lng->txt('tst_reached_points_of_max'),
142 $record->getAttemptOverviewInformation()?->getReachedPoints(),
143 $record->getAttemptOverviewInformation()?->getAvailablePoints()
144 );
145 $row['nr_of_answered_questions'] = sprintf(
146 $this->lng->txt('tst_answered_questions_of_total'),
147 $record->getAttemptOverviewInformation()?->getNrOfAnsweredQuestions(),
148 $record->getAttemptOverviewInformation()?->getNrOfTotalQuestions()
149 );
150 $row['percent_of_available_points'] = $record->getAttemptOverviewInformation()?->getReachedPointsInPercent();
151 }
152
153 if ($status_of_attempt->isFinished()) {
154 $row['test_passed'] = $record->getAttemptOverviewInformation()?->hasPassingMark() ?? false;
155 $row['mark'] = $record->getAttemptOverviewInformation()?->getMark();
156 }
157
158 yield $this->table_actions->onDataRow(
159 $row_builder->buildDataRow((string) $record->getUserId(), $row),
160 $record
161 );
162 }
163 }
164
165 private function acquireParameters($url_builder): array
166 {
167 return $url_builder->acquireParameters(
168 [self::ID],
172 );
173 }
174
178 private function getPostLoadFilters(): array
179 {
180 return [
181 'solution' => fn(string $value, Participant $record) =>
182 $value === 'true' ? $record->hasAnsweredQuestionsForScoredAttempt() : !$record->hasAnsweredQuestionsForScoredAttempt(),
183 'status_of_attempt' => fn(string $value, Participant $record) =>
184 ($value === StatusOfAttempt::NOT_YET_STARTED->value && $record->getAttemptOverviewInformation()?->getStatusOfAttempt() === null) ||
185 $value === $record->getAttemptOverviewInformation()?->getStatusOfAttempt()->value,
186 'test_passed' => fn(string $value, Participant $record) => $value === 'true'
187 ? $record->getAttemptOverviewInformation()?->hasPassingMark() === true
188 : $record->getAttemptOverviewInformation()?->hasPassingMark() !== true,
189 'scoring_finalized' => fn(string $value, Participant $record) => $value === 'true'
190 ? $record->isScoringFinalized() == true
191 : $record->isScoringFinalized() === false
192 ];
193 }
194
198 private function getPostLoadOrderFields(): array
199 {
200 $processing_time = $this->test_object->getProcessingTimeInSeconds();
201 $reset_time_on_new_attempt = $this->test_object->getResetProcessingTime();
202
203 return [
204 'attempt_started_at' => static fn(Participant $a, Participant $b) => $a->getFirstAccess() <=> $b->getFirstAccess(),
205 'total_duration' => static fn(
208 ) => $a->getTotalDuration($processing_time) <=> $b->getTotalDuration($processing_time),
209 'remaining_duration' => static fn(
212 ) => $a->getRemainingDuration($processing_time, $reset_time_on_new_attempt)
213 <=> $b->getRemainingDuration($processing_time, $reset_time_on_new_attempt),
214 'last_access' => static fn(Participant $a, Participant $b) => $a->getLastAccess() <=> $b->getLastAccess(),
215 'status_of_attempt' => static fn(
218 ) => $a->getAttemptOverviewInformation()?->getStatusOfAttempt()
219 <=> $b->getAttemptOverviewInformation()?->getStatusOfAttempt(),
220 'reached_points' => static fn(
223 ) => $a->getAttemptOverviewInformation()?->getReachedPoints()
224 <=> $b->getAttemptOverviewInformation()?->getReachedPoints(),
225 'nr_of_answered_questions' => static fn(
228 ) => $a->getAttemptOverviewInformation()?->getNrOfAnsweredQuestions()
229 <=> $b->getAttemptOverviewInformation()?->getNrOfAnsweredQuestions(),
230 'percent_of_available_points' => static fn(
233 ) => $a->getAttemptOverviewInformation()?->getReachedPointsInPercent()
234 <=> $b->getAttemptOverviewInformation()?->getReachedPointsInPercent(),
235 'test_passed' => static fn(
238 ) => $a->getAttemptOverviewInformation()?->hasPassingMark()
239 <=> $b->getAttemptOverviewInformation()?->hasPassingMark(),
240 'mark' => static fn(
243 ) => $a->getAttemptOverviewInformation()?->getMark() <=> $b->getAttemptOverviewInformation()?->getMark(),
244 'matriculation' => static fn(
247 ) => $a->getMatriculation() <=> $b->getMatriculation(),
248 'id_of_attempt' => static fn(
251 ) => $a->getAttemptOverviewInformation()?->getExamId() <=> $b->getAttemptOverviewInformation()?->getExamId(),
252 'total_time_on_task' => static fn(
255 ) => $a->getAttemptOverviewInformation()?->getTotalTimeOnTask() <=> $b->getAttemptOverviewInformation()?->getTotalTimeOnTask()
256 ];
257 }
258
259 private function getFilterComponent(string $action, ServerRequestInterface $request): FilterComponent
260 {
261 $filter_inputs = [];
262 $is_input_initially_rendered = [];
263 $field_factory = $this->ui_factory->input()->field();
264
265 foreach ($this->getFilterFields($field_factory) as $filter_id => $filter) {
266 [$filter_inputs[$filter_id], $is_input_initially_rendered[$filter_id]] = $filter;
267 }
268
269 return $this->ui_service->filter()->standard(
270 'participant_filter',
271 $action,
272 $filter_inputs,
273 $is_input_initially_rendered,
274 true,
275 true
276 );
277 }
278
284 private function getFilterFields(FieldFactory $field_factory): array
285 {
286 $yes_no_all_options = [
287 'true' => $this->lng->txt('yes'),
288 'false' => $this->lng->txt('no')
289 ];
290
291 $solution_options = [
292 'false' => $this->lng->txt('without_solution'),
293 'true' => $this->lng->txt('with_solution')
294 ];
295
296 $status_of_attempt_options = [
297 StatusOfAttempt::NOT_YET_STARTED->value => $this->lng->txt(StatusOfAttempt::NOT_YET_STARTED->value),
298 StatusOfAttempt::RUNNING->value => $this->lng->txt(StatusOfAttempt::RUNNING->value),
299 StatusOfAttempt::FINISHED_BY_UNKNOWN->value => $this->lng->txt(StatusOfAttempt::FINISHED_BY_UNKNOWN->value),
300 StatusOfAttempt::FINISHED_BY_ADMINISTRATOR->value => $this->lng->txt(StatusOfAttempt::FINISHED_BY_ADMINISTRATOR->value),
301 StatusOfAttempt::FINISHED_BY_CRONJOB->value => $this->lng->txt(StatusOfAttempt::FINISHED_BY_CRONJOB->value),
302 StatusOfAttempt::FINISHED_BY_DURATION->value => $this->lng->txt(StatusOfAttempt::FINISHED_BY_DURATION->value),
303 StatusOfAttempt::FINISHED_BY_PARTICIPANT->value => $this->lng->txt(StatusOfAttempt::FINISHED_BY_PARTICIPANT->value),
304 ];
305
306 $filters = [
307 'name' => [$field_factory->text($this->lng->txt('name')), true],
308 'login' => [$field_factory->text($this->lng->txt('login')), true],
309 'ip_range' => [$field_factory->text($this->lng->txt('client_ip_range')), true],
310 'solution' => [$field_factory->select($this->lng->txt('solutions'), $solution_options), true],
311 ];
312
313 if ($this->test_object->getEnableProcessingTime()) {
314 $filters['extra_time'] = [$field_factory->select($this->lng->txt('extratime'), $yes_no_all_options), true];
315 }
316
317 $filters['status_of_attempt'] = [
318 $field_factory->select($this->lng->txt('status_of_attempt'), $status_of_attempt_options),
319 true
320 ];
321
322 $filters['test_passed'] = [
323 $field_factory->select($this->lng->txt('tst_passed'), $yes_no_all_options),
324 true
325 ];
326
327 if ($this->scoring_enabled) {
328 $filters['scoring_finalized'] = [
329 $field_factory->select($this->lng->txt('finalized_evaluation'), $yes_no_all_options),
330 true
331 ];
332 }
333
334 return $filters;
335 }
336
337 private function getTableComponent(ServerRequestInterface $request, ?array $filter)
338 {
339 return $this->ui_factory
340 ->table()
341 ->data(
342 $this,
343 $this->lng->txt('list_of_participants'),
344 $this->getColumns(),
345 )
346 ->withId(self::ID)
347 ->withRequest($request)
348 ->withFilter($filter);
349 }
350
354 private function getColumns(): array
355 {
356 $column_factory = $this->ui_factory->table()->column();
357
358 $columns = [
359 'name' => $column_factory->text($this->lng->txt('name'))
360 ->withIsSortable(!$this->test_object->getAnonymity())
361 ];
362 if (!$this->test_object->getAnonymity()) {
363 $columns['login'] = $column_factory->text($this->lng->txt('login'))->withIsSortable(true);
364 }
365
366 $columns += [
367 'matriculation' => $column_factory->text($this->lng->txt('matriculation'))
368 ->withIsOptional(true, false)
369 ->withIsSortable(true),
370 'ip_range' => $column_factory->text($this->lng->txt('client_ip_range'))
371 ->withIsOptional(true, false)
372 ->withIsSortable(true),
373 'attempt_started_at' => $column_factory->date(
374 $this->lng->txt('tst_attempt_started'),
375 $this->current_user->getDateTimeFormat()
376 )->withIsSortable(true),
377 'total_time_on_task' => $column_factory->text($this->lng->txt('working_time'))
378 ->withIsOptional(true, false),
379 'total_attempts' => $column_factory->number($this->lng->txt('total_attempts'))
380 ->withIsOptional(true, false)
381 ->withIsSortable(true),
382 ];
383
384 $columns['status_of_attempt'] = $column_factory->text($this->lng->txt('status_of_attempt'))
385 ->withIsSortable(true);
386
387 if ($this->test_object->getEnableProcessingTime()) {
388 $columns['remaining_duration'] = $column_factory->text($this->lng->txt('remaining_duration'))
389 ->withIsOptional(true);
390 $columns['total_duration'] = $column_factory->text($this->lng->txt('total_duration'))
391 ->withIsOptional(true, false);
392 $columns['extra_time'] = $column_factory->text($this->lng->txt('extratime'))
393 ->withIsOptional(true, false);
394 }
395
396 if ($this->test_object->getMainSettings()->getTestBehaviourSettings()->getExamIdInTestAttemptEnabled()) {
397 $columns['id_of_attempt'] = $column_factory->text($this->lng->txt('exam_id_of_attempt'))
398 ->withIsOptional(true, false)
399 ->withIsSortable(true);
400 }
401
402 if ($this->test_access->checkParticipantsResultsAccess()) {
403 $columns['reached_points'] = $column_factory->text($this->lng->txt('tst_reached_points'))
404 ->withIsSortable(true);
405 $columns['nr_of_answered_questions'] = $column_factory->text($this->lng->txt('tst_answered_questions'))
406 ->withIsOptional(true, false)
407 ->withIsSortable(true);
408 $columns['percent_of_available_points'] = $column_factory->number($this->lng->txt('tst_percent_solved'))
409 ->withUnit('%')
410 ->withIsOptional(true, false)
411 ->withIsSortable(true);
412 $columns['test_passed'] = $column_factory->boolean(
413 $this->lng->txt('tst_passed'),
414 $this->ui_factory->symbol()->icon()->custom(
415 'assets/images/standard/icon_checked.svg',
416 $this->lng->txt('yes'),
417 'small'
418 ),
419 $this->ui_factory->symbol()->icon()->custom(
420 'assets/images/standard/icon_unchecked.svg',
421 $this->lng->txt('no'),
422 'small'
423 )
424 )->withIsSortable(true)
425 ->withOrderingLabels(
426 "{$this->lng->txt('tst_passed')}, {$this->lng->txt('yes')} {$this->lng->txt('order_option_first')}",
427 "{$this->lng->txt('tst_passed')}, {$this->lng->txt('no')} {$this->lng->txt('order_option_first')}"
428 );
429 $columns['mark'] = $column_factory->text($this->lng->txt('tst_mark'))
430 ->withIsOptional(true, false)
431 ->withIsSortable(true);
432 }
433 if ($this->scoring_enabled) {
434 $columns['scoring_finalized'] = $column_factory->boolean(
435 $this->lng->txt('finalized_evaluation'),
436 $this->ui_factory->symbol()->icon()->custom(
437 'assets/images/standard/icon_checked.svg',
438 $this->lng->txt('yes'),
439 'small'
440 ),
441 $this->ui_factory->symbol()->icon()->custom(
442 'assets/images/standard/icon_unchecked.svg',
443 $this->lng->txt('no'),
444 'small'
445 )
446 )->withIsOptional(true, false)
447 ->withIsSortable(true);
448 }
449
450 $columns['last_access'] = $column_factory->date(
451 $this->lng->txt('last_access'),
452 $this->current_user->getDateTimeFormat()
453 );
454
455 return $columns;
456 }
457
458 private function loadRecords(?array $filter, Order $order): iterable
459 {
460 if ($this->records !== null) {
461 return $this->records;
462 }
463
464 $records = iterator_to_array(
465 $this->repository->getParticipants(
466 $this->test_object->getTestId(),
467 $filter,
468 null,
469 $order
470 )
471 );
472
473 $this->records = array_filter(
474 $records,
475 fn(Participant $participant) => in_array(
476 $participant->getUserId(),
477 $this->buildAccessFilteredParticipantsList($records)
478 )
479 );
480
481 return $this->records;
482 }
483
489 private function buildAccessFilteredParticipantsList(array $records): array
490 {
491 $manage_access_filter = $this->participant_access_filter
492 ->getManageParticipantsUserFilter($this->test_object->getRefId());
493 $access_results_access_filter = $this->participant_access_filter
494 ->getAccessResultsUserFilter($this->test_object->getRefId());
495 $participant_ids = array_map(
496 fn(Participant $participant) => $participant->getUserId(),
497 $records
498 );
499 return $manage_access_filter($participant_ids) + $access_results_access_filter($participant_ids);
500 }
501
502
506 private function getViewControlledRecords(?array $filter_data, Range $range, Order $order): iterable
507 {
508 return $this->limitRecords(
509 $this->orderRecords(
510 $this->filterRecords(
511 $this->results_data_factory->addAttemptOverviewInformationToParticipants(
512 $this->results_presentation_settings,
513 $this->test_object,
514 $this->loadRecords($filter_data, $order)
515 ),
516 $filter_data
517 ),
518 $order
519 ),
520 $range
521 );
522 }
523
524 private function filterRecords(iterable $records, ?array $filter_data): iterable
525 {
526 foreach ($records as $record) {
527 if ($this->matchFilter($record, $filter_data)) {
528 yield $record;
529 }
530 }
531 }
532
533 private function matchFilter(Participant $record, ?array $filter): bool
534 {
535 if ($filter === null) {
536 return true;
537 }
538
539 $post_load_filters = $this->getPostLoadFilters();
540 $allow = true;
541
542 foreach ($filter as $key => $value) {
543 if (!$value) {
544 continue;
545 }
546
547 $post_load_filter = $post_load_filters[$key] ?? fn() => true;
548 $allow = $allow && $post_load_filter($value, $record);
549 }
550
551 return $allow;
552 }
553
554 private function orderRecords(iterable $records, Order $order): array
555 {
556 $post_load_order_fields = $this->getPostLoadOrderFields();
557 $records = iterator_to_array($records);
558
559 uasort($records, static function (Participant $a, Participant $b) use ($order, $post_load_order_fields) {
560 foreach ($order->get() as $subject => $direction) {
561 $post_load_order_field = $post_load_order_fields[$subject] ?? fn() => 0;
562 $index = $post_load_order_field($a, $b);
563
564 if ($index !== 0) {
565 return $direction === 'DESC' ? $index * -1 : $index;
566 }
567 }
568
569 return 0;
570 });
571
572 return $records;
573 }
574
575 private function limitRecords(array $records, Range $range): array
576 {
577 return array_slice($records, $range->getStart(), $range->getLength());
578 }
579}
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
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)
getTotalRowCount(?array $filter_data, ?array $additional_parameters)
Mainly for the purpose of pagination-support, it is important to know about the total number of recor...
__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.
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
getRows(DataRowBuilder $row_builder, array $visible_column_ids, Range $range, Order $order, ?array $filter_data, ?array $additional_parameters)
This is called by the table to retrieve rows; map data-records to rows using the $row_builder e....
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