Question::supportsAdaptiveScenario()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
3
/* For licensing terms, see /license.txt */
4
5
use Chamilo\CoreBundle\Framework\Container;
6
use Chamilo\CoreBundle\Search\Xapian\XapianIndexService;
7
use Chamilo\CourseBundle\Entity\CQuizAnswer;
8
use Chamilo\CourseBundle\Entity\CQuizQuestion;
9
use Chamilo\CourseBundle\Entity\CQuizQuestionOption;
10
11
/**
12
 * Class Question.
13
 *
14
 * This class allows to instantiate an object of type Question
15
 *
16
 * @author Olivier Brouckaert, original author
17
 * @author Patrick Cool, LaTeX support
18
 * @author Julio Montoya <[email protected]> lot of bug fixes
19
 * @author [email protected] - add question categories
20
 */
21
abstract class Question
22
{
23
    public $id;
24
    public $iid;
25
    public $question;
26
    public $description;
27
    public $weighting;
28
    public $position;
29
    public $type;
30
    public $level;
31
    public $picture;
32
    public $exerciseList; // array with the list of exercises which this question is in
33
    public $category_list;
34
    public $parent_id;
35
    public $category;
36
    public $mandatory;
37
    public $isContent;
38
    public $course;
39
    public $feedback;
40
    public $typePicture = 'new_question.png';
41
    public $explanationLangVar = '';
42
    public $questionTableClass = 'table table-striped question-answer-result__detail';
43
    public $questionTypeWithFeedback;
44
    public $extra;
45
    public $export = false;
46
    public $code;
47
    public static $questionTypes = [
48
        // — Single-choice
49
        UNIQUE_ANSWER                => ['unique_answer.class.php', 'UniqueAnswer', 'Unique answer'],
50
        UNIQUE_ANSWER_IMAGE          => ['UniqueAnswerImage.php', 'UniqueAnswerImage', 'Unique answer (image)'],
51
        UNIQUE_ANSWER_NO_OPTION      => ['unique_answer_no_option.class.php', 'UniqueAnswerNoOption', 'Unique answer (no options)'],
52
53
        // — Multiple-choice (all variants together)
54
        MULTIPLE_ANSWER              => ['multiple_answer.class.php', 'MultipleAnswer', 'Multiple answer'],
55
        GLOBAL_MULTIPLE_ANSWER       => ['global_multiple_answer.class.php', 'GlobalMultipleAnswer', 'Global multiple answer'],
56
        MULTIPLE_ANSWER_DROPDOWN     => ['MultipleAnswerDropdown.php', 'MultipleAnswerDropdown', 'Multiple answer (dropdown)'],
57
        MULTIPLE_ANSWER_DROPDOWN_COMBINATION => ['MultipleAnswerDropdownCombination.php', 'MultipleAnswerDropdownCombination', 'Multiple answer (dropdown combination)'],
58
        MULTIPLE_ANSWER_COMBINATION  => ['multiple_answer_combination.class.php', 'MultipleAnswerCombination', 'Multiple answer (combination)'],
59
        MULTIPLE_ANSWER_TRUE_FALSE   => ['multiple_answer_true_false.class.php', 'MultipleAnswerTrueFalse', 'True/False multiple answer'],
60
        MULTIPLE_ANSWER_COMBINATION_TRUE_FALSE => [
61
            'multiple_answer_combination_true_false.class.php', 'MultipleAnswerCombinationTrueFalse', 'True/False combination multiple answer'
62
        ],
63
        MULTIPLE_ANSWER_TRUE_FALSE_DEGREE_CERTAINTY => [
64
            'MultipleAnswerTrueFalseDegreeCertainty.php', 'MultipleAnswerTrueFalseDegreeCertainty', 'True/False with degree of certainty'
65
        ],
66
67
        // — Matching / draggable
68
        MATCHING                     => ['matching.class.php', 'Matching', 'Matching'],
69
        MATCHING_COMBINATION         => ['MatchingCombination.php', 'MatchingCombination', 'Matching (combination)'],
70
        DRAGGABLE                    => ['Draggable.php', 'Draggable', 'Draggable'],
71
        MATCHING_DRAGGABLE           => ['MatchingDraggable.php', 'MatchingDraggable', 'Matching (draggable)'],
72
        MATCHING_DRAGGABLE_COMBINATION => ['MatchingDraggableCombination.php', 'MatchingDraggableCombination', 'Matching (draggable combination)'],
73
74
        // — Fill-in-the-blanks / calculated
75
        FILL_IN_BLANKS               => ['fill_blanks.class.php', 'FillBlanks', 'Fill in the blanks'],
76
        FILL_IN_BLANKS_COMBINATION   => ['FillBlanksCombination.php', 'FillBlanksCombination', 'Fill in the blanks (combination)'],
77
        CALCULATED_ANSWER            => ['calculated_answer.class.php', 'CalculatedAnswer', 'Calculated answer'],
78
79
        // — Open answers / expression
80
        FREE_ANSWER                  => ['freeanswer.class.php', 'FreeAnswer', 'Free answer'],
81
        ORAL_EXPRESSION              => ['oral_expression.class.php', 'OralExpression', 'Oral expression'],
82
83
        // — Hotspot
84
        HOT_SPOT                     => ['hotspot.class.php', 'HotSpot', 'Hotspot'],
85
        HOT_SPOT_COMBINATION         => ['HotSpotCombination.php', 'HotSpotCombination', 'Hotspot (combination)'],
86
        HOT_SPOT_DELINEATION         => ['HotSpotDelineation.php', 'HotSpotDelineation', 'Hotspot delineation'],
87
88
        // — Media / annotation
89
        MEDIA_QUESTION               => ['MediaQuestion.php', 'MediaQuestion', 'Media question'],
90
        ANNOTATION                   => ['Annotation.php', 'Annotation', 'Annotation'],
91
92
        // — Special
93
        READING_COMPREHENSION        => ['ReadingComprehension.php', 'ReadingComprehension', 'Reading comprehension'],
94
        PAGE_BREAK                   => ['PageBreakQuestion.php', 'PageBreakQuestion', 'Page break'],
95
        UPLOAD_ANSWER                => ['UploadAnswer.php', 'UploadAnswer', 'Upload answer'],
96
    ];
97
98
    /**
99
     * Question types that support adaptive scenario.
100
     */
101
    protected static $adaptiveScenarioTypes = [
102
        UNIQUE_ANSWER,
103
        MULTIPLE_ANSWER,
104
        MULTIPLE_ANSWER_COMBINATION,
105
        MULTIPLE_ANSWER_TRUE_FALSE,
106
        MATCHING,
107
        MATCHING_COMBINATION,
108
        DRAGGABLE,
109
        MATCHING_DRAGGABLE,
110
        MATCHING_DRAGGABLE_COMBINATION,
111
        HOT_SPOT_DELINEATION,
112
        FILL_IN_BLANKS,
113
        FILL_IN_BLANKS_COMBINATION,
114
        CALCULATED_ANSWER,
115
        ANNOTATION,
116
        // Do NOT include FREE_ANSWER, ORAL_EXPRESSION, UPLOAD_ANSWER, MEDIA_QUESTION, PAGE_BREAK, etc.
117
    ];
118
119
    /**
120
     * constructor of the class.
121
     *
122
     * @author Olivier Brouckaert
123
     */
124
    public function __construct()
125
    {
126
        $this->id = 0;
127
        $this->iid = 0;
128
        $this->question = '';
129
        $this->description = '';
130
        $this->weighting = 0;
131
        $this->position = 1;
132
        $this->picture = '';
133
        $this->level = 1;
134
        $this->category = 0;
135
        // This variable is used when loading an exercise like an scenario with
136
        // an special hotspot: final_overlap, final_missing, final_excess
137
        $this->extra = '';
138
        $this->exerciseList = [];
139
        $this->course = api_get_course_info();
140
        $this->category_list = [];
141
        $this->parent_id = 0;
142
        $this->mandatory = 0;
143
        // See BT#12611
144
        $this->questionTypeWithFeedback = [
145
            MATCHING,
146
            MATCHING_COMBINATION,
147
            MATCHING_DRAGGABLE,
148
            MATCHING_DRAGGABLE_COMBINATION,
149
            DRAGGABLE,
150
            FILL_IN_BLANKS,
151
            FILL_IN_BLANKS_COMBINATION,
152
            FREE_ANSWER,
153
            UPLOAD_ANSWER,
154
            ORAL_EXPRESSION,
155
            CALCULATED_ANSWER,
156
            ANNOTATION,
157
        ];
158
    }
159
160
    public function getId()
161
    {
162
        return $this->iid;
163
    }
164
165
    /**
166
     * @return int|null
167
     */
168
    public function getIsContent()
169
    {
170
        $isContent = null;
171
        if (isset($_REQUEST['isContent'])) {
172
            $isContent = (int) $_REQUEST['isContent'];
173
        }
174
175
        return $this->isContent = $isContent;
176
    }
177
178
    /**
179
     * Reads question information from the data base.
180
     *
181
     * @param int   $id              - question ID
182
     * @param array $course_info
183
     * @param bool  $getExerciseList
184
     *
185
     * @return Question
186
     *
187
     * @author Olivier Brouckaert
188
     */
189
    public static function read($id, $course_info = [], $getExerciseList = true)
190
    {
191
        $id = (int) $id;
192
        if (empty($course_info)) {
193
            $course_info = api_get_course_info();
194
        }
195
        $course_id = $course_info['real_id'];
196
197
        if (empty($course_id) || -1 == $course_id) {
198
            return false;
199
        }
200
201
        $TBL_QUESTIONS = Database::get_course_table(TABLE_QUIZ_QUESTION);
202
        $TBL_EXERCISE_QUESTION = Database::get_course_table(TABLE_QUIZ_TEST_QUESTION);
203
204
        $sql = "SELECT *
205
                FROM $TBL_QUESTIONS
206
                WHERE iid = $id ";
207
        $result = Database::query($sql);
208
209
        // if the question has been found
210
        if ($object = Database::fetch_object($result)) {
211
            $objQuestion = self::getInstance($object->type);
212
            if (!empty($objQuestion)) {
213
                $objQuestion->id = $id;
214
                $objQuestion->iid = (int) $object->iid;
215
                $objQuestion->question = $object->question;
216
                $objQuestion->description = $object->description;
217
                $objQuestion->weighting = $object->ponderation;
218
                $objQuestion->position = $object->position;
219
                $objQuestion->type = (int) $object->type;
220
                $objQuestion->picture = $object->picture;
221
                $objQuestion->level = (int) $object->level;
222
                $objQuestion->extra = $object->extra;
223
                $objQuestion->course = $course_info;
224
                $objQuestion->feedback = isset($object->feedback) ? $object->feedback : '';
225
                $objQuestion->category = TestCategory::getCategoryForQuestion($id, $course_id);
226
                $objQuestion->code = isset($object->code) ? $object->code : '';
227
                $categoryInfo = TestCategory::getCategoryInfoForQuestion($id, $course_id);
228
229
                if (!empty($categoryInfo)) {
230
                    if (isset($categoryInfo['category_id'])) {
231
                        $objQuestion->category = (int) $categoryInfo['category_id'];
232
                    }
233
234
                    if (('true' === api_get_setting('exercise.allow_mandatory_question_in_category')) &&
235
                        isset($categoryInfo['mandatory'])
236
                    ) {
237
                        $objQuestion->mandatory = (int) $categoryInfo['mandatory'];
238
                    }
239
                }
240
241
                if ($getExerciseList) {
242
                    $tblQuiz   = Database::get_course_table(TABLE_QUIZ_TEST);
243
                    $sessionId = (int) api_get_session_id();
244
                    $sessionJoin = $sessionId > 0 ? "rl.session_id = $sessionId" : "rl.session_id IS NULL";
245
246
                    $sql = "SELECT DISTINCT q.quiz_id
247
            FROM $TBL_EXERCISE_QUESTION q
248
            INNER JOIN $tblQuiz e  ON e.iid = q.quiz_id
249
            INNER JOIN resource_node rn ON rn.id = e.resource_node_id
250
            INNER JOIN resource_link rl ON rl.resource_node_id = rn.id AND $sessionJoin
251
            WHERE q.question_id = $id
252
              AND rl.deleted_at IS NULL";
253
                    $result = Database::query($sql);
254
                    while ($obj = Database::fetch_object($result)) {
255
                        $objQuestion->exerciseList[] = (int) $obj->quiz_id;
256
                    }
257
                }
258
259
                $objQuestion->parent_id = isset($object->parent_media_id)
260
                    ? (int) $object->parent_media_id
261
                    : 0;
262
263
                return $objQuestion;
264
            }
265
        }
266
267
        // question not found
268
        return false;
269
    }
270
271
    /**
272
     * returns the question title.
273
     *
274
     * @author Olivier Brouckaert
275
     *
276
     * @return string - question title
277
     */
278
    public function selectTitle()
279
    {
280
        if ('true' !== api_get_setting('editor.save_titles_as_html')) {
281
            return $this->question;
282
        }
283
284
        return Display::div($this->question, ['style' => 'display: inline-block;']);
285
    }
286
287
    public function getTitleToDisplay(Exercise $exercise, int $itemNumber): string
288
    {
289
        $showQuestionTitleHtml = ('true' === api_get_setting('editor.save_titles_as_html'));
290
        $title = '';
291
        if ('true' === api_get_setting('exercise.show_question_id')) {
292
            $title .= '<h4>#'.$this->course['code'].'-'.$this->iid.'</h4>';
293
        }
294
295
        $title .= $showQuestionTitleHtml ? '' : '<strong>';
296
        if (1 !== $exercise->getHideQuestionNumber()) {
297
            $title .= $itemNumber.'. ';
298
        }
299
        $title .= $this->selectTitle();
300
        $title .= $showQuestionTitleHtml ? '' : '</strong>';
301
302
        return Display::div(
303
            $title,
304
            ['class' => 'question_title']
305
        );
306
    }
307
308
    /**
309
     * returns the question description.
310
     *
311
     * @author Olivier Brouckaert
312
     *
313
     * @return string - question description
314
     */
315
    public function selectDescription()
316
    {
317
        return $this->description;
318
    }
319
320
    /**
321
     * returns the question weighting.
322
     *
323
     * @author Olivier Brouckaert
324
     *
325
     * @return int - question weighting
326
     */
327
    public function selectWeighting()
328
    {
329
        return $this->weighting;
330
    }
331
332
    /**
333
     * returns the answer type.
334
     *
335
     * @author Olivier Brouckaert
336
     *
337
     * @return int - answer type
338
     */
339
    public function selectType()
340
    {
341
        return $this->type;
342
    }
343
344
    /**
345
     * returns the level of the question.
346
     *
347
     * @author Nicolas Raynaud
348
     *
349
     * @return int - level of the question, 0 by default
350
     */
351
    public function getLevel()
352
    {
353
        return $this->level;
354
    }
355
356
    /**
357
     * changes the question title.
358
     *
359
     * @param string $title - question title
360
     *
361
     * @author Olivier Brouckaert
362
     */
363
    public function updateTitle($title)
364
    {
365
        $this->question = $title;
366
    }
367
368
    /**
369
     * changes the question description.
370
     *
371
     * @param string $description - question description
372
     *
373
     * @author Olivier Brouckaert
374
     */
375
    public function updateDescription($description)
376
    {
377
        $this->description = $description;
378
    }
379
380
    /**
381
     * changes the question weighting.
382
     *
383
     * @param int $weighting - question weighting
384
     *
385
     * @author Olivier Brouckaert
386
     */
387
    public function updateWeighting($weighting)
388
    {
389
        $this->weighting = $weighting;
390
    }
391
392
    /**
393
     * @param array $category
394
     *
395
     * @author Hubert Borderiou 12-10-2011
396
     */
397
    public function updateCategory($category)
398
    {
399
        $this->category = $category;
400
    }
401
402
    public function setMandatory($value)
403
    {
404
        $this->mandatory = (int) $value;
405
    }
406
407
    /**
408
     * in this version, a question can only have 1 category
409
     * if category is 0, then question has no category then delete the category entry.
410
     *
411
     * @author Hubert Borderiou 12-10-2011
412
     */
413
    public function saveCategory(int $categoryId): bool
414
    {
415
        if ($categoryId <= 0) {
416
            $this->deleteCategory();
417
        } else {
418
            // update or add category for a question
419
            $table = Database::get_course_table(TABLE_QUIZ_QUESTION_REL_CATEGORY);
420
            $categoryId = (int) $categoryId;
421
            $questionId = (int) $this->id;
422
            $sql = "SELECT count(*) AS nb FROM $table
423
                    WHERE
424
                        question_id = $questionId
425
                    ";
426
            $res = Database::query($sql);
427
            $row = Database::fetch_array($res);
428
            $allowMandatory = ('true' === api_get_setting('exercise.allow_mandatory_question_in_category'));
429
            if ($row['nb'] > 0) {
430
                $extraMandatoryCondition = '';
431
                if ($allowMandatory) {
432
                    $extraMandatoryCondition = ", mandatory = {$this->mandatory}";
433
                }
434
                $sql = "UPDATE $table
435
                        SET category_id = $categoryId
436
                        $extraMandatoryCondition
437
                        WHERE
438
                            question_id = $questionId
439
                        ";
440
                Database::query($sql);
441
            } else {
442
                $sql = "INSERT INTO $table (question_id, category_id)
443
                        VALUES ($questionId, $categoryId)
444
                        ";
445
                Database::query($sql);
446
                if ($allowMandatory) {
447
                    $id = Database::insert_id();
448
                    if ($id) {
449
                        $sql = "UPDATE $table SET mandatory = {$this->mandatory}
450
                                WHERE iid = $id";
451
                        Database::query($sql);
452
                    }
453
                }
454
            }
455
        }
456
457
        return true;
458
    }
459
460
    /**
461
     * @author hubert borderiou 12-10-2011
462
     *
463
     *                      delete any category entry for question id
464
     *                      delete the category for question
465
     */
466
    public function deleteCategory(): bool
467
    {
468
        $table = Database::get_course_table(TABLE_QUIZ_QUESTION_REL_CATEGORY);
469
        $questionId = (int) $this->id;
470
        if (empty($questionId)) {
471
            return false;
472
        }
473
        $sql = "DELETE FROM $table
474
                WHERE
475
                    question_id = $questionId
476
                ";
477
        Database::query($sql);
478
479
        return true;
480
    }
481
482
    /**
483
     * changes the question position.
484
     *
485
     * @param int $position - question position
486
     *
487
     * @author Olivier Brouckaert
488
     */
489
    public function updatePosition($position)
490
    {
491
        $this->position = $position;
492
    }
493
494
    /**
495
     * changes the question level.
496
     *
497
     * @param int $level - question level
498
     *
499
     * @author Nicolas Raynaud
500
     */
501
    public function updateLevel($level)
502
    {
503
        $this->level = $level;
504
    }
505
506
    /**
507
     * changes the answer type. If the user changes the type from "unique answer" to "multiple answers"
508
     * (or conversely) answers are not deleted, otherwise yes.
509
     *
510
     * @param int $type - answer type
511
     *
512
     * @author Olivier Brouckaert
513
     */
514
    public function updateType($type)
515
    {
516
        $table = Database::get_course_table(TABLE_QUIZ_ANSWER);
517
        $course_id = $this->course['real_id'];
518
519
        if (empty($course_id)) {
520
            $course_id = api_get_course_int_id();
521
        }
522
        // if we really change the type
523
        if ($type != $this->type) {
524
            // if we don't change from "unique answer" to "multiple answers" (or conversely)
525
            if (!in_array($this->type, [UNIQUE_ANSWER, MULTIPLE_ANSWER]) ||
526
                !in_array($type, [UNIQUE_ANSWER, MULTIPLE_ANSWER])
527
            ) {
528
                // removes old answers
529
                $sql = "DELETE FROM $table
530
                        WHERE c_id = $course_id AND question_id = ".(int) ($this->id);
531
                Database::query($sql);
532
            }
533
534
            $this->type = $type;
535
        }
536
    }
537
538
    /**
539
     * Set title.
540
     *
541
     * @param string $title
542
     */
543
    public function setTitle($title)
544
    {
545
        $this->question = $title;
546
    }
547
548
    /**
549
     * Sets extra info.
550
     *
551
     * @param string $extra
552
     */
553
    public function setExtra($extra)
554
    {
555
        $this->extra = $extra;
556
    }
557
558
    /**
559
     * updates the question in the data base
560
     * if an exercise ID is provided, we add that exercise ID into the exercise list.
561
     *
562
     * @author Olivier Brouckaert
563
     *
564
     * @param Exercise $exercise
565
     */
566
    public function save($exercise)
567
    {
568
        $TBL_EXERCISE_QUESTION = Database::get_course_table(TABLE_QUIZ_TEST_QUESTION);
569
        $TBL_QUESTIONS = Database::get_course_table(TABLE_QUIZ_QUESTION);
570
        $em = Database::getManager();
571
        $exerciseId = $exercise->iId;
572
573
        $id = $this->id;
574
        $type = $this->type;
575
        $c_id = $this->course['real_id'];
576
577
        $courseEntity = api_get_course_entity($c_id);
578
        $categoryId = $this->category;
579
580
        $questionCategoryRepo = Container::getQuestionCategoryRepository();
581
        $questionRepo = Container::getQuestionRepository();
582
583
        // question already exists
584
        if (!empty($id)) {
585
            /** @var CQuizQuestion $question */
586
            $question = $questionRepo->find($id);
587
            if ($question) {
0 ignored issues
show
introduced by
$question is of type Chamilo\CourseBundle\Entity\CQuizQuestion, thus it always evaluated to true.
Loading history...
588
                $question
589
                    ->setQuestion($this->question)
590
                    ->setDescription($this->description)
591
                    ->setPonderation($this->weighting)
592
                    ->setPosition($this->position)
593
                    ->setType($this->type)
594
                    ->setExtra($this->extra)
595
                    ->setLevel((int) $this->level)
596
                    ->setFeedback($this->feedback)
597
                    ->setParentMediaId($this->parent_id);
598
599
                if (!empty($categoryId)) {
600
                    $category = $questionCategoryRepo->find($categoryId);
601
                    $question->updateCategory($category);
602
                }
603
604
                $em->persist($question);
605
                $em->flush();
606
607
                Event::addEvent(
0 ignored issues
show
Bug introduced by
The method addEvent() does not exist on Event. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

607
                Event::/** @scrutinizer ignore-call */ 
608
                       addEvent(

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
608
                    LOG_QUESTION_UPDATED,
609
                    LOG_QUESTION_ID,
610
                    $this->iid
611
                );
612
            }
613
        } else {
614
            // Creates a new question
615
            $sql = "SELECT max(position)
616
                    FROM $TBL_QUESTIONS as question,
617
                    $TBL_EXERCISE_QUESTION as test_question
618
                    WHERE
619
                        question.iid = test_question.question_id AND
620
                        test_question.quiz_id = ".$exerciseId;
621
            $result = Database::query($sql);
622
            $current_position = Database::result($result, 0, 0);
623
            $this->updatePosition($current_position + 1);
624
            $position = $this->position;
625
            //$exerciseEntity = $exerciseRepo->find($exerciseId);
626
627
            $question = (new CQuizQuestion())
628
                ->setQuestion($this->question)
629
                ->setDescription($this->description)
630
                ->setPonderation($this->weighting)
631
                ->setPosition($position)
632
                ->setType($this->type)
633
                ->setExtra($this->extra)
634
                ->setLevel((int) $this->level)
635
                ->setFeedback($this->feedback)
636
                ->setParentMediaId($this->parent_id)
637
                ->setParent($courseEntity)
638
                ->addCourseLink($courseEntity, api_get_session_entity(), api_get_group_entity());
639
640
            $em->persist($question);
641
            $em->flush();
642
643
            $this->id = $question->getIid();
644
645
            if ($this->id) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->id of type integer|null is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
646
                Event::addEvent(
647
                    LOG_QUESTION_CREATED,
648
                    LOG_QUESTION_ID,
649
                    $this->id
650
                );
651
652
                $questionRepo->addFileFromFileRequest($question, 'imageUpload');
653
654
                // If hotspot, create first answer
655
                if (in_array($type, [HOT_SPOT, HOT_SPOT_COMBINATION, HOT_SPOT_ORDER])) {
656
                    $quizAnswer = new CQuizAnswer();
657
                    $quizAnswer
658
                        ->setQuestion($question)
659
                        ->setPonderation(10)
660
                        ->setPosition(1)
661
                        ->setHotspotCoordinates('0;0|0|0')
662
                        ->setHotspotType('square');
663
664
                    $em->persist($quizAnswer);
665
                    $em->flush();
666
                }
667
668
                if (HOT_SPOT_DELINEATION == $type) {
669
                    $quizAnswer = new CQuizAnswer();
670
                    $quizAnswer
671
                        ->setQuestion($question)
672
                        ->setPonderation(10)
673
                        ->setPosition(1)
674
                        ->setHotspotCoordinates('0;0|0|0')
675
                        ->setHotspotType('delineation');
676
677
                    $em->persist($quizAnswer);
678
                    $em->flush();
679
                }
680
            }
681
        }
682
683
        // if the question is created in an exercise
684
        if (!empty($exerciseId)) {
685
            // adds the exercise into the exercise list of this question
686
            $this->addToList($exerciseId, true);
687
        }
688
    }
689
690
    /**
691
     * @param int  $exerciseId
692
     * @param bool $addQs
693
     * @param bool $rmQs
694
     */
695
    public function search_engine_edit(
696
        $exerciseId,
697
        $addQs = false,
698
        $rmQs = false
699
    ) {
700
        // Chamilo 2 uses Symfony-based indexing. Legacy indexer (course_code) is not compatible.
701
        if (class_exists(XapianIndexService::class)) {
702
            return;
703
        }
704
        // update search engine and its values table if enabled
705
        if (!empty($exerciseId) && 'true' == api_get_setting('search_enabled') &&
706
            extension_loaded('xapian')
707
        ) {
708
            $course_id = api_get_course_id();
709
            // get search_did
710
            $tbl_se_ref = Database::get_main_table(TABLE_MAIN_SEARCH_ENGINE_REF);
711
            if ($addQs || $rmQs) {
712
                //there's only one row per question on normal db and one document per question on search engine db
713
                $sql = 'SELECT * FROM %s
714
                    WHERE course_code=\'%s\' AND tool_id=\'%s\' AND ref_id_second_level=%s LIMIT 1';
715
                $sql = sprintf($sql, $tbl_se_ref, $course_id, TOOL_QUIZ, $this->id);
716
            } else {
717
                $sql = 'SELECT * FROM %s
718
                    WHERE course_code=\'%s\' AND tool_id=\'%s\'
719
                    AND ref_id_high_level=%s AND ref_id_second_level=%s LIMIT 1';
720
                $sql = sprintf($sql, $tbl_se_ref, $course_id, TOOL_QUIZ, $exerciseId, $this->id);
721
            }
722
            $res = Database::query($sql);
723
724
            if (Database::num_rows($res) > 0 || $addQs) {
725
                $di = new ChamiloIndexer();
726
                if ($addQs) {
727
                    $question_exercises = [(int) $exerciseId];
728
                } else {
729
                    $question_exercises = [];
730
                }
731
                isset($_POST['language']) ? $lang = Database::escape_string($_POST['language']) : $lang = 'english';
732
                $di->connectDb(null, null, $lang);
733
734
                // retrieve others exercise ids
735
                $se_ref = Database::fetch_array($res);
736
                $se_doc = $di->get_document((int) $se_ref['search_did']);
737
                if (false !== $se_doc) {
738
                    if (false !== ($se_doc_data = $di->get_document_data($se_doc))) {
739
                        $se_doc_data = UnserializeApi::unserialize(
740
                            'not_allowed_classes',
741
                            $se_doc_data
742
                        );
743
                        if (isset($se_doc_data[SE_DATA]['type']) &&
744
                            SE_DOCTYPE_EXERCISE_QUESTION == $se_doc_data[SE_DATA]['type']
745
                        ) {
746
                            if (isset($se_doc_data[SE_DATA]['exercise_ids']) &&
747
                                is_array($se_doc_data[SE_DATA]['exercise_ids'])
748
                            ) {
749
                                foreach ($se_doc_data[SE_DATA]['exercise_ids'] as $old_value) {
750
                                    if (!in_array($old_value, $question_exercises)) {
751
                                        $question_exercises[] = $old_value;
752
                                    }
753
                                }
754
                            }
755
                        }
756
                    }
757
                }
758
                if ($rmQs) {
759
                    while (false !== ($key = array_search($exerciseId, $question_exercises))) {
760
                        unset($question_exercises[$key]);
761
                    }
762
                }
763
764
                // build the chunk to index
765
                $ic_slide = new IndexableChunk();
766
                $ic_slide->addValue('title', $this->question);
767
                $ic_slide->addCourseId($course_id);
768
                $ic_slide->addToolId(TOOL_QUIZ);
769
                $xapian_data = [
770
                    SE_COURSE_ID => $course_id,
771
                    SE_TOOL_ID => TOOL_QUIZ,
772
                    SE_DATA => [
773
                        'type' => SE_DOCTYPE_EXERCISE_QUESTION,
774
                        'exercise_ids' => $question_exercises,
775
                        'question_id' => (int) $this->id,
776
                    ],
777
                    SE_USER => (int) api_get_user_id(),
778
                ];
779
                $ic_slide->xapian_data = serialize($xapian_data);
780
                $ic_slide->addValue('content', $this->description);
781
782
                //TODO: index answers, see also form validation on question_admin.inc.php
783
784
                $di->remove_document($se_ref['search_did']);
785
                $di->addChunk($ic_slide);
786
787
                //index and return search engine document id
788
                if (!empty($question_exercises)) { // if empty there is nothing to index
789
                    $did = $di->index();
790
                    unset($di);
791
                }
792
                if ($did || $rmQs) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $did does not seem to be defined for all execution paths leading up to this point.
Loading history...
793
                    // save it to db
794
                    if ($addQs || $rmQs) {
795
                        $sql = "DELETE FROM %s
796
                            WHERE course_code = '%s' AND tool_id = '%s' AND ref_id_second_level = '%s'";
797
                        $sql = sprintf($sql, $tbl_se_ref, $course_id, TOOL_QUIZ, $this->id);
798
                    } else {
799
                        $sql = "DELETE FROM %S
800
                            WHERE
801
                                course_code = '%s'
802
                                AND tool_id = '%s'
803
                                AND tool_id = '%s'
804
                                AND ref_id_high_level = '%s'
805
                                AND ref_id_second_level = '%s'";
806
                        $sql = sprintf($sql, $tbl_se_ref, $course_id, TOOL_QUIZ, $exerciseId, $this->id);
807
                    }
808
                    Database::query($sql);
809
                    if ($rmQs) {
810
                        if (!empty($question_exercises)) {
811
                            $sql = "INSERT INTO %s (
812
                                    id, course_code, tool_id, ref_id_high_level, ref_id_second_level, search_did
813
                                )
814
                                VALUES (
815
                                    NULL, '%s', '%s', %s, %s, %s
816
                                )";
817
                            $sql = sprintf(
818
                                $sql,
819
                                $tbl_se_ref,
820
                                $course_id,
821
                                TOOL_QUIZ,
822
                                array_shift($question_exercises),
823
                                $this->id,
824
                                $did
825
                            );
826
                            Database::query($sql);
827
                        }
828
                    } else {
829
                        $sql = "INSERT INTO %s (
830
                                id, course_code, tool_id, ref_id_high_level, ref_id_second_level, search_did
831
                            )
832
                            VALUES (
833
                                NULL , '%s', '%s', %s, %s, %s
834
                            )";
835
                        $sql = sprintf($sql, $tbl_se_ref, $course_id, TOOL_QUIZ, $exerciseId, $this->id, $did);
836
                        Database::query($sql);
837
                    }
838
                }
839
            }
