ILIAS  release_10 Revision v10.1-43-ga1241a92c2f
class.assQuestion.php
Go to the documentation of this file.
1 <?php
2 
19 declare(strict_types=1);
20 
38 use ILIAS\Notes\InternalDataService as NotesInternalDataService;
39 use ILIAS\Notes\NoteDBRepository as NotesRepo;
45 
50 abstract class assQuestion implements Question
51 {
52  protected const HAS_SPECIFIC_FEEDBACK = true;
53 
54  public const ADDITIONAL_CONTENT_EDITING_MODE_RTE = 'default';
55  public const ADDITIONAL_CONTENT_EDITING_MODE_IPE = 'pageobject';
56 
57  private const DEFAULT_THUMB_SIZE = 150;
58  private const MINIMUM_THUMB_SIZE = 20;
59  private const MAXIMUM_THUMB_SIZE = 8192;
60  public const TRIM_PATTERN = '/^[\p{C}\p{Z}]+|[\p{C}\p{Z}]+$/u';
61 
62  protected static $force_pass_results_update_enabled = false;
63 
67  protected \ilAssQuestionProcessLocker $processLocker;
70 
71  protected ILIAS $ilias;
73  protected ilLanguage $lng;
74  protected ilDBInterface $db;
77  protected HTTPServices $http;
78  protected Refinery $refinery;
80  protected LoggingServices $log;
81  protected Container $dic;
82 
84  protected \ilAssQuestionLifecycle $lifecycle;
85  public \ilAssQuestionFeedback $feedbackOBJ;
86  protected \ilAssQuestionPage $page;
87 
88  protected int $id;
89  protected string $title;
90  protected string $comment;
91  protected int $owner;
92  protected string $author;
93  protected int $thumb_size;
94  protected string $question;
95  protected float $points = 0.0;
96  protected bool $shuffle = true;
97  protected int $test_id;
98  protected int $obj_id = 0;
99  protected ?int $original_id = null;
100  private int $nr_of_tries;
101  protected ?int $lastChange = null;
102  private string $export_image_path;
103  protected ?string $external_id = null;
104  private string $additionalContentEditingMode = '';
105  public bool $prevent_rte_usage = false;
106  public bool $selfassessmenteditingmode = false;
107  public int $defaultnroftries = 0;
108  public string $questionActionCmd = 'handleQuestionAction';
109  protected ?int $step = null;
110 
114  protected array $suggested_solutions;
115 
116  public function __construct(
117  string $title = "",
118  string $comment = "",
119  string $author = "",
120  int $owner = -1,
121  string $question = ""
122  ) {
124  global $DIC;
125  $this->dic = $DIC;
126  $lng = $DIC['lng'];
127  $tpl = $DIC['tpl'];
128  $ilDB = $DIC['ilDB'];
129  $ilLog = $DIC->logger();
130  $local_dic = QuestionPoolDIC::dic();
131  $this->questionrepository = $local_dic['question.general_properties.repository'];
132  $this->questionpool_request = $local_dic['request_data_collector'];
133  $this->question_files = $local_dic['question_files'];
134  $this->suggestedsolution_repo = $local_dic['question.repo.suggestedsolutions'];
135  $this->current_user = $DIC['ilUser'];
136  $this->lng = $lng;
137  $this->tpl = $tpl;
138  $this->db = $ilDB;
139  $this->log = $ilLog;
140  $this->http = $DIC->http();
141  $this->refinery = $DIC->refinery();
142 
143  $this->thumb_size = self::DEFAULT_THUMB_SIZE;
144 
145  $this->title = $title;
146  $this->comment = $comment;
147  $this->setAuthor($author);
148  $this->setOwner($owner);
149 
150  $this->setQuestion($question);
151 
152  $this->id = -1;
153  $this->test_id = -1;
154  $this->suggested_solutions = [];
155  $this->shuffle = true;
156  $this->nr_of_tries = 0;
157  $this->setExternalId(null);
158 
159  $this->questionActionCmd = 'handleQuestionAction';
160  $this->export_image_path = '';
161  $this->shuffler = $DIC->refinery()->random()->dontShuffle();
162  $this->lifecycle = ilAssQuestionLifecycle::getDraftInstance();
163  $this->skillUsageService = $DIC->skills()->usage();
164  }
165 
166  abstract public function getQuestionType(): string;
167 
168  abstract public function isComplete(): bool;
169 
170  abstract public function saveWorkingData(int $active_id, ?int $pass = null, bool $authorized = true): bool;
171 
172  abstract public function calculateReachedPoints(
173  int $active_id,
174  ?int $pass = null,
175  bool $authorized_solution = true
176  ): float;
177 
178  abstract public function getAdditionalTableName(): string;
179  abstract public function getAnswerTableName(): string|array;
180 
189  abstract public function toLog(AdditionalInformationGenerator $additional_info): array;
190 
199  abstract protected function solutionValuesToLog(
200  AdditionalInformationGenerator $additional_info,
201  array $solution_values
202  ): array|string;
203 
208  abstract protected function solutionValuesToText(
209  array $solution_values
210  ): array|string;
211 
213  {
214  self::$force_pass_results_update_enabled = $force_pass_results_update_enabled;
215  }
216 
217  public static function isForcePassResultUpdateEnabled(): bool
218  {
219  return self::$force_pass_results_update_enabled;
220  }
221 
222  protected function getQuestionAction(): string
223  {
224  return $this->questionpool_request->getCmdIndex($this->questionActionCmd) ?? '';
225  }
226 
227  protected function isNonEmptyItemListPostSubmission(string $post_submission_field_name): bool
228  {
229  return !empty($this->questionpool_request->strArray($post_submission_field_name));
230  }
231 
232  public function getCurrentUser(): ilObjUser
233  {
234  return $this->current_user;
235  }
236 
237  public function getShuffler(): Transformation
238  {
239  return $this->shuffler;
240  }
241 
242  public function setShuffler(Transformation $shuffler): void
243  {
244  $this->shuffler = $shuffler;
245  }
246 
247  public function setProcessLocker(ilAssQuestionProcessLocker $processLocker): void
248  {
249  $this->processLocker = $processLocker;
250  }
251 
253  {
254  return $this->processLocker;
255  }
256 
257  final public function fromXML(
258  string $importdirectory,
259  int $user_id,
260  ilQTIItem $item,
261  int $questionpool_id,
262  ?int $tst_id,
263  ?ilObject &$tst_object,
264  int &$question_counter,
265  array $import_mapping,
266  array $solutionhints = []
267  ): array {
268  $classname = $this->getQuestionType() . "Import";
269  $import = new $classname($this);
270  $new_import_mapping = $import->fromXML(
271  $importdirectory,
272  $user_id,
273  $item,
274  $questionpool_id,
275  $tst_id,
276  $tst_object,
277  $question_counter,
278  $import_mapping
279  );
280 
281  foreach ($solutionhints as $hint) {
282  $this->importHint($import->getQuestionId(), $hint);
283  }
284 
285  return $new_import_mapping;
286  }
287 
288  private function importHint(int $question_id, array $hint_array): void
289  {
290  $hint = new ilAssQuestionHint();
291  $hint->setQuestionId($question_id);
292  $hint->setIndex($hint_array['index'] ?? '');
293  $hint->setPoints($hint_array['points'] ?? '');
294  if ($this->getAdditionalContentEditingMode() === self::ADDITIONAL_CONTENT_EDITING_MODE_IPE) {
295  $hint->save();
296  $hint_page = (new ilAssHintPage());
297  $hint_page->setParentId($question_id);
298  $hint_page->setId($hint->getId());
299  $hint_page->setXMLContent($hint_array['txt']);
300  $hint_page->createFromXML();
301  return;
302  }
303 
304  $hint->setText($hint_array['txt'] ?? '');
305  $hint->save();
306  }
307 
313  final public function toXML(
314  bool $a_include_header = true,
315  bool $a_include_binary = true,
316  bool $a_shuffle = false,
317  bool $test_output = false,
318  bool $force_image_references = false
319  ): string {
320  $classname = $this->getQuestionType() . "Export";
321  $export = new $classname($this);
322  return $export->toXML($a_include_header, $a_include_binary, $a_shuffle, $test_output, $force_image_references);
323  }
324 
325  public function setTitle(string $title = ""): void
326  {
327  $this->title = $title;
328  }
329 
330  public function setId(int $id = -1): void
331  {
332  $this->id = $id;
333  }
334 
335  public function setTestId(int $id = -1): void
336  {
337  $this->test_id = $id;
338  }
339 
340  public function setComment(string $comment = ""): void
341  {
342  $this->comment = $comment;
343  }
344 
345  public function setShuffle(?bool $shuffle = true): void
346  {
347  $this->shuffle = $shuffle ?? false;
348  }
349 
350  public function setAuthor(string $author = ""): void
351  {
352  if ($author === '') {
353  $author = $this->current_user->getFullname();
354  }
355  $this->author = $author;
356  }
357 
358  public function setOwner(int $owner = -1): void
359  {
360  $this->owner = $owner;
361  }
362 
363  public function getTitle(): string
364  {
365  return $this->title;
366  }
367 
368  public function getTitleForHTMLOutput(): string
369  {
370  return $this->refinery->encode()->htmlSpecialCharsAsEntities()->transform($this->title);
371  }
372 
373  public function getTitleFilenameCompliant(): string
374  {
375  return ilFileUtils::getASCIIFilename($this->getTitle());
376  }
377 
378  public function getId(): int
379  {
380  return $this->id;
381  }
382 
383  public function getShuffle(): bool
384  {
385  return $this->shuffle;
386  }
387 
388  public function getTestId(): int
389  {
390  return $this->test_id;
391  }
392 
393  public function getComment(): string
394  {
395  return $this->comment;
396  }
397 
398  public function getDescriptionForHTMLOutput(): string
399  {
400  return $this->refinery->encode()->htmlSpecialCharsAsEntities()->transform($this->comment);
401  }
402 
403  public function getThumbSize(): int
404  {
405  return $this->thumb_size;
406  }
407 
408  public function setThumbSize(int $a_size): void
409  {
410  if ($a_size >= self::MINIMUM_THUMB_SIZE) {
411  $this->thumb_size = $a_size;
412  } else {
413  throw new ilException("Thumb size must be at least " . self::MINIMUM_THUMB_SIZE . "px");
414  }
415  }
416 
417  public function getMinimumThumbSize(): int
418  {
419  return self::MINIMUM_THUMB_SIZE;
420  }
421 
422  public function getMaximumThumbSize(): int
423  {
424  return self::MAXIMUM_THUMB_SIZE;
425  }
426 
427  public function getAuthor(): string
428  {
429  return $this->author;
430  }
431 
432  public function getAuthorForHTMLOutput(): string
433  {
434  return $this->refinery->string()->stripTags()->transform($this->author);
435  }
436 
437  public function getOwner(): int
438  {
439  return $this->owner;
440  }
441 
442  public function getObjId(): int
443  {
444  return $this->obj_id;
445  }
446 
447  public function setObjId(int $obj_id = 0): void
448  {
449  $this->obj_id = $obj_id;
450  }
451 
453  {
454  return $this->lifecycle;
455  }
456 
457  public function setLifecycle(ilAssQuestionLifecycle $lifecycle): void
458  {
459  $this->lifecycle = $lifecycle;
460  }
461 
462  public function setExternalId(?string $external_id): void
463  {
464  $this->external_id = $external_id;
465  }
466 
467  public function getExternalId(): string
468  {
469  if ($this->external_id === null || $this->external_id === '') {
470  if ($this->getId() > 0) {
471  return 'il_' . IL_INST_ID . '_qst_' . $this->getId();
472  }
473  return uniqid('', true);
474  }
475  return $this->external_id;
476  }
477 
478  public static function _getSuggestedSolutionOutput(int $question_id): string
479  {
480  $question = self::instantiateQuestion($question_id);
481  if (!is_object($question)) {
482  return "";
483  }
484  return $question->getSuggestedSolutionOutput();
485  }
486 
487  public function getSuggestedSolutionOutput(): string
488  {
489  $output = [];
490  foreach ($this->suggested_solutions as $solution) {
491  switch ($solution->getType()) {
492  case SuggestedSolution::TYPE_LM:
493  case SuggestedSolution::TYPE_LM_CHAPTER:
494  case SuggestedSolution::TYPE_LM_PAGE:
495  case SuggestedSolution::TYPE_GLOSARY_TERM:
496  $output[] = '<a href="'
497  . $this->getInternalLinkHref($solution->getInternalLink())
498  . '">'
499  . $this->lng->txt("solution_hint")
500  . '</a>';
501  break;
502 
503  case SuggestedSolution::TYPE_FILE:
504  $possible_texts = array_values(
505  array_filter(
506  [
507  ilLegacyFormElementsUtil::prepareFormOutput($solution->getTitle()),
508  ilLegacyFormElementsUtil::prepareFormOutput($solution->getFilename()),
509  $this->lng->txt('tst_show_solution_suggested')
510  ]
511  )
512  );
513 
515  $path_to_solution = $this->getSuggestedSolutionPathWeb() . $solution->getFilename();
516  if (!file_exists($path_to_solution)) {
517  break;
518  }
519  $output[] = '<a href="'
520  . ilWACSignedPath::signFile($path_to_solution)
521  . '">'
522  . $possible_texts[0]
523  . '</a>';
524  break;
525  }
526  }
527  return implode("<br />", $output);
528  }
529 
530  public function getSuggestedSolutions(): array
531  {
533  }
534 
535  public static function _getReachedPoints(int $active_id, int $question_id, int $pass): float
536  {
537  global $DIC;
538  $ilDB = $DIC['ilDB'];
539 
540  $points = 0.0;
541 
542  $result = $ilDB->queryF(
543  "SELECT * FROM tst_test_result WHERE active_fi = %s AND question_fi = %s AND pass = %s",
544  ['integer','integer','integer'],
545  [$active_id, $question_id, $pass]
546  );
547  if ($result->numRows() == 1) {
548  $row = $ilDB->fetchAssoc($result);
549  $points = (float) $row["points"];
550  }
551  return $points;
552  }
553 
554  public function getReachedPoints(int $active_id, int $pass): float
555  {
556  return round(self::_getReachedPoints($active_id, $this->getId(), $pass), 2);
557  }
558 
559  public function getMaximumPoints(): float
560  {
561  return $this->points;
562  }
563 
564  final public function getAdjustedReachedPoints(int $active_id, int $pass, bool $authorized_solution = true): float
565  {
566  // determine reached points for submitted solution
567  $reached_points = $this->calculateReachedPoints($active_id, $pass, $authorized_solution);
568  $hint_tracking = new ilAssQuestionHintTracking($this->getId(), $active_id, $pass);
569  $requests_statistic_data = $hint_tracking->getRequestStatisticDataByQuestionAndTestpass();
570  $reached_points = $reached_points - $requests_statistic_data->getRequestsPoints();
571 
572  // adjust reached points regarding to tests scoring options
573  $reached_points = $this->adjustReachedPointsByScoringOptions($reached_points, $active_id);
574 
575  return $reached_points;
576  }
577 
581  final public function calculateResultsFromSolution(int $active_id, int $pass): void
582  {
583  // determine reached points for submitted solution
584  $reached_points = $this->calculateReachedPoints($active_id, $pass);
585  $questionHintTracking = new ilAssQuestionHintTracking($this->getId(), $active_id, $pass);
586  $requests_statistic_data = $questionHintTracking->getRequestStatisticDataByQuestionAndTestpass();
587  $reached_points = $reached_points - $requests_statistic_data->getRequestsPoints();
588 
589  // adjust reached points regarding to tests scoring options
590  $reached_points = $this->adjustReachedPointsByScoringOptions($reached_points, $active_id);
591 
592  if (is_null($reached_points)) {
593  $reached_points = 0.0;
594  }
595 
596  // fau: testNav - check for existing authorized solution to know if a result record should be written
597  $existing_solutions = $this->lookupForExistingSolutions($active_id, $pass);
598 
599  $this->getProcessLocker()->executeUserQuestionResultUpdateOperation(
600  function () use ($active_id, $pass, $reached_points, $requests_statistic_data, $existing_solutions) {
601  $query = "
602  DELETE FROM tst_test_result
603 
604  WHERE active_fi = %s
605  AND question_fi = %s
606  AND pass = %s
607  ";
608 
609  $types = ['integer', 'integer', 'integer'];
610  $values = [$active_id, $this->getId(), $pass];
611 
612  if ($this->getStep() !== null) {
613  $query .= "
614  AND step = %s
615  ";
616 
617  $types[] = 'integer';
618  $values[] = $this->getStep();
619  }
620  $this->db->manipulateF($query, $types, $values);
621 
622  if ($existing_solutions['authorized']) {
623  $next_id = $this->db->nextId("tst_test_result");
624  $fieldData = [
625  'test_result_id' => ['integer', $next_id],
626  'active_fi' => ['integer', $active_id],
627  'question_fi' => ['integer', $this->getId()],
628  'pass' => ['integer', $pass],
629  'points' => ['float', $reached_points],
630  'tstamp' => ['integer', time()],
631  'hint_count' => ['integer', $requests_statistic_data->getRequestsCount()],
632  'hint_points' => ['float', $requests_statistic_data->getRequestsPoints()],
633  'answered' => ['integer', true]
634  ];
635 
636  if ($this->getStep() !== null) {
637  $fieldData['step'] = ['integer', $this->getStep()];
638  }
639 
640  $this->db->insert('tst_test_result', $fieldData);
641  }
642  }
643  );
644 
645  // update test pass results
646  $test = new ilObjTest(
647  $this->getObjId(),
648  false
649  );
650  $test->updateTestPassResults($active_id, $pass, $this->getProcessLocker());
651  ilCourseObjectiveResult::_updateObjectiveResult($this->current_user->getId(), $active_id, $this->getId());
652  }
653 
658  final public function persistWorkingState(int $active_id, $pass, bool $authorized = true): bool
659  {
660  if (!$this instanceof QuestionPartiallySaveable && !$this->validateSolutionSubmit()) {
661  return false;
662  }
663 
664  $saveStatus = false;
665 
666  $this->getProcessLocker()->executePersistWorkingStateLockOperation(function () use ($active_id, $pass, $authorized, &$saveStatus) {
667  if ($pass === null) {
668  $pass = ilObjTest::_getPass($active_id);
669  }
670 
671  $saveStatus = $this->saveWorkingData($active_id, $pass, $authorized);
672 
673  if ($authorized) {
674  // fau: testNav - remove an intermediate solution if the authorized solution is saved
675  // the intermediate solution would set the displayed question status as "editing ..."
676  $this->removeIntermediateSolution($active_id, $pass);
677  // fau.
678  $this->calculateResultsFromSolution($active_id, $pass);
679  }
680  });
681 
682  return $saveStatus;
683  }
684 
688  final public function persistPreviewState(ilAssQuestionPreviewSession $preview_session): bool
689  {
690  $this->savePreviewData($preview_session);
691  return $this->validateSolutionSubmit();
692  }
693 
694  public function validateSolutionSubmit(): bool
695  {
696  return true;
697  }
698 
699  protected function savePreviewData(ilAssQuestionPreviewSession $preview_session): void
700  {
701  $preview_session->setParticipantsSolution($this->getSolutionSubmit());
702  }
703 
704  public function getSuggestedSolutionPath(): string
705  {
706  return CLIENT_WEB_DIR . "/assessment/$this->obj_id/$this->id/solution/";
707  }
708 
713  public function getImagePath($question_id = null, $object_id = null): string
714  {
715  if ($question_id === null) {
716  $question_id = $this->id;
717  }
718 
719  if ($object_id === null) {
720  $object_id = $this->obj_id;
721  }
722 
723  return $this->question_files->buildImagePath($question_id, $object_id);
724  }
725 
726  public function getSuggestedSolutionPathWeb(): string
727  {
729  . "/assessment/{$this->obj_id}/{$this->id}/solution/";
730  return str_replace(
731  ilFileUtils::removeTrailingPathSeparators(ILIAS_ABSOLUTE_PATH . '/public'),
733  $webdir
734  );
735  }
736 
742  public function getImagePathWeb(): string
743  {
744  if (!$this->export_image_path) {
746  . "/assessment/{$this->obj_id}/{$this->id}/images/";
747  return str_replace(
748  ilFileUtils::removeTrailingPathSeparators(ILIAS_ABSOLUTE_PATH . '/public'),
750  $webdir
751  );
752  }
754  }
755 
756  public function getTestOutputSolutions(int $activeId, int $pass): array
757  {
758  if ($this->getTestPresentationConfig()->isSolutionInitiallyPrefilled()) {
759  return $this->getSolutionValues($activeId, $pass, true);
760  }
761  return $this->getUserSolutionPreferingIntermediate($activeId, $pass);
762  }
763 
764 
766  int $active_id,
767  ?int $pass = null
768  ): array {
769  $solution = $this->getSolutionValues($active_id, $pass, false);
770 
771  if (!count($solution)) {
772  $solution = $this->getSolutionValues($active_id, $pass, true);
773  }
774 
775  return $solution;
776  }
777 
781  public function getSolutionValues(
782  int $active_id,
783  ?int $pass = null,
784  bool $authorized = true
785  ): array {
786  if ($pass === null && is_numeric($active_id)) {
787  $pass = $this->getSolutionMaxPass((int) $active_id);
788  }
789 
790  if ($this->getStep() !== null) {
791  $query = "
792  SELECT *
793  FROM tst_solutions
794  WHERE active_fi = %s
795  AND question_fi = %s
796  AND pass = %s
797  AND step = %s
798  AND authorized = %s
799  ORDER BY solution_id";
800 
801  $result = $this->db->queryF(
802  $query,
803  ['integer', 'integer', 'integer', 'integer', 'integer'],
804  [(int) $active_id, $this->getId(), $pass, $this->getStep(), (int) $authorized]
805  );
806  } else {
807  $query = "
808  SELECT *
809  FROM tst_solutions
810  WHERE active_fi = %s
811  AND question_fi = %s
812  AND pass = %s
813  AND authorized = %s
814  ORDER BY solution_id
815  ";
816 
817  $result = $this->db->queryF(
818  $query,
819  ['integer', 'integer', 'integer', 'integer'],
820  [(int) $active_id, $this->getId(), $pass, (int) $authorized]
821  );
822  }
823 
824  $values = [];
825 
826  while ($row = $this->db->fetchAssoc($result)) {
827  $values[] = $row;
828  }
829 
830  return $values;
831  }
832 
833  public function deleteAnswers(int $question_id): void
834  {
835  $answer_table_name = $this->getAnswerTableName();
836 
837  if (!is_array($answer_table_name)) {
838  $answer_table_name = [$answer_table_name];
839  }
840 
841  foreach ($answer_table_name as $table) {
842  if (strlen($table)) {
843  $this->db->manipulateF(
844  "DELETE FROM $table WHERE question_fi = %s",
845  ['integer'],
846  [$question_id]
847  );
848  }
849  }
850  }
851 
852  public function deleteAdditionalTableData(int $question_id): void
853  {
854  $additional_table_name = $this->getAdditionalTableName();
855 
856  if (!is_array($additional_table_name)) {
857  $additional_table_name = [$additional_table_name];
858  }
859 
860  foreach ($additional_table_name as $table) {
861  if (strlen($table)) {
862  $this->db->manipulateF(
863  "DELETE FROM $table WHERE question_fi = %s",
864  ['integer'],
865  [$question_id]
866  );
867  }
868  }
869  }
870 
871  protected function deletePageOfQuestion(int $question_id): void
872  {
873  if (ilAssQuestionPage::_exists('qpl', $question_id, "", true)) {
874  $page = new ilAssQuestionPage($question_id);
875  $page->delete();
876  }
877  }
878 
879  public function delete(int $question_id): void
880  {
881  if ($question_id < 1) {
882  return;
883  }
884 
885  $result = $this->db->queryF(
886  "SELECT obj_fi FROM qpl_questions WHERE question_id = %s",
887  ['integer'],
888  [$question_id]
889  );
890  if ($this->db->numRows($result) !== 1) {
891  return;
892  }
893 
894  $row = $this->db->fetchAssoc($result);
895  $obj_id = $row["obj_fi"];
896 
897  try {
898  $this->deletePageOfQuestion($question_id);
899  } catch (Exception $e) {
900  $this->log->root()->error("EXCEPTION: Could not delete page of question $question_id: $e");
901  return;
902  }
903 
904  $affectedRows = $this->db->manipulateF(
905  "DELETE FROM qpl_questions WHERE question_id = %s",
906  ['integer'],
907  [$question_id]
908  );
909  if ($affectedRows == 0) {
910  return;
911  }
912 
913  try {
914  $this->deleteAdditionalTableData($question_id);
915  $this->deleteAnswers($question_id);
916  $this->feedbackOBJ->deleteGenericFeedbacks($question_id, $this->isAdditionalContentEditingModePageObject());
917  $this->feedbackOBJ->deleteSpecificAnswerFeedbacks($question_id, $this->isAdditionalContentEditingModePageObject());
918  } catch (Exception $e) {
919  $this->log->root()->error("EXCEPTION: Could not delete additional table data of question {$question_id}: {$e}");
920  }
921 
922  try {
923  // delete the question in the tst_test_question table (list of test questions)
924  $affectedRows = $this->db->manipulateF(
925  "DELETE FROM tst_test_question WHERE question_fi = %s",
926  ['integer'],
927  [$question_id]
928  );
929  } catch (Exception $e) {
930  $this->log->root()->error("EXCEPTION: Could not delete delete question {$question_id} from a test: {$e}");
931  }
932 
933  try {
934  $this->getSuggestedSolutionsRepo()->deleteForQuestion($question_id);
935  } catch (Exception $e) {
936  $this->log->root()->error("EXCEPTION: Could not delete suggested solutions of question {$question_id}: {$e}");
937  }
938 
939  $directory = CLIENT_WEB_DIR . "/assessment/" . $obj_id . "/$question_id";
940  try {
941  if (is_dir($directory)) {
942  ilFileUtils::delDir($directory);
943  }
944  } catch (Exception $e) {
945  $this->log->root()->error("EXCEPTION: Could not delete question file directory {$directory} of question {$question_id}: {$e}");
946  }
947 
948  try {
949  $mobs = ilObjMediaObject::_getMobsOfObject("qpl:html", $question_id);
950  // remaining usages are not in text anymore -> delete them
951  // and media objects (note: delete method of ilObjMediaObject
952  // checks whether object is used in another context; if yes,
953  // the object is not deleted!)
954  foreach ($mobs as $mob) {
955  ilObjMediaObject::_removeUsage($mob, "qpl:html", $question_id);
956  if (ilObjMediaObject::_exists($mob)) {
957  $mob_obj = new ilObjMediaObject($mob);
958  $mob_obj->delete();
959  }
960  }
961  } catch (Exception $e) {
962  $this->log->root()->error("EXCEPTION: Error deleting the media objects of question {$question_id}: {$e}");
963  }
964  ilAssQuestionHintTracking::deleteRequestsByQuestionIds([$question_id]);
966  $assignmentList = new ilAssQuestionSkillAssignmentList($this->db);
967  $assignmentList->setParentObjId($obj_id);
968  $assignmentList->setQuestionIdFilter($question_id);
969  $assignmentList->loadFromDb();
970  foreach ($assignmentList->getAssignmentsByQuestionId($question_id) as $assignment) {
971  /* @var ilAssQuestionSkillAssignment $assignment */
972  $assignment->deleteFromDb();
973 
974  // remove skill usage
975  if (!$assignment->isSkillUsed()) {
976  $this->skillUsageService->removeUsage(
977  $assignment->getParentObjId(),
978  $assignment->getSkillBaseId(),
979  $assignment->getSkillTrefId()
980  );
981  }
982  }
983 
984  $this->deleteTaxonomyAssignments();
985  $this->deleteComments();
986 
987  try {
989  } catch (Exception $e) {
990  $this->log->root()->error(
991  "EXCEPTION: Error updating the question pool question count of"
992  . " question pool {$this->getObjId()} when deleting question {$question_id}: {$e}"
993  );
994  }
995  }
996 
997  private function deleteTaxonomyAssignments(): void
998  {
999  $taxIds = ilObjTaxonomy::getUsageOfObject($this->getObjId());
1000 
1001  foreach ($taxIds as $taxId) {
1002  $taxNodeAssignment = new ilTaxNodeAssignment('qpl', $this->getObjId(), 'quest', $taxId);
1003  $taxNodeAssignment->deleteAssignmentsOfItem($this->getId());
1004  }
1005  }
1006 
1007  public function getTotalAnswers(): int
1008  {
1009  // get all question references to the question id
1010  $result = $this->db->queryF(
1011  "SELECT question_id FROM qpl_questions WHERE original_id = %s OR question_id = %s",
1012  ['integer','integer'],
1013  [$this->id, $this->id]
1014  );
1015  if ($this->db->numRows($result) == 0) {
1016  return 0;
1017  }
1018  $found_id = [];
1019  while ($row = $this->db->fetchAssoc($result)) {
1020  $found_id[] = $row["question_id"];
1021  }
1022 
1023  $result = $this->db->query("SELECT * FROM tst_test_result WHERE " . $this->db->in('question_fi', $found_id, false, 'integer'));
1024 
1025  return $this->db->numRows($result);
1026  }
1027 
1028  public static function isFileAvailable(string $file): bool
1029  {
1030  if (!file_exists($file)) {
1031  return false;
1032  }
1033 
1034  if (!is_file($file)) {
1035  return false;
1036  }
1037 
1038  if (!is_readable($file)) {
1039  return false;
1040  }
1041 
1042  return true;
1043  }
1044 
1045  public function cloneXHTMLMediaObjectsOfQuestion(int $source_question_id): void
1046  {
1047  $mobs = ilObjMediaObject::_getMobsOfObject("qpl:html", $source_question_id);
1048  foreach ($mobs as $mob) {
1049  ilObjMediaObject::_saveUsage($mob, "qpl:html", $this->getId());
1050  }
1051  }
1052 
1053  public function createPageObject(): void
1054  {
1055  $qpl_id = $this->getObjId();
1056  $this->page = new ilAssQuestionPage(0);
1057  $this->page->setId($this->getId());
1058  $this->page->setParentId($qpl_id);
1059  $this->page->setXMLContent("<PageObject><PageContent>" .
1060  "<Question QRef=\"il__qst_" . $this->getId() . "\"/>" .
1061  "</PageContent></PageObject>");
1062  $this->page->create(false);
1063  }
1064 
1065  public function clonePageOfQuestion(int $a_q_id): void
1066  {
1067  if ($a_q_id > 0) {
1068  $page = new ilAssQuestionPage($a_q_id);
1069  $page->buildDom();
1070  ilPCPlugged::handleCopiedPluggedContent($page, $page->getDomDoc());
1071  $xml = str_replace("il__qst_" . $a_q_id, "il__qst_" . $this->id, $page->getXMLFromDom());
1072  $this->page->setXMLContent($xml);
1073  $this->page->updateFromXML();
1074  }
1075  }
1076 
1077  public function getPageOfQuestion(): string
1078  {
1079  $page = new ilAssQuestionPage($this->id);
1080  return $page->getXMLContent();
1081  }
1082 
1083  public function setOriginalId(?int $original_id): void
1084  {
1085  $this->original_id = $original_id;
1086  }
1087 
1088  public function getOriginalId(): ?int
1089  {
1090  return $this->original_id;
1091  }
1092 
1093  protected static $imageSourceFixReplaceMap = [
1094  'ok.svg' => 'ok.png',
1095  'not_ok.svg' => 'not_ok.png',
1096  'object/checkbox_checked.svg' => 'checkbox_checked.png',
1097  'object/checkbox_unchecked.svg' => 'checkbox_unchecked.png',
1098  'object/radiobutton_checked.svg' => 'radiobutton_checked.png',
1099  'object/radiobutton_unchecked.svg' => 'radiobutton_unchecked.png'
1100  ];
1101 
1102  public function fixSvgToPng(string $imageFilenameContainingString): string
1103  {
1104  $needles = array_keys(self::$imageSourceFixReplaceMap);
1105  $replacements = array_values(self::$imageSourceFixReplaceMap);
1106  return str_replace($needles, $replacements, $imageFilenameContainingString);
1107  }
1108 
1109  public function fixUnavailableSkinImageSources(string $html): string
1110  {
1111  $matches = null;
1112  if (preg_match_all('/src="(.*?)"/m', $html, $matches)) {
1113  $sources = $matches[1];
1114 
1115  $needleReplacementMap = [];
1116 
1117  foreach ($sources as $src) {
1118  $file = ilFileUtils::removeTrailingPathSeparators(ILIAS_ABSOLUTE_PATH) . DIRECTORY_SEPARATOR . $src;
1119 
1120  if (file_exists($file)) {
1121  continue;
1122  }
1123 
1124  $levels = explode(DIRECTORY_SEPARATOR, $src);
1125  if (count($levels) < 5 || $levels[0] !== 'Customizing' || $levels[2] !== 'skin') {
1126  continue;
1127  }
1128 
1129  $component = '';
1130 
1131  if ($levels[4] === 'components/ILIAS' || $levels[4] === 'components/ILIAS') {
1132  $component = $levels[4] . DIRECTORY_SEPARATOR . $levels[5];
1133  }
1134 
1135  $needleReplacementMap[$src] = ilUtil::getImagePath(basename($src), $component);
1136  }
1137 
1138  if (count($needleReplacementMap)) {
1139  $html = str_replace(array_keys($needleReplacementMap), array_values($needleReplacementMap), $html);
1140  }
1141  }
1142 
1143  return $html;
1144  }
1145 
1146  public function loadFromDb(int $question_id): void
1147  {
1148  $result = $this->db->queryF(
1149  'SELECT external_id FROM qpl_questions WHERE question_id = %s',
1150  ['integer'],
1151  [$question_id]
1152  );
1153  if ($this->db->numRows($result) === 1) {
1154  $data = $this->db->fetchAssoc($result);
1155  $this->external_id = $data['external_id'];
1156  }
1157 
1158  $suggested_solutions = $this->loadSuggestedSolutions();
1159  $this->suggested_solutions = [];
1160  if ($suggested_solutions) {
1161  foreach ($suggested_solutions as $solution) {
1162  $this->suggested_solutions[$solution->getSubquestionIndex()] = $solution;
1163  }
1164  }
1165  }
1166 
1173  public function createNewQuestion(bool $a_create_page = true): int
1174  {
1175  $complete = '0';
1176  $obj_id = $this->getObjId();
1177  if ($obj_id <= 0
1178  && $this->questionpool_request->hasRefId()) {
1179  $obj_id = $this->questionpool_request->getRefId();
1180  }
1181 
1182  if ($obj_id <= 0) {
1183  $obj_id = $this->questionpool_request->int('sel_qpl');
1184  }
1185 
1186  if ($obj_id <= 0) {
1187  return $this->getId();
1188  }
1189 
1190  $tstamp = time();
1191  if ($a_create_page) {
1192  $tstamp = 0;
1193  }
1194 
1195  $next_id = $this->db->nextId('qpl_questions');
1196  $this->db->insert("qpl_questions", [
1197  "question_id" => ["integer", $next_id],
1198  "question_type_fi" => ["integer", $this->getQuestionTypeID()],
1199  "obj_fi" => ["integer", $obj_id],
1200  "title" => ["text", ''],
1201  "description" => ["text", ''],
1202  "author" => ["text", $this->getAuthor()],
1203  "owner" => ["integer", $this->current_user->getId()],
1204  "question_text" => ["clob", ''],
1205  "points" => ["float", "0.0"],
1206  "nr_of_tries" => ["integer", $this->getDefaultNrOfTries()], // #10771
1207  "complete" => ["text", $complete],
1208  "created" => ["integer", time()],
1209  "original_id" => ["integer", null],
1210  "tstamp" => ["integer", $tstamp],
1211  "external_id" => ["text", $this->getExternalId()],
1212  'add_cont_edit_mode' => ['text', $this->getAdditionalContentEditingMode()]
1213  ]);
1214  $this->setId($next_id);
1215 
1216  if ($a_create_page) {
1217  // create page object of question
1218  $this->createPageObject();
1219  }
1220 
1221  return $this->getId();
1222  }
1223 
1224  public function saveQuestionDataToDb(?int $original_id = null): void
1225  {
1226  if ($this->getId() === -1) {
1227  $next_id = $this->db->nextId('qpl_questions');
1228  $this->db->insert("qpl_questions", [
1229  "question_id" => ["integer", $next_id],
1230  "question_type_fi" => ["integer", $this->getQuestionTypeID()],
1231  "obj_fi" => ["integer", $this->getObjId()],
1232  "title" => ["text", mb_substr($this->getTitle(), 0, 124)],
1233  "description" => ["text", mb_substr($this->getComment(), 0, 1000)],
1234  "author" => ["text", mb_substr($this->getAuthor(), 0, 512)],
1235  "owner" => ["integer", $this->getOwner()],
1236  "question_text" => ["clob", ilRTE::_replaceMediaObjectImageSrc($this->getQuestion(), 0)],
1237  "points" => ["float", $this->getMaximumPoints()],
1238  "nr_of_tries" => ["integer", $this->getNrOfTries()],
1239  "created" => ["integer", time()],
1240  "original_id" => ["integer", $original_id],
1241  "tstamp" => ["integer", time()],
1242  "external_id" => ["text", $this->getExternalId()],
1243  'add_cont_edit_mode' => ['text', $this->getAdditionalContentEditingMode()]
1244  ]);
1245  $this->setId($next_id);
1246  // create page object of question
1247  $this->createPageObject();
1248  return;
1249  }
1250 
1251  // Vorhandenen Datensatz aktualisieren
1252  $this->db->update("qpl_questions", [
1253  "obj_fi" => ["integer", $this->getObjId()],
1254  "title" => ["text", mb_substr($this->getTitle(), 0, 124)],
1255  "description" => ["text", mb_substr($this->getComment(), 0, 1000)],
1256  "author" => ["text", mb_substr($this->getAuthor(), 0, 512)],
1257  "question_text" => ["clob", ilRTE::_replaceMediaObjectImageSrc($this->getQuestion(), 0)],
1258  "points" => ["float", $this->getMaximumPoints()],
1259  "nr_of_tries" => ["integer", $this->getNrOfTries()],
1260  "tstamp" => ["integer", time()],
1261  'complete' => ['integer', $this->isComplete()],
1262  "external_id" => ["text", $this->getExternalId()]
1263  ], [
1264  "question_id" => ["integer", $this->getId()]
1265  ]);
1266  }
1267 
1268  public function duplicate(
1269  bool $for_test = true,
1270  string $title = '',
1271  string $author = '',
1272  int $owner = -1,
1273  $test_obj_id = null
1274  ): int {
1275  if ($this->id <= 0) {
1276  // The question has not been saved. It cannot be duplicated
1277  return -1;
1278  }
1279 
1280  $clone = clone $this;
1281  $clone->id = -1;
1282 
1283  if ((int) $test_obj_id > 0) {
1284  $clone->setObjId($test_obj_id);
1285  }
1286 
1287  if ($title) {
1288  $clone->setTitle($title);
1289  }
1290  if ($author) {
1291  $clone->setAuthor($author);
1292  }
1293  if ($owner) {
1294  $clone->setOwner($owner);
1295  }
1296  if ($for_test) {
1297  $clone->saveToDb($this->id);
1298  } else {
1299  $clone->saveToDb();
1300  }
1301 
1302  $clone->clonePageOfQuestion($this->getId());
1303  $clone->cloneXHTMLMediaObjectsOfQuestion($this->getId());
1304 
1305  $clone = $this->cloneQuestionTypeSpecificProperties($clone);
1306 
1307  $clone->onDuplicate($this->getObjId(), $this->getId(), $clone->getObjId(), $clone->getId());
1308 
1309  return $clone->id;
1310  }
1311 
1312  final public function copyObject(
1313  int $target_parent_id,
1314  string $title = ''
1315  ): int {
1316  if ($this->getId() <= 0) {
1317  throw new RuntimeException('The question has not been saved. It cannot be duplicated');
1318  }
1319  // duplicate the question in database
1320  $clone = clone $this;
1321  $clone->id = -1;
1322  $source_parent_id = $this->getObjId();
1323  $clone->setObjId($target_parent_id);
1324  if ($title) {
1325  $clone->setTitle($title);
1326  }
1327  $clone->saveToDb();
1328  $clone->clonePageOfQuestion($this->id);
1329  $clone->cloneXHTMLMediaObjectsOfQuestion($this->id);
1330  $clone = $this->cloneQuestionTypeSpecificProperties($clone);
1331 
1332  $clone->onCopy($source_parent_id, $this->id, $clone->getObjId(), $clone->getId());
1333 
1334  return $clone->id;
1335  }
1336 
1337  final public function createNewOriginalFromThisDuplicate(
1338  int $target_parent_id,
1339  string $target_question_title = ''
1340  ): int {
1341  if ($this->getId() <= 0) {
1342  throw new RuntimeException('The question has not been saved. It cannot be duplicated');
1343  }
1344 
1345  $source_question_id = $this->id;
1346  $source_parent_id = $this->getObjId();
1347 
1348  // duplicate the question in database
1349  $clone = clone $this;
1350  $clone->id = -1;
1351 
1352  $clone->setObjId($target_parent_id);
1353 
1354  if ($target_question_title) {
1355  $clone->setTitle($target_question_title);
1356  }
1357 
1358  $clone->saveToDb();
1359  $clone->clonePageOfQuestion($source_question_id);
1360  $clone->cloneXHTMLMediaObjectsOfQuestion($source_question_id);
1361 
1362  $clone = $this->cloneQuestionTypeSpecificProperties($clone);
1363 
1364  $clone->onCopy($source_parent_id, $source_question_id, $clone->getObjId(), $clone->getId());
1365 
1366  return $clone->id;
1367  }
1368 
1370  self $target
1371  ): self {
1372  return $target;
1373  }
1374 
1375  public function saveToDb(?int $original_id = null): void
1376  {
1377  // remove unused media objects from ILIAS
1378  $this->cleanupMediaObjectUsage();
1379 
1380  $complete = "0";
1381  if ($this->isComplete()) {
1382  $complete = "1";
1383  }
1384 
1385  $this->db->update(
1386  'qpl_questions',
1387  [
1388  'tstamp' => ['integer', time()],
1389  'owner' => ['integer', $this->getOwner()],
1390  'complete' => ['integer', $complete],
1391  'lifecycle' => ['text', $this->getLifecycle()->getIdentifier()],
1392  ],
1393  [
1394  'question_id' => ['integer', $this->getId()]
1395  ]
1396  );
1397 
1399  }
1400 
1401  protected function removeAllImageFiles(string $image_target_path): void
1402  {
1403  $target = opendir($image_target_path);
1404  while ($target_file = readdir($target)) {
1405  if ($target_file === '.' || $target_file === '..') {
1406  continue;
1407  }
1408  copy(
1409  $image_target_path . DIRECTORY_SEPARATOR . $target_file,
1410  $image_target_path . DIRECTORY_SEPARATOR . $target_file
1411  );
1412  }
1413  }
1414 
1415  public static function saveOriginalId(int $questionId, int $originalId): void
1416  {
1417  global $DIC;
1418  $ilDB = $DIC->database();
1419  $query = "UPDATE qpl_questions SET tstamp = %s, original_id = %s WHERE question_id = %s";
1420 
1421  $ilDB->manipulateF(
1422  $query,
1423  ['integer','integer', 'text'],
1424  [time(), $originalId, $questionId]
1425  );
1426  }
1427 
1428  public static function resetOriginalId(int $questionId): void
1429  {
1430  global $DIC;
1431  $ilDB = $DIC->database();
1432 
1433  $query = "UPDATE qpl_questions SET tstamp = %s, original_id = NULL WHERE question_id = %s";
1434 
1435  $ilDB->manipulateF(
1436  $query,
1437  ['integer', 'text'],
1438  [time(), $questionId]
1439  );
1440  }
1441 
1442  protected function onDuplicate(
1443  int $original_parent_id,
1444  int $original_question_id,
1445  int $duplicate_parent_id,
1446  int $duplicate_question_id
1447  ): void {
1448  $this->copySuggestedSolutions($duplicate_question_id);
1449  $this->cloneSuggestedSolutionFiles($original_parent_id, $original_question_id);
1450  $this->feedbackOBJ->duplicateFeedback($original_question_id, $duplicate_question_id);
1451  $this->duplicateQuestionHints($original_question_id, $duplicate_question_id);
1452  $this->duplicateSkillAssignments($original_parent_id, $original_question_id, $duplicate_parent_id, $duplicate_question_id);
1453  $this->duplicateComments($original_parent_id, $original_question_id, $duplicate_parent_id, $duplicate_question_id);
1454  }
1455 
1456  protected function afterSyncWithOriginal(
1457  int $original_question_id,
1458  int $clone_question_id,
1459  int $original_parent_id,
1460  int $clone_parent_id
1461  ): void {
1462  $this->feedbackOBJ->cloneFeedback($original_question_id, $clone_question_id);
1463  }
1464 
1465  protected function onCopy(int $sourceParentId, int $sourceQuestionId, int $targetParentId, int $targetQuestionId): void
1466  {
1467  $this->copySuggestedSolutions($targetQuestionId);
1468  $this->duplicateSuggestedSolutionFiles($sourceParentId, $sourceQuestionId);
1469  $this->feedbackOBJ->duplicateFeedback($sourceQuestionId, $targetQuestionId);
1470  $this->duplicateQuestionHints($sourceQuestionId, $targetQuestionId);
1471  $this->duplicateSkillAssignments($sourceParentId, $sourceQuestionId, $targetParentId, $targetQuestionId);
1472  $this->duplicateComments($sourceParentId, $sourceQuestionId, $targetParentId, $targetQuestionId);
1473  }
1474 
1475  protected function duplicateComments(
1476  int $parent_source_id,
1477  int $source_id,
1478  int $parent_target_id,
1479  int $target_id
1480  ): void {
1481  $manager = $this->getNotesManager();
1482  $data_service = $this->getNotesDataService();
1483  $notes = $manager->getNotesForRepositoryObjIds([$parent_source_id], Note::PUBLIC);
1484  $notes = array_filter(
1485  $notes,
1486  fn($n) => $n->getContext()->getSubObjId() === $source_id
1487  );
1488 
1489  foreach ($notes as $note) {
1490  $new_context = $data_service->context(
1491  $parent_target_id,
1492  $target_id,
1493  $note->getContext()->getType()
1494  );
1495  $new_note = $data_service->note(
1496  -1,
1497  $new_context,
1498  $note->getText(),
1499  $note->getAuthor(),
1500  $note->getType(),
1501  $note->getCreationDate(),
1502  $note->getUpdateDate(),
1503  $note->getRecipient()
1504  );
1505  $manager->createNote($new_note, [], true);
1506  }
1507  }
1508 
1509  protected function deleteComments(): void
1510  {
1511  $repo = $this->getNotesRepo();
1512  $manager = $this->getNotesManager();
1513  $source_id = $this->getId();
1514  $notes = $manager->getNotesForRepositoryObjIds([$this->getObjId()], Note::PUBLIC);
1515  $notes = array_filter(
1516  $notes,
1517  fn($n) => $n->getContext()->getSubObjId() === $source_id
1518  );
1519  foreach ($notes as $note) {
1520  $repo->deleteNote($note->getId());
1521  }
1522  }
1523 
1524  protected function getNotesManager(): NotesManager
1525  {
1526  $service = new NotesService($this->dic);
1527  return $service->internal()->domain()->notes();
1528  }
1529 
1530  protected function getNotesDataService(): NotesInternalDataService
1531  {
1532  $service = new NotesService($this->dic);
1533  return $service->internal()->data();
1534  }
1535 
1536  protected function getNotesRepo(): NotesRepo
1537  {
1538  $service = new NotesService($this->dic);
1539  return $service->internal()->repo()->note();
1540  }
1541 
1542  public function deleteSuggestedSolutions(): void
1543  {
1544  $this->getSuggestedSolutionsRepo()->deleteForQuestion($this->getId());
1547  $this->suggested_solutions = [];
1548  }
1549 
1550 
1551  public function getSuggestedSolution(int $subquestion_index = 0): ?SuggestedSolution
1552  {
1553  if (array_key_exists($subquestion_index, $this->suggested_solutions)) {
1554  return $this->suggested_solutions[$subquestion_index];
1555  }
1556  return null;
1557  }
1558 
1559  protected function cloneSuggestedSolutions(
1560  int $source_question_id,
1561  int $target_question_id
1562  ): void {
1563  $this->getSuggestedSolutionsRepo()->clone($source_question_id, $target_question_id);
1564  $this->cloneSuggestedSolutionFiles($source_question_id, $target_question_id);
1565  }
1566 
1570  protected function duplicateSuggestedSolutionFiles(int $parent_id, int $question_id): void
1571  {
1572  foreach ($this->suggested_solutions as $solution) {
1573  if (!$solution->isOfTypeFile()
1574  || $solution->getFilename() === '') {
1575  continue;
1576  }
1577 
1578  $filepath = $this->getSuggestedSolutionPath();
1579  $filepath_original = str_replace(
1580  "/{$this->obj_id}/{$this->id}/solution",
1581  "/{$parent_id}/{$question_id}/solution",
1582  $filepath
1583  );
1584  if (!file_exists($filepath)) {
1585  ilFileUtils::makeDirParents($filepath);
1586  }
1587  if (!is_file($filepath_original . $solution->getFilename())
1588  || !copy($filepath_original . $solution->getFilename(), $filepath . $solution->getFilename())) {
1589  $this->log->root()->error('File for suggested solutions could not be duplicated:');
1590  $this->log->root()->error("Question-Id: {$this->id}; Question-Title: {$this->title}; File: {$filepath_original}{$solution->getFilename()}");
1591  }
1592  }
1593  }
1594 
1595  protected function cloneSuggestedSolutionFiles(
1596  int $source_question_id,
1597  int $target_question_id
1598  ): void {
1599  $filepath_target = $this->getSuggestedSolutionPath();
1600  $filepath_original = str_replace("/$target_question_id/solution", "/$source_question_id/solution", $filepath_target);
1601  ilFileUtils::delDir($filepath_original);
1602  foreach ($this->suggested_solutions as $solution) {
1603  if (!$solution->isOfTypeFile()
1604  || $solution->getFilename() === '') {
1605  continue;
1606  }
1607 
1608  if (!file_exists($filepath_original)) {
1609  ilFileUtils::makeDirParents($filepath_original);
1610  }
1611 
1612  if (!is_file($filepath_original . $solution->getFilename())
1613  || copy($filepath_target . $solution->getFilename(), $filepath_target . $solution->getFilename())) {
1614  $this->log->root()->error('File for suggested solutions could not be cloned:');
1615  $this->log->root()->error("Question-Id: {$this->id}; Question-Title: {$this->title}; File: {$filepath_original}{$solution->getFilename()}");
1616  }
1617  }
1618  }
1619 
1620  protected function copySuggestedSolutions(int $target_question_id): void
1621  {
1622  $update = [];
1623  foreach ($this->getSuggestedSolutions() as $index => $solution) {
1624  $solution = $solution->withQuestionId($target_question_id);
1625  $update[] = $solution;
1626  }
1627  $this->getSuggestedSolutionsRepo()->update($update);
1628  }
1629 
1630  public function resolveInternalLink(string $internal_link): string
1631  {
1632  if (preg_match("/il_(\d+)_(\w+)_(\d+)/", $internal_link, $matches) === false) {
1633  return $internal_link;
1634  }
1635  switch ($matches[2]) {
1636  case "lm":
1637  $resolved_link = ilLMObject::_getIdForImportId($internal_link);
1638  break;
1639  case "pg":
1640  $resolved_link = ilInternalLink::_getIdForImportId("PageObject", $internal_link);
1641  break;
1642  case "st":
1643  $resolved_link = ilInternalLink::_getIdForImportId("StructureObject", $internal_link);
1644  break;
1645  case "git":
1646  $resolved_link = ilInternalLink::_getIdForImportId("GlossaryItem", $internal_link);
1647  break;
1648  case "mob":
1649  $resolved_link = ilInternalLink::_getIdForImportId("MediaObject", $internal_link);
1650  break;
1651  }
1652  if ($resolved_link === null) {
1653  return "il__{$matches[2]}_{$matches[3]}";
1654  }
1655  return $internal_link;
1656  }
1657 
1658 
1659  //TODO: move this to import or suggested solutions repo.
1660  //use in LearningModule and Survey as well ;(
1661  public function resolveSuggestedSolutionLinks(): void
1662  {
1663  $resolvedlinks = 0;
1664  $result_pre = $this->db->queryF(
1665  "SELECT internal_link, suggested_solution_id FROM qpl_sol_sug WHERE question_fi = %s",
1666  ['integer'],
1667  [$this->getId()]
1668  );
1669  if ($this->db->numRows($result_pre) < 1) {
1670  return;
1671  }
1672 
1673  while ($row = $this->db->fetchAssoc($result_pre)) {
1674  $internal_link = $row["internal_link"];
1675  $resolved_link = $this->resolveInternalLink($internal_link);
1676  if ($internal_link === $resolved_link) {
1677  continue;
1678  }
1679  // internal link was resolved successfully
1680  $this->db->manipulateF(
1681  "UPDATE qpl_sol_sug SET internal_link = %s WHERE suggested_solution_id = %s",
1682  ['text','integer'],
1683  [$resolved_link, $row["suggested_solution_id"]]
1684  );
1685  $resolvedlinks++;
1686  }
1687  if ($resolvedlinks === 0) {
1688  return;
1689  }
1690 
1692 
1693  $result_post = $this->db->queryF(
1694  "SELECT internal_link FROM qpl_sol_sug WHERE question_fi = %s",
1695  ['integer'],
1696  [$this->getId()]
1697  );
1698  if ($this->db->numRows($result_post) < 1) {
1699  return;
1700  }
1701 
1702  while ($row = $this->db->fetchAssoc($result_post)) {
1703  if (preg_match("/il_(\d*?)_(\w+)_(\d+)/", $row["internal_link"], $matches)) {
1704  ilInternalLink::_saveLink("qst", $this->getId(), $matches[2], $matches[3], $matches[1]);
1705  }
1706  }
1707  }
1708 
1709  public function getInternalLinkHref(string $target): string
1710  {
1711  $linktypes = [
1712  "lm" => "LearningModule",
1713  "pg" => "PageObject",
1714  "st" => "StructureObject",
1715  "git" => "GlossaryItem",
1716  "mob" => "MediaObject"
1717  ];
1718  $href = "";
1719  if (preg_match("/il__(\w+)_(\d+)/", $target, $matches)) {
1720  $type = $matches[1];
1721  $target_id = $matches[2];
1722  switch ($linktypes[$matches[1]]) {
1723  case "MediaObject":
1724  $href = "./ilias.php?baseClass=ilLMPresentationGUI&obj_type=" . $linktypes[$type]
1725  . "&cmd=media&ref_id=" . $this->questionpool_request->getRefId()
1726  . "&mob_id=" . $target_id;
1727  break;
1728  case "StructureObject":
1729  case "GlossaryItem":
1730  case "PageObject":
1731  case "LearningModule":
1732  default:
1733  $href = "./goto.php?target=" . $type . "_" . $target_id;
1734  break;
1735  }
1736  }
1737  return $href;
1738  }
1739 
1740  public function syncWithOriginal(): void
1741  {
1742  if ($this->getOriginalId() === null) {
1743  return;
1744  }
1745 
1746  $original_parent_id = self::lookupParentObjId($this->getOriginalId());
1747 
1748  if ($original_parent_id === null) {
1749  return;
1750  }
1751 
1752  $this->cloneSuggestedSolutions($this->getId(), $this->getOriginalId());
1753  $original = clone $this;
1754  // Now we become the original
1755  $original->setId($this->getOriginalId());
1756  $original->setOriginalId(null);
1757  $original->setObjId($original_parent_id);
1758 
1759  $original->saveToDb();
1760 
1761  $original->deletePageOfQuestion($this->getOriginalId());
1762  $original->createPageObject();
1763  $original->clonePageOfQuestion($this->getId());
1764  $original = $this->cloneQuestionTypeSpecificProperties($original);
1765  $this->cloneXHTMLMediaObjectsOfQuestion($original->getId());
1766  $this->afterSyncWithOriginal($this->getOriginalId(), $this->getId(), $original_parent_id, $this->getObjId());
1767  $this->cloneHints($this->id, $this->original_id);
1768  }
1769 
1775  public static function instantiateQuestion(int $question_id): assQuestion
1776  {
1777  global $DIC;
1778  $ilCtrl = $DIC['ilCtrl'];
1779  $ilDB = $DIC['ilDB'];
1780  $lng = $DIC['lng'];
1781  $questionrepository = QuestionPoolDIC::dic()['question.general_properties.repository'];
1782  $question_type = $questionrepository->getForQuestionId($question_id)?->getClassName() ?? '';
1783  if ($question_type === '') {
1784  throw new InvalidArgumentException('No question with ID ' . $question_id . ' exists');
1785  }
1786 
1787  $question = new $question_type();
1788  $question->loadFromDb($question_id);
1789 
1790  $feedbackObjectClassname = self::getFeedbackClassNameByQuestionType($question_type);
1791  $question->feedbackOBJ = new $feedbackObjectClassname($question, $ilCtrl, $ilDB, $lng);
1792 
1793  return $question;
1794  }
1795 
1796  public function getPoints(): float
1797  {
1798  return $this->points;
1799  }
1800 
1801  public function setPoints(float $points): void
1802  {
1803  $this->points = $points;
1804  }
1805 
1806  public function getSolutionMaxPass(int $active_id): ?int
1807  {
1808  return self::_getSolutionMaxPass($this->getId(), $active_id);
1809  }
1810 
1814  public static function _getSolutionMaxPass(int $question_id, int $active_id): ?int
1815  {
1816  // the following code was the old solution which added the non answered
1817  // questions of a pass from the answered questions of the previous pass
1818  // with the above solution, only the answered questions of the last pass are counted
1819  global $DIC;
1820  $ilDB = $DIC['ilDB'];
1821 
1822  $result = $ilDB->queryF(
1823  "SELECT MAX(pass) maxpass FROM tst_test_result WHERE active_fi = %s AND question_fi = %s",
1824  ['integer','integer'],
1825  [$active_id, $question_id]
1826  );
1827  if ($result->numRows() === 1) {
1828  $row = $ilDB->fetchAssoc($result);
1829  return $row["maxpass"];
1830  }
1831 
1832  return null;
1833  }
1834 
1835  public function isWriteable(): bool
1836  {
1837  return ilObjQuestionPool::_isWriteable($this->getObjId(), $this->getCurrentUser()->getId());
1838  }
1839 
1840  public function deductHintPointsFromReachedPoints(ilAssQuestionPreviewSession $preview_session, $reached_points): ?float
1841  {
1842  $hint_tracking = new ilAssQuestionPreviewHintTracking($this->db, $preview_session);
1843  $requests_statistic_data = $hint_tracking->getRequestStatisticData();
1844  return $reached_points - $requests_statistic_data->getRequestsPoints();
1845  }
1846 
1848  {
1849  $reached_points = $this->deductHintPointsFromReachedPoints(
1850  $preview_session,
1851  $this->calculateReachedPointsForSolution($preview_session->getParticipantsSolution())
1852  );
1853 
1854  return $this->ensureNonNegativePoints($reached_points);
1855  }
1856 
1857  protected function ensureNonNegativePoints(float $points): float
1858  {
1859  return $points > 0.0 ? $points : 0.0;
1860  }
1861 
1862  public function isPreviewSolutionCorrect(ilAssQuestionPreviewSession $preview_session): bool
1863  {
1864  $reached_points = $this->calculateReachedPointsFromPreviewSession($preview_session);
1865 
1866  return !($reached_points < $this->getMaximumPoints());
1867  }
1868 
1869 
1880  final public function adjustReachedPointsByScoringOptions(float $points, int $active_id): float
1881  {
1882  $count_system = ilObjTest::_getCountSystem($active_id);
1883  if ($count_system == 1) {
1884  if (abs($this->getMaximumPoints() - $points) > 0.0000000001) {
1885  $points = 0;
1886  }
1887  }
1888  $score_cutting = ilObjTest::_getScoreCutting($active_id);
1889  if ($score_cutting == 0) {
1890  if ($points < 0) {
1891  $points = 0;
1892  }
1893  }
1894  return $points;
1895  }
1896  public function buildHashedImageFilename(string $plain_image_filename, bool $unique = false): string
1897  {
1898  $extension = "";
1899 
1900  if (preg_match("/.*\.(png|jpg|gif|jpeg)$/i", $plain_image_filename, $matches)) {
1901  $extension = "." . $matches[1];
1902  }
1903 
1904  if ($unique) {
1905  $plain_image_filename = uniqid($plain_image_filename . microtime(true), true);
1906  }
1907 
1908  return md5($plain_image_filename) . $extension;
1909  }
1910 
1921  public static function _setReachedPoints(
1922  int $active_id,
1923  int $question_id,
1924  float $points,
1925  float $maxpoints,
1926  int $pass,
1927  bool $manualscoring
1928  ): void {
1929  global $DIC;
1930  $ilDB = $DIC['ilDB'];
1932  $question_properties_repository = QuestionPoolDIC::dic()['question.general_properties.repository'];
1933 
1934  if ($points > $maxpoints) {
1935  return;
1936  }
1937 
1938  if ($pass === null) {
1939  $pass = assQuestion::_getSolutionMaxPass($question_id, $active_id);
1940  }
1941 
1942  $old_points = 0;
1943  $result = $ilDB->queryF(
1944  "SELECT points FROM tst_test_result WHERE active_fi = %s AND question_fi = %s AND pass = %s",
1945  ['integer','integer','integer'],
1946  [$active_id, $question_id, $pass]
1947  );
1948  $manual = ($manualscoring) ? 1 : 0;
1949  $rowsnum = $result->numRows();
1950  if ($rowsnum > 0) {
1951  $row = $ilDB->fetchAssoc($result);
1952  $old_points = $row['points'];
1953  if ($old_points !== $points) {
1954  $affectedRows = $ilDB->manipulateF(
1955  "UPDATE tst_test_result SET points = %s, manual = %s, tstamp = %s WHERE active_fi = %s AND question_fi = %s AND pass = %s",
1956  ['float', 'integer', 'integer', 'integer', 'integer', 'integer'],
1957  [$points, $manual, time(), $active_id, $question_id, $pass]
1958  );
1959  }
1960  } else {
1961  $next_id = $ilDB->nextId('tst_test_result');
1962  $affectedRows = $ilDB->manipulateF(
1963  "INSERT INTO tst_test_result (test_result_id, active_fi, question_fi, points, pass, manual, tstamp) VALUES (%s, %s, %s, %s, %s, %s, %s)",
1964  ['integer', 'integer','integer', 'float', 'integer', 'integer','integer'],
1965  [$next_id, $active_id, $question_id, $points, $pass, $manual, time()]
1966  );
1967  }
1968 
1969  if (!self::isForcePassResultUpdateEnabled() && $old_points === $points && $rowsnum !== 0) {
1970  return;
1971  }
1972 
1973  $test_obj_id = $question_properties_repository->getForQuestionId($question_id)
1974  ->getParentObjectId();
1975  if ($test_obj_id === null) {
1976  return;
1977  }
1978  $test = new ilObjTest(
1979  $test_obj_id,
1980  false
1981  );
1982  $test->updateTestPassResults($active_id, $pass);
1984  }
1985 
1986  public function getQuestion(): string
1987  {
1988  return $this->question;
1989  }
1990 
1991  public function getQuestionForHTMLOutput(): string
1992  {
1993  return $this->purifyAndPrepareTextAreaOutput($this->question);
1994  }
1995 
1996  protected function purifyAndPrepareTextAreaOutput(string $content): string
1997  {
1998  $purified_content = $this->getHtmlQuestionContentPurifier()->purify($content);
2000  || !(new ilSetting('advanced_editing'))->get('advanced_editing_javascript_editor') === 'tinymce') {
2001  $purified_content = nl2br($purified_content);
2002  }
2004  $purified_content,
2005  true,
2006  true
2007  );
2008  }
2009 
2010  public function setQuestion(string $question = ""): void
2011  {
2012  $this->question = $question;
2013  }
2014 
2015  public function getQuestionTypeID(): int
2016  {
2017  $result = $this->db->queryF(
2018  "SELECT question_type_id FROM qpl_qst_type WHERE type_tag = %s",
2019  ['text'],
2020  [$this->getQuestionType()]
2021  );
2022  if ($this->db->numRows($result) == 1) {
2023  $row = $this->db->fetchAssoc($result);
2024  return (int) $row["question_type_id"];
2025  }
2026  return 0;
2027  }
2028 
2029  public function cloneHints(
2030  int $source_question_id,
2031  int $target_question_id
2032  ): void {
2033  // delete hints of the original
2034  $this->db->manipulateF(
2035  "DELETE FROM qpl_hints WHERE qht_question_fi = %s",
2036  ['integer'],
2037  [$target_question_id]
2038  );
2039 
2040  // get hints of the actual question
2041  $result = $this->db->queryF(
2042  "SELECT * FROM qpl_hints WHERE qht_question_fi = %s",
2043  ['integer'],
2044  [$source_question_id]
2045  );
2046 
2047  // save hints to the original
2048  if ($this->db->numRows($result) < 1) {
2049  return;
2050  }
2051 
2052  while ($row = $this->db->fetchAssoc($result)) {
2053  $next_id = $this->db->nextId('qpl_hints');
2054  $this->db->insert(
2055  'qpl_hints',
2056  [
2057  'qht_hint_id' => ['integer', $next_id],
2058  'qht_question_fi' => ['integer', $target_question_id],
2059  'qht_hint_index' => ['integer', $row["qht_hint_index"]],
2060  'qht_hint_points' => ['float', $row["qht_hint_points"]],
2061  'qht_hint_text' => ['text', $row["qht_hint_text"]],
2062  ]
2063  );
2064  }
2065  }
2066 
2067  protected function getRTETextWithMediaObjects(): string
2068  {
2069  // must be called in parent classes. add additional RTE text in the parent
2070  // classes and call this method to add the standard RTE text
2071  $collected = $this->getQuestion();
2072  $collected .= $this->feedbackOBJ->getGenericFeedbackContent($this->getId(), false);
2073  $collected .= $this->feedbackOBJ->getGenericFeedbackContent($this->getId(), true);
2074  $collected .= $this->feedbackOBJ->getAllSpecificAnswerFeedbackContents($this->getId());
2075 
2076  $questionHintList = ilAssQuestionHintList::getListByQuestionId($this->getId());
2077  foreach ($questionHintList as $questionHint) {
2078  /* @var $questionHint ilAssQuestionHint */
2079  $collected .= $questionHint->getText();
2080  }
2081 
2082  return $collected;
2083  }
2084 
2085  public function cleanupMediaObjectUsage(): void
2086  {
2087  $combinedtext = $this->getRTETextWithMediaObjects();
2088  ilRTE::_cleanupMediaObjectUsage($combinedtext, "qpl:html", $this->getId());
2089  }
2090 
2091  public function getInstances(): array
2092  {
2093  $result = $this->db->queryF(
2094  "SELECT question_id FROM qpl_questions WHERE original_id = %s",
2095  ["integer"],
2096  [$this->getId()]
2097  );
2098  $instances = [];
2099  $ids = [];
2100  while ($row = $this->db->fetchAssoc($result)) {
2101  $ids[] = $row["question_id"];
2102  }
2103  foreach ($ids as $question_id) {
2104  // check non random tests
2105  $result = $this->db->queryF(
2106  "SELECT tst_tests.obj_fi FROM tst_tests, tst_test_question WHERE tst_test_question.question_fi = %s AND tst_test_question.test_fi = tst_tests.test_id",
2107  ["integer"],
2108  [$question_id]
2109  );
2110  while ($row = $this->db->fetchAssoc($result)) {
2111  $instances[$row['obj_fi']] = ilObject::_lookupTitle($row['obj_fi']);
2112  }
2113  // check random tests
2114  $result = $this->db->queryF(
2115  "SELECT tst_tests.obj_fi FROM tst_tests, tst_test_rnd_qst, tst_active WHERE tst_test_rnd_qst.active_fi = tst_active.active_id AND tst_test_rnd_qst.question_fi = %s AND tst_tests.test_id = tst_active.test_fi",
2116  ["integer"],
2117  [$question_id]
2118  );
2119  while ($row = $this->db->fetchAssoc($result)) {
2120  $instances[$row['obj_fi']] = ilObject::_lookupTitle($row['obj_fi']);
2121  }
2122  }
2123  foreach ($instances as $key => $value) {
2124  $instances[$key] = ["obj_id" => $key, "title" => $value, "author" => ilObjTest::_lookupAuthor($key), "refs" => ilObject::_getAllReferences($key)];
2125  }
2126  return $instances;
2127  }
2128 
2135  public function getActiveUserData(int $active_id): array
2136  {
2137  $result = $this->db->queryF(
2138  "SELECT * FROM tst_active WHERE active_id = %s",
2139  ['integer'],
2140  [$active_id]
2141  );
2142  if ($this->db->numRows($result)) {
2143  $row = $this->db->fetchAssoc($result);
2144  return ["user_id" => $row["user_fi"], "test_id" => $row["test_fi"]];
2145  }
2146 
2147  return [];
2148  }
2149 
2150  public function hasSpecificFeedback(): bool
2151  {
2152  return static::HAS_SPECIFIC_FEEDBACK;
2153  }
2154 
2155  public static function getFeedbackClassNameByQuestionType(string $questionType): string
2156  {
2157  return str_replace('ass', 'ilAss', $questionType) . 'Feedback';
2158  }
2159 
2160 
2161 
2162  public static function instantiateQuestionGUI(int $question_id): ?assQuestionGUI
2163  {
2165  global $DIC;
2166  $ilCtrl = $DIC['ilCtrl'];
2167  $ilDB = $DIC['ilDB'];
2168  $lng = $DIC['lng'];
2169  $ilUser = $DIC['ilUser'];
2170  $ilLog = $DIC['ilLog'];
2171 
2172  if ($question_id <= 0) {
2173  $ilLog->warning('Instantiate question called without question id. (instantiateQuestionGUI@assQuestion)');
2174  throw new InvalidArgumentException('Instantiate question called without question id. (instantiateQuestionGUI@assQuestion)');
2175  }
2176 
2177  $questionrepository = QuestionPoolDIC::dic()['question.general_properties.repository'];
2178  $question_type = $questionrepository->getForQuestionId($question_id)?->getClassName();
2179 
2180  if ($question_type === null) {
2181  return null;
2182  }
2183 
2184  $question_type_gui = $question_type . 'GUI';
2185  $question_gui = new $question_type_gui($question_id);
2186 
2187  $feedback_object_classname = self::getFeedbackClassNameByQuestionType($question_type);
2188  $question = $question_gui->getObject();
2189  $question->feedbackOBJ = new $feedback_object_classname($question, $ilCtrl, $ilDB, $lng);
2190 
2191  $assSettings = new ilSetting('assessment');
2192  $processLockerFactory = new ilAssQuestionProcessLockerFactory($assSettings, $ilDB);
2193  $processLockerFactory->setQuestionId($question_gui->getObject()->getId());
2194  $processLockerFactory->setUserId($ilUser->getId());
2195  $question->setProcessLocker($processLockerFactory->getLocker());
2196  $question_gui->setObject($question);
2197 
2198  return $question_gui;
2199  }
2200 
2201  public function getNrOfTries(): int
2202  {
2203  return $this->nr_of_tries;
2204  }
2205 
2206  public function setNrOfTries(int $a_nr_of_tries): void
2207  {
2208  $this->nr_of_tries = $a_nr_of_tries;
2209  }
2210 
2211  public function setExportImagePath(string $path): void
2212  {
2213  $this->export_image_path = $path;
2214  }
2215 
2216  public static function _questionExistsInTest(int $question_id, int $test_id): bool
2217  {
2218  global $DIC;
2219  $ilDB = $DIC['ilDB'];
2220 
2221  if ($question_id < 1) {
2222  return false;
2223  }
2224 
2225  $result = $ilDB->queryF(
2226  "SELECT question_fi FROM tst_test_question WHERE question_fi = %s AND test_fi = %s",
2227  ['integer', 'integer'],
2228  [$question_id, $test_id]
2229  );
2230  return $ilDB->numRows($result) == 1;
2231  }
2232 
2233  public function formatSAQuestion($a_q): string
2234  {
2235  return $this->getSelfAssessmentFormatter()->format($a_q);
2236  }
2237 
2239  {
2240  return new \ilAssSelfAssessmentQuestionFormatter();
2241  }
2242 
2243  // scorm2004-start ???
2244 
2245  public function setPreventRteUsage(bool $prevent_rte_usage): void
2246  {
2247  $this->prevent_rte_usage = $prevent_rte_usage;
2248  }
2249 
2250  public function getPreventRteUsage(): bool
2251  {
2252  return $this->prevent_rte_usage;
2253  }
2254 
2256  {
2257  $this->lmMigrateQuestionTypeGenericContent($migrator);
2258  $this->lmMigrateQuestionTypeSpecificContent($migrator);
2259  $this->saveToDb();
2260 
2261  $this->feedbackOBJ->migrateContentForLearningModule($migrator, $this->getId());
2262  }
2263 
2265  {
2266  $this->setQuestion($migrator->migrateToLmContent($this->getQuestion()));
2267  }
2268 
2270  {
2271  // overwrite if any question type specific content except feedback needs to be migrated
2272  }
2273 
2274  public function setSelfAssessmentEditingMode(bool $selfassessmenteditingmode): void
2275  {
2276  $this->selfassessmenteditingmode = $selfassessmenteditingmode;
2277  }
2278 
2279  public function getSelfAssessmentEditingMode(): bool
2280  {
2282  }
2283 
2284  public function setDefaultNrOfTries(int $defaultnroftries): void
2285  {
2286  $this->defaultnroftries = $defaultnroftries;
2287  }
2288 
2289  public function getDefaultNrOfTries(): int
2290  {
2291  return $this->defaultnroftries;
2292  }
2293 
2294  // scorm2004-end ???
2295 
2296  public static function lookupParentObjId(int $question_id): ?int
2297  {
2298  global $DIC;
2299  $ilDB = $DIC['ilDB'];
2300 
2301  $query = "SELECT obj_fi FROM qpl_questions WHERE question_id = %s";
2302 
2303  $res = $ilDB->queryF($query, ['integer'], [$question_id]);
2304  $row = $ilDB->fetchAssoc($res);
2305 
2306  return $row['obj_fi'] ?? null;
2307  }
2308 
2309  protected function duplicateQuestionHints(int $original_question_id, int $duplicate_question_id): void
2310  {
2311  $hintIds = ilAssQuestionHintList::duplicateListForQuestion($original_question_id, $duplicate_question_id);
2312 
2314  foreach ($hintIds as $originalHintId => $duplicateHintId) {
2315  $this->ensureHintPageObjectExists($originalHintId);
2316  $originalPageObject = new ilAssHintPage($originalHintId);
2317  $originalXML = $originalPageObject->getXMLContent();
2318 
2319  $duplicatePageObject = new ilAssHintPage();
2320  $duplicatePageObject->setId($duplicateHintId);
2321  $duplicatePageObject->setParentId($this->getId());
2322  $duplicatePageObject->setXMLContent($originalXML);
2323  $duplicatePageObject->createFromXML();
2324  }
2325  }
2326  }
2327 
2328  protected function duplicateSkillAssignments(int $srcParentId, int $srcQuestionId, int $trgParentId, int $trgQuestionId): void
2329  {
2330  $assignmentList = new ilAssQuestionSkillAssignmentList($this->db);
2331  $assignmentList->setParentObjId($srcParentId);
2332  $assignmentList->setQuestionIdFilter($srcQuestionId);
2333  $assignmentList->loadFromDb();
2334 
2335  foreach ($assignmentList->getAssignmentsByQuestionId($srcQuestionId) as $assignment) {
2336  $assignment->setParentObjId($trgParentId);
2337  $assignment->setQuestionId($trgQuestionId);
2338  $assignment->saveToDb();
2339 
2340  // add skill usage
2341  $this->skillUsageService->addUsage(
2342  $trgParentId,
2343  $assignment->getSkillBaseId(),
2344  $assignment->getSkillTrefId()
2345  );
2346  }
2347  }
2348 
2349  public function syncSkillAssignments(int $srcParentId, int $srcQuestionId, int $trgParentId, int $trgQuestionId): void
2350  {
2351  $assignmentList = new ilAssQuestionSkillAssignmentList($this->db);
2352  $assignmentList->setParentObjId($trgParentId);
2353  $assignmentList->setQuestionIdFilter($trgQuestionId);
2354  $assignmentList->loadFromDb();
2355 
2356  foreach ($assignmentList->getAssignmentsByQuestionId($trgQuestionId) as $assignment) {
2357  $assignment->deleteFromDb();
2358 
2359  // remove skill usage
2360  if (!$assignment->isSkillUsed()) {
2361  $this->skillUsageService->removeUsage(
2362  $assignment->getParentObjId(),
2363  $assignment->getSkillBaseId(),
2364  $assignment->getSkillTrefId()
2365  );
2366  }
2367  }
2368 
2369  $this->duplicateSkillAssignments($srcParentId, $srcQuestionId, $trgParentId, $trgQuestionId);
2370  }
2371 
2372  public function ensureHintPageObjectExists($pageObjectId): void
2373  {
2374  if (!ilAssHintPage::_exists('qht', $pageObjectId)) {
2375  $pageObject = new ilAssHintPage();
2376  $pageObject->setParentId($this->getId());
2377  $pageObject->setId($pageObjectId);
2378  $pageObject->createFromXML();
2379  }
2380  }
2381 
2382  public function isAnswered(int $active_id, int $pass): bool
2383  {
2384  $numExistingSolutionRecords = assQuestion::getNumExistingSolutionRecords($active_id, $pass, $this->getId());
2385  return $numExistingSolutionRecords > 0;
2386  }
2387 
2388  protected static function getNumExistingSolutionRecords(int $activeId, int $pass, int $questionId): int
2389  {
2390  global $DIC;
2391  $ilDB = $DIC['ilDB'];
2392 
2393  $query = "
2394  SELECT count(active_fi) cnt
2395 
2396  FROM tst_solutions
2397 
2398  WHERE active_fi = %s
2399  AND question_fi = %s
2400  AND pass = %s
2401  ";
2402 
2403  $res = $ilDB->queryF(
2404  $query,
2405  ['integer','integer','integer'],
2406  [$activeId, $questionId, $pass]
2407  );
2408 
2409  $row = $ilDB->fetchAssoc($res);
2410 
2411  return (int) $row['cnt'];
2412  }
2413 
2414  public function getAdditionalContentEditingMode(): string
2415  {
2417  }
2418 
2419  public function setAdditionalContentEditingMode(?string $additionalContentEditingMode): void
2420  {
2421  if (!in_array($additionalContentEditingMode, $this->getValidAdditionalContentEditingModes())) {
2422  throw new ilTestQuestionPoolException('invalid additional content editing mode given: ' . $additionalContentEditingMode);
2423  }
2424 
2425  $this->additionalContentEditingMode = $additionalContentEditingMode;
2426  }
2427 
2429  {
2431  }
2432 
2433  public function isValidAdditionalContentEditingMode(string $additionalContentEditingMode): bool
2434  {
2435  if (in_array($additionalContentEditingMode, $this->getValidAdditionalContentEditingModes())) {
2436  return true;
2437  }
2438 
2439  return false;
2440  }
2441 
2442  public function getValidAdditionalContentEditingModes(): array
2443  {
2444  return [
2445  self::ADDITIONAL_CONTENT_EDITING_MODE_RTE,
2446  self::ADDITIONAL_CONTENT_EDITING_MODE_IPE
2447  ];
2448  }
2449 
2454  {
2455  return ilHtmlPurifierFactory::getInstanceByType('qpl_usersolution');
2456  }
2457 
2462  {
2463  return ilHtmlPurifierFactory::getInstanceByType('qpl_usersolution');
2464  }
2465 
2466  protected function buildQuestionDataQuery(): string
2467  {
2468  return "
2469  SELECT qpl_questions.*,
2470  {$this->getAdditionalTableName()}.*
2471  FROM qpl_questions
2472  LEFT JOIN {$this->getAdditionalTableName()}
2473  ON {$this->getAdditionalTableName()}.question_fi = qpl_questions.question_id
2474  WHERE qpl_questions.question_id = %s
2475  ";
2476  }
2477 
2478  public function setLastChange(int $lastChange): void
2479  {
2480  $this->lastChange = $lastChange;
2481  }
2482 
2483  public function getLastChange(): ?int
2484  {
2485  return $this->lastChange;
2486  }
2487 
2488  protected function getCurrentSolutionResultSet(int $active_id, int $pass, bool $authorized = true): \ilDBStatement
2489  {
2490  if ($this->getStep() !== null) {
2491  $query = "
2492  SELECT *
2493  FROM tst_solutions
2494  WHERE active_fi = %s
2495  AND question_fi = %s
2496  AND pass = %s
2497  AND step = %s
2498  AND authorized = %s
2499  ";
2500 
2501  return $this->db->queryF(
2502  $query,
2503  ['integer', 'integer', 'integer', 'integer', 'integer'],
2504  [$active_id, $this->getId(), $pass, $this->getStep(), (int) $authorized]
2505  );
2506  }
2507 
2508  $query = "
2509  SELECT *
2510  FROM tst_solutions
2511  WHERE active_fi = %s
2512  AND question_fi = %s
2513  AND pass = %s
2514  AND authorized = %s
2515  ";
2516 
2517  return $this->db->queryF(
2518  $query,
2519  ['integer', 'integer', 'integer', 'integer'],
2520  [$active_id, $this->getId(), $pass, (int) $authorized]
2521  );
2522  }
2523 
2524  protected function removeSolutionRecordById(int $solutionId): int
2525  {
2526  return $this->db->manipulateF(
2527  "DELETE FROM tst_solutions WHERE solution_id = %s",
2528  ['integer'],
2529  [$solutionId]
2530  );
2531  }
2532 
2533  // hey: prevPassSolutions - selected file reuse, copy solution records
2537  protected function getSolutionRecordById(int $solutionId): array
2538  {
2539  $result = $this->db->queryF(
2540  "SELECT * FROM tst_solutions WHERE solution_id = %s",
2541  ['integer'],
2542  [$solutionId]
2543  );
2544 
2545  if ($this->db->numRows($result) > 0) {
2546  return $this->db->fetchAssoc($result);
2547  }
2548  return [];
2549  }
2550  // hey.
2551 
2552  public function removeIntermediateSolution(int $active_id, int $pass): void
2553  {
2554  $this->getProcessLocker()->executeUserSolutionUpdateLockOperation(function () use ($active_id, $pass) {
2555  $this->removeCurrentSolution($active_id, $pass, false);
2556  });
2557  }
2558 
2562  public function removeCurrentSolution(int $active_id, int $pass, bool $authorized = true): int
2563  {
2564  if ($this->getStep() !== null) {
2565  $query = '
2566  DELETE FROM tst_solutions
2567  WHERE active_fi = %s
2568  AND question_fi = %s
2569  AND pass = %s
2570  AND step = %s
2571  AND authorized = %s
2572  ';
2573 
2574  return $this->db->manipulateF(
2575  $query,
2576  ['integer', 'integer', 'integer', 'integer', 'integer'],
2577  [$active_id, $this->getId(), $pass, $this->getStep(), (int) $authorized]
2578  );
2579  }
2580 
2581  $query = "
2582  DELETE FROM tst_solutions
2583  WHERE active_fi = %s
2584  AND question_fi = %s
2585  AND pass = %s
2586  AND authorized = %s
2587  ";
2588 
2589  return $this->db->manipulateF(
2590  $query,
2591  ['integer', 'integer', 'integer', 'integer'],
2592  [$active_id, $this->getId(), $pass, (int) $authorized]
2593  );
2594  }
2595 
2596  // fau: testNav - add timestamp as parameter to saveCurrentSolution
2597  public function saveCurrentSolution(int $active_id, int $pass, $value1, $value2, bool $authorized = true, $tstamp = 0): int
2598  {
2599  $next_id = $this->db->nextId("tst_solutions");
2600 
2601  $fieldData = [
2602  "solution_id" => ["integer", $next_id],
2603  "active_fi" => ["integer", $active_id],
2604  "question_fi" => ["integer", $this->getId()],
2605  "value1" => ["clob", $value1],
2606  "value2" => ["clob", $value2],
2607  "pass" => ["integer", $pass],
2608  "tstamp" => ["integer", ((int) $tstamp > 0) ? (int) $tstamp : time()],
2609  'authorized' => ['integer', (int) $authorized]
2610  ];
2611 
2612  if ($this->getStep() !== null) {
2613  $fieldData['step'] = ["integer", $this->getStep()];
2614  }
2615 
2616  return $this->db->insert("tst_solutions", $fieldData);
2617  }
2618  // fau.
2619 
2620  public function updateCurrentSolution(int $solutionId, $value1, $value2, bool $authorized = true): int
2621  {
2622  $fieldData = [
2623  "value1" => ["clob", $value1],
2624  "value2" => ["clob", $value2],
2625  "tstamp" => ["integer", time()],
2626  'authorized' => ['integer', (int) $authorized]
2627  ];
2628 
2629  if ($this->getStep() !== null) {
2630  $fieldData['step'] = ["integer", $this->getStep()];
2631  }
2632 
2633  return $this->db->update("tst_solutions", $fieldData, [
2634  'solution_id' => ['integer', $solutionId]
2635  ]);
2636  }
2637 
2638  // fau: testNav - added parameter to keep the timestamp (default: false)
2639  public function updateCurrentSolutionsAuthorization(int $activeId, int $pass, bool $authorized, bool $keepTime = false): int
2640  {
2641  $fieldData = [
2642  'authorized' => ['integer', (int) $authorized]
2643  ];
2644 
2645  if (!$keepTime) {
2646  $fieldData['tstamp'] = ['integer', time()];
2647  }
2648 
2649  $whereData = [
2650  'question_fi' => ['integer', $this->getId()],
2651  'active_fi' => ['integer', $activeId],
2652  'pass' => ['integer', $pass]
2653  ];
2654 
2655  if ($this->getStep() !== null) {
2656  $whereData['step'] = ["integer", $this->getStep()];
2657  }
2658 
2659  return $this->db->update('tst_solutions', $fieldData, $whereData);
2660  }
2661  // fau.
2662 
2663  // hey: prevPassSolutions - motivation slowly decreases on imagemap
2665 
2666  public static function implodeKeyValues(array $keyValues): string
2667  {
2668  return implode(assQuestion::KEY_VALUES_IMPLOSION_SEPARATOR, $keyValues);
2669  }
2670 
2671  public static function explodeKeyValues(string $keyValues): array
2672  {
2673  return explode(assQuestion::KEY_VALUES_IMPLOSION_SEPARATOR, $keyValues);
2674  }
2675 
2676  protected function deleteDummySolutionRecord(int $activeId, int $passIndex): void
2677  {
2678  foreach ($this->getSolutionValues($activeId, $passIndex, false) as $solutionRec) {
2679  if ($solutionRec['value1'] == '' && $solutionRec['value2'] == '') {
2680  $this->removeSolutionRecordById($solutionRec['solution_id']);
2681  }
2682  }
2683  }
2684 
2685  protected function isDummySolutionRecord(array $solutionRecord): bool
2686  {
2687  return !strlen($solutionRecord['value1']) && !strlen($solutionRecord['value2']);
2688  }
2689 
2690  protected function deleteSolutionRecordByValues(int $activeId, int $passIndex, bool $authorized, array $matchValues): void
2691  {
2692  $types = ["integer", "integer", "integer", "integer"];
2693  $values = [$activeId, $this->getId(), $passIndex, (int) $authorized];
2694  $valuesCondition = [];
2695 
2696  foreach ($matchValues as $valueField => $value) {
2697  switch ($valueField) {
2698  case 'value1':
2699  case 'value2':
2700  $valuesCondition[] = "{$valueField} = %s";
2701  $types[] = 'text';
2702  $values[] = $value;
2703  break;
2704 
2705  default:
2706  throw new ilTestQuestionPoolException('invalid value field given: ' . $valueField);
2707  }
2708  }
2709 
2710  $valuesCondition = implode(' AND ', $valuesCondition);
2711 
2712  $query = "
2713  DELETE FROM tst_solutions
2714  WHERE active_fi = %s
2715  AND question_fi = %s
2716  AND pass = %s
2717  AND authorized = %s
2718  AND $valuesCondition
2719  ";
2720 
2721  if ($this->getStep() !== null) {
2722  $query .= " AND step = %s ";
2723  $types[] = 'integer';
2724  $values[] = $this->getStep();
2725  }
2726 
2727  $this->db->manipulateF($query, $types, $values);
2728  }
2729 
2730  protected function duplicateIntermediateSolutionAuthorized(int $activeId, int $passIndex): void
2731  {
2732  foreach ($this->getSolutionValues($activeId, $passIndex, false) as $rec) {
2733  $this->saveCurrentSolution($activeId, $passIndex, $rec['value1'], $rec['value2'], true, $rec['tstamp']);
2734  }
2735  }
2736 
2737  protected function forceExistingIntermediateSolution(int $activeId, int $passIndex, bool $considerDummyRecordCreation): void
2738  {
2739  $intermediateSolution = $this->getSolutionValues($activeId, $passIndex, false);
2740 
2741  if (!count($intermediateSolution)) {
2742  $this->updateCurrentSolutionsAuthorization($activeId, $passIndex, false, true);
2743 
2744  // create a backup as authorized solution again (keeping timestamps)
2745  $this->duplicateIntermediateSolutionAuthorized($activeId, $passIndex);
2746 
2747  if ($considerDummyRecordCreation) {
2748  // create an additional dummy record to indicate the existence of an intermediate solution
2749  // even if all entries are deleted from the intermediate solution later
2750  $this->saveCurrentSolution($activeId, $passIndex, null, null, false);
2751  }
2752  }
2753  }
2754  // hey.
2755 
2759  public function setStep($step): void
2760  {
2761  $this->step = $step;
2762  }
2763 
2767  public function getStep(): ?int
2768  {
2769  return $this->step;
2770  }
2771 
2772  public static function convertISO8601FormatH_i_s_ExtendedToSeconds(string $time): int
2773  {
2774  $sec = 0;
2775  $time_array = explode(':', $time);
2776  if (count($time_array) == 3) {
2777  $sec += (int) $time_array[0] * 3600;
2778  $sec += (int) $time_array[1] * 60;
2779  $sec += (int) $time_array[2];
2780  }
2781  return $sec;
2782  }
2783 
2784  public function toJSON(): string
2785  {
2786  return json_encode([]);
2787  }
2788 
2789  // hey: prevPassSolutions - check for authorized solution
2790  public function intermediateSolutionExists(int $active_id, int $pass): bool
2791  {
2792  $solutionAvailability = $this->lookupForExistingSolutions($active_id, $pass);
2793  return (bool) $solutionAvailability['intermediate'];
2794  }
2795 
2796  public function authorizedSolutionExists(int $active_id, ?int $pass): bool
2797  {
2798  if ($pass === null) {
2799  return false;
2800  }
2801  $solutionAvailability = $this->lookupForExistingSolutions($active_id, $pass);
2802  return (bool) $solutionAvailability['authorized'];
2803  }
2804 
2805  public function authorizedOrIntermediateSolutionExists(int $active_id, int $pass): bool
2806  {
2807  $solutionAvailability = $this->lookupForExistingSolutions($active_id, $pass);
2808  return $solutionAvailability['authorized'] || $solutionAvailability['intermediate'];
2809  }
2810  // hey.
2811 
2812  protected function lookupMaxStep(int $active_id, int $pass): int
2813  {
2814  $result = $this->db->queryF(
2815  "SELECT MAX(step) max_step FROM tst_solutions WHERE active_fi = %s AND pass = %s AND question_fi = %s",
2816  ["integer", "integer", "integer"],
2817  [$active_id, $pass, $this->getId()]
2818  );
2819 
2820  $row = $this->db->fetchAssoc($result);
2821 
2822  return (int) $row['max_step'];
2823  }
2824 
2825  // fau: testNav - new function lookupForExistingSolutions
2830  public function lookupForExistingSolutions(int $activeId, int $pass): array
2831  {
2832  $return = [
2833  'authorized' => false,
2834  'intermediate' => false
2835  ];
2836 
2837  $query = "
2838  SELECT authorized, COUNT(*) cnt
2839  FROM tst_solutions
2840  WHERE active_fi = %s
2841  AND question_fi = %s
2842  AND pass = %s
2843  ";
2844 
2845  if ($this->getStep() !== null) {
2846  $query .= " AND step = " . $this->db->quote((int) $this->getStep(), 'integer') . " ";
2847  }
2848 
2849  $query .= "
2850  GROUP BY authorized
2851  ";
2852 
2853  $result = $this->db->queryF($query, ['integer', 'integer', 'integer'], [$activeId, $this->getId(), $pass]);
2854 
2855  while ($row = $this->db->fetchAssoc($result)) {
2856  if ($row['authorized']) {
2857  $return['authorized'] = $row['cnt'] > 0;
2858  } else {
2859  $return['intermediate'] = $row['cnt'] > 0;
2860  }
2861  }
2862  return $return;
2863  }
2864  // fau.
2865 
2866  public function isAddableAnswerOptionValue(int $qIndex, string $answerOptionValue): bool
2867  {
2868  return false;
2869  }
2870 
2871  public function addAnswerOptionValue(int $qIndex, string $answerOptionValue, float $points): void
2872  {
2873  }
2874 
2875  public function removeAllExistingSolutions(): void
2876  {
2877  $query = "DELETE FROM tst_solutions WHERE question_fi = %s";
2878  $this->db->manipulateF($query, ['integer'], [$this->getId()]);
2879  }
2880 
2881  public function removeExistingSolutions(int $activeId, int $pass): int
2882  {
2883  $query = "
2884  DELETE FROM tst_solutions
2885  WHERE active_fi = %s
2886  AND question_fi = %s
2887  AND pass = %s
2888  ";
2889 
2890  if ($this->getStep() !== null) {
2891  $query .= " AND step = " . $this->db->quote((int) $this->getStep(), 'integer') . " ";
2892  }
2893 
2894  return $this->db->manipulateF(
2895  $query,
2896  ['integer', 'integer', 'integer'],
2897  [$activeId, $this->getId(), $pass]
2898  );
2899  }
2900 
2901  public function resetUsersAnswer(int $activeId, int $pass): void
2902  {
2903  $this->removeExistingSolutions($activeId, $pass);
2904  $this->removeResultRecord($activeId, $pass);
2905 
2906  $test = new ilObjTest(
2907  $this->test_id,
2908  false
2909  );
2910  $test->updateTestPassResults(
2911  $activeId,
2912  $pass,
2913  $this->getProcessLocker(),
2914  $this->getTestId()
2915  );
2916  }
2917 
2918  public function removeResultRecord(int $activeId, int $pass): int
2919  {
2920  $query = "
2921  DELETE FROM tst_test_result
2922  WHERE active_fi = %s
2923  AND question_fi = %s
2924  AND pass = %s
2925  ";
2926 
2927  if ($this->getStep() !== null) {
2928  $query .= " AND step = " . $this->db->quote((int) $this->getStep(), 'integer') . " ";
2929  }
2930 
2931  return $this->db->manipulateF(
2932  $query,
2933  ['integer', 'integer', 'integer'],
2934  [$activeId, $this->getId(), $pass]
2935  );
2936  }
2937 
2938  public function fetchValuePairsFromIndexedValues(array $indexedValues): array
2939  {
2940  $valuePairs = [];
2941 
2942  foreach ($indexedValues as $value1 => $value2) {
2943  $valuePairs[] = ['value1' => $value1, 'value2' => $value2];
2944  }
2945 
2946  return $valuePairs;
2947  }
2948 
2949  public function fetchIndexedValuesFromValuePairs(array $value_pairs): array
2950  {
2951  $indexed_values = [];
2952 
2953  foreach ($value_pairs as $valuePair) {
2954  $indexed_values[$valuePair['value1']] = $valuePair['value2'];
2955  }
2956 
2957  return $indexed_values;
2958  }
2959 
2960  public function updateTimestamp(): void
2961  {
2962  $this->db->manipulateF(
2963  "UPDATE qpl_questions SET tstamp = %s WHERE question_id = %s",
2964  ['integer', 'integer'],
2965  [time(), $this->getId()]
2966  );
2967  }
2968 
2970  {
2971  if ($this->test_question_config === null) {
2972  $this->test_question_config = $this->buildTestPresentationConfig();
2973  }
2974 
2976  }
2977 
2979  {
2980  return new ilTestQuestionConfig();
2981  }
2982 
2984  {
2986  }
2987 
2988  protected function loadSuggestedSolutions(): array
2989  {
2990  $question_id = $this->getId();
2991  return $this->getSuggestedSolutionsRepo()->selectFor($question_id);
2992  }
2993 
3008  public static function extendedTrim(string $value): string
3009  {
3010  return preg_replace(self::TRIM_PATTERN, '', $value);
3011  }
3012 
3013  public function hasWritableOriginalInQuestionPool(): bool
3014  {
3015  return !is_null($this->original_id)
3016  && $this->questionrepository->questionExistsInPool($this->original_id)
3017  && assQuestion::instantiateQuestion($this->original_id)->isWriteable();
3018  }
3019 
3021  AdditionalInformationGenerator $additional_info,
3022  int $test_ref_id,
3023  int $active_id,
3024  int $pass,
3025  string $source_ip,
3026  TestParticipantInteractionTypes $interaction_type
3028  return new TestParticipantInteraction(
3029  $test_ref_id,
3030  $this->id,
3031  $this->current_user->getId(),
3032  $source_ip,
3033  $interaction_type,
3034  time(),
3035  $this->answerToLog($additional_info, $active_id, $pass)
3036  );
3037  }
3038 
3040  AdditionalInformationGenerator $additional_info,
3041  int $test_ref_id,
3045  $test_ref_id,
3046  $this->id,
3047  $this->current_user->getId(),
3048  $interaction_type,
3049  time(),
3050  $this->toLog($additional_info)
3051  );
3052  }
3053 
3054  protected function answerToLog(
3055  AdditionalInformationGenerator $additional_info,
3056  int $active_id,
3057  int $pass
3058  ): array {
3059  return [
3060  AdditionalInformationGenerator::KEY_PASS => $pass,
3061  AdditionalInformationGenerator::KEY_REACHED_POINTS => $this->getReachedPoints($active_id, $pass),
3062  AdditionalInformationGenerator::KEY_PAX_ANSWER => $this->solutionValuesToLog(
3063  $additional_info,
3064  $this->getSolutionValues($active_id, $pass)
3065  )
3066  ];
3067  }
3068 
3069  public function getSolutionForTextOutput(
3070  int $active_id,
3071  int $pass
3072  ): array|string {
3073  return $this->solutionValuesToText(
3074  $this->getSolutionValues($active_id, $pass)
3075  );
3076  }
3077 
3079  int $active_id,
3080  int $pass
3081  ): array|string {
3082  return $this->solutionValuesToText(
3083  $this->getSolutionValues($active_id, $pass)
3084  );
3085  }
3086 
3087  public function getVariablesAsTextArray(
3088  int $active_id,
3089  int $pass
3090  ): array {
3091  return [];
3092  }
3093 }
static _replaceMediaObjectImageSrc(string $a_text, int $a_direction=0, string $nic='')
Replaces image source from mob image urls with the mob id or replaces mob id with the correct image s...
loadFromDb(int $question_id)
const ADDITIONAL_CONTENT_EDITING_MODE_IPE
ilLanguage $lng
cloneXHTMLMediaObjectsOfQuestion(int $source_question_id)
setNrOfTries(int $a_nr_of_tries)
ilTestQuestionConfig $testQuestionConfig
ilAssQuestionFeedback $feedbackOBJ
syncSkillAssignments(int $srcParentId, int $srcQuestionId, int $trgParentId, int $trgQuestionId)
ilGlobalPageTemplate $tpl
static getListByQuestionId($questionId)
instantiates a question hint list for the passed question id
deletePageOfQuestion(int $question_id)
getSolutionValues(int $active_id, ?int $pass=null, bool $authorized=true)
Loads solutions of a given user from the database an returns it.
$res
Definition: ltiservices.php:69
This file is part of ILIAS, a powerful learning management system published by ILIAS open source e-Le...
SuggestedSolutionsDatabaseRepository $suggestedsolution_repo
authorizedOrIntermediateSolutionExists(int $active_id, int $pass)
This file is part of ILIAS, a powerful learning management system published by ILIAS open source e-Le...
const MAXIMUM_THUMB_SIZE
getActiveUserData(int $active_id)
Returns the user id and the test id for a given active id.
This file is part of ILIAS, a powerful learning management system published by ILIAS open source e-Le...
migrateContentForLearningModule(ilAssSelfAssessmentMigrator $migrator)
toXML(bool $a_include_header=true, bool $a_include_binary=true, bool $a_shuffle=false, bool $test_output=false, bool $force_image_references=false)
Returns a QTI xml representation of the question.
static _isWriteable($object_id, $user_id)
Returns true, if the question pool is writeable by a given user.
calculateResultsFromSolution(int $active_id, int $pass)
Calculates the question results from a previously saved question solution.
static _getPass($active_id)
Retrieves the actual pass of a given user for a given test.
const IL_INST_ID
Definition: constants.php:40
ilTestQuestionConfig $test_question_config
isPreviewSolutionCorrect(ilAssQuestionPreviewSession $preview_session)
answerToLog(AdditionalInformationGenerator $additional_info, int $active_id, int $pass)
removeResultRecord(int $activeId, int $pass)
deleteAdditionalTableData(int $question_id)
deleteAnswers(int $question_id)
cloneHints(int $source_question_id, int $target_question_id)
duplicate(bool $for_test=true, string $title='', string $author='', int $owner=-1, $test_obj_id=null)
static _getSuggestedSolutionOutput(int $question_id)
updateCurrentSolutionsAuthorization(int $activeId, int $pass, bool $authorized, bool $keepTime=false)
onDuplicate(int $original_parent_id, int $original_question_id, int $duplicate_parent_id, int $duplicate_question_id)
resetUsersAnswer(int $activeId, int $pass)
static isFileAvailable(string $file)
removeAllImageFiles(string $image_target_path)
setProcessLocker(ilAssQuestionProcessLocker $processLocker)
Provides fluid interface to LoggingServices.
Interface Observer Contains several chained tasks and infos about them.
setOwner(int $owner=-1)
cloneSuggestedSolutionFiles(int $source_question_id, int $target_question_id)
ensureNonNegativePoints(float $points)
static _getAllReferences(int $id)
get all reference ids for object ID
ilAssQuestionProcessLocker $processLocker
ensureHintPageObjectExists($pageObjectId)
Refinery $refinery
setSelfAssessmentEditingMode(bool $selfassessmenteditingmode)
SkillUsageService $skillUsageService
isDummySolutionRecord(array $solutionRecord)
solutionValuesToText(array $solution_values)
MUST convert the given solution values into text.
toLog(AdditionalInformationGenerator $additional_info)
MUST return an array of the question settings that can be stored in the log.
adjustReachedPointsByScoringOptions(float $points, int $active_id)
Adjust the given reached points by checks for all special scoring options in the test container...
createNewOriginalFromThisDuplicate(int $target_parent_id, string $target_question_title='')
static _saveUsage(int $a_mob_id, string $a_type, int $a_id, int $a_usage_hist_nr=0, string $a_lang="-")
Save usage of mob within another container (e.g.
getAdditionalTableName()
static isForcePassResultUpdateEnabled()
This file is part of ILIAS, a powerful learning management system published by ILIAS open source e-Le...
getImagePathWeb()
Returns the web image path for web accessable images of a question.
This file is part of ILIAS, a powerful learning management system published by ILIAS open source e-Le...
setThumbSize(int $a_size)
static deleteHintsByQuestionIds(array $questionIds)
Deletes all question hints relating to questions included in given question ids.
static getUsageOfObject(int $a_obj_id, bool $a_include_titles=false)
cloneQuestionTypeSpecificProperties(self $target)
lmMigrateQuestionTypeSpecificContent(ilAssSelfAssessmentMigrator $migrator)
static getNumExistingSolutionRecords(int $activeId, int $pass, int $questionId)
duplicateSkillAssignments(int $srcParentId, int $srcQuestionId, int $trgParentId, int $trgQuestionId)
cloneSuggestedSolutions(int $source_question_id, int $target_question_id)
RequestDataCollector $questionpool_request
static _cleanupMediaObjectUsage(string $a_text, string $a_usage_type, int $a_usage_id)
Synchronises appearances of media objects in $a_text with media object usage table.
persistWorkingState(int $active_id, $pass, bool $authorized=true)
persists the working state for current testactive and testpass
importHint(int $question_id, array $hint_array)
static prepareFormOutput($a_str, bool $a_strip=false)
QuestionFiles $question_files
static setTokenMaxLifetimeInSeconds(int $token_max_lifetime_in_seconds)
persistPreviewState(ilAssQuestionPreviewSession $preview_session)
persists the preview state for current user and question
setPreventRteUsage(bool $prevent_rte_usage)
static makeDirParents(string $a_dir)
Create a new directory and all parent directories.
getSuggestedSolution(int $subquestion_index=0)
duplicateQuestionHints(int $original_question_id, int $duplicate_question_id)
setComment(string $comment="")
static _questionExistsInTest(int $question_id, int $test_id)
string $questionActionCmd
getValidAdditionalContentEditingModes()
HTTPServices $http
Customizing of pimple-DIC for ILIAS.
Definition: Container.php:35
getUserSolutionPreferingIntermediate(int $active_id, ?int $pass=null)
afterSyncWithOriginal(int $original_question_id, int $clone_question_id, int $original_parent_id, int $clone_parent_id)
fromXML(string $importdirectory, int $user_id, ilQTIItem $item, int $questionpool_id, ?int $tst_id, ?ilObject &$tst_object, int &$question_counter, array $import_mapping, array $solutionhints=[])
bool $selfassessmenteditingmode
$path
Definition: ltiservices.php:30
static implodeKeyValues(array $keyValues)
solutionValuesToLog(AdditionalInformationGenerator $additional_info, array $solution_values)
MUST convert the given solution values into an array or a string that can be stored in the log...
setExportImagePath(string $path)
static removeTrailingPathSeparators(string $path)
Repository internal data service.
calculateReachedPoints(int $active_id, ?int $pass=null, bool $authorized_solution=true)
fixUnavailableSkinImageSources(string $html)
isAddableAnswerOptionValue(int $qIndex, string $answerOptionValue)
updateCurrentSolution(int $solutionId, $value1, $value2, bool $authorized=true)
static getASCIIFilename(string $a_filename)
deleteDummySolutionRecord(int $activeId, int $passIndex)
getVariablesAsTextArray(int $active_id, int $pass)
removeIntermediateSolution(int $active_id, int $pass)
static _getUserIdFromActiveId(int $active_id)
LoggingServices $log
static resetOriginalId(int $questionId)
resolveInternalLink(string $internal_link)
static setForcePassResultUpdateEnabled(bool $force_pass_results_update_enabled)
ilAssQuestionPage $page
static instantiateQuestion(int $question_id)
Interface for html sanitizing functionality.
saveCurrentSolution(int $active_id, int $pass, $value1, $value2, bool $authorized=true, $tstamp=0)
This file is part of ILIAS, a powerful learning management system published by ILIAS open source e-Le...
purifyAndPrepareTextAreaOutput(string $content)
setTestId(int $id=-1)
static http()
Fetches the global http state from ILIAS.
clonePageOfQuestion(int $a_q_id)
getImagePath($question_id=null, $object_id=null)
Returns the image path for web accessable images of a question.
duplicateComments(int $parent_source_id, int $source_id, int $parent_target_id, int $target_id)
buildHashedImageFilename(string $plain_image_filename, bool $unique=false)
static _lookupTitle(int $obj_id)
lookupForExistingSolutions(int $activeId, int $pass)
Lookup if an authorized or intermediate solution exists.
Transformation $shuffler
copyObject(int $target_parent_id, string $title='')
getAdjustedReachedPoints(int $active_id, int $pass, bool $authorized_solution=true)
static _exists(string $a_parent_type, int $a_id, string $a_lang="", bool $a_no_cache=false)
Checks whether page exists.
answerToParticipantInteraction(AdditionalInformationGenerator $additional_info, int $test_ref_id, int $active_id, int $pass, string $source_ip, TestParticipantInteractionTypes $interaction_type)
removeSolutionRecordById(int $solutionId)
static _getReachedPoints(int $active_id, int $question_id, int $pass)
saveWorkingData(int $active_id, ?int $pass=null, bool $authorized=true)
getCorrectSolutionForTextOutput(int $active_id, int $pass)
static delDir(string $a_dir, bool $a_clean_only=false)
removes a dir and all its content (subdirs and files) recursively
setLastChange(int $lastChange)
const MINIMUM_THUMB_SIZE
getSolutionForTextOutput(int $active_id, int $pass)
static _getIdForImportId(string $a_import_id)
get current object id for import id (static)
static _getSolutionMaxPass(int $question_id, int $active_id)
Returns the maximum pass a users question solution.
deductHintPointsFromReachedPoints(ilAssQuestionPreviewSession $preview_session, $reached_points)
intermediateSolutionExists(int $active_id, int $pass)
static _getCountSystem($active_id)
global $DIC
Definition: shib_login.php:25
static getImagePath(string $image_name, string $module_path="", string $mode="output", bool $offline=false)
get image path (for images located in a template directory)
static getInstanceByType(string $type)
const CLIENT_WEB_DIR
Definition: constants.php:47
static _lookupAuthor($obj_id)
Gets the authors name of the ilObjTest object.
setPoints(float $points)
setObjId(int $obj_id=0)
saveQuestionDataToDb(?int $original_id=null)
static _exists(int $id, bool $reference=false, ?string $type=null)
removeExistingSolutions(int $activeId, int $pass)
setDefaultNrOfTries(int $defaultnroftries)
saveToDb(?int $original_id=null)
static _getMobsOfObject(string $a_type, int $a_id, int $a_usage_hist_nr=0, string $a_lang="-")
fetchIndexedValuesFromValuePairs(array $value_pairs)
static saveOriginalId(int $questionId, int $originalId)
static _removeUsage(int $a_mob_id, string $a_type, int $a_id, int $a_usage_hist_nr=0, string $a_lang="-")
Remove usage of mob in another container.
const DEFAULT_THUMB_SIZE
static _updateQuestionCount(int $object_id)
savePreviewData(ilAssQuestionPreviewSession $preview_session)
ilAssQuestionLifecycle $lifecycle
fetchValuePairsFromIndexedValues(array $indexedValues)
createNewQuestion(bool $a_create_page=true)
Creates a new question without an owner when a new question is created This assures that an ID is giv...
static duplicateListForQuestion($originalQuestionId, $duplicateQuestionId)
duplicates a hint list from given original question id to given duplicate question id and returns an ...
isAdditionalContentEditingModePageObject()
duplicateIntermediateSolutionAuthorized(int $activeId, int $passIndex)
const KEY_VALUES_IMPLOSION_SEPARATOR
toQuestionAdministrationInteraction(AdditionalInformationGenerator $additional_info, int $test_ref_id, TestQuestionAdministrationInteractionTypes $interaction_type)
static $imageSourceFixReplaceMap
static _getScoreCutting(int $active_id)
Determines if the score of a question should be cut at 0 points or the score of the whole test...
getSolutionMaxPass(int $active_id)
isValidAdditionalContentEditingMode(string $additionalContentEditingMode)
static extendedTrim(string $value)
Trim non-printable characters from the beginning and end of a string.
removeCurrentSolution(int $active_id, int $pass, bool $authorized=true)
array $suggested_solutions
lmMigrateQuestionTypeGenericContent(ilAssSelfAssessmentMigrator $migrator)
static lookupParentObjId(int $question_id)
setId(int $id=-1)
This file is part of ILIAS, a powerful learning management system published by ILIAS open source e-Le...
$service
Definition: ltiresult.php:36
ilDBInterface $db
__construct(Container $dic, ilPlugin $plugin)
onCopy(int $sourceParentId, int $sourceQuestionId, int $targetParentId, int $targetQuestionId)
ilObjUser $current_user
const ADDITIONAL_CONTENT_EDITING_MODE_RTE
setOriginalId(?int $original_id)
setExternalId(?string $external_id)
getTestOutputSolutions(int $activeId, int $pass)
setShuffler(Transformation $shuffler)
static convertISO8601FormatH_i_s_ExtendedToSeconds(string $time)
setTitle(string $title="")
deleteSolutionRecordByValues(int $activeId, int $passIndex, bool $authorized, array $matchValues)
A transformation is a function from one datatype to another.
getAnswerTableName()
static signFile(string $path_to_file)
authorizedSolutionExists(int $active_id, ?int $pass)
setLifecycle(ilAssQuestionLifecycle $lifecycle)
comment()
description: > Example for rendring a comment glyph.
Definition: comment.php:25
getCurrentSolutionResultSet(int $active_id, int $pass, bool $authorized=true)
getReachedPoints(int $active_id, int $pass)
isAnswered(int $active_id, int $pass)
addAnswerOptionValue(int $qIndex, string $answerOptionValue, float $points)
forceExistingIntermediateSolution(int $activeId, int $passIndex, bool $considerDummyRecordCreation)
static $force_pass_results_update_enabled
static _updateObjectiveResult(int $a_user_id, int $a_active_id, int $a_question_id)
GeneralQuestionPropertiesRepository $questionrepository
static fixSvgToPng(string $imageFilenameContainingString)
const HAS_SPECIFIC_FEEDBACK
lookupMaxStep(int $active_id, int $pass)
calculateReachedPointsFromPreviewSession(ilAssQuestionPreviewSession $preview_session)
setAuthor(string $author="")
isNonEmptyItemListPostSubmission(string $post_submission_field_name)
static prepareTextareaOutput(string $txt_output, bool $prepare_for_latex_output=false, bool $omitNl2BrWhenTextArea=false)
Prepares a string for a text area output where latex code may be in it If the text is HTML-free...
duplicateSuggestedSolutionFiles(int $parent_id, int $question_id)
Duplicates the files of a suggested solution if the question is duplicated.
string $additionalContentEditingMode
copySuggestedSolutions(int $target_question_id)
getSolutionRecordById(int $solutionId)
setShuffle(?bool $shuffle=true)
setAdditionalContentEditingMode(?string $additionalContentEditingMode)
static explodeKeyValues(string $keyValues)
getInternalLinkHref(string $target)
static getFeedbackClassNameByQuestionType(string $questionType)
setQuestion(string $question="")
string $export_image_path