Question   F
last analyzed

Complexity

Total Complexity 278

Size/Duplication

Total Lines 2463
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 1176
dl 0
loc 2463
rs 0.896
c 1
b 0
f 0
wmc 278

60 Methods

Rating   Name   Duplication   Size   Complexity  
A updateDescription() 0 3 1
D read() 0 80 16
A deleteCategory() 0 14 2
A setTitle() 0 3 1
A selectWeighting() 0 3 1
A selectTitle() 0 7 2
A updateCategory() 0 3 1
A __construct() 0 33 1
A updateTitle() 0 3 1
A updateType() 0 21 5
A setMandatory() 0 3 1
A getLevel() 0 3 1
A updateLevel() 0 3 1
A selectDescription() 0 3 1
A selectType() 0 3 1
A setExtra() 0 3 1
A getTitleToDisplay() 0 18 5
A updateWeighting() 0 3 1
B saveCategory() 0 45 6
A updatePosition() 0 3 1
A getId() 0 3 1
A getIsContent() 0 8 2
F search_engine_edit() 0 138 27
A getInstance() 0 14 4
B duplicate() 0 75 8
B get_question_type_name() 0 20 7
A getQuestionTypeList() 0 11 2
F createForm() 0 262 34
B delete() 0 99 7
B save() 0 121 8
A processCreation() 0 22 2
A removeFromList() 0 48 5
A get_question_type() 0 3 1
A addToList() 0 21 5
A getTypePicture() 0 3 1
A supportsAdaptiveScenario() 0 3 1
A addAdaptiveScenarioFields() 0 65 4
A deleteAllQuestionOptions() 0 9 1
F return_header() 0 159 35
A getExerciseListWhereQuestionExists() 0 13 1
A show_media_content() 0 12 2
A saveQuestionOption() 0 11 1
C saveAdaptiveScenario() 0 52 12
A getExplanation() 0 3 1
A isQuestionWaitingReview() 0 10 4
A returnFormatFeedback() 0 3 1
A getCountExercise() 0 13 1
A create_question() 0 53 2
A countAnswers() 0 10 1
A setFeedback() 0 3 1
A showFeedback() 0 9 3
A existsInAnotherExercise() 0 5 1
A swapSimpleAnswerTypes() 0 24 2
A get_count_course_medias() 0 22 3
C loadAdaptiveScenarioDefaults() 0 59 12
A prepare_course_media_select() 0 29 3
F displayTypeMenu() 0 123 19
A get_default_levels() 0 8 1
A get_course_medias() 0 23 1
A readQuestionOption() 0 14 1

How to fix   Complexity   

Complex Class

Complex classes like Question often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Question, and based on these observations, apply Extract Interface, too.

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

606
                Event::/** @scrutinizer ignore-call */ 
607
                       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...
607
                    LOG_QUESTION_UPDATED,
608
                    LOG_QUESTION_ID,
609
                    $this->iid
610
                );
611
            }
612
        } else {
613
            // Creates a new question
614
            $sql = "SELECT max(position)
615
                    FROM $TBL_QUESTIONS as question,
616
                    $TBL_EXERCISE_QUESTION as test_question
617
                    WHERE
618
                        question.iid = test_question.question_id AND
619
                        test_question.quiz_id = ".$exerciseId;
620
            $result = Database::query($sql);
621
            $current_position = Database::result($result, 0, 0);
622
            $this->updatePosition($current_position + 1);
623
            $position = $this->position;
624
            //$exerciseEntity = $exerciseRepo->find($exerciseId);
625
626
            $question = (new CQuizQuestion())
627
                ->setQuestion($this->question)
628
                ->setDescription($this->description)
629
                ->setPonderation($this->weighting)
630
                ->setPosition($position)
631
                ->setType($this->type)
632
                ->setExtra($this->extra)
633
                ->setLevel((int) $this->level)
634
                ->setFeedback($this->feedback)
635
                ->setParentMediaId($this->parent_id)
636
                ->setParent($courseEntity)
637
                ->addCourseLink($courseEntity, api_get_session_entity(), api_get_group_entity());
638
639
            $em->persist($question);
640
            $em->flush();
641
642
            $this->id = $question->getIid();
643
644
            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...
645
                Event::addEvent(
646
                    LOG_QUESTION_CREATED,
647
                    LOG_QUESTION_ID,
648
                    $this->id
649
                );
650
651
                $questionRepo->addFileFromFileRequest($question, 'imageUpload');
652
653
                // If hotspot, create first answer
654
                if (in_array($type, [HOT_SPOT, HOT_SPOT_COMBINATION, HOT_SPOT_ORDER])) {
655
                    $quizAnswer = new CQuizAnswer();
656
                    $quizAnswer
657
                        ->setQuestion($question)
658
                        ->setPonderation(10)
659
                        ->setPosition(1)
660
                        ->setHotspotCoordinates('0;0|0|0')
661
                        ->setHotspotType('square');
662
663
                    $em->persist($quizAnswer);
664
                    $em->flush();
665
                }
666
667
                if (HOT_SPOT_DELINEATION == $type) {
668
                    $quizAnswer = new CQuizAnswer();
669
                    $quizAnswer
670
                        ->setQuestion($question)
671
                        ->setPonderation(10)
672
                        ->setPosition(1)
673
                        ->setHotspotCoordinates('0;0|0|0')
674
                        ->setHotspotType('delineation');
675
676
                    $em->persist($quizAnswer);
677
                    $em->flush();
678
                }
679
            }
680
        }
681
682
        // if the question is created in an exercise
683
        if (!empty($exerciseId)) {
684
            // adds the exercise into the exercise list of this question
685
            $this->addToList($exerciseId, true);
686
        }
687
    }
688
689
    /**
690
     * @param int  $exerciseId
691
     * @param bool $addQs
692
     * @param bool $rmQs
693
     */
694
    public function search_engine_edit(
695
        $exerciseId,
696
        $addQs = false,
697
        $rmQs = false
698
    ) {
699
        // update search engine and its values table if enabled
700
        if (!empty($exerciseId) && 'true' == api_get_setting('search_enabled') &&
701
            extension_loaded('xapian')
702
        ) {
703
            $course_id = api_get_course_id();
704
            // get search_did
705
            $tbl_se_ref = Database::get_main_table(TABLE_MAIN_SEARCH_ENGINE_REF);
706
            if ($addQs || $rmQs) {
707
                //there's only one row per question on normal db and one document per question on search engine db
708
                $sql = 'SELECT * FROM %s
709
                    WHERE course_code=\'%s\' AND tool_id=\'%s\' AND ref_id_second_level=%s LIMIT 1';
710
                $sql = sprintf($sql, $tbl_se_ref, $course_id, TOOL_QUIZ, $this->id);
711
            } else {
712
                $sql = 'SELECT * FROM %s
713
                    WHERE course_code=\'%s\' AND tool_id=\'%s\'
714
                    AND ref_id_high_level=%s AND ref_id_second_level=%s LIMIT 1';
715
                $sql = sprintf($sql, $tbl_se_ref, $course_id, TOOL_QUIZ, $exerciseId, $this->id);
716
            }
717
            $res = Database::query($sql);
718
719
            if (Database::num_rows($res) > 0 || $addQs) {
720
                $di = new ChamiloIndexer();
721
                if ($addQs) {
722
                    $question_exercises = [(int) $exerciseId];
723
                } else {
724
                    $question_exercises = [];
725
                }
726
                isset($_POST['language']) ? $lang = Database::escape_string($_POST['language']) : $lang = 'english';
727
                $di->connectDb(null, null, $lang);
728
729
                // retrieve others exercise ids
730
                $se_ref = Database::fetch_array($res);
731
                $se_doc = $di->get_document((int) $se_ref['search_did']);
732
                if (false !== $se_doc) {
733
                    if (false !== ($se_doc_data = $di->get_document_data($se_doc))) {
734
                        $se_doc_data = UnserializeApi::unserialize(
735
                            'not_allowed_classes',
736
                            $se_doc_data
737
                        );
738
                        if (isset($se_doc_data[SE_DATA]['type']) &&
739
                            SE_DOCTYPE_EXERCISE_QUESTION == $se_doc_data[SE_DATA]['type']
740
                        ) {
741
                            if (isset($se_doc_data[SE_DATA]['exercise_ids']) &&
742
                                is_array($se_doc_data[SE_DATA]['exercise_ids'])
743
                            ) {
744
                                foreach ($se_doc_data[SE_DATA]['exercise_ids'] as $old_value) {
745
                                    if (!in_array($old_value, $question_exercises)) {
746
                                        $question_exercises[] = $old_value;
747
                                    }
748
                                }
749
                            }
750
                        }
751
                    }
752
                }
753
                if ($rmQs) {
754
                    while (false !== ($key = array_search($exerciseId, $question_exercises))) {
755
                        unset($question_exercises[$key]);
756
                    }
757
                }
758
759
                // build the chunk to index
760
                $ic_slide = new IndexableChunk();
761
                $ic_slide->addValue('title', $this->question);
762
                $ic_slide->addCourseId($course_id);
763
                $ic_slide->addToolId(TOOL_QUIZ);
764
                $xapian_data = [
765
                    SE_COURSE_ID => $course_id,
766
                    SE_TOOL_ID => TOOL_QUIZ,
767
                    SE_DATA => [
768
                        'type' => SE_DOCTYPE_EXERCISE_QUESTION,
769
                        'exercise_ids' => $question_exercises,
770
                        'question_id' => (int) $this->id,
771
                    ],
772
                    SE_USER => (int) api_get_user_id(),
773
                ];
774
                $ic_slide->xapian_data = serialize($xapian_data);
775
                $ic_slide->addValue('content', $this->description);
776
777
                //TODO: index answers, see also form validation on question_admin.inc.php
778
779
                $di->remove_document($se_ref['search_did']);
780
                $di->addChunk($ic_slide);
781
782
                //index and return search engine document id
783
                if (!empty($question_exercises)) { // if empty there is nothing to index
784
                    $did = $di->index();
785
                    unset($di);
786
                }
787
                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...
788
                    // save it to db
789
                    if ($addQs || $rmQs) {
790
                        $sql = "DELETE FROM %s
791
                            WHERE course_code = '%s' AND tool_id = '%s' AND ref_id_second_level = '%s'";
792
                        $sql = sprintf($sql, $tbl_se_ref, $course_id, TOOL_QUIZ, $this->id);
793
                    } else {
794
                        $sql = "DELETE FROM %S
795
                            WHERE
796
                                course_code = '%s'
797
                                AND tool_id = '%s'
798
                                AND tool_id = '%s'
799
                                AND ref_id_high_level = '%s'
800
                                AND ref_id_second_level = '%s'";
801
                        $sql = sprintf($sql, $tbl_se_ref, $course_id, TOOL_QUIZ, $exerciseId, $this->id);
802
                    }
803
                    Database::query($sql);
804
                    if ($rmQs) {
805
                        if (!empty($question_exercises)) {
806
                            $sql = "INSERT INTO %s (
807
                                    id, course_code, tool_id, ref_id_high_level, ref_id_second_level, search_did
808
                                )
809
                                VALUES (
810
                                    NULL, '%s', '%s', %s, %s, %s
811
                                )";
812
                            $sql = sprintf(
813
                                $sql,
814
                                $tbl_se_ref,
815
                                $course_id,
816
                                TOOL_QUIZ,
817
                                array_shift($question_exercises),
818
                                $this->id,
819
                                $did
820
                            );
821
                            Database::query($sql);
822
                        }
823
                    } else {
824
                        $sql = "INSERT INTO %s (
825
                                id, course_code, tool_id, ref_id_high_level, ref_id_second_level, search_did
826
                            )
827
                            VALUES (
828
                                NULL , '%s', '%s', %s, %s, %s
829
                            )";
830
                        $sql = sprintf($sql, $tbl_se_ref, $course_id, TOOL_QUIZ, $exerciseId, $this->id, $did);
831
                        Database::query($sql);
832
                    }
833
                }