840
        }
841
    }
842
843
    /**
844
     * adds an exercise into the exercise list.
845
     *
846
     * @author Olivier Brouckaert
847
     *
848
     * @param int  $exerciseId - exercise ID
849
     * @param bool $fromSave   - from $this->save() or not
850
     */
851
    public function addToList($exerciseId, $fromSave = false)
852
    {
853
        $exerciseRelQuestionTable = Database::get_course_table(TABLE_QUIZ_TEST_QUESTION);
854
        $id = (int) $this->id;
855
        $exerciseId = (int) $exerciseId;
856
857
        // checks if the exercise ID is not in the list
858
        if (!empty($exerciseId) && !in_array($exerciseId, $this->exerciseList)) {
859
            $this->exerciseList[] = $exerciseId;
860
            $courseId = isset($this->course['real_id']) ? $this->course['real_id'] : 0;
861
            $newExercise = new Exercise($courseId);
862
            $newExercise->read($exerciseId, false);
863
            $count = $newExercise->getQuestionCount();
864
            $count++;
865
            $sql = "INSERT INTO $exerciseRelQuestionTable (question_id, quiz_id, question_order)
866
                    VALUES (".$id.', '.$exerciseId.", '$count')";
867
            Database::query($sql);
868
869
            // we do not want to reindex if we had just saved adnd indexed the question
870
            if (!$fromSave) {
871
                $this->search_engine_edit($exerciseId, true);
872
            }
873
        }
874
    }
