ILIAS  trunk Revision v11.0_alpha-2638-g80c1d007f79
Repository.php
Go to the documentation of this file.
1 <?php
2 
19 declare(strict_types=1);
20 
21 namespace ILIAS\Test\Results\Data;
22 
28 
30 {
31  private Container $cache;
32 
33  public function __construct(
34  private readonly \ilDBInterface $db,
35  private readonly Refinery $refinery,
36  private readonly MarksRepository $marks_repository,
37  Services $global_cache
38  ) {
39  $this->cache = $global_cache->get(new BaseRequest('test_result'));
40  }
41 
45  public function getPassedParticipants(int $test_obj_id): array
46  {
47  $result = $this->db->queryF(
48  'SELECT tst_result_cache.active_fi AS active_id, tst_active.user_fi AS user_id FROM tst_result_cache' . PHP_EOL
49  . 'INNER JOIN tst_active ON tst_active.active_id = tst_result_cache.active_fi' . PHP_EOL
50  . 'INNER JOIN tst_tests ON tst_tests.test_id = tst_active.test_fi' . PHP_EOL
51  . 'WHERE tst_tests.obj_fi = %s AND tst_result_cache.passed_once = 1' . PHP_EOL,
53  [$test_obj_id]
54  );
55  return $this->db->fetchAll($result);
56  }
57 
58  public function isPassed(int $user_id, int $test_obj_id): bool
59  {
60  return ($status = $this->readOrQueryStatus($user_id, $test_obj_id)) !== null && $status['passed'];
61  }
62 
63  public function isFailed(int $user_id, int $test_obj_id): bool
64  {
65  return ($status = $this->readOrQueryStatus($user_id, $test_obj_id)) !== null && $status['failed'];
66  }
67 
68  public function hasFinished(int $user_id, int $test_obj_id): bool
69  {
70  return ($status = $this->readOrQueryStatus($user_id, $test_obj_id)) !== null && $status['finished'];
71  }
72 
73  public function getTestResult(int $active_id): ?ParticipantResult
74  {
75  $result = $this->db->queryF(
76  'SELECT tst_result_cache.*, tst_active.test_fi AS test_id FROM tst_result_cache' . PHP_EOL
77  . 'JOIN tst_active ON tst_result_cache.active_fi = tst_active.active_id' . PHP_EOL
78  . 'WHERE active_fi = %s',
80  [$active_id]
81  );
82 
83  return $this->toParticipantResult($this->db->fetchAssoc($result));
84  }
85 
86  public function updateTestResultCache(int $active_id, ?\ilAssQuestionProcessLocker $process_locker = null): ?ParticipantResult
87  {
88  $attempt = \ilObjTest::_getResultPass($active_id);
89  $attempt_result = $this->fetchTestAttemptResult($active_id, $attempt);
90  if (!$attempt_result) {
91  return null;
92  }
93 
94  // Prevent unfinished passes from being entered in the table so that no inconsistencies occur during an attempt
95  $status = StatusOfAttempt::build(
96  $attempt,
97  $attempt_result['last_finished_pass'],
98  $attempt_result['finalized_by'],
99  );
100 
101  $result = $this->buildTestResultObject($attempt_result);
102  $callback = function () use ($result) {
103  $values = [
104  'active_fi' => [\ilDBConstants::T_INTEGER, $result->getActiveId()],
105  'pass' => [\ilDBConstants::T_INTEGER, $result->getAttempt()],
106  'max_points' => [\ilDBConstants::T_FLOAT, $result->getMaxPoints()],
107  'reached_points' => [\ilDBConstants::T_FLOAT, $result->getReachedPoints()],
108  'mark_short' => [\ilDBConstants::T_TEXT, $result->getMarkShort()],
109  'mark_official' => [\ilDBConstants::T_TEXT, $result->getMarkOfficial()],
110  'passed_once' => [\ilDBConstants::T_INTEGER, $result->isPassedOnce()],
111  'passed' => [\ilDBConstants::T_INTEGER, (int) $result->isPassed()],
112  'failed' => [\ilDBConstants::T_INTEGER, (int) $result->isFailed()],
113  'tstamp' => [\ilDBConstants::T_INTEGER, time()],
114  ];
115  $this->db->replace(
116  'tst_result_cache',
117  ['active_fi' => $result->getActiveId()],
118  $values
119  );
120  };
121 
122  if (is_object($process_locker)) {
123  $process_locker->executeUserTestResultUpdateLockOperation($callback);
124  } else {
125  $callback();
126  }
127 
128  $this->updateStatusCache(
129  $attempt_result['user_id'],
130  $attempt_result['test_obj_id'],
131  [
132  'passed' => $result->isPassed(),
133  'failed' => $result->isFailed(),
134  'finished' => $status->isFinished(),
135  ]
136  );
137 
138  return $result;
139  }
140 
141  public function getTestAttemptResult(int $active_id): ?AttemptResult
142  {
143  $result = $this->db->queryF(
144  "SELECT * FROM tst_pass_result WHERE active_fi = %s",
146  [$active_id]
147  );
148  return $this->toTestAttemptResult($this->db->fetchAssoc($result));
149  }
150 
151  public function updateTestAttemptResult(
152  int $active_id,
153  int $attempt,
154  ?\ilAssQuestionProcessLocker $process_locker = null,
155  ?int $test_obj_id = null,
156  bool $update_result_cache_table = true
157  ): ?AttemptResult {
158  $test_result = $this->fetchTestResult($active_id, $attempt);
159  if (!$test_result) {
160  return null;
161  }
162 
163  $result_object = $this->buildTestAttemptResultObject(
164  $active_id,
165  $test_result,
166  $test_obj_id
167  );
168 
169  $callback = function () use ($result_object, $attempt) {
170  $this->db->replace(
171  'tst_pass_result',
172  [
173  'active_fi' => [\ilDBConstants::T_INTEGER, $result_object->getActiveId()],
174  'pass' => [\ilDBConstants::T_INTEGER, $attempt]
175  ],
176  [
177  'points' => [\ilDBConstants::T_FLOAT, $result_object->getReachedPoints()],
178  'maxpoints' => [\ilDBConstants::T_FLOAT, $result_object->getMaxPoints()],
179  'questioncount' => [\ilDBConstants::T_INTEGER, $result_object->getQuestionCount()],
180  'answeredquestions' => [\ilDBConstants::T_INTEGER, $result_object->getAnsweredQuestions()],
181  'workingtime' => [\ilDBConstants::T_INTEGER, $result_object->getWorkingTime()],
182  'tstamp' => [\ilDBConstants::T_INTEGER, time()],
183  'exam_id' => [\ilDBConstants::T_TEXT, $result_object->getExamId()],
184  'finalized_by' => [\ilDBConstants::T_TEXT, $result_object->getFinalizedBy()]
185  ]
186  );
187  };
188 
189  if (is_object($process_locker)) {
190  $process_locker->executeUserPassResultUpdateLockOperation($callback);
191  } else {
192  $callback();
193  }
194 
195  if ($update_result_cache_table) {
196  $this->updateTestResultCache($active_id, $process_locker);
197  }
198 
199  return $result_object;
200  }
201 
202  public function finalizeTestAttemptResult(int $active_id, int $attempt, StatusOfAttempt $status_of_attempt): void
203  {
204  if (!$status_of_attempt->isFinished()) {
205  throw new \RuntimeException('Status of attempt must be finished to finalize test attempt result');
206  }
207 
208  $this->db->manipulateF(
209  'UPDATE tst_pass_result SET tstamp = %s, finalized_by = %s WHERE active_fi = %s AND pass = %s',
210  ['integer', 'text', 'integer', 'integer'],
211  [time(), $status_of_attempt->value, $active_id, $attempt]
212  );
213  }
214 
215  private function fetchTestAttemptResult(int $active_id, int $attempt): ?array
216  {
217  return $this->db->fetchAssoc($this->db->queryF(
218  "SELECT tst_pass_result.*, tst_active.last_finished_pass, tst_active.user_fi AS user_id, tst_tests.test_id,
219  tst_tests.obj_fi AS test_obj_id, tst_pass_result.maxpoints AS max_points, points AS reached_points,
220  tst_result_cache.passed_once AS passed_once_before
221  FROM tst_pass_result
222  INNER JOIN tst_active ON tst_pass_result.active_fi = tst_active.active_id
223  INNER JOIN tst_tests ON tst_tests.test_id = tst_active.test_fi
224  LEFT JOIN tst_result_cache ON tst_result_cache.active_fi = tst_active.active_id
225  WHERE tst_pass_result.active_fi = %s AND tst_pass_result.pass = %s",
227  [$active_id, $attempt]
228  ));
229  }
230 
231  private function buildTestResultObject(array $test_attempt_result_array): ParticipantResult
232  {
233  $test_attempt_result = $this->toParticipantResult($test_attempt_result_array);
234 
235  $is_passed = $test_attempt_result->getAttempt() <= $test_attempt_result_array['last_finished_pass'] && $test_attempt_result->isPassed();
236  $passed_once_before = (bool) ($test_attempt_result_array['passed_once_before'] ?? false);
237  return $test_attempt_result->withPassedOnce($is_passed || $passed_once_before);
238  }
239 
240  private function fetchTestResult(int $active_id, int $attempt): ?array
241  {
242  return $this->db->fetchAssoc($this->db->queryF(
243  'SELECT r.pass,' . PHP_EOL
244  . 'SUM(r.points) AS points,' . PHP_EOL
245  . 'COUNT(DISTINCT(r.question_fi)) answeredquestions,' . PHP_EOL
246  . 'pr.exam_id,' . PHP_EOL
247  . 'pr.finalized_by' . PHP_EOL
248  . 'FROM tst_test_result r' . PHP_EOL
249  . 'INNER JOIN tst_pass_result pr' . PHP_EOL
250  . 'ON r.active_fi = pr.active_fi AND r.pass = pr.pass' . PHP_EOL
251  . 'WHERE r.active_fi = %s AND r.pass = %s',
253  [$active_id, $attempt]
254  ));
255  }
256 
257  private function buildTestAttemptResultObject(int $active_id, array $test_result, ?int $test_obj_id): AttemptResult
258  {
259  $test_result['active_fi'] = $active_id;
260  $test_attempt_result = $this->toTestAttemptResult($test_result);
261  $additional_data = $this->fetchAdditionalTestData($test_attempt_result->getActiveId(), $test_attempt_result->getAttempt());
262 
263  return $test_attempt_result->withMaxPoints($additional_data['max_points'])
264  ->withQuestionCount($additional_data['question_count'])
265  ->withWorkingTime(
266  $this->fetchWorkingTime($test_attempt_result->getActiveId(), $test_attempt_result->getAttempt())
267  )
268  ->withExamId(
270  $test_attempt_result->getActiveId(),
271  $test_attempt_result->getAttempt(),
272  $test_obj_id
273  )
274  )
275  ->withTimestamp();
276  }
277 
281  private function fetchAdditionalTestData(int $active_id, int $attempt): array
282  {
283  $result = $this->db->queryF(
284  "SELECT tst_tests.question_set_type FROM tst_active
285  INNER JOIN tst_tests ON tst_active.test_fi = tst_tests.test_id
286  WHERE tst_active.active_id = %s",
288  [$active_id]
289  );
290  $question_set_type = $result->numRows() > 0 ? $this->db->fetchAssoc($result)['question_set_type'] : '';
291 
292  $result = match ($question_set_type) {
293  \ilObjTest::QUESTION_SET_TYPE_RANDOM => $this->db->queryF(
294  "SELECT tst_test_rnd_qst.pass, COUNT(tst_test_rnd_qst.question_fi) qcount, SUM(qpl_questions.points) qsum
295  FROM tst_test_rnd_qst, qpl_questions
296  WHERE tst_test_rnd_qst.question_fi = qpl_questions.question_id
297  AND tst_test_rnd_qst.active_fi = %s AND pass = %s
298  GROUP BY tst_test_rnd_qst.active_fi, tst_test_rnd_qst.pass",
300  [$active_id, $attempt]
301  ),
302  \ilObjTest::QUESTION_SET_TYPE_FIXED => $this->db->queryF(
303  "SELECT COUNT(tst_test_question.question_fi) qcount, SUM(qpl_questions.points) qsum
304  FROM tst_test_question, qpl_questions, tst_active
305  WHERE tst_test_question.question_fi = qpl_questions.question_id
306  AND tst_test_question.test_fi = tst_active.test_fi AND tst_active.active_id = %s
307  GROUP BY tst_test_question.test_fi",
309  [$active_id]
310  ),
311  default => throw new \ilTestException('not supported question set type: ' . $question_set_type),
312  };
313 
314  $row = $this->db->fetchAssoc($result);
315  return is_array($row)
316  ? ['question_count' => (int) $row['qcount'], 'max_points' => (float) $row['qsum']]
317  : ['question_count' => 0, 'max_points' => 0.0];
318  }
319 
320  public function fetchWorkingTime(int $active_id, int $attempt): int
321  {
322  $result = $this->db->queryF(
323  "SELECT started, finished FROM tst_times WHERE active_fi = %s AND pass = %s ORDER BY started",
325  [$active_id, $attempt]
326  );
327 
328  $time = 0;
329  while ($row = $this->db->fetchAssoc($result)) {
330  $time += (strtotime($row['finished']) - strtotime($row['started']));
331  }
332  return $time;
333  }
334 
335  public function removeTestResults(array $active_ids, int $test_obj_id): void
336  {
337  $condition = $this->db->in('active_fi', $active_ids, false, \ilDBConstants::T_INTEGER);
338 
339  $this->db->manipulate("DELETE FROM tst_test_result WHERE {$condition}");
340  $this->db->manipulate("DELETE FROM tst_pass_result WHERE {$condition}");
341 
342  $user_ids = $this->db->fetchAll(
343  $this->db->query(
344  'SELECT user_fi FROM tst_active WHERE' . PHP_EOL
345  . $this->db->in('active_id', $active_ids, false, \ilDBConstants::T_INTEGER)
346  )
347  );
348  foreach ($user_ids as $row) {
349  $this->cache->delete($row['user_fi'] . ':' . $test_obj_id);
350  }
351  }
352 
353 
354  private function toParticipantResult(?array $row): ?ParticipantResult
355  {
356  if ($row === null) {
357  return null;
358  }
359 
360  $max_points = $this->ensurePositive($row['max_points'] ?? 0.0);
361  $reached_points = $this->ensurePositive($row['reached_points'] ?? 0.0);
362  $percentage = ($max_points > 0 ? $reached_points / $max_points : 0.0) * 100;
363 
364  $mark = $this->marks_repository->getMarkSchemaFor($row['test_id'])->getMatchingMark($percentage);
365 
366  return new ParticipantResult(
367  $row['active_fi'],
368  (int) $row['pass'],
369  $max_points,
370  $reached_points,
371  $mark,
372  (int) ($row['tstamp'] ?? -1),
373  (bool) ($row['passed_once'] ?? false),
374  );
375  }
376 
377  private function toTestAttemptResult(?array $row): ?AttemptResult
378  {
379  if ($row === null) {
380  return null;
381  }
382 
383  return new AttemptResult(
384  $row['active_fi'],
385  (int) $row['pass'],
386  $this->ensurePositive($row['maxpoints'] ?? 0.0),
387  $this->ensurePositive($row['points'] ?? 0.0),
388  (int) ($row['questioncount'] ?? 0),
389  (int) ($row['answeredquestions'] ?? 0),
390  (int) ($row['workingtime'] ?? 0),
391  (int) ($row['tstamp'] ?? -1),
392  $row['exam_id'] ?? '',
393  $row['finalized_by'] ?? '',
394  );
395  }
396 
397  private function ensurePositive(mixed $value): float
398  {
399  return max(0.0, (float) $value);
400  }
401 
405  private function updateStatusCache(int $user_id, int $test_obj_id, array $status): void
406  {
407  $this->cache->set($user_id . ':' . $test_obj_id, $status);
408  }
409 
413  private function readOrQueryStatus(int $user_id, int $test_obj_id): ?array
414  {
415  $cached_status = $this->cache->get($user_id . ':' . $test_obj_id, $this->refinery->identity());
416  if ($cached_status !== null) {
417  return $cached_status;
418  }
419 
420  $status = $this->db->fetchAssoc($this->db->queryF(
421  "SELECT tst_result_cache.passed, tst_result_cache.failed, (tst_active.last_finished_pass IS NOT NULL) AS finished
422  FROM tst_result_cache
423  INNER JOIN tst_active ON tst_active.active_id = tst_result_cache.active_fi
424  INNER JOIN tst_tests ON tst_tests.test_id = tst_active.test_fi
425  WHERE tst_active.user_fi = %s AND tst_tests.obj_fi = %s",
427  [$user_id, $test_obj_id]
428  ));
429  if ($status === null) {
430  return null;
431  }
432 
433  $this->updateStatusCache($user_id, $test_obj_id, $status);
434  return $status;
435  }
436 }
const QUESTION_SET_TYPE_RANDOM
buildTestAttemptResultObject(int $active_id, array $test_result, ?int $test_obj_id)
Definition: Repository.php:257
updateTestResultCache(int $active_id, ?\ilAssQuestionProcessLocker $process_locker=null)
Definition: Repository.php:86
fetchTestResult(int $active_id, int $attempt)
Definition: Repository.php:240
isPassed(int $user_id, int $test_obj_id)
Definition: Repository.php:58
isFailed(int $user_id, int $test_obj_id)
Definition: Repository.php:63
updateTestAttemptResult(int $active_id, int $attempt, ?\ilAssQuestionProcessLocker $process_locker=null, ?int $test_obj_id=null, bool $update_result_cache_table=true)
Definition: Repository.php:151
fetchWorkingTime(int $active_id, int $attempt)
Definition: Repository.php:320
buildTestResultObject(array $test_attempt_result_array)
Definition: Repository.php:231
while($session_entry=$r->fetchRow(ilDBConstants::FETCHMODE_ASSOC)) return null
updateStatusCache(int $user_id, int $test_obj_id, array $status)
Definition: Repository.php:405
static _getResultPass($active_id)
Retrieves the pass number that should be counted for a given user.
fetchTestAttemptResult(int $active_id, int $attempt)
Definition: Repository.php:215
getPassedParticipants(int $test_obj_id)
Definition: Repository.php:45
Class ParticipantResult is a model representation of an entry in the test_result_cache table...
finalizeTestAttemptResult(int $active_id, int $attempt, StatusOfAttempt $status_of_attempt)
Definition: Repository.php:202
removeTestResults(array $active_ids, int $test_obj_id)
Definition: Repository.php:335
__construct(private readonly \ilDBInterface $db, private readonly Refinery $refinery, private readonly MarksRepository $marks_repository, Services $global_cache)
Definition: Repository.php:33
This file is part of ILIAS, a powerful learning management system published by ILIAS open source e-Le...
readOrQueryStatus(int $user_id, int $test_obj_id)
Definition: Repository.php:413
const QUESTION_SET_TYPE_FIXED
static buildExamId($active_id, $pass, $test_obj_id=null)
hasFinished(int $user_id, int $test_obj_id)
Definition: Repository.php:68
fetchAdditionalTestData(int $active_id, int $attempt)
Definition: Repository.php:281