834
            }
835
        }
836
    }
837
838
    /**
839
     * adds an exercise into the exercise list.
840
     *
841
     * @author Olivier Brouckaert
842
     *
843
     * @param int  $exerciseId - exercise ID
844
     * @param bool $fromSave   - from $this->save() or not
845
     */
846
    public function addToList($exerciseId, $fromSave = false)
847
    {
848
        $exerciseRelQuestionTable = Database::get_course_table(TABLE_QUIZ_TEST_QUESTION);
849
        $id = (int) $this->id;
850
        $exerciseId = (int) $exerciseId;
851
852
        // checks if the exercise ID is not in the list
853
        if (!empty($exerciseId) && !in_array($exerciseId, $this->exerciseList)) {
854
            $this->exerciseList[] = $exerciseId;
855
            $courseId = isset($this->course['real_id']) ? $this->course['real_id'] : 0;
856
            $newExercise = new Exercise($courseId);
857
            $newExercise->read($exerciseId, false);
858
            $count = $newExercise->getQuestionCount();
859
            $count++;
860
            $sql = "INSERT INTO $exerciseRelQuestionTable (question_id, quiz_id, question_order)
861
                    VALUES (".$id.', '.$exerciseId.", '$count')";
862
            Database::query($sql);
863
864
            // we do not want to reindex if we had just saved adnd indexed the question
865
            if (!$fromSave) {
866
                $this->search_engine_edit($exerciseId, true);
867
            }
868
        }
869
    }
870
871
    /**
872
     * removes an exercise from the exercise list.
873
     *
874
     * @author Olivier Brouckaert
875
     *
876
     * @param int $exerciseId - exercise ID
877
     * @param int $courseId
878
     *
879
     * @return bool - true if removed, otherwise false
880
     */
881
    public function removeFromList($exerciseId, $courseId = 0)
882
    {
883
        $table = Database::get_course_table(TABLE_QUIZ_TEST_QUESTION);
884
        $tableQuestion = Database::get_course_table(TABLE_QUIZ_QUESTION);
885
        $id = (int) $this->id;
886
        $exerciseId = (int) $exerciseId;
887
888
        // searches the position of the exercise ID in the list
889
        $pos = array_search($exerciseId, $this->exerciseList);
890
        $courseId = empty($courseId) ? api_get_course_int_id() : (int) $courseId;
891
892
        // exercise not found
893
        if (false === $pos) {
894
            return false;
895
        } else {
896
            // deletes the position in the array containing the wanted exercise ID
897
            unset($this->exerciseList[$pos]);
898
            //update order of other elements
899
            $sql = "SELECT question_order
900
                    FROM $table
901
                    WHERE
902
                        question_id = $id AND
903
                        quiz_id = $exerciseId";
904
            $res = Database::query($sql);
905
            if (Database::num_rows($res) > 0) {
906
                $row = Database::fetch_array($res);
907
                if (!empty($row['question_order'])) {
908
                    $sql = "UPDATE $table
909
                            SET question_order = question_order-1
910
                            WHERE
911
                                quiz_id = $exerciseId AND
912
                                question_order > ".$row['question_order'];
913
                    Database::query($sql);
914
                }
915
            }
916
917
            $sql = "DELETE FROM $table
918
                    WHERE
919
                        question_id = $id AND
920
                        quiz_id = $exerciseId";
921
            Database::query($sql);
922
923
            $reset = "UPDATE $tableQuestion
924
                  SET parent_media_id = NULL
925
                  WHERE parent_media_id = $id";
926
            Database::query($reset);
927
928
            return true;
929
        }
930
    }
931
932
    /**
933
     * Deletes a question from the database
934
     * the parameter tells if the question is removed from all exercises (value = 0),
935
     * or just from one exercise (value = exercise ID).
936
     *
937
     * @author Olivier Brouckaert
938
     *
939
     * @param int $deleteFromEx - exercise ID if the question is only removed from one exercise
940
     *
941
     * @return bool
942
     */
943
    public function delete($deleteFromEx = 0)
944
    {
945
        if (empty($this->course)) {
946
            return false;
947
        }
948
949
        $courseId = $this->course['real_id'];
950
951
        if (empty($courseId)) {
952
            return false;
953
        }
954
955
        $TBL_EXERCISE_QUESTION = Database::get_course_table(TABLE_QUIZ_TEST_QUESTION);
956
        $TBL_QUESTIONS = Database::get_course_table(TABLE_QUIZ_QUESTION);
957
        $TBL_REPONSES = Database::get_course_table(TABLE_QUIZ_ANSWER);
958
        $TBL_QUIZ_QUESTION_REL_CATEGORY = Database::get_course_table(TABLE_QUIZ_QUESTION_REL_CATEGORY);
959
960
        $id = (int) $this->id;
961
962
        // if the question must be removed from all exercises
963
        if (!$deleteFromEx) {
964
            //update the question_order of each question to avoid inconsistencies
965
            $sql = "SELECT quiz_id, question_order
966
                    FROM $TBL_EXERCISE_QUESTION
967
                    WHERE question_id = ".$id;
968
969
            $res = Database::query($sql);
970
            if (Database::num_rows($res) > 0) {
971
                while ($row = Database::fetch_array($res)) {
972
                    if (!empty($row['question_order'])) {
973
                        $sql = "UPDATE $TBL_EXERCISE_QUESTION
974
                                SET question_order = question_order-1
975
                                WHERE
976
                                    quiz_id = ".(int) ($row['quiz_id']).' AND
977
                                    question_order > '.$row['question_order'];
978
                        Database::query($sql);
979
                    }
980
                }
981
            }
982
983
            $reset = "UPDATE $TBL_QUESTIONS
984
                  SET parent_media_id = NULL
985
                  WHERE parent_media_id = $id";
986
            Database::query($reset);
987
988
            $sql = "DELETE FROM $TBL_EXERCISE_QUESTION
989
                    WHERE question_id = ".$id;
990
            Database::query($sql);
991
992
            $sql = "DELETE FROM $TBL_QUESTIONS
993
                    WHERE iid = ".$id;
994
            Database::query($sql);
995
996
            $sql = "DELETE FROM $TBL_REPONSES
997
                    WHERE question_id = ".$id;
998
            Database::query($sql);
999
1000
            // remove the category of this question in the question_rel_category table
1001
            $sql = "DELETE FROM $TBL_QUIZ_QUESTION_REL_CATEGORY
1002
                    WHERE
1003
                        question_id = ".$id;
1004
            Database::query($sql);
1005
1006
            // Add extra fields.
1007
            $extraField = new ExtraFieldValue('question');
1008
            $extraField->deleteValuesByItem($this->iid);
1009
1010
            /*api_item_property_update(
1011
                $this->course,
1012
                TOOL_QUIZ,
1013
                $id,
1014
                'QuizQuestionDeleted',
1015
                api_get_user_id()
1016
            );*/
1017
            Event::addEvent(
1018
                LOG_QUESTION_DELETED,
1019
                LOG_QUESTION_ID,
1020
                $this->iid
1021
            );
1022
            //$this->removePicture();
1023
        } else {
1024
            // just removes the exercise from the list
1025
            $this->removeFromList($deleteFromEx, $courseId);
1026
            /*
1027
            api_item_property_update(
1028
                $this->course,
1029
                TOOL_QUIZ,
1030
                $id,
1031
                'QuizQuestionDeleted',
1032
                api_get_user_id()
1033
            );*/
1034
            Event::addEvent(
1035
                LOG_QUESTION_REMOVED_FROM_QUIZ,
1036
                LOG_QUESTION_ID,
1037
                $this->iid
1038
            );
1039
        }
1040
1041
        return true;
1042
    }