875
876
    /**
877
     * removes an exercise from the exercise list.
878
     *
879
     * @author Olivier Brouckaert
880
     *
881
     * @param int $exerciseId - exercise ID
882
     * @param int $courseId
883
     *
884
     * @return bool - true if removed, otherwise false
885
     */
886
    public function removeFromList($exerciseId, $courseId = 0)
887
    {
888
        $table = Database::get_course_table(TABLE_QUIZ_TEST_QUESTION);
889
        $tableQuestion = Database::get_course_table(TABLE_QUIZ_QUESTION);
890
        $id = (int) $this->id;
891
        $exerciseId = (int) $exerciseId;
892
893
        // searches the position of the exercise ID in the list
894
        $pos = array_search($exerciseId, $this->exerciseList);
895
        $courseId = empty($courseId) ? api_get_course_int_id() : (int) $courseId;
896
897
        // exercise not found
898
        if (false === $pos) {
899
            return false;
900
        } else {
901
            // deletes the position in the array containing the wanted exercise ID
902
            unset($this->exerciseList[$pos]);
903
            //update order of other elements
904
            $sql = "SELECT question_order
905
                    FROM $table
906
                    WHERE
907
                        question_id = $id AND
908
                        quiz_id = $exerciseId";
909
            $res = Database::query($sql);
910
            if (Database::num_rows($res) > 0) {
911
                $row = Database::fetch_array($res);
912
                if (!empty($row['question_order'])) {
913
                    $sql = "UPDATE $table
914
                            SET question_order = question_order-1
915
                            WHERE
916
                                quiz_id = $exerciseId AND
917
                                question_order > ".$row['question_order'];
918
                    Database::query($sql);
919
                }
920
            }
921
922
            $sql = "DELETE FROM $table
923
                    WHERE
924
                        question_id = $id AND
925
                        quiz_id = $exerciseId";
926
            Database::query($sql);
927
928
            $reset = "UPDATE $tableQuestion
929
                  SET parent_media_id = NULL
930
                  WHERE parent_media_id = $id";
931
            Database::query($reset);
932
933
            return true;
934
        }
935
    }
936
937
    /**
938
     * Deletes a question from the database
939
     * the parameter tells if the question is removed from all exercises (value = 0),
940
     * or just from one exercise (value = exercise ID).
941
     *
942
     * @author Olivier Brouckaert
943
     *
944
     * @param int $deleteFromEx - exercise ID if the question is only removed from one exercise
945
     *
946
     * @return bool
947
     */
948
    public function delete($deleteFromEx = 0)
949
    {
950
        if (empty($this->course)) {
951
            return false;
952
        }
953
954
        $courseId = $this->course['real_id'];
955
956
        if (empty($courseId)) {
957
            return false;
958
        }
959
960
        $TBL_EXERCISE_QUESTION = Database::get_course_table(TABLE_QUIZ_TEST_QUESTION);
961
        $TBL_QUESTIONS = Database::get_course_table(TABLE_QUIZ_QUESTION);
962
        $TBL_REPONSES = Database::get_course_table(TABLE_QUIZ_ANSWER);
963
        $TBL_QUIZ_QUESTION_REL_CATEGORY = Database::get_course_table(TABLE_QUIZ_QUESTION_REL_CATEGORY);
964
965
        $id = (int) $this->id;
966
967
        // if the question must be removed from all exercises
968
        if (!$deleteFromEx) {
969
            //update the question_order of each question to avoid inconsistencies
970
            $sql = "SELECT quiz_id, question_order
971
                    FROM $TBL_EXERCISE_QUESTION
972
                    WHERE question_id = ".$id;
973
974
            $res = Database::query($sql);
975
            if (Database::num_rows($res) > 0) {
976
                while ($row = Database::fetch_array($res)) {
977
                    if (!empty($row['question_order'])) {
978
                        $sql = "UPDATE $TBL_EXERCISE_QUESTION
979
                                SET question_order = question_order-1
980
                                WHERE
981
                                    quiz_id = ".(int) ($row['quiz_id']).' AND
982
                                    question_order > '.$row['question_order'];
983
                        Database::query($sql);
984
                    }
985
                }
986
            }
987
988
            $reset = "UPDATE $TBL_QUESTIONS
989
                  SET parent_media_id = NULL
990
                  WHERE parent_media_id = $id";
991
            Database::query($reset);
992
993
            $sql = "DELETE FROM $TBL_EXERCISE_QUESTION
994
                    WHERE question_id = ".$id;
995
            Database::query($sql);
996
997
            $sql = "DELETE FROM $TBL_QUESTIONS
998
                    WHERE iid = ".$id;
999
            Database::query($sql);
1000
1001
            $sql = "DELETE FROM $TBL_REPONSES
1002
                    WHERE question_id = ".$id;
1003
            Database::query($sql);
1004
1005
            // remove the category of this question in the question_rel_category table
1006
            $sql = "DELETE FROM $TBL_QUIZ_QUESTION_REL_CATEGORY
1007
                    WHERE
1008
                        question_id = ".$id;
1009
            Database::query($sql);
1010
1011
            // Add extra fields.
1012
            $extraField = new ExtraFieldValue('question');
1013
            $extraField->deleteValuesByItem($this->iid);
1014
1015
            /*api_item_property_update(
1016
                $this->course,
1017
                TOOL_QUIZ,
1018
                $id,
1019
                'QuizQuestionDeleted',
1020
                api_get_user_id()
1021
            );*/
1022
            Event::addEvent(
1023
                LOG_QUESTION_DELETED,
1024
                LOG_QUESTION_ID,
1025
                $this->iid
1026
            );
1027
            //$this->removePicture();
1028
        } else {
1029
            // just removes the exercise from the list
1030
            $this->removeFromList($deleteFromEx, $courseId);
1031
            /*
1032
            api_item_property_update(
1033
                $this->course,
1034
                TOOL_QUIZ,
1035
                $id,
1036
                'QuizQuestionDeleted',
1037
                api_get_user_id()
1038
            );*/
1039
            Event::addEvent(
1040
                LOG_QUESTION_REMOVED_FROM_QUIZ,
1041
                LOG_QUESTION_ID,
1042
                $this->iid
1043
            );
1044
        }
1045
1046
        return true;
1047
    }
1048
1049
    /**
1050
     * Duplicates the question.
1051
     *
1052
     * @author Olivier Brouckaert
1053
     *
1054
     * @param array $courseInfo Course info of the destination course
1055
     *
1056
     * @return false|string ID of the new question
1057
     */
1058
    public function duplicate($courseInfo = [])
1059
    {
1060
        $courseInfo = empty($courseInfo) ? $this->course : $courseInfo;
1061
1062
        if (empty($courseInfo)) {
1063
            return false;
1064
        }
1065
        $TBL_QUESTION_OPTIONS = Database::get_course_table(TABLE_QUIZ_QUESTION_OPTION);
1066
1067
        $questionText = $this->question;
1068
        $description = $this->description;
1069
1070
        // Using the same method used in the course copy to transform URLs
1071
        if ($this->course['id'] != $courseInfo['id']) {
1072
            $description = DocumentManager::replaceUrlWithNewCourseCode(
1073
                $description,
1074
                $this->course['code'],
1075
                $courseInfo['id']
1076
            );
1077
            $questionText = DocumentManager::replaceUrlWithNewCourseCode(
1078
                $questionText,
1079
                $this->course['code'],
1080
                $courseInfo['id']
1081
            );
1082
        }
1083
1084
        $course_id = $courseInfo['real_id'];
1085
1086
        // Read the source options
1087
        $options = self::readQuestionOption($this->id, $this->course['real_id']);
1088
1089
        $em = Database::getManager();
1090
        $courseEntity = api_get_course_entity($course_id);
1091
1092
        $question = (new CQuizQuestion())
1093
            ->setQuestion($questionText)
1094
            ->setDescription($description)
1095
            ->setPonderation($this->weighting)
1096
            ->setPosition($this->position)
1097
            ->setType($this->type)
1098
            ->setExtra($this->extra)
1099
            ->setLevel($this->level)
1100
            ->setFeedback($this->feedback)
1101
            ->setParent($courseEntity)
1102
            ->addCourseLink($courseEntity)
1103
        ;
1104
1105
        $em->persist($question);
1106
        $em->flush();
1107
        $newQuestionId = $question->getIid();
1108
1109
        if ($newQuestionId) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $newQuestionId of type integer|null is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
1110
            // Add extra fields.
1111
            $extraField = new ExtraFieldValue('question');
1112
            $extraField->copy($this->iid, $newQuestionId);
1113
1114
            if (!empty($options)) {
1115
                // Saving the quiz_options
1116
                foreach ($options as $item) {
1117
                    $item['question_id'] = $newQuestionId;
1118
                    $item['c_id'] = $course_id;
1119
                    unset($item['iid']);
1120
                    unset($item['iid']);
1121
                    Database::insert($TBL_QUESTION_OPTIONS, $item);
1122
                }
1123
            }
1124
1125
            // Duplicates the picture of the hotspot
1126
            // @todo implement copy of hotspot question
1127
            if (HOT_SPOT == $this->type) {
1128
                throw new Exception('implement copy of hotspot question');
1129
            }
1130
        }
1131
1132
        return $newQuestionId;
1133
    }
1134
1135
    /**
1136
     * @return string
1137
     */
1138
    public function get_question_type_name(): string
1139
    {
1140
        $labelKey = trim((string) $this->explanationLangVar);
1141
        if ($labelKey !== '') {
1142
            $translated = get_lang($labelKey);
1143
            if ($translated !== $labelKey) {
1144
                return $translated;
1145
            }
1146
        }
1147
1148
        $def = self::$questionTypes[$this->type] ?? null;
1149
        $className = is_array($def) ? ($def[1] ?? '') : '';
1150
        if ($className !== '') {
1151
            $human = preg_replace('/(?<!^)(?=[A-Z])/', ' ', $className) ?: $className;
1152
            $translated = get_lang($human);
1153
1154
            return $translated !== $human ? $translated : $human;
1155
        }
1156
1157
        return '';
1158
    }
1159
1160
    /**
1161
     * @param string $type
1162
     */
1163
    public static function get_question_type($type)
1164
    {
1165
        return self::$questionTypes[$type];
1166
    }
1167
1168
    /**
1169
     * @return array
1170
     */
1171
    public static function getQuestionTypeList(): array
1172
    {
1173
        $list = self::$questionTypes;
1174
1175
        if ('true' !== api_get_setting('enable_quiz_scenario')) {
1176
            unset($list[HOT_SPOT_DELINEATION]);
1177
        }
1178
1179
        ksort($list, SORT_NUMERIC);
1180
1181
        return $list;
1182
    }
1183
1184
    /**
1185
     * Returns an instance of the class corresponding to the type.
1186
     *
1187
     * @param int $type the type of the question
1188
     *
1189
     * @return $this instance of a Question subclass (or of Questionc class by default)
1190
     */
1191
    public static function getInstance($type)
1192
    {
1193
        if (null !== $type) {
1194
            [$fileName, $className] = self::get_question_type($type);
1195
            if (!empty($fileName)) {
1196
                if (class_exists($className)) {
1197
                    return new $className();
1198
                } else {
1199
                    echo 'Can\'t instanciate class '.$className.' of type '.$type;
1200
                }
1201
            }
1202
        }
1203
1204
        return null;
1205
    }
1206
1207
    /**
1208
     * Creates the form to create / edit a question
1209
     * A subclass can redefine this function to add fields...
1210
     *
1211
     * @param FormValidator $form
1212
     * @param Exercise      $exercise
1213
     */
1214
    public function createForm(&$form, $exercise)