1043
1044
    /**
1045
     * Duplicates the question.
1046
     *
1047
     * @author Olivier Brouckaert
1048
     *
1049
     * @param array $courseInfo Course info of the destination course
1050
     *
1051
     * @return false|string ID of the new question
1052
     */
1053
    public function duplicate($courseInfo = [])
1054
    {
1055
        $courseInfo = empty($courseInfo) ? $this->course : $courseInfo;
1056
1057
        if (empty($courseInfo)) {
1058
            return false;
1059
        }
1060
        $TBL_QUESTION_OPTIONS = Database::get_course_table(TABLE_QUIZ_QUESTION_OPTION);
1061
1062
        $questionText = $this->question;
1063
        $description = $this->description;
1064
1065
        // Using the same method used in the course copy to transform URLs
1066
        if ($this->course['id'] != $courseInfo['id']) {
1067
            $description = DocumentManager::replaceUrlWithNewCourseCode(
1068
                $description,
1069
                $this->course['code'],
1070
                $courseInfo['id']
1071
            );
1072
            $questionText = DocumentManager::replaceUrlWithNewCourseCode(
1073
                $questionText,
1074
                $this->course['code'],
1075
                $courseInfo['id']
1076
            );
1077
        }
1078
1079
        $course_id = $courseInfo['real_id'];
1080
1081
        // Read the source options
1082
        $options = self::readQuestionOption($this->id, $this->course['real_id']);
1083
1084
        $em = Database::getManager();
1085
        $courseEntity = api_get_course_entity($course_id);
1086
1087
        $question = (new CQuizQuestion())
1088
            ->setQuestion($questionText)
1089
            ->setDescription($description)
1090
            ->setPonderation($this->weighting)
1091
            ->setPosition($this->position)
1092
            ->setType($this->type)
1093
            ->setExtra($this->extra)
1094
            ->setLevel($this->level)
1095
            ->setFeedback($this->feedback)
1096
            ->setParent($courseEntity)
1097
            ->addCourseLink($courseEntity)
1098
        ;
1099
1100
        $em->persist($question);
1101
        $em->flush();
1102
        $newQuestionId = $question->getIid();
1103
1104
        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...
1105
            // Add extra fields.
1106
            $extraField = new ExtraFieldValue('question');
1107
            $extraField->copy($this->iid, $newQuestionId);
1108
1109
            if (!empty($options)) {
1110
                // Saving the quiz_options
1111
                foreach ($options as $item) {
1112
                    $item['question_id'] = $newQuestionId;
1113
                    $item['c_id'] = $course_id;
1114
                    unset($item['iid']);
1115
                    unset($item['iid']);
1116
                    Database::insert($TBL_QUESTION_OPTIONS, $item);
1117
                }
1118
            }
1119
1120
            // Duplicates the picture of the hotspot
1121
            // @todo implement copy of hotspot question
1122
            if (HOT_SPOT == $this->type) {
1123
                throw new Exception('implement copy of hotspot question');
1124
            }
1125
        }
1126
1127
        return $newQuestionId;
1128
    }
1129
1130
    /**
1131
     * @return string
1132
     */
1133
    public function get_question_type_name(): string
1134
    {
1135
        $labelKey = trim((string) $this->explanationLangVar);
1136
        if ($labelKey !== '') {
1137
            $translated = get_lang($labelKey);
1138
            if ($translated !== $labelKey) {
1139
                return $translated;
1140
            }
1141
        }
1142
1143
        $def = self::$questionTypes[$this->type] ?? null;
1144
        $className = is_array($def) ? ($def[1] ?? '') : '';
1145
        if ($className !== '') {
1146
            $human = preg_replace('/(?<!^)(?=[A-Z])/', ' ', $className) ?: $className;
1147
            $translated = get_lang($human);
1148
1149
            return $translated !== $human ? $translated : $human;
1150
        }
1151
1152
        return '';
1153
    }
1154
1155
    /**
1156
     * @param string $type
1157
     */
1158
    public static function get_question_type($type)
1159
    {
1160
        return self::$questionTypes[$type];
1161
    }
1162
1163
    /**
1164
     * @return array
1165
     */
1166
    public static function getQuestionTypeList(): array
1167
    {
1168
        $list = self::$questionTypes;
1169
1170
        if ('true' !== api_get_setting('enable_quiz_scenario')) {
1171
            unset($list[HOT_SPOT_DELINEATION]);
1172
        }
1173
1174
        ksort($list, SORT_NUMERIC);
1175
1176
        return $list;
1177
    }
1178
1179
    /**
1180
     * Returns an instance of the class corresponding to the type.
1181
     *
1182
     * @param int $type the type of the question
1183
     *
1184
     * @return $this instance of a Question subclass (or of Questionc class by default)
1185
     */
1186
    public static function getInstance($type)
1187
    {
1188
        if (null !== $type) {
1189
            [$fileName, $className] = self::get_question_type($type);
1190
            if (!empty($fileName)) {
1191
                if (class_exists($className)) {
1192
                    return new $className();
1193
                } else {
1194
                    echo 'Can\'t instanciate class '.$className.' of type '.$type;
1195
                }
1196
            }
1197
        }
1198
1199
        return null;
1200
    }
1201
1202
    /**
1203
     * Creates the form to create / edit a question
1204
     * A subclass can redefine this function to add fields...
1205
     *
1206
     * @param FormValidator $form
1207
     * @param Exercise      $exercise
1208
     */
1209
    public function createForm(&$form, $exercise)
1210
    {
1211
        $zoomOptions = api_get_setting('exercise.quiz_image_zoom', true);
1212
        if (isset($zoomOptions['options'])) {
1213
            $finderFolder = api_get_path(WEB_PATH).'vendor/studio-42/elfinder/';
1214
            echo '<!-- elFinder CSS (REQUIRED) -->';
1215
            echo '<link rel="stylesheet" type="text/css" media="screen" href="'.$finderFolder.'css/elfinder.full.css">';
1216
            echo '<link rel="stylesheet" type="text/css" media="screen" href="'.$finderFolder.'css/theme.css">';
1217
1218
            echo '<!-- elFinder JS (REQUIRED) -->';
1219
            echo '<script src="'.$finderFolder.'js/elfinder.full.js"></script>';
1220
1221
            echo '<!-- elFinder translation (OPTIONAL) -->';
1222
            $language = 'en';
1223
            $platformLanguage = api_get_language_isocode();
1224
            $iso = api_get_language_isocode($platformLanguage);
1225
            $filePart = "vendor/studio-42/elfinder/js/i18n/elfinder.$iso.js";
1226
            $file = api_get_path(SYS_PATH).$filePart;
1227
            $includeFile = '';
1228
            if (file_exists($file)) {
1229
                $includeFile = '<script src="'.api_get_path(WEB_PATH).$filePart.'"></script>';
1230
                $language = $iso;
1231
            }
1232
            echo $includeFile;
1233
            echo '<script>
1234
        $(function() {
1235
            $(".create_img_link").click(function(e){
1236
                e.preventDefault();
1237
                e.stopPropagation();
1238
                var imageZoom = $("input[name=\'imageZoom\']").val();
1239
                var imageWidth = $("input[name=\'imageWidth\']").val();
1240
                CKEDITOR.instances.questionDescription.insertHtml(\'<img id="zoom_picture" class="zoom_picture" src="\'+imageZoom+\'" data-zoom-image="\'+imageZoom+\'" width="\'+imageWidth+\'px" />\');
1241
            });
1242
1243
            $("input[name=\'imageZoom\']").on("click", function(){
1244
                var elf = $("#elfinder").elfinder({
1245
                    url : "'.api_get_path(WEB_LIBRARY_PATH).'elfinder/connectorAction.php?'.api_get_cidreq().'",
1246
                    getFileCallback: function(file) {
1247
                        var filePath = file; //file contains the relative url.
1248
                        var imgPath = "<img src = \'"+filePath+"\'/>";
1249
                        $("input[name=\'imageZoom\']").val(filePath.url);
1250
                        $("#elfinder").remove(); //close the window after image is selected
1251
                    },
1252
                    startPathHash: "l2_Lw", // Sets the course driver as default
1253
                    resizable: false,
1254
                    lang: "'.$language.'"
1255
                }).elfinder("instance");
1256
            });
1257
        });
1258
        </script>';
1259
            echo '<div id="elfinder"></div>';
1260
        }
1261
1262
        // Question name
1263
        if ('true' === api_get_setting('editor.save_titles_as_html')) {
1264
            $editorConfig = ['ToolbarSet' => 'TitleAsHtml'];
1265
            $form->addHtmlEditor(
1266
                'questionName',
1267
                get_lang('Question'),
1268
                false,
1269
                false,
1270
                $editorConfig
1271
            );
1272
        } else {
1273
            $form->addText('questionName', get_lang('Question'));
1274
        }
1275
1276
        $form->addRule('questionName', get_lang('Please type the question'), 'required');
1277
1278
        // Default content
1279
        $isContent = isset($_REQUEST['isContent']) ? (int) $_REQUEST['isContent'] : null;
1280
1281
        // Question type (answer type)
1282
        $answerType = isset($_REQUEST['answerType']) ? (int) $_REQUEST['answerType'] : null;
1283
        $form->addHidden('answerType', $answerType);
1284
1285
        // HTML editor for description
1286
        $editorConfig = [
1287
            'ToolbarSet' => 'TestQuestionDescription',
1288
            'Height' => '150',
1289
        ];
1290
1291
        if (!api_is_allowed_to_edit(null, true)) {
1292
            $editorConfig['UserStatus'] = 'student';
1293
        }
1294
1295
        $form->addButtonAdvancedSettings('advanced_params');
1296
        $form->addHtml('<div id="advanced_params_options" style="display:none">');
1297
1298
        if (isset($zoomOptions['options'])) {
1299
            $form->addElement('text', 'imageZoom', get_lang('Image URL'));
1300
            $form->addElement('text', 'imageWidth', get_lang('px width'));
1301
            $form->addButton('btn_create_img', get_lang('Add to editor'), 'plus', 'info', 'small', 'create_img_link');
1302
        }
1303
1304
        $form->addHtmlEditor(
1305
            'questionDescription',
1306
            get_lang('Enrich question'),
1307
            false,
1308
            false,
1309
            $editorConfig
1310
        );
1311
1312
        if (MEDIA_QUESTION != $this->type) {
1313
            // Advanced parameters.
1314
            $form->addSelect(
1315
                'questionLevel',
1316
                get_lang('Difficulty'),
1317
                self::get_default_levels()
1318
            );
1319
1320
            // Categories.
1321
            $form->addSelect(
1322
                'questionCategory',
1323
                get_lang('Category'),
1324
                TestCategory::getCategoriesIdAndName()
1325
            );
1326
1327
            $courseMedias = self::prepare_course_media_select($exercise->iId);
1328
            $form->addSelect(
1329
                'parent_id',
1330
                get_lang('Attach to media'),
1331
                $courseMedias
1332
            );
1333
1334
            if (EX_Q_SELECTION_CATEGORIES_ORDERED_QUESTIONS_RANDOM == $exercise->getQuestionSelectionType() &&
1335
                ('true' === api_get_setting('exercise.allow_mandatory_question_in_category'))
1336
            ) {
1337
                $form->addCheckBox('mandatory', get_lang('Mandatory?'));
1338
            }
1339
1340
            $text = get_lang('Save the question');
1341
            switch ($this->type) {
1342
                case UNIQUE_ANSWER:
1343
                    $buttonGroup = [];
1344
                    $buttonGroup[] = $form->addButtonSave(
1345
                        $text,
1346
                        'submitQuestion',
1347
                        true
1348
                    );
1349
                    $buttonGroup[] = $form->addButton(
1350
                        'convertAnswer',
1351
                        get_lang('Convert to multiple answer'),
1352
                        'dot-circle-o',
1353
                        'default',
1354
                        null,
1355
                        null,
1356
                        null,
1357
                        true
1358
                    );
1359
                    $form->addGroup($buttonGroup);
1360
1361
                    break;
1362
                case MULTIPLE_ANSWER:
1363
                    $buttonGroup = [];
1364
                    $buttonGroup[] = $form->addButtonSave(
1365
                        $text,
1366
                        'submitQuestion',
1367
                        true
1368
                    );
1369
                    $buttonGroup[] = $form->addButton(
1370
                        'convertAnswer',
1371
                        get_lang('Convert to unique answer'),
1372
                        'check-square-o',
1373
                        'default',
1374
                        null,
1375
                        null,
1376
                        null,
1377
                        true
1378
                    );
1379
                    $form->addGroup($buttonGroup);
1380
1381
                    break;
1382
            }
1383
        }
1384
1385
        $form->addElement('html', '</div>');
1386
1387
        // Sample default questions when creating from templates
1388
        if (!isset($_GET['fromExercise'])) {
1389
            switch ($answerType) {
1390
                case 1:
1391
                    $this->question = get_lang('Select the good reasoning');
1392
1393
                    break;
1394
                case 2:
1395
                    $this->question = get_lang('The marasmus is a consequence of');
1396
1397
                    break;
1398
                case 3:
1399
                    $this->question = get_lang('Calculate the Body Mass Index');
1400
1401
                    break;
1402
                case 4:
1403
                    $this->question = get_lang('Order the operations');
1404
1405
                    break;
1406
                case 5:
1407
                    $this->question = get_lang('List what you consider the 10 top qualities of a good project manager?');
1408
1409
                    break;
1410
                case 9:
1411
                    $this->question = get_lang('The marasmus is a consequence of');
1412
1413
                    break;
1414
            }
1415
        }
1416
1417
        // -------------------------------------------------------------------------
1418
        // Adaptive scenario (success/failure) — centralised for supported types
1419
        // -------------------------------------------------------------------------
1420
        $scenarioEnabled    = ('true' === api_get_setting('enable_quiz_scenario'));
1421
        $hasExercise        = ($exercise instanceof Exercise);
1422
        $isAdaptiveFeedback = $hasExercise &&
1423
            EXERCISE_FEEDBACK_TYPE_DIRECT === $exercise->getFeedbackType();
1424
        $supportsScenario   = in_array(
1425
            (int) $this->type,
1426
            static::$adaptiveScenarioTypes,
1427
            true
1428
        );
1429
1430
        if ($scenarioEnabled && $isAdaptiveFeedback && $supportsScenario && $hasExercise) {
1431
            // Build the question list once per exercise to feed the scenario selector
1432
            $exercise->setQuestionList(true);
1433
            $questionList = $exercise->getQuestionList();
1434
1435
            if (is_array($questionList) && !empty($questionList)) {
1436
                $this->addAdaptiveScenarioFields($form, $questionList);
1437
                // Pre-fill selector defaults when editing an existing question in this exercise.
1438
                if (!empty($this->id)) {
1439
                    $this->loadAdaptiveScenarioDefaults($form, $exercise);
1440
                }
1441
            }
1442
        }
1443
1444
        if (null !== $exercise) {
1445
            if ($exercise->questionFeedbackEnabled && $this->showFeedback($exercise)) {
1446
                $form->addTextarea('feedback', get_lang('Feedback if not correct'));
1447
            }
1448
        }
1449
1450
        $extraField = new ExtraField('question');
1451
        $extraField->addElements($form, $this->iid);
1452
1453
        // Default values
1454
        $defaults = [
1455
            'questionName'        => $this->question,
1456
            'questionDescription' => $this->description,
1457
            'questionLevel'       => $this->level,
1458
            'questionCategory'    => $this->category,
1459
            'feedback'            => $this->feedback,
1460
            'mandatory'           => $this->mandatory,
1461
            'parent_id'           => $this->parent_id,
1462
        ];
1463
1464
        // Came from the question pool
1465
        if (isset($_GET['fromExercise'])) {
1466
            $form->setDefaults($defaults);
1467
        }
1468
1469
        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...
1470
            $form->setDefaults($defaults);
1471
        }
1472
1473
        /*if (!empty($_REQUEST['myid'])) {
1474
            $form->setDefaults($defaults);
1475
        } else {
1476
            if ($isContent == 1) {
1477
                $form->setDefaults($defaults);
1478
            }
1479
        }*/
1480
    }
1481
1482
    /**
1483
     * Function which process the creation of questions.
1484
     */
1485
    public function processCreation(FormValidator $form, Exercise $exercise)