1215
    {
1216
        $zoomOptions = api_get_setting('exercise.quiz_image_zoom', true);
1217
        if (isset($zoomOptions['options'])) {
1218
            $finderFolder = api_get_path(WEB_PATH).'vendor/studio-42/elfinder/';
1219
            echo '<!-- elFinder CSS (REQUIRED) -->';
1220
            echo '<link rel="stylesheet" type="text/css" media="screen" href="'.$finderFolder.'css/elfinder.full.css">';
1221
            echo '<link rel="stylesheet" type="text/css" media="screen" href="'.$finderFolder.'css/theme.css">';
1222
1223
            echo '<!-- elFinder JS (REQUIRED) -->';
1224
            echo '<script src="'.$finderFolder.'js/elfinder.full.js"></script>';
1225
1226
            echo '<!-- elFinder translation (OPTIONAL) -->';
1227
            $language = 'en';
1228
            $platformLanguage = api_get_language_isocode();
1229
            $iso = api_get_language_isocode($platformLanguage);
1230
            $filePart = "vendor/studio-42/elfinder/js/i18n/elfinder.$iso.js";
1231
            $file = api_get_path(SYS_PATH).$filePart;
1232
            $includeFile = '';
1233
            if (file_exists($file)) {
1234
                $includeFile = '<script src="'.api_get_path(WEB_PATH).$filePart.'"></script>';
1235
                $language = $iso;
1236
            }
1237
            echo $includeFile;
1238
            echo '<script>
1239
        $(function() {
1240
            $(".create_img_link").click(function(e){
1241
                e.preventDefault();
1242
                e.stopPropagation();
1243
                var imageZoom = $("input[name=\'imageZoom\']").val();
1244
                var imageWidth = $("input[name=\'imageWidth\']").val();
1245
                CKEDITOR.instances.questionDescription.insertHtml(\'<img id="zoom_picture" class="zoom_picture" src="\'+imageZoom+\'" data-zoom-image="\'+imageZoom+\'" width="\'+imageWidth+\'px" />\');
1246
            });
1247
1248
            $("input[name=\'imageZoom\']").on("click", function(){
1249
                var elf = $("#elfinder").elfinder({
1250
                    url : "'.api_get_path(WEB_LIBRARY_PATH).'elfinder/connectorAction.php?'.api_get_cidreq().'",
1251
                    getFileCallback: function(file) {
1252
                        var filePath = file; //file contains the relative url.
1253
                        var imgPath = "<img src = \'"+filePath+"\'/>";
1254
                        $("input[name=\'imageZoom\']").val(filePath.url);
1255
                        $("#elfinder").remove(); //close the window after image is selected
1256
                    },
1257
                    startPathHash: "l2_Lw", // Sets the course driver as default
1258
                    resizable: false,
1259
                    lang: "'.$language.'"
1260
                }).elfinder("instance");
1261
            });
1262
        });
1263
        </script>';
1264
            echo '<div id="elfinder"></div>';
1265
        }
1266
1267
        // Question name
1268
        if ('true' === api_get_setting('editor.save_titles_as_html')) {
1269
            $editorConfig = ['ToolbarSet' => 'TitleAsHtml'];
1270
            $form->addHtmlEditor(
1271
                'questionName',
1272
                get_lang('Question'),
1273
                false,
1274
                false,
1275
                $editorConfig
1276
            );
1277
        } else {
1278
            $form->addText('questionName', get_lang('Question'));
1279
        }
1280
1281
        $form->addRule('questionName', get_lang('Please type the question'), 'required');
1282
1283
        // Default content
1284
        $isContent = isset($_REQUEST['isContent']) ? (int) $_REQUEST['isContent'] : null;
1285
1286
        // Question type (answer type)
1287
        $answerType = isset($_REQUEST['answerType']) ? (int) $_REQUEST['answerType'] : null;
1288
        $form->addHidden('answerType', $answerType);
1289
1290
        // HTML editor for description
1291
        $editorConfig = [
1292
            'ToolbarSet' => 'TestQuestionDescription',
1293
            'Height' => '150',
1294
        ];
1295
1296
        if (!api_is_allowed_to_edit(null, true)) {
1297
            $editorConfig['UserStatus'] = 'student';
1298
        }
1299
1300
        $form->addButtonAdvancedSettings('advanced_params');
1301
        $form->addHtml('<div id="advanced_params_options" style="display:none">');
1302
1303
        if (isset($zoomOptions['options'])) {
1304
            $form->addElement('text', 'imageZoom', get_lang('Image URL'));
1305
            $form->addElement('text', 'imageWidth', get_lang('px width'));
1306
            $form->addButton('btn_create_img', get_lang('Add to editor'), 'plus', 'info', 'small', 'create_img_link');
1307
        }
1308
1309
        $form->addHtmlEditor(
1310
            'questionDescription',
1311
            get_lang('Enrich question'),
1312
            false,
1313
            false,
1314
            $editorConfig
1315
        );
1316
1317
        if (MEDIA_QUESTION != $this->type) {
1318
            // Advanced parameters.
1319
            $form->addSelect(
1320
                'questionLevel',
1321
                get_lang('Difficulty'),
1322
                self::get_default_levels()
1323
            );
1324
1325
            // Categories.
1326
            $form->addSelect(
1327
                'questionCategory',
1328
                get_lang('Category'),
1329
                TestCategory::getCategoriesIdAndName()
1330
            );
1331
1332
            $courseMedias = self::prepare_course_media_select($exercise->iId);
1333
            $form->addSelect(
1334
                'parent_id',
1335
                get_lang('Attach to media'),
1336
                $courseMedias
1337
            );
1338
1339
            if (EX_Q_SELECTION_CATEGORIES_ORDERED_QUESTIONS_RANDOM == $exercise->getQuestionSelectionType() &&
1340
                ('true' === api_get_setting('exercise.allow_mandatory_question_in_category'))
1341
            ) {
1342
                $form->addCheckBox('mandatory', get_lang('Mandatory?'));
1343
            }
1344
1345
            $text = get_lang('Save the question');
1346
            switch ($this->type) {
1347
                case UNIQUE_ANSWER:
1348
                    $buttonGroup = [];
1349
                    $buttonGroup[] = $form->addButtonSave(
1350
                        $text,
1351
                        'submitQuestion',
1352
                        true
1353
                    );
1354
                    $buttonGroup[] = $form->addButton(
1355
                        'convertAnswer',
1356
                        get_lang('Convert to multiple answer'),
1357
                        'dot-circle-o',
1358
                        'default',
1359
                        null,
1360
                        null,
1361
                        null,
1362
                        true
1363
                    );
1364
                    $form->addGroup($buttonGroup);
1365
1366
                    break;
1367
                case MULTIPLE_ANSWER:
1368
                    $buttonGroup = [];
1369
                    $buttonGroup[] = $form->addButtonSave(
1370
                        $text,
1371
                        'submitQuestion',
1372
                        true
1373
                    );
1374
                    $buttonGroup[] = $form->addButton(
1375
                        'convertAnswer',
1376
                        get_lang('Convert to unique answer'),
1377
                        'check-square-o',
1378
                        'default',
1379
                        null,
1380
                        null,
1381
                        null,
1382
                        true
1383
                    );
1384
                    $form->addGroup($buttonGroup);
1385
1386
                    break;
1387
            }
1388
        }
1389
1390
        $form->addElement('html', '</div>');
1391
1392
        // Sample default questions when creating from templates
1393
        if (!isset($_GET['fromExercise'])) {
1394
            switch ($answerType) {
1395
                case 1:
1396
                    $this->question = get_lang('Select the good reasoning');
1397
1398
                    break;
1399
                case 2:
1400
                    $this->question = get_lang('The marasmus is a consequence of');
1401
1402
                    break;
1403
                case 3:
1404
                    $this->question = get_lang('Calculate the Body Mass Index');
1405
1406
                    break;
1407
                case 4:
1408
                    $this->question = get_lang('Order the operations');
1409
1410
                    break;
1411
                case 5:
1412
                    $this->question = get_lang('List what you consider the 10 top qualities of a good project manager?');
1413
1414
                    break;
1415
                case 9:
1416
                    $this->question = get_lang('The marasmus is a consequence of');
1417
1418
                    break;
1419
            }
1420
        }
1421
1422
        // -------------------------------------------------------------------------
1423
        // Adaptive scenario (success/failure) — centralised for supported types
1424
        // -------------------------------------------------------------------------
1425
        $scenarioEnabled    = ('true' === api_get_setting('enable_quiz_scenario'));
1426
        $hasExercise        = ($exercise instanceof Exercise);
1427
        $isAdaptiveFeedback = $hasExercise &&
1428
            EXERCISE_FEEDBACK_TYPE_DIRECT === $exercise->getFeedbackType();
1429
        $supportsScenario   = in_array(
1430
            (int) $this->type,
1431
            static::$adaptiveScenarioTypes,
1432
            true
1433
        );
1434
1435
        if ($scenarioEnabled && $isAdaptiveFeedback && $supportsScenario && $hasExercise) {
1436
            // Build the question list once per exercise to feed the scenario selector
1437
            $exercise->setQuestionList(true);
1438
            $questionList = $exercise->getQuestionList();
1439
1440
            if (is_array($questionList) && !empty($questionList)) {
1441
                $this->addAdaptiveScenarioFields($form, $questionList);
1442
                // Pre-fill selector defaults when editing an existing question in this exercise.
1443
                if (!empty($this->id)) {
1444
                    $this->loadAdaptiveScenarioDefaults($form, $exercise);
1445
                }
1446
            }
1447
        }
1448
1449
        if (null !== $exercise) {
1450
            if ($exercise->questionFeedbackEnabled && $this->showFeedback($exercise)) {
1451
                $form->addTextarea('feedback', get_lang('Feedback if not correct'));
1452
            }
1453
        }
1454
1455
        $extraField = new ExtraField('question');
1456
        $extraField->addElements($form, $this->iid);
1457
1458
        // Default values
1459
        $defaults = [
1460
            'questionName'        => $this->question,
1461
            'questionDescription' => $this->description,
1462
            'questionLevel'       => $this->level,
1463
            'questionCategory'    => $this->category,
1464
            'feedback'            => $this->feedback,
1465
            'mandatory'           => $this->mandatory,
1466
            'parent_id'           => $this->parent_id,
1467
        ];
1468
1469
        // Came from the question pool
1470
        if (isset($_GET['fromExercise'])) {
1471
            $form->setDefaults($defaults);
1472
        }
1473
1474
        if (!isset($_GET['newQuestion']) || $isContent) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $isContent of type integer|null is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
1475
            $form->setDefaults($defaults);
1476
        }
1477
1478
        /*if (!empty($_REQUEST['myid'])) {
1479
            $form->setDefaults($defaults);
1480
        } else {
1481
            if ($isContent == 1) {
1482
                $form->setDefaults($defaults);
1483
            }
1484
        }*/
1485
    }
1486
1487
    /**
1488
     * Function which process the creation of questions.
1489
     */
1490
    public function processCreation(FormValidator $form, Exercise $exercise)
1491
    {
1492
        $this->parent_id = (int) $form->getSubmitValue('parent_id');
1493
        $this->updateTitle($form->getSubmitValue('questionName'));
1494
        $this->updateDescription($form->getSubmitValue('questionDescription'));
1495
        $this->updateLevel($form->getSubmitValue('questionLevel'));
1496
        $this->updateCategory($form->getSubmitValue('questionCategory'));
1497
        $this->setMandatory($form->getSubmitValue('mandatory'));
1498
        $this->setFeedback($form->getSubmitValue('feedback'));
1499
1500
        //Save normal question if NOT media
1501
        if (MEDIA_QUESTION != $this->type) {
1502
            $this->save($exercise);
1503
            // modify the exercise
1504
            $exercise->addToList($this->id);
1505
            $exercise->update_question_positions();
1506
1507
            $params = $form->exportValues();
1508
            $params['item_id'] = $this->id;
1509
1510
            $extraFieldValues = new ExtraFieldValue('question');
1511
            $extraFieldValues->saveFieldValues($params);
1512
        }
1513
    }
1514
1515
    /**
1516
     * Creates the form to create / edit the answers of the question.
1517
     */
1518
    abstract public function createAnswersForm(FormValidator $form);
1519
1520
    /**
1521
     * Process the creation of answers.
1522
     *
1523
     * @param FormValidator $form
1524
     * @param Exercise      $exercise
1525
     */
1526
    abstract public function processAnswersCreation($form, $exercise);
1527
1528
    /**
1529
     * Displays the menu of question types.
1530
     *
1531
     * @param Exercise $objExercise
1532
     */
1533
    public static function displayTypeMenu(Exercise $objExercise)