1486
    {
1487
        $this->parent_id = (int) $form->getSubmitValue('parent_id');
1488
        $this->updateTitle($form->getSubmitValue('questionName'));
1489
        $this->updateDescription($form->getSubmitValue('questionDescription'));
1490
        $this->updateLevel($form->getSubmitValue('questionLevel'));
1491
        $this->updateCategory($form->getSubmitValue('questionCategory'));
1492
        $this->setMandatory($form->getSubmitValue('mandatory'));
1493
        $this->setFeedback($form->getSubmitValue('feedback'));
1494
1495
        //Save normal question if NOT media
1496
        if (MEDIA_QUESTION != $this->type) {
1497
            $this->save($exercise);
1498
            // modify the exercise
1499
            $exercise->addToList($this->id);
1500
            $exercise->update_question_positions();
1501
1502
            $params = $form->exportValues();
1503
            $params['item_id'] = $this->id;
1504
1505
            $extraFieldValues = new ExtraFieldValue('question');
1506
            $extraFieldValues->saveFieldValues($params);
1507
        }
1508
    }
1509
1510
    /**
1511
     * Creates the form to create / edit the answers of the question.
1512
     */
1513
    abstract public function createAnswersForm(FormValidator $form);
1514
1515
    /**
1516
     * Process the creation of answers.
1517
     *
1518
     * @param FormValidator $form
1519
     * @param Exercise      $exercise
1520
     */
1521
    abstract public function processAnswersCreation($form, $exercise);
1522
1523
    /**
1524
     * Displays the menu of question types.
1525
     *
1526
     * @param Exercise $objExercise
1527
     */
1528
    public static function displayTypeMenu(Exercise $objExercise)
1529
    {
1530
        if (empty($objExercise)) {
1531
            return '';
1532
        }
1533
1534
        $feedbackType = $objExercise->getFeedbackType();
1535
        $exerciseId   = $objExercise->id;
1536
1537
        $questionTypeList = self::getQuestionTypeList();
1538
1539
        if (!isset($feedbackType)) {
1540
            $feedbackType = 0;
1541
        }
1542
1543
        switch ($feedbackType) {
1544
            case EXERCISE_FEEDBACK_TYPE_DIRECT:
1545
                // Keep original behavior: base types for adaptative tests.
1546
                $questionTypeList = [
1547
                    UNIQUE_ANSWER        => self::$questionTypes[UNIQUE_ANSWER],
1548
                    HOT_SPOT_DELINEATION => self::$questionTypes[HOT_SPOT_DELINEATION],
1549
                ];
1550
1551
                // Add all other non-open question types.
1552
                $allTypes = self::getQuestionTypeList();
1553
1554
                // Exclude the classic open question types from the filter list
1555
                // as the system cannot provide immediate feedback on these.
1556
                if (isset($allTypes[FREE_ANSWER])) {
1557
                    unset($allTypes[FREE_ANSWER]);
1558
                }
1559
                if (isset($allTypes[ORAL_EXPRESSION])) {
1560
                    unset($allTypes[ORAL_EXPRESSION]);
1561
                }
1562
                if (isset($allTypes[ANNOTATION])) {
1563
                    unset($allTypes[ANNOTATION]);
1564
                }
1565
                if (isset($allTypes[MULTIPLE_ANSWER_TRUE_FALSE_DEGREE_CERTAINTY])) {
1566
                    unset($allTypes[MULTIPLE_ANSWER_TRUE_FALSE_DEGREE_CERTAINTY]);
1567
                }
1568
                if (isset($allTypes[UPLOAD_ANSWER])) {
1569
                    unset($allTypes[UPLOAD_ANSWER]);
1570
                }
1571
                if (isset($allTypes[ANSWER_IN_OFFICE_DOC])) {
1572
                    unset($allTypes[ANSWER_IN_OFFICE_DOC]);
1573
                }
1574
                if (isset($allTypes[PAGE_BREAK])) {
1575
                    unset($allTypes[PAGE_BREAK]);
1576
                }
1577
1578
                // Append remaining types, without overriding the original ones.
1579
                foreach ($allTypes as $typeId => $def) {
1580
                    if (!isset($questionTypeList[$typeId])) {
1581
                        $questionTypeList[$typeId] = $def;
1582
                    }
1583
                }
1584
1585
                break;
1586
            case EXERCISE_FEEDBACK_TYPE_POPUP:
1587
                $questionTypeList = [
1588
                    UNIQUE_ANSWER        => self::$questionTypes[UNIQUE_ANSWER],
1589
                    MULTIPLE_ANSWER      => self::$questionTypes[MULTIPLE_ANSWER],
1590
                    DRAGGABLE            => self::$questionTypes[DRAGGABLE],
1591
                    HOT_SPOT_DELINEATION => self::$questionTypes[HOT_SPOT_DELINEATION],
1592
                    CALCULATED_ANSWER    => self::$questionTypes[CALCULATED_ANSWER],
1593
                ];
1594
1595
                break;
1596
            default:
1597
                unset($questionTypeList[HOT_SPOT_DELINEATION]);
1598
1599
                break;
1600
        }
1601
1602
        echo '<div class="card">';
1603
        echo '  <div class="card-body">';
1604
        echo '    <ul class="qtype-menu flex flex-wrap gap-x-2 gap-y-2 items-center justify-start w-full">';
1605
        foreach ($questionTypeList as $i => $type) {
1606
            /** @var Question $type */
1607
            $type = new $type[1]();
1608
            $img  = $type->getTypePicture();
1609
            $expl = $type->getExplanation();
1610
1611
            echo '      <li class="flex items-center justify-center">';
1612
1613
            $icon = Display::url(
1614
                Display::return_icon($img, $expl, null, ICON_SIZE_BIG),
1615
                'admin.php?' . api_get_cidreq() . '&' . http_build_query([
1616
                    'newQuestion' => 'yes',
1617
                    'answerType'  => $i,
1618
                    'exerciseId'  => $exerciseId,
1619
                ]),
1620
                ['title' => $expl, 'class' => 'block']
1621
            );
1622
1623
            if (false === $objExercise->force_edit_exercise_in_lp && $objExercise->exercise_was_added_in_lp) {
1624
                $img  = pathinfo($img);
1625
                $img  = $img['filename'].'_na.'.$img['extension'];
1626
                $icon = Display::return_icon($img, $expl, null, ICON_SIZE_BIG);
1627
            }
1628
            echo $icon;
1629
            echo '      </li>';
1630
        }
1631
1632
        echo '      <li class="flex items-center justify-center">';
1633
        if ($objExercise->exercise_was_added_in_lp) {
1634
            echo Display::getMdiIcon('database', 'ch-tool-icon-disabled', null, ICON_SIZE_BIG, get_lang('Recycle existing questions'));
1635
        } else {
1636
            $href = in_array($feedbackType, [EXERCISE_FEEDBACK_TYPE_DIRECT, EXERCISE_FEEDBACK_TYPE_POPUP])
1637
                ? 'question_pool.php?' . api_get_cidreq() . "&type=1&fromExercise={$exerciseId}"
1638
                : 'question_pool.php?' . api_get_cidreq() . "&fromExercise={$exerciseId}";
1639
1640
            echo Display::url(
1641
                Display::getMdiIcon('database', 'ch-tool-icon', null, ICON_SIZE_BIG, get_lang('Recycle existing questions')),
1642
                $href,
1643
                ['class' => 'block', 'title' => get_lang('Recycle existing questions')]
1644
            );
1645
        }
1646
        echo '      </li>';
1647
1648
        echo '    </ul>';
1649
        echo '  </div>';
1650
        echo '</div>';
1651
    }
1652
1653
    /**
1654
     * @param string $name
1655
     * @param int    $position
1656
     *
1657
     * @return CQuizQuestion|null
1658
     */
1659
    public static function saveQuestionOption(CQuizQuestion $question, $name, $position = 0)
1660
    {
1661
        $option = new CQuizQuestionOption();
1662
        $option
1663
            ->setQuestion($question)
1664
            ->setTitle($name)
1665
            ->setPosition($position)
1666
        ;
1667
        $em = Database::getManager();
1668
        $em->persist($option);
1669
        $em->flush();
1670
    }
1671
1672
    /**
1673
     * @param int $question_id
1674
     * @param int $course_id
1675
     */
1676
    public static function deleteAllQuestionOptions($question_id, $course_id)
1677
    {
1678
        $table = Database::get_course_table(TABLE_QUIZ_QUESTION_OPTION);
1679
        Database::delete(
1680
            $table,
1681
            [
1682
                'c_id = ? AND question_id = ?' => [
1683
                    $course_id,
1684
                    $question_id,
1685
                ],
1686
            ]
1687
        );
1688
    }
1689
1690
    /**
1691
     * @param int $question_id
1692
     *
1693
     * @return array
1694
     */
1695
    public static function readQuestionOption($question_id)
1696
    {
1697
        $table = Database::get_course_table(TABLE_QUIZ_QUESTION_OPTION);
1698
1699
        return Database::select(
1700
            '*',
1701
            $table,
1702
            [
1703
                'where' => [
1704
                    'question_id = ?' => [
1705
                        $question_id,
1706
                    ],
1707
                ],
1708
                'order' => 'iid ASC',
1709
            ]
1710
        );
1711
    }
1712
1713
    /**
1714
     * Shows question title an description.
1715
     *
1716
     * @param int   $counter
1717
     * @param array $score
1718
     *
1719
     * @return string HTML string with the header of the question (before the answers table)
1720
     */
1721
    public function return_header(Exercise $exercise, $counter = null, $score = [])
1722
    {
1723
        $counterLabel = '';
1724
        if (!empty($counter)) {
1725
            $counterLabel = (int) $counter;
1726
        }
1727
1728
        $scoreLabel = get_lang('Wrong');
1729
        if (in_array($exercise->results_disabled, [
1730
            RESULT_DISABLE_SHOW_ONLY_IN_CORRECT_ANSWER,
1731
            RESULT_DISABLE_SHOW_SCORE_AND_EXPECTED_ANSWERS_AND_RANKING,
1732
        ])
1733
        ) {
1734
            $scoreLabel = get_lang('Wrong answer. The correct one was:');
1735
        }
1736
1737
        $class = 'error';
1738
        if (isset($score['pass']) && true == $score['pass']) {
1739
            $scoreLabel = get_lang('Correct');
1740
1741
            if (in_array($exercise->results_disabled, [
1742
                RESULT_DISABLE_SHOW_ONLY_IN_CORRECT_ANSWER,
1743
                RESULT_DISABLE_SHOW_SCORE_AND_EXPECTED_ANSWERS_AND_RANKING,
1744
            ])
1745
            ) {
1746
                $scoreLabel = get_lang('Correct answer');
1747
            }
1748
            $class = 'success';
1749
        }
1750
1751
        switch ($this->type) {
1752
            case FREE_ANSWER:
1753
            case UPLOAD_ANSWER:
1754
            case ORAL_EXPRESSION:
1755
            case ANNOTATION:
1756
                $score['revised'] = isset($score['revised']) ? $score['revised'] : false;
1757
                if (true == $score['revised']) {
1758
                    $scoreLabel = get_lang('Reviewed');
1759
                    $class = '';
1760
                } else {
1761
                    $scoreLabel = get_lang('Not reviewed');
1762
                    $class = 'warning';
1763
                    if (isset($score['weight'])) {
1764
                        $weight = float_format($score['weight'], 1);
1765
                        $score['result'] = ' ? / '.$weight;
1766
                    }
1767
                    $model = ExerciseLib::getCourseScoreModel();
1768
                    if (!empty($model)) {
1769
                        $score['result'] = ' ? ';
1770
                    }
1771
1772
                    $hide = ('true' === api_get_setting('exercise.hide_free_question_score'));
1773
                    if (true === $hide) {
1774
                        $score['result'] = '-';
1775
                    }
1776
                }
1777
1778
                break;
1779
            case UNIQUE_ANSWER:
1780
                if (in_array($exercise->results_disabled, [
1781
                    RESULT_DISABLE_SHOW_ONLY_IN_CORRECT_ANSWER,
1782
                    RESULT_DISABLE_SHOW_SCORE_AND_EXPECTED_ANSWERS_AND_RANKING,
1783
                ])
1784
                ) {
1785
                    if (isset($score['user_answered'])) {
1786
                        if (false === $score['user_answered']) {
1787
                            $scoreLabel = get_lang('Unanswered');
1788
                            $class = 'info';
1789
                        }
1790
                    }
1791
                }
1792
1793
                break;
1794
        }
1795
1796
        // display question category, if any
1797
        $header = '';
1798
        if ($exercise->display_category_name) {
1799
            $header = TestCategory::returnCategoryAndTitle($this->id);
1800
        }
1801
        $show_media = '';
1802
        if ($show_media) {
1803
            $header .= $this->show_media_content();
1804
        }
1805
1806
        $scoreCurrent = [
1807
            'used' => isset($score['score']) ? $score['score'] : '',
1808
            'missing' => isset($score['weight']) ? $score['weight'] : '',
1809
        ];
1810
        $header .= Display::page_subheader2($counterLabel.'. '.$this->question);
1811
1812
        $showRibbon = true;
1813
        // dont display score for certainty degree questions
1814
        if (MULTIPLE_ANSWER_TRUE_FALSE_DEGREE_CERTAINTY == $this->type) {
1815
            $showRibbon = false;
1816
            $ribbonResult = ('true' === api_get_setting('exercise.show_exercise_question_certainty_ribbon_result'));
1817
            if (true === $ribbonResult) {
1818
                $showRibbon = true;
1819
            }
1820
        }
1821
1822
        if ($showRibbon && isset($score['result'])) {
1823
            if (in_array($exercise->results_disabled, [
1824
                RESULT_DISABLE_SHOW_ONLY_IN_CORRECT_ANSWER,
1825
                RESULT_DISABLE_SHOW_SCORE_AND_EXPECTED_ANSWERS_AND_RANKING,
1826
            ])
1827
            ) {
1828
                $score['result'] = null;
1829
            }
1830
            $header .= $exercise->getQuestionRibbon($class, $scoreLabel, $score['result'], $scoreCurrent);
1831
        }
1832
1833
        if (READING_COMPREHENSION != $this->type) {
1834
            // Do not show the description (the text to read) if the question is of type READING_COMPREHENSION
1835
            $header .= Display::div(
1836
                $this->description,
1837
                ['class' => 'question-answer-result__header-description']
1838
            );
1839
        } else {
1840
            /** @var ReadingComprehension $this */
1841
            if (true === $score['pass']) {
1842
                $message = Display::div(
1843
                    sprintf(
1844
                        get_lang(
1845
                            'Congratulations, you have reached and correctly understood, at a speed of %s words per minute, a text of a total %s words.'
1846
                        ),
1847
                        ReadingComprehension::$speeds[$this->level],
1848
                        $this->getWordsCount()
1849
                    )
1850
                );
1851
            } else {
1852
                $message = Display::div(
1853
                    sprintf(
1854
                        get_lang(
1855
                            'Sorry, it seems like a speed of %s words/minute was too fast for this text of %s words.'
1856
                        ),
1857
                        ReadingComprehension::$speeds[$this->level],
1858
                        $this->getWordsCount()
1859
                    )
1860
                );
1861
            }
1862
            $header .= $message.'<br />';
1863
        }
1864
1865
        if ($exercise->hideComment && in_array($this->type, [HOT_SPOT, HOT_SPOT_COMBINATION])) {
1866
            $header .= Display::return_message(get_lang('Results only available online'));
1867
1868
            return $header;
1869
        }
1870
1871
        if (isset($score['pass']) && false === $score['pass']) {
1872
            if ($this->showFeedback($exercise)) {
1873
                $header .= $this->returnFormatFeedback();
1874
            }
1875
        }
1876
1877
        return Display::div(
1878
            $header,
1879
            ['class' => 'question-answer-result__header']
1880
        );
1881
    }
1882
1883
    /**
1884
     * @deprecated
1885
     * Create a question from a set of parameters
1886
     *
1887
     * @param int    $question_name        Quiz ID
1888
     * @param string $question_description Question name
1889
     * @param int    $max_score            Maximum result for the question
1890
     * @param int    $type                 Type of question (see constants at beginning of question.class.php)
1891
     * @param int    $level                Question level/category
1892
     * @param string $quiz_id
1893
     */
1894
    public function create_question(
1895
        $quiz_id,
1896
        $question_name,
1897
        $question_description = '',
1898
        $max_score = 0,
1899
        $type = 1,
1900
        $level = 1
1901
    ) {
1902
        $tbl_quiz_question = Database::get_course_table(TABLE_QUIZ_QUESTION);
1903
        $tbl_quiz_rel_question = Database::get_course_table(TABLE_QUIZ_TEST_QUESTION);
1904
1905
        $quiz_id = (int) $quiz_id;
1906
        $max_score = (float) $max_score;
1907
        $type = (int) $type;
1908
        $level = (int) $level;
1909
1910
        // Get the max position
1911
        $sql = "SELECT max(position) as max_position
1912
                FROM $tbl_quiz_question q
1913
                INNER JOIN $tbl_quiz_rel_question r
1914
                ON
1915
                    q.iid = r.question_id AND
1916
                    quiz_id = $quiz_id";
1917
        $rs_max = Database::query($sql);
1918
        $row_max = Database::fetch_object($rs_max);
1919
        $max_position = $row_max->max_position + 1;
1920
1921
        $params = [
1922
            'question' => $question_name,
1923
            'description' => $question_description,
1924
            'ponderation' => $max_score,
1925
            'position' => $max_position,
1926
            'type' => $type,
1927
            'level' => $level,
1928
            'mandatory' => 0,
1929
        ];
1930
        $question_id = Database::insert($tbl_quiz_question, $params);
1931
1932
        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...
1933
            // Get the max question_order
1934
            $sql = "SELECT max(question_order) as max_order
1935
                    FROM $tbl_quiz_rel_question
1936
                    WHERE quiz_id = $quiz_id ";
1937
            $rs_max_order = Database::query($sql);
1938
            $row_max_order = Database::fetch_object($rs_max_order);
1939
            $max_order = $row_max_order->max_order + 1;
1940
            // Attach questions to quiz
1941
            $sql = "INSERT INTO $tbl_quiz_rel_question (question_id, quiz_id, question_order)
1942
                    VALUES($question_id, $quiz_id, $max_order)";
1943
            Database::query($sql);
1944
        }