1534
    {
1535
        if (empty($objExercise)) {
1536
            return '';
1537
        }
1538
1539
        $feedbackType = $objExercise->getFeedbackType();
1540
        $exerciseId   = $objExercise->id;
1541
1542
        $questionTypeList = self::getQuestionTypeList();
1543
1544
        if (!isset($feedbackType)) {
1545
            $feedbackType = 0;
1546
        }
1547
1548
        switch ($feedbackType) {
1549
            case EXERCISE_FEEDBACK_TYPE_DIRECT:
1550
                // Keep original behavior: base types for adaptative tests.
1551
                $questionTypeList = [
1552
                    UNIQUE_ANSWER        => self::$questionTypes[UNIQUE_ANSWER],
1553
                    HOT_SPOT_DELINEATION => self::$questionTypes[HOT_SPOT_DELINEATION],
1554
                ];
1555
1556
                // Add all other non-open question types.
1557
                $allTypes = self::getQuestionTypeList();
1558
1559
                // Exclude the classic open question types from the filter list
1560
                // as the system cannot provide immediate feedback on these.
1561
                if (isset($allTypes[FREE_ANSWER])) {
1562
                    unset($allTypes[FREE_ANSWER]);
1563
                }
1564
                if (isset($allTypes[ORAL_EXPRESSION])) {
1565
                    unset($allTypes[ORAL_EXPRESSION]);
1566
                }
1567
                if (isset($allTypes[ANNOTATION])) {
1568
                    unset($allTypes[ANNOTATION]);
1569
                }
1570
                if (isset($allTypes[MULTIPLE_ANSWER_TRUE_FALSE_DEGREE_CERTAINTY])) {
1571
                    unset($allTypes[MULTIPLE_ANSWER_TRUE_FALSE_DEGREE_CERTAINTY]);
1572
                }
1573
                if (isset($allTypes[UPLOAD_ANSWER])) {
1574
                    unset($allTypes[UPLOAD_ANSWER]);
1575
                }
1576
                if (isset($allTypes[ANSWER_IN_OFFICE_DOC])) {
1577
                    unset($allTypes[ANSWER_IN_OFFICE_DOC]);
1578
                }
1579
                if (isset($allTypes[PAGE_BREAK])) {
1580
                    unset($allTypes[PAGE_BREAK]);
1581
                }
1582
1583
                // Append remaining types, without overriding the original ones.
1584
                foreach ($allTypes as $typeId => $def) {
1585
                    if (!isset($questionTypeList[$typeId])) {
1586
                        $questionTypeList[$typeId] = $def;
1587
                    }
1588
                }
1589
1590
                break;
1591
            case EXERCISE_FEEDBACK_TYPE_POPUP:
1592
                $questionTypeList = [
1593
                    UNIQUE_ANSWER        => self::$questionTypes[UNIQUE_ANSWER],
1594
                    MULTIPLE_ANSWER      => self::$questionTypes[MULTIPLE_ANSWER],
1595
                    DRAGGABLE            => self::$questionTypes[DRAGGABLE],
1596
                    HOT_SPOT_DELINEATION => self::$questionTypes[HOT_SPOT_DELINEATION],
1597
                    CALCULATED_ANSWER    => self::$questionTypes[CALCULATED_ANSWER],
1598
                ];
1599
1600
                break;
1601
            default:
1602
                unset($questionTypeList[HOT_SPOT_DELINEATION]);
1603
1604
                break;
1605
        }
1606
1607
        echo '<div class="card">';
1608
        echo '  <div class="card-body">';
1609
        echo '    <ul class="qtype-menu flex flex-wrap gap-x-2 gap-y-2 items-center justify-start w-full">';
1610
        foreach ($questionTypeList as $i => $type) {
1611
            /** @var Question $type */
1612
            $type = new $type[1]();
1613
            $img  = $type->getTypePicture();
1614
            $expl = $type->getExplanation();
1615
1616
            echo '      <li class="flex items-center justify-center">';
1617
1618
            $icon = Display::url(
1619
                Display::return_icon($img, $expl, null, ICON_SIZE_BIG),
1620
                'admin.php?' . api_get_cidreq() . '&' . http_build_query([
1621
                    'newQuestion' => 'yes',
1622
                    'answerType'  => $i,
1623
                    'exerciseId'  => $exerciseId,
1624
                ]),
1625
                ['title' => $expl, 'class' => 'block']
1626
            );
1627
1628
            if (false === $objExercise->force_edit_exercise_in_lp && $objExercise->exercise_was_added_in_lp) {
1629
                $img  = pathinfo($img);
1630
                $img  = $img['filename'].'_na.'.$img['extension'];
1631
                $icon = Display::return_icon($img, $expl, null, ICON_SIZE_BIG);
1632
            }
1633
            echo $icon;
1634
            echo '      </li>';
1635
        }
1636
1637
        echo '      <li class="flex items-center justify-center">';
1638
        if ($objExercise->exercise_was_added_in_lp) {
1639
            echo Display::getMdiIcon('database', 'ch-tool-icon-disabled', null, ICON_SIZE_BIG, get_lang('Recycle existing questions'));
1640
        } else {
1641
            $href = in_array($feedbackType, [EXERCISE_FEEDBACK_TYPE_DIRECT, EXERCISE_FEEDBACK_TYPE_POPUP])
1642
                ? 'question_pool.php?' . api_get_cidreq() . "&type=1&fromExercise={$exerciseId}"
1643
                : 'question_pool.php?' . api_get_cidreq() . "&fromExercise={$exerciseId}";
1644
1645
            echo Display::url(
1646
                Display::getMdiIcon('database', 'ch-tool-icon', null, ICON_SIZE_BIG, get_lang('Recycle existing questions')),
1647
                $href,
1648
                ['class' => 'block', 'title' => get_lang('Recycle existing questions')]
1649
            );
1650
        }
1651
        echo '      </li>';
1652
1653
        echo '    </ul>';
1654
        echo '  </div>';
1655
        echo '</div>';
1656
    }
1657
1658
    /**
1659
     * @param string $name
1660
     * @param int    $position
1661
     *
1662
     * @return CQuizQuestion|null
1663
     */
1664
    public static function saveQuestionOption(CQuizQuestion $question, $name, $position = 0)
1665
    {
1666
        $option = new CQuizQuestionOption();
1667
        $option
1668
            ->setQuestion($question)
1669
            ->setTitle($name)
1670
            ->setPosition($position)
1671
        ;
1672
        $em = Database::getManager();
1673
        $em->persist($option);
1674
        $em->flush();
1675
    }
1676
1677
    /**
1678
     * @param int $question_id
1679
     * @param int $course_id
1680
     */
1681
    public static function deleteAllQuestionOptions($question_id, $course_id)
1682
    {
1683
        $table = Database::get_course_table(TABLE_QUIZ_QUESTION_OPTION);
1684
        Database::delete(
1685
            $table,
1686
            [
1687
                'c_id = ? AND question_id = ?' => [
1688
                    $course_id,
1689
                    $question_id,
1690
                ],
1691
            ]
1692
        );
1693
    }
1694
1695
    /**
1696
     * @param int $question_id
1697
     *
1698
     * @return array
1699
     */
1700
    public static function readQuestionOption($question_id)
1701
    {
1702
        $table = Database::get_course_table(TABLE_QUIZ_QUESTION_OPTION);
1703
1704
        return Database::select(
1705
            '*',
1706
            $table,
1707
            [
1708
                'where' => [
1709
                    'question_id = ?' => [
1710
                        $question_id,
1711
                    ],
1712
                ],
1713
                'order' => 'iid ASC',
1714
            ]
1715
        );
1716
    }
1717
1718
    /**
1719
     * Shows question title an description.
1720
     *
1721
     * @param int   $counter
1722
     * @param array $score
1723
     *
1724
     * @return string HTML string with the header of the question (before the answers table)
1725
     */
1726
    public function return_header(Exercise $exercise, $counter = null, $score = [])
1727
    {
1728
        $counterLabel = '';
1729
        if (!empty($counter)) {
1730
            $counterLabel = (int) $counter;
1731
        }
1732
1733
        $scoreLabel = get_lang('Wrong');
1734
        if (in_array($exercise->results_disabled, [
1735
            RESULT_DISABLE_SHOW_ONLY_IN_CORRECT_ANSWER,
1736
            RESULT_DISABLE_SHOW_SCORE_AND_EXPECTED_ANSWERS_AND_RANKING,
1737
        ])
1738
        ) {
1739
            $scoreLabel = get_lang('Wrong answer. The correct one was:');
1740
        }
1741
1742
        $class = 'error';
1743
        if (isset($score['pass']) && true == $score['pass']) {
1744
            $scoreLabel = get_lang('Correct');
1745
1746
            if (in_array($exercise->results_disabled, [
1747
                RESULT_DISABLE_SHOW_ONLY_IN_CORRECT_ANSWER,
1748
                RESULT_DISABLE_SHOW_SCORE_AND_EXPECTED_ANSWERS_AND_RANKING,
1749
            ])
1750
            ) {
1751
                $scoreLabel = get_lang('Correct answer');
1752
            }
1753
            $class = 'success';
1754
        }
1755
1756
        switch ($this->type) {
1757
            case FREE_ANSWER:
1758
            case UPLOAD_ANSWER:
1759
            case ORAL_EXPRESSION:
1760
            case ANNOTATION:
1761
                $score['revised'] = isset($score['revised']) ? $score['revised'] : false;
1762
                if (true == $score['revised']) {
1763
                    $scoreLabel = get_lang('Reviewed');
1764
                    $class = '';
1765
                } else {
1766
                    $scoreLabel = get_lang('Not reviewed');
1767
                    $class = 'warning';
1768
                    if (isset($score['weight'])) {
1769
                        $weight = float_format($score['weight'], 1);
1770
                        $score['result'] = ' ? / '.$weight;
1771
                    }
1772
                    $model = ExerciseLib::getCourseScoreModel();
1773
                    if (!empty($model)) {
1774
                        $score['result'] = ' ? ';
1775
                    }
1776
1777
                    $hide = ('true' === api_get_setting('exercise.hide_free_question_score'));
1778
                    if (true === $hide) {
1779
                        $score['result'] = '-';
1780
                    }
1781
                }
1782
1783
                break;
1784
            case UNIQUE_ANSWER:
1785
                if (in_array($exercise->results_disabled, [
1786
                    RESULT_DISABLE_SHOW_ONLY_IN_CORRECT_ANSWER,
1787
                    RESULT_DISABLE_SHOW_SCORE_AND_EXPECTED_ANSWERS_AND_RANKING,
1788
                ])
1789
                ) {
1790
                    if (isset($score['user_answered'])) {
1791
                        if (false === $score['user_answered']) {
1792
                            $scoreLabel = get_lang('Unanswered');
1793
                            $class = 'info';
1794
                        }
1795
                    }
1796
                }
1797
1798
                break;
1799
        }
1800
1801
        // display question category, if any
1802
        $header = '';
1803
        if ($exercise->display_category_name) {
1804
            $header = TestCategory::returnCategoryAndTitle($this->id);
1805
        }
1806
        $show_media = '';
1807
        if ($show_media) {
1808
            $header .= $this->show_media_content();
1809
        }
1810
1811
        $scoreCurrent = [
1812
            'used' => isset($score['score']) ? $score['score'] : '',
1813
            'missing' => isset($score['weight']) ? $score['weight'] : '',
1814
        ];
1815
        $header .= Display::page_subheader2($counterLabel.'. '.$this->question);
1816
1817
        $showRibbon = true;
1818
        // dont display score for certainty degree questions
1819
        if (MULTIPLE_ANSWER_TRUE_FALSE_DEGREE_CERTAINTY == $this->type) {
1820
            $showRibbon = false;
1821
            $ribbonResult = ('true' === api_get_setting('exercise.show_exercise_question_certainty_ribbon_result'));
1822
            if (true === $ribbonResult) {
1823
                $showRibbon = true;
1824
            }
1825
        }
1826
1827
        if ($showRibbon && isset($score['result'])) {
1828
            if (in_array($exercise->results_disabled, [
1829
                RESULT_DISABLE_SHOW_ONLY_IN_CORRECT_ANSWER,
1830
                RESULT_DISABLE_SHOW_SCORE_AND_EXPECTED_ANSWERS_AND_RANKING,
1831
            ])
1832
            ) {
1833
                $score['result'] = null;
1834
            }
1835
            $header .= $exercise->getQuestionRibbon($class, $scoreLabel, $score['result'], $scoreCurrent);
1836
        }
1837
1838
        if (READING_COMPREHENSION != $this->type) {
1839
            // Do not show the description (the text to read) if the question is of type READING_COMPREHENSION
1840
            $header .= Display::div(
1841
                $this->description,
1842
                ['class' => 'question-answer-result__header-description']
1843
            );
1844
        } else {
1845
            /** @var ReadingComprehension $this */
1846
            if (true === $score['pass']) {
1847
                $message = Display::div(
1848
                    sprintf(
1849
                        get_lang(
1850
                            'Congratulations, you have reached and correctly understood, at a speed of %s words per minute, a text of a total %s words.'
1851
                        ),
1852
                        ReadingComprehension::$speeds[$this->level],
1853
                        $this->getWordsCount()
1854
                    )
1855
                );
1856
            } else {
1857
                $message = Display::div(
1858
                    sprintf(
1859
                        get_lang(
1860
                            'Sorry, it seems like a speed of %s words/minute was too fast for this text of %s words.'
1861
                        ),
1862
                        ReadingComprehension::$speeds[$this->level],
1863
                        $this->getWordsCount()
1864
                    )
1865
                );
1866
            }
1867
            $header .= $message.'<br />';
1868
        }
1869
1870
        if ($exercise->hideComment && in_array($this->type, [HOT_SPOT, HOT_SPOT_COMBINATION])) {
1871
            $header .= Display::return_message(get_lang('Results only available online'));
1872
1873
            return $header;
1874
        }
1875
1876
        if (isset($score['pass']) && false === $score['pass']) {
1877
            if ($this->showFeedback($exercise)) {
1878
                $header .= $this->returnFormatFeedback();
1879
            }
1880
        }
1881
1882
        return Display::div(
1883
            $header,
1884
            ['class' => 'question-answer-result__header']
1885
        );
1886
    }