1945
1946
        return $question_id;
1947
    }
1948
1949
    /**
1950
     * @return string
1951
     */
1952
    public function getTypePicture()
1953
    {
1954
        return $this->typePicture;
1955
    }
1956
1957
    /**
1958
     * @return string
1959
     */
1960
    public function getExplanation()
1961
    {
1962
        return get_lang($this->explanationLangVar);
1963
    }
1964
1965
    /**
1966
     * Get course medias.
1967
     *
1968
     * @param int $course_id
1969
     *
1970
     * @return array
1971
     */
1972
    public static function get_course_medias(
1973
        $course_id,
1974
        $start = 0,
1975
        $limit = 100,
1976
        $sidx = 'question',
1977
        $sord = 'ASC',
1978
        $where_condition = []
1979
    ) {
1980
        $table_question = Database::get_course_table(TABLE_QUIZ_QUESTION);
1981
        $default_where = [
1982
            'c_id = ? AND parent_id = 0 AND type = ?' => [
1983
                $course_id,
1984
                MEDIA_QUESTION,
1985
            ],
1986
        ];
1987
1988
        return Database::select(
1989
            '*',
1990
            $table_question,
1991
            [
1992
                'limit' => " $start, $limit",
1993
                'where' => $default_where,
1994
                'order' => "$sidx $sord",
1995
            ]
1996
        );
1997
    }
1998
1999
    /**
2000
     * Get count course medias.
2001
     *
2002
     * @param int $course_id course id
2003
     *
2004
     * @return int
2005
     */
2006
    public static function get_count_course_medias($course_id)
2007
    {
2008
        $table_question = Database::get_course_table(TABLE_QUIZ_QUESTION);
2009
        $result = Database::select(
2010
            'count(*) as count',
2011
            $table_question,
2012
            [
2013
                'where' => [
2014
                    'c_id = ? AND parent_id = 0 AND type = ?' => [
2015
                        $course_id,
2016
                        MEDIA_QUESTION,
2017
                    ],
2018
                ],
2019
            ],
2020
            'first'
2021
        );
2022
2023
        if ($result && isset($result['count'])) {
2024
            return $result['count'];
2025
        }
2026
2027
        return 0;
2028
    }
2029
2030
    /**
2031
     * @param int $course_id
2032
     *
2033
     * @return array
2034
     */
2035
    public static function prepare_course_media_select(int $quizId): array
2036
    {
2037
        $tableQuestion     = Database::get_course_table(TABLE_QUIZ_QUESTION);
2038
        $tableRelQuestion  = Database::get_course_table(TABLE_QUIZ_TEST_QUESTION);
2039
2040
        $medias = Database::select(
2041
            '*',
2042
            "$tableQuestion q
2043
         JOIN $tableRelQuestion rq ON rq.question_id = q.iid",
2044
            [
2045
                'where' => [
2046
                    'rq.quiz_id = ? AND (q.parent_media_id IS NULL OR q.parent_media_id = 0) AND q.type = ?'
2047
                    => [$quizId, MEDIA_QUESTION],
2048
                ],
2049
                'order' => 'question ASC',
2050
            ]
2051
        );
2052
2053
        $mediaList = [
2054
            0 => get_lang('Not linked to media'),
2055
        ];
2056
2057
        foreach ($medias as $media) {
2058
            $mediaList[$media['question_id']] = empty($media['question'])
2059
                ? get_lang('Untitled')
2060
                : $media['question'];
2061
        }
2062
2063
        return $mediaList;
2064
    }
2065
2066
    /**
2067
     * @return array
2068
     */
2069
    public static function get_default_levels()
2070
    {
2071
        return [
2072
            1 => 1,
2073
            2 => 2,
2074
            3 => 3,
2075
            4 => 4,
2076
            5 => 5,
2077
        ];
2078
    }
2079
2080
    /**
2081
     * @return string
2082
     */
2083
    public function show_media_content()
2084
    {
2085
        $html = '';
2086
        if (0 != $this->parent_id) {
2087
            $parent_question = self::read($this->parent_id);
2088
            $html = $parent_question->show_media_content();
2089
        } else {
2090
            $html .= Display::page_subheader($this->selectTitle());
2091
            $html .= $this->selectDescription();
2092
        }
2093
2094
        return $html;
2095
    }
2096
2097
    /**
2098
     * Swap between unique and multiple type answers.
2099
     *
2100
     * @return UniqueAnswer|MultipleAnswer
2101
     */
2102
    public function swapSimpleAnswerTypes()
2103
    {
2104
        $oppositeAnswers = [
2105
            UNIQUE_ANSWER => MULTIPLE_ANSWER,
2106
            MULTIPLE_ANSWER => UNIQUE_ANSWER,
2107
        ];
2108
        $this->type = $oppositeAnswers[$this->type];
2109
        Database::update(
2110
            Database::get_course_table(TABLE_QUIZ_QUESTION),
2111
            ['type' => $this->type],
2112
            ['c_id = ? AND id = ?' => [$this->course['real_id'], $this->id]]
2113
        );
2114
        $answerClasses = [
2115
            UNIQUE_ANSWER => 'UniqueAnswer',
2116
            MULTIPLE_ANSWER => 'MultipleAnswer',
2117
            MULTIPLE_ANSWER_DROPDOWN => 'MultipleAnswerDropdown',
2118
            MULTIPLE_ANSWER_DROPDOWN_COMBINATION => 'MultipleAnswerDropdownCombination',
2119
        ];
2120
        $swappedAnswer = new $answerClasses[$this->type]();
2121
        foreach ($this as $key => $value) {
2122
            $swappedAnswer->$key = $value;
2123
        }
2124
2125
        return $swappedAnswer;
2126
    }
2127
2128
    /**
2129
     * @param array $score
2130
     *
2131
     * @return bool
2132
     */
2133
    public function isQuestionWaitingReview($score)
2134
    {
2135
        $isReview = false;
2136
        if (!empty($score)) {
2137
            if (!empty($score['comments']) || $score['score'] > 0) {
2138
                $isReview = true;
2139
            }
2140
        }
2141
2142
        return $isReview;
2143
    }
2144
2145
    /**
2146
     * @param string $value
2147
     */
2148
    public function setFeedback($value)
2149
    {
2150
        $this->feedback = $value;
2151
    }
2152
2153
    /**
2154
     * @param Exercise $exercise
2155
     *
2156
     * @return bool
2157
     */
2158
    public function showFeedback($exercise)
2159
    {
2160
        if (false === $exercise->hideComment) {
2161
            return false;
2162
        }
2163
2164
        return
2165
            in_array($this->type, $this->questionTypeWithFeedback) &&
2166
            EXERCISE_FEEDBACK_TYPE_EXAM != $exercise->getFeedbackType();
2167
    }
2168
2169
    /**
2170
     * @return string
2171
     */
2172
    public function returnFormatFeedback()
2173
    {
2174
        return '<br />'.Display::return_message($this->feedback, 'normal', false);
2175
    }
2176
2177
    /**
2178
     * Check if this question exists in another exercise.
2179
     *
2180
     * @throws \Doctrine\ORM\Query\QueryException
2181
     *
2182
     * @return bool
2183
     */
2184
    public function existsInAnotherExercise()
2185
    {
2186
        $count = $this->getCountExercise();
2187
2188
        return $count > 1;
2189
    }
2190
2191
    /**
2192
     * @throws \Doctrine\ORM\Query\QueryException
2193
     *
2194
     * @return int
2195
     */
2196
    public function getCountExercise()