1887
1888
    /**
1889
     * @deprecated
1890
     * Create a question from a set of parameters
1891
     *
1892
     * @param int    $question_name        Quiz ID
1893
     * @param string $question_description Question name
1894
     * @param int    $max_score            Maximum result for the question
1895
     * @param int    $type                 Type of question (see constants at beginning of question.class.php)
1896
     * @param int    $level                Question level/category
1897
     * @param string $quiz_id
1898
     */
1899
    public function create_question(
1900
        $quiz_id,
1901
        $question_name,
1902
        $question_description = '',
1903
        $max_score = 0,
1904
        $type = 1,
1905
        $level = 1
1906
    ) {
1907
        $tbl_quiz_question = Database::get_course_table(TABLE_QUIZ_QUESTION);
1908
        $tbl_quiz_rel_question = Database::get_course_table(TABLE_QUIZ_TEST_QUESTION);
1909
1910
        $quiz_id = (int) $quiz_id;
1911
        $max_score = (float) $max_score;
1912
        $type = (int) $type;
1913
        $level = (int) $level;
1914
1915
        // Get the max position
1916
        $sql = "SELECT max(position) as max_position
1917
                FROM $tbl_quiz_question q
1918
                INNER JOIN $tbl_quiz_rel_question r
1919
                ON
1920
                    q.iid = r.question_id AND
1921
                    quiz_id = $quiz_id";
1922
        $rs_max = Database::query($sql);
1923
        $row_max = Database::fetch_object($rs_max);
1924
        $max_position = $row_max->max_position + 1;
1925
1926
        $params = [
1927
            'question' => $question_name,
1928
            'description' => $question_description,
1929
            'ponderation' => $max_score,
1930
            'position' => $max_position,
1931
            'type' => $type,
1932
            'level' => $level,
1933
            'mandatory' => 0,
1934
        ];
1935
        $question_id = Database::insert($tbl_quiz_question, $params);
1936
1937
        if ($question_id) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $question_id of type false|integer is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== false instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
1938
            // Get the max question_order
1939
            $sql = "SELECT max(question_order) as max_order
1940
                    FROM $tbl_quiz_rel_question
1941
                    WHERE quiz_id = $quiz_id ";
1942
            $rs_max_order = Database::query($sql);
1943
            $row_max_order = Database::fetch_object($rs_max_order);
1944
            $max_order = $row_max_order->max_order + 1;
1945
            // Attach questions to quiz
1946
            $sql = "INSERT INTO $tbl_quiz_rel_question (question_id, quiz_id, question_order)
1947
                    VALUES($question_id, $quiz_id, $max_order)";
1948
            Database::query($sql);
1949
        }
1950
1951
        return $question_id;
1952
    }
1953
1954
    /**
1955
     * @return string
1956
     */
1957
    public function getTypePicture()
1958
    {
1959
        return $this->typePicture;
1960
    }
1961
1962
    /**
1963
     * @return string
1964
     */
1965
    public function getExplanation()
1966
    {
1967
        return get_lang($this->explanationLangVar);
1968
    }
1969
1970
    /**
1971
     * Get course medias.
1972
     *
1973
     * @param int $course_id
1974
     *
1975
     * @return array
1976
     */
1977
    public static function get_course_medias(
1978
        $course_id,
1979
        $start = 0,
1980
        $limit = 100,
1981
        $sidx = 'question',
1982
        $sord = 'ASC',
1983
        $where_condition = []
1984
    ) {
1985
        $table_question = Database::get_course_table(TABLE_QUIZ_QUESTION);
1986
        $default_where = [
1987
            'c_id = ? AND parent_id = 0 AND type = ?' => [
1988
                $course_id,
1989
                MEDIA_QUESTION,
1990
            ],
1991
        ];
1992
1993
        return Database::select(
1994
            '*',
1995
            $table_question,
1996
            [
1997
                'limit' => " $start, $limit",
1998
                'where' => $default_where,
1999
                'order' => "$sidx $sord",
2000
            ]
2001
        );
2002
    }
2003
2004
    /**
2005
     * Get count course medias.
2006
     *
2007
     * @param int $course_id course id
2008
     *
2009
     * @return int
2010
     */
2011
    public static function get_count_course_medias($course_id)
2012
    {
2013
        $table_question = Database::get_course_table(TABLE_QUIZ_QUESTION);
2014
        $result = Database::select(
2015
            'count(*) as count',
2016
            $table_question,
2017
            [
2018
                'where' => [
2019
                    'c_id = ? AND parent_id = 0 AND type = ?' => [
2020
                        $course_id,
2021
                        MEDIA_QUESTION,
2022
                    ],
2023
                ],
2024
            ],
2025
            'first'
2026
        );
2027
2028
        if ($result && isset($result['count'])) {
2029
            return $result['count'];
2030
        }
2031
2032
        return 0;
2033
    }
2034
2035
    /**
2036
     * @param int $course_id
2037
     *
2038
     * @return array
2039
     */
2040
    public static function prepare_course_media_select(int $quizId): array
2041
    {
2042
        $tableQuestion     = Database::get_course_table(TABLE_QUIZ_QUESTION);
2043
        $tableRelQuestion  = Database::get_course_table(TABLE_QUIZ_TEST_QUESTION);
2044
2045
        $medias = Database::select(
2046
            '*',
2047
            "$tableQuestion q
2048
         JOIN $tableRelQuestion rq ON rq.question_id = q.iid",
2049
            [
2050
                'where' => [
2051
                    'rq.quiz_id = ? AND (q.parent_media_id IS NULL OR q.parent_media_id = 0) AND q.type = ?'
2052
                    => [$quizId, MEDIA_QUESTION],
2053
                ],
2054
                'order' => 'question ASC',
2055
            ]
2056
        );
2057
2058
        $mediaList = [
2059
            0 => get_lang('Not linked to media'),
2060
        ];
2061
2062
        foreach ($medias as $media) {
2063
            $mediaList[$media['question_id']] = empty($media['question'])
2064
                ? get_lang('Untitled')
2065
                : $media['question'];
2066
        }
2067
2068
        return $mediaList;
2069
    }
2070
2071
    /**
2072
     * @return array
2073
     */
2074
    public static function get_default_levels()
2075
    {
2076
        return [
2077
            1 => 1,
2078
            2 => 2,
2079
            3 => 3,
2080
            4 => 4,
2081
            5 => 5,
2082
        ];
2083
    }
2084
2085
    /**
2086
     * @return string
2087
     */
2088
    public function show_media_content()
2089
    {
2090
        $html = '';
2091
        if (0 != $this->parent_id) {
2092
            $parent_question = self::read($this->parent_id);
2093
            $html = $parent_question->show_media_content();
2094
        } else {
2095
            $html .= Display::page_subheader($this->selectTitle());
2096
            $html .= $this->selectDescription();
2097
        }
2098
2099
        return $html;
2100
    }
2101
2102
    /**
2103
     * Swap between unique and multiple type answers.
2104
     *
2105
     * @return UniqueAnswer|MultipleAnswer
2106
     */
2107
    public function swapSimpleAnswerTypes()
2108
    {
2109
        $oppositeAnswers = [
2110
            UNIQUE_ANSWER => MULTIPLE_ANSWER,
2111
            MULTIPLE_ANSWER => UNIQUE_ANSWER,
2112
        ];
2113
        $this->type = $oppositeAnswers[$this->type];
2114
        Database::update(
2115
            Database::get_course_table(TABLE_QUIZ_QUESTION),
2116
            ['type' => $this->type],
2117
            ['c_id = ? AND id = ?' => [$this->course['real_id'], $this->id]]
2118
        );
2119
        $answerClasses = [
2120
            UNIQUE_ANSWER => 'UniqueAnswer',
2121
            MULTIPLE_ANSWER => 'MultipleAnswer',
2122
            MULTIPLE_ANSWER_DROPDOWN => 'MultipleAnswerDropdown',
2123
            MULTIPLE_ANSWER_DROPDOWN_COMBINATION => 'MultipleAnswerDropdownCombination',
2124
        ];
2125
        $swappedAnswer = new $answerClasses[$this->type]();
2126
        foreach ($this as $key => $value) {
2127
            $swappedAnswer->$key = $value;
2128
        }
2129
2130
        return $swappedAnswer;
2131
    }
2132
2133
    /**
2134
     * @param array $score
2135
     *
2136
     * @return bool
2137
     */
2138
    public function isQuestionWaitingReview($score)
2139
    {
2140
        $isReview = false;
2141
        if (!empty($score)) {
2142
            if (!empty($score['comments']) || $score['score'] > 0) {
2143
                $isReview = true;
2144
            }
2145
        }
2146
2147
        return $isReview;
2148
    }
2149
2150
    /**
2151
     * @param string $value
2152
     */
2153
    public function setFeedback($value)
2154
    {
2155
        $this->feedback = $value;
2156
    }
2157
2158
    /**
2159
     * @param Exercise $exercise
2160
     *
2161
     * @return bool
2162
     */
2163
    public function showFeedback($exercise)
2164
    {
2165
        if (false === $exercise->hideComment) {
2166
            return false;
2167
        }
2168
2169
        return
2170
            in_array($this->type, $this->questionTypeWithFeedback) &&
2171
            EXERCISE_FEEDBACK_TYPE_EXAM != $exercise->getFeedbackType();
2172
    }
2173
2174
    /**
2175
     * @return string
2176
     */
2177
    public function returnFormatFeedback()
2178
    {
2179
        return '<br />'.Display::return_message($this->feedback, 'normal', false);
2180
    }
2181
2182
    /**
2183
     * Check if this question exists in another exercise.
2184
     *
2185
     * @throws \Doctrine\ORM\Query\QueryException
2186
     *
2187
     * @return bool
2188
     */
2189
    public function existsInAnotherExercise()
2190
    {
2191
        $count = $this->getCountExercise();
2192
2193
        return $count > 1;
2194
    }
2195
2196
    /**
2197
     * @throws \Doctrine\ORM\Query\QueryException
2198
     *
2199
     * @return int
2200
     */
2201
    public function getCountExercise()