2197
    {
2198
        $em = Database::getManager();
2199
2200
        $count = $em
2201
            ->createQuery('
2202
                SELECT COUNT(qq.iid) FROM ChamiloCourseBundle:CQuizRelQuestion qq
2203
                WHERE qq.question = :id
2204
            ')
2205
            ->setParameters(['id' => (int) $this->id])
2206
            ->getSingleScalarResult();
2207
2208
        return (int) $count;
2209
    }
2210
2211
    /**
2212
     * Check if this question exists in another exercise.
2213
     *
2214
     * @throws \Doctrine\ORM\Query\QueryException
2215
     */
2216
    public function getExerciseListWhereQuestionExists()
2217
    {
2218
        $em = Database::getManager();
2219
2220
        return $em
2221
            ->createQuery('
2222
                SELECT e
2223
                FROM ChamiloCourseBundle:CQuizRelQuestion qq
2224
                JOIN ChamiloCourseBundle:CQuiz e
2225
                WHERE e.iid = qq.exerciceId AND qq.questionId = :id
2226
            ')
2227
            ->setParameters(['id' => (int) $this->id])
2228
            ->getResult();
2229
    }
2230
2231
    /**
2232
     * @return int
2233
     */
2234
    public function countAnswers()
2235
    {
2236
        $result = Database::select(
2237
            'COUNT(1) AS c',
2238
            Database::get_course_table(TABLE_QUIZ_ANSWER),
2239
            ['where' => ['question_id = ?' => [$this->id]]],
2240
            'first'
2241
        );
2242
2243
        return (int) $result['c'];
2244
    }
2245
2246
    /**
2247
     * Add adaptive scenario selector fields (success/failure) to the question form.
2248
     *
2249
     * @param FormValidator $form
2250
     * @param array         $questionList List of question IDs from the exercise.
2251
     */
2252
    protected function addAdaptiveScenarioFields(FormValidator $form, array $questionList): void
2253
    {
2254
        // Section header
2255
        $form->addHtml('<h4 class="m-4">'.get_lang('Adaptive behavior (success/failure)').'</h4>');
2256
2257
        // Options for redirection behavior
2258
        $questionListOptions = [
2259
            ''      => get_lang('Select destination'),
2260
            'repeat'=> get_lang('Repeat question'),
2261
            '-1'    => get_lang('End of test'),
2262
            'url'   => get_lang('Other (custom URL)'),
2263
        ];
2264
2265
        // Append available questions to the dropdown
2266
        foreach ($questionList as $index => $qid) {
2267
            if (!is_numeric($qid)) {
2268
                continue;
2269
            }
2270
2271
            $q = self::read((int) $qid);
2272
            if (!$q) {
2273
                continue;
2274
            }
2275
2276
            $questionListOptions[(string) $qid] = 'Q'.$index.': '.strip_tags($q->selectTitle());
2277
        }
2278
2279
        // Success selector and optional URL field
2280
        $form->addSelect(
2281
            'scenario_success_selector',
2282
            get_lang('On success'),
2283
            $questionListOptions,
2284
            ['id' => 'scenario_success_selector']
2285
        );
2286
        $form->addText(
2287
            'scenario_success_url',
2288
            get_lang('Custom URL'),
2289
            false,
2290
            [
2291
                'class'       => 'form-control mb-5',
2292
                'id'          => 'scenario_success_url',
2293
                'placeholder' => '/main/lp/134',
2294
            ]
2295
        );
2296
2297
        // Failure selector and optional URL field
2298
        $form->addSelect(
2299
            'scenario_failure_selector',
2300
            get_lang('On failure'),
2301
            $questionListOptions,
2302
            ['id' => 'scenario_failure_selector']
2303
        );
2304
        $form->addText(
2305
            'scenario_failure_url',
2306
            get_lang('Custom URL'),
2307
            false,
2308
            [
2309
                'class'       => 'form-control mb-5',
2310
                'id'          => 'scenario_failure_url',
2311
                'placeholder' => '/main/lp/134',
2312
            ]
2313
        );
2314
2315
        // JavaScript to toggle custom URL fields when 'url' is selected
2316
        $form->addHtml('
2317
            <script>
2318
                function toggleScenarioUrlFields() {
2319
                    var successSelector = document.getElementById("scenario_success_selector");
2320
                    var successUrlRow = document.getElementById("scenario_success_url").parentNode.parentNode;
2321
2322
                    var failureSelector = document.getElementById("scenario_failure_selector");
2323
                    var failureUrlRow = document.getElementById("scenario_failure_url").parentNode.parentNode;
2324
2325
                    if (successSelector && successSelector.value === "url") {
2326
                        successUrlRow.style.display = "table-row";
2327
                    } else {
2328
                        successUrlRow.style.display = "none";
2329
                    }
2330
2331
                    if (failureSelector && failureSelector.value === "url") {
2332
                        failureUrlRow.style.display = "table-row";
2333
                    } else {
2334
                        failureUrlRow.style.display = "none";
2335
                    }
2336
                }
2337
2338
                document.addEventListener("DOMContentLoaded", toggleScenarioUrlFields);
2339
                document.getElementById("scenario_success_selector")
2340
                    .addEventListener("change", toggleScenarioUrlFields);
2341
                document.getElementById("scenario_failure_selector")
2342
                    .addEventListener("change", toggleScenarioUrlFields);
2343
            </script>
2344
        ');
2345
    }
2346
2347
    /**
2348
     * Persist adaptive scenario (success/failure) configuration for this question.
2349
     *
2350
     * This stores the "destination" JSON in TABLE_QUIZ_TEST_QUESTION for the
2351
     * current (question, exercise) pair. It is intended to be called from
2352
     * processAnswersCreation() implementations.
2353
     *
2354
     * @param FormValidator $form
2355
     * @param Exercise      $exercise
2356
     */
2357
    public function saveAdaptiveScenario(FormValidator $form, Exercise $exercise): void
2358
    {
2359
        // Global feature flag disabled → nothing to do.
2360
        if ('true' !== api_get_setting('enable_quiz_scenario')) {
2361
            return;
2362
        }
2363
2364
        // We only support adaptive scenarios when feedback is "direct".
2365
        if (EXERCISE_FEEDBACK_TYPE_DIRECT !== $exercise->getFeedbackType()) {
2366
            return;
2367
        }
2368
2369
        // This question type is not listed as "scenario capable".
2370
        if (!$this->supportsAdaptiveScenario()) {
2371
            return;
2372
        }
2373
2374
        $successSelector = trim((string) $form->getSubmitValue('scenario_success_selector'));
2375
        $successUrl      = trim((string) $form->getSubmitValue('scenario_success_url'));
2376
        $failureSelector = trim((string) $form->getSubmitValue('scenario_failure_selector'));
2377
        $failureUrl      = trim((string) $form->getSubmitValue('scenario_failure_url'));
2378
2379
        // Map "url" selector to the actual custom URL, keep other values as-is.
2380
        $success = ('url' === $successSelector) ? $successUrl : $successSelector;
2381
        $failure = ('url' === $failureSelector) ? $failureUrl : $failureSelector;
2382
2383
        // If nothing is configured at all, avoid touching the DB.
2384
        if ('' === $success && '' === $failure) {
2385
            return;
2386
        }
2387
2388
        $destination = json_encode(
2389
            [
2390
                'success' => $success ?: '',
2391
                'failure' => $failure ?: '',
2392
            ],
2393
            JSON_UNESCAPED_UNICODE
2394
        );
2395
2396
        $table      = Database::get_course_table(TABLE_QUIZ_TEST_QUESTION);
2397
        $questionId = (int) $this->id;
2398
        $exerciseId = (int) $exercise->id; // Consistent with existing code in UniqueAnswer
2399
2400
        if ($questionId <= 0 || $exerciseId <= 0) {
2401
            // The (question, exercise) relation does not exist yet.
2402
            return;
2403
        }
2404
2405
        Database::update(
2406
            $table,
2407
            ['destination' => $destination],
2408
            ['question_id = ? AND quiz_id = ?' => [$questionId, $exerciseId]]
2409
        );
2410
    }
2411
2412
    /**
2413
     * Pre-fill adaptive scenario fields from the stored (question, exercise) relation.
2414
     */
2415
    protected function loadAdaptiveScenarioDefaults(FormValidator $form, Exercise $exercise): void
2416
    {
2417
        if ('true' !== api_get_setting('enable_quiz_scenario')) {
2418
            return;
2419
        }
2420
2421
        if (!$this->supportsAdaptiveScenario()) {
2422
            return;
2423
        }
2424
2425
        if (empty($this->id) || empty($exercise->id)) {
2426
            return;
2427
        }
2428
2429
        $table = Database::get_course_table(TABLE_QUIZ_TEST_QUESTION);
2430
2431
        $row = Database::select(
2432
            'destination',
2433
            $table,
2434
            [
2435
                'where' => [
2436
                    'question_id = ? AND quiz_id = ?' => [
2437
                        (int) $this->id,
2438
                        (int) $exercise->id,
2439
                    ],
2440
                ],
2441
                'limit' => 1,
2442
            ],
2443
            'first'
2444
        );
2445
2446
        if (empty($row['destination'])) {
2447
            return;
2448
        }
2449
2450
        $json = json_decode((string) $row['destination'], true) ?: [];
2451
2452
        $defaults = [];
2453
2454
        if (!empty($json['success'])) {
2455
            if (str_starts_with($json['success'], '/')) {
2456
                $defaults['scenario_success_selector'] = 'url';
2457
                $defaults['scenario_success_url']      = $json['success'];
2458
            } else {
2459
                $defaults['scenario_success_selector'] = $json['success'];
2460
            }
2461
        }
2462
2463
        if (!empty($json['failure'])) {
2464
            if (str_starts_with($json['failure'], '/')) {
2465
                $defaults['scenario_failure_selector'] = 'url';
2466
                $defaults['scenario_failure_url']      = $json['failure'];
2467
            } else {
2468
                $defaults['scenario_failure_selector'] = $json['failure'];
2469
            }
2470
        }
2471
2472
        if (!empty($defaults)) {
2473
            $form->setDefaults($defaults);
2474
        }
2475
    }
2476
2477
    /**
2478
     * Check if this question type supports adaptive scenarios.
2479
     */
2480
    protected function supportsAdaptiveScenario(): bool
2481
    {
2482
        return in_array((int) $this->type, static::$adaptiveScenarioTypes, true);
2483
    }
2484
}
2485