2202
    {
2203
        $em = Database::getManager();
2204
2205
        $count = $em
2206
            ->createQuery('
2207
            SELECT COUNT(qq.iid)
2208
            FROM ChamiloCourseBundle:CQuizRelQuestion qq
2209
            WHERE IDENTITY(qq.question) = :id
2210
        ')
2211
            ->setParameters(['id' => (int) $this->id])
2212
            ->getSingleScalarResult();
2213
2214
        return (int) $count;
2215
    }
2216
2217
    /**
2218
     * Check if this question exists in another exercise.
2219
     *
2220
     * @throws \Doctrine\ORM\Query\QueryException
2221
     */
2222
    public function getExerciseListWhereQuestionExists(): array
2223
    {
2224
        $em = Database::getManager();
2225
        $questionId = (int) $this->id;
2226
2227
        // Doctrine does not allow selecting only a JOIN alias unless it is a root alias.
2228
        // So we select CQuiz as root and join the relation entity with a WITH clause.
2229
        $dql = '
2230
        SELECT DISTINCT q
2231
        FROM ChamiloCourseBundle:CQuiz q
2232
        JOIN ChamiloCourseBundle:CQuizRelQuestion qq WITH qq.quiz = q
2233
        WHERE IDENTITY(qq.question) = :id
2234
    ';
2235
2236
        try {
2237
            return $em->createQuery($dql)
2238
                ->setParameter('id', $questionId)
2239
                ->getResult();
2240
        } catch (\Throwable $e) {
2241
            // Fallback (best effort): use DBAL on the relation table and then load quizzes.
2242
            // We keep this non-fatal to avoid breaking the admin listing.
2243
        }
2244
2245
        try {
2246
            $conn = Database::getConnection();
2247
2248
            // DBAL 2/3 schema manager compatibility
2249
            $sm = method_exists($conn, 'createSchemaManager')
2250
                ? $conn->createSchemaManager()
2251
                : $conn->getSchemaManager();
2252
2253
            $tableNames = method_exists($sm, 'listTableNames') ? $sm->listTableNames() : [];
2254
2255
            $relCandidates = [
2256
                'c_quiz_rel_question',
2257
                'quiz_rel_question',
2258
                'c_quiz_question_rel_exercise',
2259
                'quiz_question_rel_exercise',
2260
            ];
2261
2262
            $relTable = null;
2263
            foreach ($relCandidates as $t) {
2264
                if (\in_array($t, $tableNames, true)) {
2265
                    $relTable = $t;
2266
                    break;
2267
                }
2268
            }
2269
2270
            if (empty($relTable)) {
2271
                return [];
2272
            }
2273
2274
            // Detect the exercise/quiz id column name
2275
            $columns = [];
2276
            try {
2277
                foreach ($sm->listTableColumns($relTable) as $colName => $col) {
2278
                    $columns[] = $colName;
2279
                }
2280
            } catch (\Throwable $e) {
2281
                $columns = [];
2282
            }
2283
2284
            $qCol = \in_array('question_id', $columns, true) ? 'question_id' : 'question_id';
2285
            $eCol = null;
2286
            foreach (['quiz_id', 'exercise_id', 'exercice_id', 'quizid'] as $c) {
2287
                if (\in_array($c, $columns, true)) {
2288
                    $eCol = $c;
2289
                    break;
2290
                }
2291
            }
2292
            if (null === $eCol) {
2293
                return [];
2294
            }
2295
2296
            $sql = "SELECT DISTINCT $eCol AS quiz_id FROM $relTable WHERE $qCol = ?";
2297
2298
            // DBAL 3: fetchFirstColumn, DBAL 2: fetchAll
2299
            if (method_exists($conn, 'fetchFirstColumn')) {
2300
                $ids = $conn->fetchFirstColumn($sql, [$questionId]);
2301
            } else {
2302
                $rows = (array) $conn->fetchAll($sql, [$questionId]);
2303
                $ids = [];
2304
                foreach ($rows as $r) {
2305
                    $ids[] = (int) ($r['quiz_id'] ?? (is_array($r) ? reset($r) : 0));
2306
                }
2307
            }
2308
2309
            $ids = array_values(array_filter(array_map('intval', (array) $ids), static fn ($v) => $v > 0));
2310
            if (empty($ids)) {
2311
                return [];
2312
            }
2313
2314
            // Load quizzes by ids (iid is the usual PK in Chamilo entities)
2315
            return $em->getRepository(\Chamilo\CourseBundle\Entity\CQuiz::class)->findBy(['iid' => $ids]);
2316
        } catch (\Throwable $e) {
2317
            return [];
2318
        }
2319
    }
2320
2321
    /**
2322
     * @return int
2323
     */
2324
    public function countAnswers()
2325
    {
2326
        $result = Database::select(
2327
            'COUNT(1) AS c',
2328
            Database::get_course_table(TABLE_QUIZ_ANSWER),
2329
            ['where' => ['question_id = ?' => [$this->id]]],
2330
            'first'
2331
        );
2332
2333
        return (int) $result['c'];
2334
    }
2335
2336
    /**
2337
     * Add adaptive scenario selector fields (success/failure) to the question form.
2338
     *
2339
     * @param FormValidator $form
2340
     * @param array         $questionList List of question IDs from the exercise.
2341
     */
2342
    protected function addAdaptiveScenarioFields(FormValidator $form, array $questionList): void
2343
    {
2344
        // Section header
2345
        $form->addHtml('<h4 class="m-4">'.get_lang('Adaptive behavior (success/failure)').'</h4>');
2346
2347
        // Options for redirection behavior
2348
        $questionListOptions = [
2349
            ''      => get_lang('Select destination'),
2350
            'repeat'=> get_lang('Repeat question'),
2351
            '-1'    => get_lang('End of test'),
2352
            'url'   => get_lang('Other (custom URL)'),
2353
        ];
2354
2355
        // Append available questions to the dropdown
2356
        foreach ($questionList as $index => $qid) {
2357
            if (!is_numeric($qid)) {
2358
                continue;
2359
            }
2360
2361
            $q = self::read((int) $qid);
2362
            if (!$q) {
2363
                continue;
2364
            }
2365
2366
            $questionListOptions[(string) $qid] = 'Q'.$index.': '.strip_tags($q->selectTitle());
2367
        }
2368
2369
        // Success selector and optional URL field
2370
        $form->addSelect(
2371
            'scenario_success_selector',
2372
            get_lang('On success'),
2373
            $questionListOptions,
2374
            ['id' => 'scenario_success_selector']
2375
        );
2376
        $form->addText(
2377
            'scenario_success_url',
2378
            get_lang('Custom URL'),
2379
            false,
2380
            [
2381
                'class'       => 'form-control mb-5',
2382
                'id'          => 'scenario_success_url',
2383
                'placeholder' => '/main/lp/134',
2384
            ]
2385
        );
2386
2387
        // Failure selector and optional URL field
2388
        $form->addSelect(
2389
            'scenario_failure_selector',
2390
            get_lang('On failure'),
2391
            $questionListOptions,
2392
            ['id' => 'scenario_failure_selector']
2393
        );
2394
        $form->addText(
2395
            'scenario_failure_url',
2396
            get_lang('Custom URL'),
2397
            false,
2398
            [
2399
                'class'       => 'form-control mb-5',
2400
                'id'          => 'scenario_failure_url',
2401
                'placeholder' => '/main/lp/134',
2402
            ]
2403
        );
2404
2405
        // JavaScript to toggle custom URL fields when 'url' is selected
2406
        $form->addHtml('
2407
            <script>
2408
                function toggleScenarioUrlFields() {
2409
                    var successSelector = document.getElementById("scenario_success_selector");
2410
                    var successUrlRow = document.getElementById("scenario_success_url").parentNode.parentNode;
2411
2412
                    var failureSelector = document.getElementById("scenario_failure_selector");
2413
                    var failureUrlRow = document.getElementById("scenario_failure_url").parentNode.parentNode;
2414
2415
                    if (successSelector && successSelector.value === "url") {
2416
                        successUrlRow.style.display = "table-row";
2417
                    } else {
2418
                        successUrlRow.style.display = "none";
2419
                    }
2420
2421
                    if (failureSelector && failureSelector.value === "url") {
2422
                        failureUrlRow.style.display = "table-row";
2423
                    } else {
2424
                        failureUrlRow.style.display = "none";
2425
                    }
2426
                }
2427
2428
                document.addEventListener("DOMContentLoaded", toggleScenarioUrlFields);
2429
                document.getElementById("scenario_success_selector")
2430
                    .addEventListener("change", toggleScenarioUrlFields);
2431
                document.getElementById("scenario_failure_selector")
2432
                    .addEventListener("change", toggleScenarioUrlFields);
2433
            </script>
2434
        ');
2435
    }
2436
2437
    /**
2438
     * Persist adaptive scenario (success/failure) configuration for this question.
2439
     *
2440
     * This stores the "destination" JSON in TABLE_QUIZ_TEST_QUESTION for the
2441
     * current (question, exercise) pair. It is intended to be called from
2442
     * processAnswersCreation() implementations.
2443
     *
2444
     * @param FormValidator $form
2445
     * @param Exercise      $exercise
2446
     */
2447
    public function saveAdaptiveScenario(FormValidator $form, Exercise $exercise): void
2448
    {
2449
        // Global feature flag disabled → nothing to do.
2450
        if ('true' !== api_get_setting('enable_quiz_scenario')) {
2451
            return;
2452
        }
2453
2454
        // We only support adaptive scenarios when feedback is "direct".
2455
        if (EXERCISE_FEEDBACK_TYPE_DIRECT !== $exercise->getFeedbackType()) {
2456
            return;
2457
        }
2458
2459
        // This question type is not listed as "scenario capable".
2460
        if (!$this->supportsAdaptiveScenario()) {
2461
            return;
2462
        }
2463
2464
        $successSelector = trim((string) $form->getSubmitValue('scenario_success_selector'));
2465
        $successUrl      = trim((string) $form->getSubmitValue('scenario_success_url'));
2466
        $failureSelector = trim((string) $form->getSubmitValue('scenario_failure_selector'));
2467
        $failureUrl      = trim((string) $form->getSubmitValue('scenario_failure_url'));
2468
2469
        // Map "url" selector to the actual custom URL, keep other values as-is.
2470
        $success = ('url' === $successSelector) ? $successUrl : $successSelector;
2471
        $failure = ('url' === $failureSelector) ? $failureUrl : $failureSelector;
2472
2473
        // If nothing is configured at all, avoid touching the DB.
2474
        if ('' === $success && '' === $failure) {
2475
            return;
2476
        }
2477
2478
        $destination = json_encode(
2479
            [
2480
                'success' => $success ?: '',
2481
                'failure' => $failure ?: '',
2482
            ],
2483
            JSON_UNESCAPED_UNICODE
2484
        );
2485
2486
        $table      = Database::get_course_table(TABLE_QUIZ_TEST_QUESTION);
2487
        $questionId = (int) $this->id;
2488
        $exerciseId = (int) $exercise->id; // Consistent with existing code in UniqueAnswer
2489
2490
        if ($questionId <= 0 || $exerciseId <= 0) {
2491
            // The (question, exercise) relation does not exist yet.
2492
            return;
2493
        }
2494
2495
        Database::update(
2496
            $table,
2497
            ['destination' => $destination],
2498
            ['question_id = ? AND quiz_id = ?' => [$questionId, $exerciseId]]
2499
        );
2500
    }
2501
2502
    /**
2503
     * Pre-fill adaptive scenario fields from the stored (question, exercise) relation.
2504
     */
2505
    protected function loadAdaptiveScenarioDefaults(FormValidator $form, Exercise $exercise): void
2506
    {
2507
        if ('true' !== api_get_setting('enable_quiz_scenario')) {
2508
            return;
2509
        }
2510
2511
        if (!$this->supportsAdaptiveScenario()) {
2512
            return;
2513
        }
2514
2515
        if (empty($this->id) || empty($exercise->id)) {
2516
            return;
2517
        }
2518
2519
        $table = Database::get_course_table(TABLE_QUIZ_TEST_QUESTION);
2520
2521
        $row = Database::select(
2522
            'destination',
2523
            $table,
2524
            [
2525
                'where' => [
2526
                    'question_id = ? AND quiz_id = ?' => [
2527
                        (int) $this->id,
2528
                        (int) $exercise->id,
2529
                    ],
2530
                ],
2531
                'limit' => 1,
2532
            ],
2533
            'first'
2534
        );
2535
2536
        if (empty($row['destination'])) {
2537
            return;
2538
        }
2539
2540
        $json = json_decode((string) $row['destination'], true) ?: [];
2541
2542
        $defaults = [];
2543
2544
        if (!empty($json['success'])) {
2545
            if (str_starts_with($json['success'], '/')) {
2546
                $defaults['scenario_success_selector'] = 'url';
2547
                $defaults['scenario_success_url']      = $json['success'];
2548
            } else {
2549
                $defaults['scenario_success_selector'] = $json['success'];
2550
            }
2551
        }
2552
2553
        if (!empty($json['failure'])) {
2554
            if (str_starts_with($json['failure'], '/')) {
2555
                $defaults['scenario_failure_selector'] = 'url';
2556
                $defaults['scenario_failure_url']      = $json['failure'];
2557
            } else {
2558
                $defaults['scenario_failure_selector'] = $json['failure'];
2559
            }
2560
        }
2561
2562
        if (!empty($defaults)) {
2563
            $form->setDefaults($defaults);
2564
        }
2565
    }
2566
2567
    /**
2568
     * Check if this question type supports adaptive scenarios.
2569
     */
2570
    protected function supportsAdaptiveScenario(): bool
2571
    {
2572
        return in_array((int) $this->type, static::$adaptiveScenarioTypes, true);
2573
    }
2574
}
2575