ExerciseLib::getFeedbackText()   A
last analyzed

Complexity

Conditions 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nop 1
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
3
/* For licensing terms, see /license.txt */
4
5
use Chamilo\CoreBundle\Entity\Course as CourseEntity;
6
use Chamilo\CoreBundle\Entity\ResourceNode;
7
use Chamilo\CoreBundle\Entity\Session as SessionEntity;
8
use Chamilo\CoreBundle\Entity\GradebookCategory;
9
use Chamilo\CoreBundle\Entity\TrackEExercise;
10
use Chamilo\CoreBundle\Enums\ActionIcon;
11
use Chamilo\CoreBundle\Framework\Container;
12
use Chamilo\CoreBundle\Helpers\AiHelper;
13
use Chamilo\CoreBundle\Helpers\ChamiloHelper;
14
use Chamilo\CoreBundle\Repository\ResourceNodeRepository;
15
use Chamilo\CourseBundle\Entity\CLpItem;
16
use Chamilo\CourseBundle\Entity\CLpItemView;
17
use Chamilo\CourseBundle\Entity\CQuiz;
18
use ChamiloSession as Session;
19
20
/**
21
 * Class ExerciseLib
22
 * shows a question and its answers.
23
 *
24
 * @author Olivier Brouckaert <[email protected]>
25
 * @author Hubert Borderiou 2011-10-21
26
 * @author ivantcholakov2009-07-20
27
 * @author Julio Montoya
28
 */
29
class ExerciseLib
30
{
31
    /**
32
     * Shows a question.
33
     *
34
     * @param Exercise $exercise
35
     * @param int      $questionId     $questionId question id
36
     * @param bool     $only_questions if true only show the questions, no exercise title
37
     * @param bool     $origin         i.e = learnpath
38
     * @param string   $current_item   current item from the list of questions
39
     * @param bool     $show_title
40
     * @param bool     $freeze
41
     * @param array    $user_choice
42
     * @param bool     $show_comment
43
     * @param bool     $show_answers
44
     *
45
     * @throws \Exception
46
     *
47
     * @return bool|int
48
     */
49
    public static function showQuestion(
50
        $exercise,
51
        $questionId,
52
        $only_questions = false,
53
        $origin = false,
54
        $current_item = '',
55
        $show_title = true,
56
        $freeze = false,
57
        $user_choice = [],
58
        $show_comment = false,
59
        $show_answers = false,
60
        $show_icon = false
61
    ) {
62
        $course_id = $exercise->course_id;
63
        $exerciseId = $exercise->iId;
64
65
        if (empty($course_id)) {
66
            return '';
67
        }
68
        $course = $exercise->course;
69
70
        // Change false to true in the following line to enable answer hinting
71
        $debug_mark_answer = $show_answers;
72
        // Reads question information
73
        if (!$objQuestionTmp = Question::read($questionId, $course)) {
74
            // Question not found
75
            return false;
76
        }
77
78
        if (EXERCISE_FEEDBACK_TYPE_END != $exercise->getFeedbackType()) {
79
            $show_comment = false;
80
        }
81
82
        $answerType = $objQuestionTmp->selectType();
83
84
        if (MEDIA_QUESTION === $answerType) {
85
            $mediaHtml = $objQuestionTmp->selectDescription();
86
            if (!empty($mediaHtml)) {
87
                echo '<div class="media-content wysiwyg">'. $mediaHtml .'</div>';
88
            }
89
            return 0;
90
        }
91
92
        if (PAGE_BREAK === $answerType) {
93
            $description = $objQuestionTmp->selectDescription();
94
            if (!$only_questions && !empty($description)) {
95
                echo '<div class="page-break-content wysiwyg">'
96
                    . $description .
97
                    '</div>';
98
            }
99
            return 0;
100
        }
101
102
        $s = '';
103
        if (HOT_SPOT != $answerType &&
104
            HOT_SPOT_DELINEATION != $answerType &&
105
             HOT_SPOT_COMBINATION != $answerType &&
106
            ANNOTATION != $answerType
107
        ) {
108
            // Question is not a hotspot
109
            if (!$only_questions) {
110
                $questionDescription = $objQuestionTmp->selectDescription();
111
                if ($show_title) {
112
                    if ($exercise->display_category_name) {
113
                        TestCategory::displayCategoryAndTitle($objQuestionTmp->id);
114
                    }
115
                    $titleToDisplay = $objQuestionTmp->getTitleToDisplay($exercise, $current_item);
116
                    if (READING_COMPREHENSION == $answerType) {
117
                        // In READING_COMPREHENSION, the title of the question
118
                        // contains the question itself, which can only be
119
                        // shown at the end of the given time, so hide for now
120
                        $titleToDisplay = Display::div(
121
                            $current_item.'. '.get_lang('Reading comprehension'),
122
                            ['class' => 'question_title']
123
                        );
124
                    }
125
                    echo $titleToDisplay;
126
                }
127
128
                if (!empty($questionDescription) && READING_COMPREHENSION != $answerType) {
129
                    echo Display::div(
130
                        $questionDescription,
131
                        ['class' => 'question_description wysiwyg']
132
                    );
133
                }
134
            }
135
136
            if (in_array($answerType, [FREE_ANSWER, ORAL_EXPRESSION, UPLOAD_ANSWER]) && $freeze) {
137
                return '';
138
            }
139
140
            echo '<div class="question_options type-'.$answerType.'">';
141
            // construction of the Answer object (also gets all answers details)
142
            $objAnswerTmp = new Answer($questionId, $course_id, $exercise);
143
            $nbrAnswers = $objAnswerTmp->selectNbrAnswers();
144
            $quizQuestionOptions = Question::readQuestionOption($questionId, $course_id);
145
            $selectableOptions = [];
146
147
            for ($i = 1; $i <= $objAnswerTmp->nbrAnswers; $i++) {
148
                $selectableOptions[$objAnswerTmp->iid[$i]] = $objAnswerTmp->answer[$i];
149
            }
150
151
            // For "matching" type here, we need something a little bit special
152
            // because the match between the suggestions and the answers cannot be
153
            // done easily (suggestions and answers are in the same table), so we
154
            // have to go through answers first (elems with "correct" value to 0).
155
            $select_items = [];
156
            //This will contain the number of answers on the left side. We call them
157
            // suggestions here, for the sake of comprehensions, while the ones
158
            // on the right side are called answers
159
            $num_suggestions = 0;
160
            switch ($answerType) {
161
                case MATCHING:
162
                case MATCHING_COMBINATION:
163
                case DRAGGABLE:
164
                case MATCHING_DRAGGABLE:
165
                case MATCHING_DRAGGABLE_COMBINATION:
166
                    if (DRAGGABLE == $answerType) {
167
                        $isVertical = 'v' === $objQuestionTmp->extra;
168
                        $s .= '<p class="small">'
169
                            .get_lang('Sort the following options from the list as you see fit by dragging them to the lower areas. You can put them back in this area to modify your answer.')
170
                            .'</p>
171
                            <div class="w-full ui-widget ui-helper-clearfix">
172
                                <div class="clearfix">
173
                                    <ul class="exercise-draggable-answer '.($isVertical ? 'vertical' : 'list-inline w-full').'"
174
                                        id="question-'.$questionId.'" data-question="'.$questionId.'">
175
                            ';
176
                    } else {
177
                        $s .= '<div id="drag'.$questionId.'_question" class="drag_question">
178
                               <table class="table table-hover table-striped data_table">';
179
                    }
180
181
                    // Iterate through answers.
182
                    $x = 1;
183
                    // Mark letters for each answer.
184
                    $letter = 'A';
185
                    $answer_matching = [];
186
                    $cpt1 = [];
187
                    for ($answerId = 1; $answerId <= $nbrAnswers; $answerId++) {
188
                        $answerCorrect = $objAnswerTmp->isCorrect($answerId);
189
                        $numAnswer = $objAnswerTmp->selectAutoId($answerId);
190
                        if (0 == $answerCorrect) {
191
                            // options (A, B, C, ...) that will be put into the list-box
192
                            // have the "correct" field set to 0 because they are answer
193
                            $cpt1[$x] = $letter;
194
                            $answer_matching[$x] = $objAnswerTmp->selectAnswerByAutoId($numAnswer);
195
                            $x++;
196
                            $letter++;
197
                        }
198
                    }
199
200
                    $i = 1;
201
                    $select_items[0]['id'] = 0;
202
                    $select_items[0]['letter'] = '--';
203
                    $select_items[0]['answer'] = '';
204
                    foreach ($answer_matching as $id => $value) {
205
                        $select_items[$i]['id'] = $value['iid'];
206
                        $select_items[$i]['letter'] = $cpt1[$id];
207
                        $select_items[$i]['answer'] = $value['answer'];
208
                        $i++;
209
                    }
210
211
                    $user_choice_array_position = [];
212
                    if (!empty($user_choice)) {
213
                        foreach ($user_choice as $item) {
214
                            $user_choice_array_position[$item['position']] = $item['answer'];
215
                        }
216
                    }
217
                    $num_suggestions = ($nbrAnswers - $x) + 1;
218
                    break;
219
                case FREE_ANSWER:
220
                    $fck_content = isset($user_choice[0]) && !empty($user_choice[0]['answer']) ? $user_choice[0]['answer'] : null;
221
                    $form = new FormValidator('free_choice_'.$questionId);
222
                    $config = [
223
                        'ToolbarSet' => 'TestFreeAnswer',
224
                    ];
225
                    $form->addHtmlEditor(
226
                        'choice['.$questionId.']',
227
                        null,
228
                        false,
229
                        false,
230
                        $config
231
                    );
232
                    $form->setDefaults(["choice[".$questionId."]" => $fck_content]);
233
                    $s .= $form->returnForm();
234
                    break;
235
                case UPLOAD_ANSWER:
236
                    $url = api_get_path(WEB_AJAX_PATH).'exercise.ajax.php?'.api_get_cidreq().'&a=upload_answer&question_id='.$questionId;
237
                    $multipleForm = new FormValidator(
238
                        'drag_drop',
239
                        'post',
240
                        '#',
241
                        '',
242
                        ['enctype' => 'multipart/form-data', 'id' => 'drag_drop']
243
                    );
244
245
                    $iconDelete = Display::return_icon('delete.png', get_lang('Delete'), [], ICON_SIZE_SMALL);
246
                    $multipleForm->addMultipleUpload($url);
247
248
                    $s .= '<script>
249
                        function setRemoveLink(dataContext) {
250
                            var removeLink = $("<a>", {
251
                                html: "&nbsp;'.addslashes($iconDelete).'",
252
                                href: "#",
253
                                click: function(e) {
254
                                  e.preventDefault();
255
                                  dataContext.parent().remove();
256
                                }
257
                            });
258
                            dataContext.append(removeLink);
259
                        }
260
261
                        $(function() {
262
                            $("#input_file_upload").bind("fileuploaddone", function (e, data) {
263
                                $.each(data.result.files, function (index, file) {
264
                                    // El backend ahora devuelve asset_id y url
265
                                    if (file.asset_id) {
266
                                        var input = $("<input>", {
267
                                            type: "hidden",
268
                                            name: "uploadAsset['.$questionId.'][]",
269
                                            value: file.asset_id
270
                                        });
271
                                        $(data.context.children()[index]).parent().append(input);
272
                                        // set the remove link
273
                                        setRemoveLink($(data.context.children()[index]).parent());
274
                                    }
275
                                });
276
                            });
277
                        });
278
                    </script>';
279
                    $sessionKey = 'upload_answer_assets_'.$questionId;
280
                    $assetIds = (array) ChamiloSession::read($sessionKey);
281
282
                    if (!empty($assetIds)) {
283
                        $icon = Display::return_icon('file_txt.gif');
284
                        $default = "";
285
                        $assetRepo = Container::getAssetRepository();
286
                        $basePath = rtrim(api_get_path(WEB_PATH), "/");
287
288
                        foreach ($assetIds as $id) {
289
                            try {
290
                                $asset = $assetRepo->find(\Symfony\Component\Uid\Uuid::fromRfc4122((string)$id));
291
                            } catch (\Throwable $e) {
292
                                $asset = null;
293
                            }
294
                            if (!$asset) { continue; }
295
296
                            $title = Security::remove_XSS($asset->getTitle());
297
                            $urlAsset = $basePath.$assetRepo->getAssetUrl($asset);
298
299
                            $default .= Display::tag(
300
                                "a",
301
                                Display::div(
302
                                    Display::div($icon, ['class' => 'col-sm-4'])
303
                                    . Display::div($title, ['class' => 'col-sm-5 file_name'])
304
                                    . Display::tag("input", "", [
305
                                        "type" => "hidden",
306
                                        "name" => "uploadAsset['.$questionId.'][]",
307
                                        "value" => (string)$id
308
                                    ])
309
                                    . Display::div("", ["class" => "col-sm-3"]),
310
                                    ["class" => "row"]
311
                                ),
312
                                ["target" => "_blank", "class" => "panel-image", "href" => $urlAsset]
313
                            );
314
                        }
315
316
                        $s .= '<script>
317
                            $(function() {
318
                                if ($("#files").length > 0) {
319
                                    $("#files").html("'.addslashes($default).'");
320
                                    var links = $("#files").children();
321
                                    links.each(function(index) {
322
                                        var dataContext = $(links[index]).find(".row");
323
                                        setRemoveLink(dataContext);
324
                                    });
325
                                }
326
                            });
327
                        </script>';
328
                    }
329
330
                    $s .= $multipleForm->returnForm();
331
                    break;
332
                case ORAL_EXPRESSION:
333
                    // Add nanog
334
                    //@todo pass this as a parameter
335
                    global $exercise_stat_info;
336
                    if (!empty($exercise_stat_info)) {
337
                        echo $objQuestionTmp->returnRecorder((int) $exercise_stat_info['exe_id']);
338
                        $generatedFile = self::getOralFileAudio($exercise_stat_info['exe_id'], $questionId);
339
                        if (!empty($generatedFile)) {
340
                            echo $generatedFile;
341
                        }
342
                    }
343
344
                    $form = new FormValidator('free_choice_'.$questionId);
345
                    $config = ['ToolbarSet' => 'TestFreeAnswer'];
346
347
                    $form->addHtml('<div id="'.'hide_description_'.$questionId.'_options" style="display: none;">');
348
                    $form->addHtmlEditor(
349
                        "choice[$questionId]",
350
                        null,
351
                        false,
352
                        false,
353
                        $config
354
                    );
355
                    $form->addHtml('</div>');
356
                    $s .= $form->returnForm();
357
                    break;
358
                case MULTIPLE_ANSWER_DROPDOWN:
359
                case MULTIPLE_ANSWER_DROPDOWN_COMBINATION:
360
                    if ($debug_mark_answer) {
361
                        $s .= '<p><strong>'
362
                            .(
363
                                MULTIPLE_ANSWER_DROPDOWN == $answerType
364
                                    ? '<span class="pull-right">'.get_lang('Score').'</span>'
365
                                    : ''
366
                            )
367
                            .get_lang('Correct answer').'</strong></p>';
368
                    }
369
                    break;
370
            }
371
372
            // Now navigate through the possible answers, using the max number of
373
            // answers for the question as a limiter
374
            $lines_count = 1; // a counter for matching-type answers
375
            if (MULTIPLE_ANSWER_TRUE_FALSE == $answerType ||
376
                MULTIPLE_ANSWER_COMBINATION_TRUE_FALSE == $answerType
377
            ) {
378
                $header = Display::tag('th', get_lang('Options'));
379
                foreach ($objQuestionTmp->options as $item) {
380
                    if (MULTIPLE_ANSWER_TRUE_FALSE == $answerType) {
381
                        if (in_array($item, $objQuestionTmp->options)) {
382
                            $header .= Display::tag('th', get_lang($item));
383
                        } else {
384
                            $header .= Display::tag('th', $item);
385
                        }
386
                    } else {
387
                        $header .= Display::tag('th', $item);
388
                    }
389
                }
390
                if ($show_comment) {
391
                    $header .= Display::tag('th', get_lang('Feedback'));
392
                }
393
                $s .= '<table class="table table-hover table-striped">';
394
                $s .= Display::tag(
395
                    'tr',
396
                    $header,
397
                    ['style' => 'text-align:left;']
398
                );
399
            } elseif (MULTIPLE_ANSWER_TRUE_FALSE_DEGREE_CERTAINTY == $answerType) {
400
                $header = Display::tag('th', get_lang('Options'), ['width' => '50%']);
401
                echo "
402
                <script>
403
                    function RadioValidator(question_id, answer_id)
404
                    {
405
                        var ShowAlert = '';
406
                        var typeRadioB = '';
407
                        var AllFormElements = window.document.getElementById('exercise_form').elements;
408
409
                        for (i = 0; i < AllFormElements.length; i++) {
410
                            if (AllFormElements[i].type == 'radio') {
411
                                var ThisRadio = AllFormElements[i].name;
412
                                var ThisChecked = 'No';
413
                                var AllRadioOptions = document.getElementsByName(ThisRadio);
414
415
                                for (x = 0; x < AllRadioOptions.length; x++) {
416
                                     if (AllRadioOptions[x].checked && ThisChecked == 'No') {
417
                                         ThisChecked = 'Yes';
418
                                         break;
419
                                     }
420
                                }
421
422
                                var AlreadySearched = ShowAlert.indexOf(ThisRadio);
423
                                if (ThisChecked == 'No' && AlreadySearched == -1) {
424
                                    ShowAlert = ShowAlert + ThisRadio;
425
                                }
426
                            }
427
                        }
428
                        if (ShowAlert != '') {
429
430
                        } else {
431
                            $('.question-validate-btn').removeAttr('disabled');
432
                        }
433
                    }
434
435
                    function handleRadioRow(event, question_id, answer_id) {
436
                        var t = event.target;
437
                        if (t && t.tagName == 'INPUT')
438
                            return;
439
                        while (t && t.tagName != 'TD') {
440
                            t = t.parentElement;
441
                        }
442
                        var r = t.getElementsByTagName('INPUT')[0];
443
                        r.click();
444
                        RadioValidator(question_id, answer_id);
445
                    }
446
447
                    $(function() {
448
                        var ShowAlert = '';
449
                        var typeRadioB = '';
450
                        var question_id = $('input[name=question_id]').val();
451
                        var AllFormElements = window.document.getElementById('exercise_form').elements;
452
453
                        for (i = 0; i < AllFormElements.length; i++) {
454
                            if (AllFormElements[i].type == 'radio') {
455
                                var ThisRadio = AllFormElements[i].name;
456
                                var ThisChecked = 'No';
457
                                var AllRadioOptions = document.getElementsByName(ThisRadio);
458
459
                                for (x = 0; x < AllRadioOptions.length; x++) {
460
                                    if (AllRadioOptions[x].checked && ThisChecked == 'No') {
461
                                        ThisChecked = \"Yes\";
462
                                        break;
463
                                    }
464
                                }
465
466
                                var AlreadySearched = ShowAlert.indexOf(ThisRadio);
467
                                if (ThisChecked == 'No' && AlreadySearched == -1) {
468
                                    ShowAlert = ShowAlert + ThisRadio;
469
                                }
470
                            }
471
                        }
472
473
                        if (ShowAlert != '') {
474
                             $('.question-validate-btn').attr('disabled', 'disabled');
475
                        } else {
476
                            $('.question-validate-btn').removeAttr('disabled');
477
                        }
478
                    });
479
                </script>";
480
481
                foreach ($objQuestionTmp->optionsTitle as $item) {
482
                    if (in_array($item, $objQuestionTmp->optionsTitle)) {
483
                        $properties = [];
484
                        if ('Answers' === $item) {
485
                            $properties['colspan'] = 2;
486
                            $properties['style'] = 'background-color: #F56B2A; color: #ffffff;';
487
                        } elseif ('DegreeOfCertaintyThatMyAnswerIsCorrect' === $item) {
488
                            $properties['colspan'] = 6;
489
                            $properties['style'] = 'background-color: #330066; color: #ffffff;';
490
                        }
491
                        $header .= Display::tag('th', get_lang($item), $properties);
492
                    } else {
493
                        $header .= Display::tag('th', $item);
494
                    }
495
                }
496
497
                if ($show_comment) {
498
                    $header .= Display::tag('th', get_lang('Feedback'));
499
                }
500
501
                $s .= '<table class="table table-hover table-striped data_table">';
502
                $s .= Display::tag('tr', $header, ['style' => 'text-align:left;']);
503
504
                // ajout de la 2eme ligne d'entête pour true/falss et les pourcentages de certitude
505
                $header1 = Display::tag('th', '&nbsp;');
506
                $cpt1 = 0;
507
                foreach ($objQuestionTmp->options as $item) {
508
                    $colorBorder1 = ($cpt1 == (count($objQuestionTmp->options) - 1))
509
                        ? '' : 'border-right: solid #FFFFFF 1px;';
510
                    if ('True' === $item || 'False' === $item) {
511
                        $header1 .= Display::tag(
512
                            'th',
513
                            get_lang($item),
514
                            ['style' => 'background-color: #F7C9B4; color: black;'.$colorBorder1]
515
                        );
516
                    } else {
517
                        $header1 .= Display::tag(
518
                            'th',
519
                            $item,
520
                            ['style' => 'background-color: #e6e6ff; color: black;padding:5px; '.$colorBorder1]
521
                        );
522
                    }
523
                    $cpt1++;
524
                }
525
                if ($show_comment) {
526
                    $header1 .= Display::tag('th', '&nbsp;');
527
                }
528
529
                $s .= Display::tag('tr', $header1);
530
531
                // add explanation
532
                $header2 = Display::tag('th', '&nbsp;');
533
                $descriptionList = [
534
                    get_lang('I don\'t know the answer and I\'ve picked at random'),
535
                    get_lang('I am very unsure'),
536
                    get_lang('I am unsure'),
537
                    get_lang('I am pretty sure'),
538
                    get_lang('I am almost 100% sure'),
539
                    get_lang('I am totally sure'),
540
                ];
541
                $counter2 = 0;
542
                foreach ($objQuestionTmp->options as $item) {
543
                    if ('True' === $item || 'False' === $item) {
544
                        $header2 .= Display::tag('td',
545
                            '&nbsp;',
546
                            ['style' => 'background-color: #F7E1D7; color: black;border-right: solid #FFFFFF 1px;']);
547
                    } else {
548
                        $color_border2 = ($counter2 == (count($objQuestionTmp->options) - 1)) ?
549
                            '' : 'border-right: solid #FFFFFF 1px;font-size:11px;';
550
                        $header2 .= Display::tag(
551
                            'td',
552
                            nl2br($descriptionList[$counter2]),
553
                            ['style' => 'background-color: #EFEFFC; color: black; width: 110px; text-align:center;
554
                                vertical-align: top; padding:5px; '.$color_border2]);
555
                        $counter2++;
556
                    }
557
                }
558
                if ($show_comment) {
559
                    $header2 .= Display::tag('th', '&nbsp;');
560
                }
561
                $s .= Display::tag('tr', $header2);
562
            }
563
564
            if ($show_comment) {
565
                if (in_array(
566
                    $answerType,
567
                    [
568
                        MULTIPLE_ANSWER,
569
                        MULTIPLE_ANSWER_COMBINATION,
570
                        UNIQUE_ANSWER,
571
                        UNIQUE_ANSWER_IMAGE,
572
                        UNIQUE_ANSWER_NO_OPTION,
573
                        GLOBAL_MULTIPLE_ANSWER,
574
                    ]
575
                )) {
576
                    $header = Display::tag('th', get_lang('Options'));
577
                    if (EXERCISE_FEEDBACK_TYPE_END == $exercise->getFeedbackType()) {
578
                        $header .= Display::tag('th', get_lang('Feedback'));
579
                    }
580
                    $s .= '<table class="table table-hover table-striped">';
581
                    $s .= Display::tag(
582
                        'tr',
583
                        $header,
584
                        ['style' => 'text-align:left;']
585
                    );
586
                }
587
            }
588
589
            $matching_correct_answer = 0;
590
            $userChoiceList = [];
591
            if (!empty($user_choice)) {
592
                foreach ($user_choice as $item) {
593
                    $userChoiceList[] = $item['answer'];
594
                }
595
            }
596
597
            $hidingClass = '';
598
            if (READING_COMPREHENSION == $answerType) {
599
                /** @var ReadingComprehension */
600
                $objQuestionTmp->setExerciseType($exercise->selectType());
601
                $objQuestionTmp->processText($objQuestionTmp->selectDescription());
602
                $hidingClass = 'hide-reading-answers';
603
                $s .= Display::div(
604
                    $objQuestionTmp->selectTitle(),
605
                    ['class' => 'question_title '.$hidingClass]
606
                );
607
            }
608
609
            for ($answerId = 1; $answerId <= $nbrAnswers; $answerId++) {
610
                $answer = $objAnswerTmp->selectAnswer($answerId);
611
                $answerCorrect = $objAnswerTmp->isCorrect($answerId);
612
                $numAnswer = $objAnswerTmp->selectAutoId($answerId);
613
                $comment = $objAnswerTmp->selectComment($answerId);
614
                $attributes = [];
615
616
                switch ($answerType) {
617
                    case UNIQUE_ANSWER:
618
                    case UNIQUE_ANSWER_NO_OPTION:
619
                    case UNIQUE_ANSWER_IMAGE:
620
                    case READING_COMPREHENSION:
621
                        $input_id = 'choice-'.$questionId.'-'.$answerId;
622
                        if (isset($user_choice[0]['answer']) && $user_choice[0]['answer'] == $numAnswer) {
623
                            $attributes = [
624
                                'id' => $input_id,
625
                                'checked' => 1,
626
                                'selected' => 1,
627
                            ];
628
                        } else {
629
                            $attributes = ['id' => $input_id];
630
                        }
631
632
                        if ($debug_mark_answer) {
633
                            if ($answerCorrect) {
634
                                $attributes['checked'] = 1;
635
                                $attributes['selected'] = 1;
636
                            }
637
                        }
638
639
                        if ($show_comment) {
640
                            $s .= '<tr><td>';
641
                        }
642
643
                        if (UNIQUE_ANSWER_IMAGE == $answerType) {
644
                            if ($show_comment) {
645
                                if (empty($comment)) {
646
                                    $s .= '<div id="answer'.$questionId.$numAnswer.'"
647
                                            class="exercise-unique-answer-image text-center">';
648
                                } else {
649
                                    $s .= '<div id="answer'.$questionId.$numAnswer.'"
650
                                            class="exercise-unique-answer-image col-xs-6 col-sm-12 text-center">';
651
                                }
652
                            } else {
653
                                $s .= '<div id="answer'.$questionId.$numAnswer.'"
654
                                        class="exercise-unique-answer-image col-xs-6 col-md-3 text-center">';
655
                            }
656
                        }
657
658
                        if (UNIQUE_ANSWER_IMAGE != $answerType) {
659
                            $userStatus = STUDENT;
660
                            // Allows to do a remove_XSS in question of exersice with user status COURSEMANAGER
661
                            // see BT#18242
662
                            if (api_get_configuration_value('question_exercise_html_strict_filtering')) {
663
                                $userStatus = COURSEMANAGERLOWSECURITY;
664
                            }
665
                            $answer = Security::remove_XSS($answer, $userStatus);
666
                        }
667
                        $s .= Display::input(
668
                            'hidden',
669
                            'choice2['.$questionId.']',
670
                            '0'
671
                        );
672
673
                        $answer_input = null;
674
                        $attributes['class'] = 'checkradios';
675
                        if (UNIQUE_ANSWER_IMAGE == $answerType) {
676
                            $attributes['class'] = '';
677
                            $attributes['style'] = 'display: none;';
678
                            $answer = '<div class="thumbnail">'.$answer.'</div>';
679
                        }
680
681
                        $answer_input .= '<label class="radio '.$hidingClass.'">';
682
                        $answer_input .= Display::input(
683
                            'radio',
684
                            'choice['.$questionId.']',
685
                            $numAnswer,
686
                            $attributes
687
                        );
688
                        $answer_input .= $answer;
689
                        $answer_input .= '</label>';
690
691
                        if (UNIQUE_ANSWER_IMAGE == $answerType) {
692
                            $answer_input .= "</div>";
693
                        }
694
695
                        if ($show_comment) {
696
                            $s .= $answer_input;
697
                            $s .= '</td>';
698
                            $s .= '<td>';
699
                            $s .= $comment;
700
                            $s .= '</td>';
701
                            $s .= '</tr>';
702
                        } else {
703
                            $s .= $answer_input;
704
                        }
705
                        break;
706
                    case MULTIPLE_ANSWER:
707
                    case MULTIPLE_ANSWER_TRUE_FALSE:
708
                    case GLOBAL_MULTIPLE_ANSWER:
709
                    case MULTIPLE_ANSWER_TRUE_FALSE_DEGREE_CERTAINTY:
710
                        $input_id = 'choice-'.$questionId.'-'.$answerId;
711
                        $userStatus = STUDENT;
712
                        // Allows to do a remove_XSS in question of exersice with user status COURSEMANAGER
713
                        // see BT#18242
714
                        if (api_get_setting('exercise.question_exercise_html_strict_filtering')) {
715
                            $userStatus = COURSEMANAGERLOWSECURITY;
716
                        }
717
                        $answer = Security::remove_XSS($answer, $userStatus);
718
719
                        if (in_array($numAnswer, $userChoiceList)) {
720
                            $attributes = [
721
                                'id' => $input_id,
722
                                'checked' => 1,
723
                                'selected' => 1,
724
                            ];
725
                        } else {
726
                            $attributes = ['id' => $input_id];
727
                        }
728
729
                        if ($debug_mark_answer) {
730
                            if ($answerCorrect) {
731
                                $attributes['checked'] = 1;
732
                                $attributes['selected'] = 1;
733
                            }
734
                        }
735
736
                        if (MULTIPLE_ANSWER == $answerType || GLOBAL_MULTIPLE_ANSWER == $answerType) {
737
                            $s .= '<input type="hidden" name="choice2['.$questionId.']" value="0" />';
738
                            $answer_input = '<div class="flex gap-2 items-center">';
739
                            $answer_input .= Display::input(
740
                                'checkbox',
741
                                'choice['.$questionId.']['.$numAnswer.']',
742
                                $numAnswer,
743
                                $attributes
744
                            );
745
                            $answer_input .= $answer;
746
                            $answer_input .= '</div>';
747
748
                            if ($show_comment) {
749
                                $s .= '<tr><td>';
750
                                $s .= $answer_input;
751
                                $s .= '</td>';
752
                                $s .= '<td>';
753
                                $s .= $comment;
754
                                $s .= '</td>';
755
                                $s .= '</tr>';
756
                            } else {
757
                                $s .= $answer_input;
758
                            }
759
                        } elseif (MULTIPLE_ANSWER_TRUE_FALSE == $answerType) {
760
                            $myChoice = [];
761
                            if (!empty($userChoiceList)) {
762
                                foreach ($userChoiceList as $item) {
763
                                    $item = explode(':', $item);
764
                                    if (!empty($item)) {
765
                                        $myChoice[$item[0]] = isset($item[1]) ? $item[1] : '';
766
                                    }
767
                                }
768
                            }
769
770
                            $s .= '<tr>';
771
                            $s .= Display::tag('td', $answer);
772
773
                            if (!empty($quizQuestionOptions)) {
774
                                $j = 1;
775
                                foreach ($quizQuestionOptions as $id => $item) {
776
                                    if (isset($myChoice[$numAnswer]) && $item['iid'] == $myChoice[$numAnswer]) {
777
                                        $attributes = [
778
                                            'checked' => 1,
779
                                            'selected' => 1,
780
                                        ];
781
                                    } else {
782
                                        $attributes = [];
783
                                    }
784
785
                                    if ($debug_mark_answer) {
786
                                        if ($j == $answerCorrect) {
787
                                            $attributes['checked'] = 1;
788
                                            $attributes['selected'] = 1;
789
                                        }
790
                                    }
791
                                    $s .= Display::tag(
792
                                        'td',
793
                                        Display::input(
794
                                            'radio',
795
                                            'choice['.$questionId.']['.$numAnswer.']',
796
                                            $item['iid'],
797
                                            $attributes
798
                                        ),
799
                                        ['style' => '']
800
                                    );
801
                                    $j++;
802
                                }
803
                            }
804
805
                            if ($show_comment) {
806
                                $s .= '<td>';
807
                                $s .= $comment;
808
                                $s .= '</td>';
809
                            }
810
                            $s .= '</tr>';
811
                        } elseif (MULTIPLE_ANSWER_TRUE_FALSE_DEGREE_CERTAINTY == $answerType) {
812
                            $myChoice = [];
813
                            if (!empty($userChoiceList)) {
814
                                foreach ($userChoiceList as $item) {
815
                                    $item = explode(':', $item);
816
                                    $myChoice[$item[0]] = $item[1];
817
                                }
818
                            }
819
                            $myChoiceDegreeCertainty = [];
820
                            if (!empty($userChoiceList)) {
821
                                foreach ($userChoiceList as $item) {
822
                                    $item = explode(':', $item);
823
                                    $myChoiceDegreeCertainty[$item[0]] = $item[2];
824
                                }
825
                            }
826
                            $s .= '<tr>';
827
                            $s .= Display::tag('td', $answer);
828
829
                            if (!empty($quizQuestionOptions)) {
830
                                $j = 1;
831
                                foreach ($quizQuestionOptions as $id => $item) {
832
                                    if (isset($myChoice[$numAnswer]) && $id == $myChoice[$numAnswer]) {
833
                                        $attributes = ['checked' => 1, 'selected' => 1];
834
                                    } else {
835
                                        $attributes = [];
836
                                    }
837
                                    $attributes['onChange'] = 'RadioValidator('.$questionId.', '.$numAnswer.')';
838
839
                                    // radio button selection
840
                                    if (isset($myChoiceDegreeCertainty[$numAnswer]) &&
841
                                        $id == $myChoiceDegreeCertainty[$numAnswer]
842
                                    ) {
843
                                        $attributes1 = ['checked' => 1, 'selected' => 1];
844
                                    } else {
845
                                        $attributes1 = [];
846
                                    }
847
848
                                    $attributes1['onChange'] = 'RadioValidator('.$questionId.', '.$numAnswer.')';
849
850
                                    if ($debug_mark_answer) {
851
                                        if ($j == $answerCorrect) {
852
                                            $attributes['checked'] = 1;
853
                                            $attributes['selected'] = 1;
854
                                        }
855
                                    }
856
857
                                    if ('True' == $item['title'] || 'False' == $item['title']) {
858
                                        $s .= Display::tag('td',
859
                                            Display::input('radio',
860
                                                'choice['.$questionId.']['.$numAnswer.']',
861
                                                $id,
862
                                                $attributes
863
                                            ),
864
                                            ['style' => 'text-align:center; background-color:#F7E1D7;',
865
                                                'onclick' => 'handleRadioRow(event, '.
866
                                                    $questionId.', '.
867
                                                    $numAnswer.')',
868
                                            ]
869
                                        );
870
                                    } else {
871
                                        $s .= Display::tag('td',
872
                                            Display::input('radio',
873
                                                'choiceDegreeCertainty['.$questionId.']['.$numAnswer.']',
874
                                                $id,
875
                                                $attributes1
876
                                            ),
877
                                            ['style' => 'text-align:center; background-color:#EFEFFC;',
878
                                                'onclick' => 'handleRadioRow(event, '.
879
                                                    $questionId.', '.
880
                                                    $numAnswer.')',
881
                                            ]
882
                                        );
883
                                    }
884
                                    $j++;
885
                                }
886
                            }
887
888
                            if ($show_comment) {
889
                                $s .= '<td>';
890
                                $s .= $comment;
891
                                $s .= '</td>';
892
                            }
893
                            $s .= '</tr>';
894
                        }
895
                        break;
896
                    case MULTIPLE_ANSWER_COMBINATION:
897
                        // multiple answers
898
                        $input_id = 'choice-'.$questionId.'-'.$answerId;
899
900
                        if (in_array($numAnswer, $userChoiceList)) {
901
                            $attributes = [
902
                                'id' => $input_id,
903
                                'checked' => 1,
904
                                'selected' => 1,
905
                            ];
906
                        } else {
907
                            $attributes = ['id' => $input_id];
908
                        }
909
910
                        if ($debug_mark_answer) {
911
                            if ($answerCorrect) {
912
                                $attributes['checked'] = 1;
913
                                $attributes['selected'] = 1;
914
                            }
915
                        }
916
917
                        $userStatus = STUDENT;
918
                        // Allows to do a remove_XSS in question of exersice with user status COURSEMANAGER
919
                        // see BT#18242
920
                        if (api_get_configuration_value('question_exercise_html_strict_filtering')) {
921
                            $userStatus = COURSEMANAGERLOWSECURITY;
922
                        }
923
                        $answer = Security::remove_XSS($answer, $userStatus);
924
                        $answer_input = '<input type="hidden" name="choice2['.$questionId.']" value="0" />';
925
                        $answer_input .= '<label class="checkbox">';
926
                        $answer_input .= Display::input(
927
                            'checkbox',
928
                            'choice['.$questionId.']['.$numAnswer.']',
929
                            1,
930
                            $attributes
931
                        );
932
                        $answer_input .= $answer;
933
                        $answer_input .= '</label>';
934
935
                        if ($show_comment) {
936
                            $s .= '<tr>';
937
                            $s .= '<td>';
938
                            $s .= $answer_input;
939
                            $s .= '</td>';
940
                            $s .= '<td>';
941
                            $s .= $comment;
942
                            $s .= '</td>';
943
                            $s .= '</tr>';
944
                        } else {
945
                            $s .= $answer_input;
946
                        }
947
                        break;
948
                    case MULTIPLE_ANSWER_COMBINATION_TRUE_FALSE:
949
                        $s .= '<input type="hidden" name="choice2['.$questionId.']" value="0" />';
950
                        $myChoice = [];
951
                        if (!empty($userChoiceList)) {
952
                            foreach ($userChoiceList as $item) {
953
                                $item = explode(':', $item);
954
                                if (isset($item[1]) && isset($item[0])) {
955
                                    $myChoice[$item[0]] = $item[1];
956
                                }
957
                            }
958
                        }
959
                        $userStatus = STUDENT;
960
                        // Allows to do a remove_XSS in question of exersice with user status COURSEMANAGER
961
                        // see BT#18242
962
                        if (api_get_configuration_value('question_exercise_html_strict_filtering')) {
963
                            $userStatus = COURSEMANAGERLOWSECURITY;
964
                        }
965
                        $answer = Security::remove_XSS($answer, $userStatus);
966
                        $s .= '<tr>';
967
                        $s .= Display::tag('td', $answer);
968
                        foreach ($objQuestionTmp->options as $key => $item) {
969
                            if (isset($myChoice[$numAnswer]) && $key == $myChoice[$numAnswer]) {
970
                                $attributes = [
971
                                    'checked' => 1,
972
                                    'selected' => 1,
973
                                ];
974
                            } else {
975
                                $attributes = [];
976
                            }
977
978
                            if ($debug_mark_answer) {
979
                                if ($key == $answerCorrect) {
980
                                    $attributes['checked'] = 1;
981
                                    $attributes['selected'] = 1;
982
                                }
983
                            }
984
                            $s .= Display::tag(
985
                                'td',
986
                                Display::input(
987
                                    'radio',
988
                                    'choice['.$questionId.']['.$numAnswer.']',
989
                                    $key,
990
                                    $attributes
991
                                )
992
                            );
993
                        }
994
995
                        if ($show_comment) {
996
                            $s .= '<td>';
997
                            $s .= $comment;
998
                            $s .= '</td>';
999
                        }
1000
                        $s .= '</tr>';
1001
                        break;
1002
                    case FILL_IN_BLANKS:
1003
                    case FILL_IN_BLANKS_COMBINATION:
1004
                        // display the question, with field empty, for student to fill it,
1005
                        // or filled to display the answer in the Question preview of the exercise/admin.php page
1006
                        $displayForStudent = true;
1007
                        $listAnswerInfo = FillBlanks::getAnswerInfo($answer);
1008
                        // Correct answers
1009
                        $correctAnswerList = $listAnswerInfo['words'];
1010
                        // Student's answer
1011
                        $studentAnswerList = [];
1012
                        if (isset($user_choice[0]['answer'])) {
1013
                            $arrayStudentAnswer = FillBlanks::getAnswerInfo(
1014
                                $user_choice[0]['answer'],
1015
                                true
1016
                            );
1017
                            $studentAnswerList = $arrayStudentAnswer['student_answer'];
1018
                        }
1019
1020
                        // If the question must be shown with the answer (in page exercise/admin.php)
1021
                        // for teacher preview set the student-answer to the correct answer
1022
                        if ($debug_mark_answer) {
1023
                            $studentAnswerList = $correctAnswerList;
1024
                            $displayForStudent = false;
1025
                        }
1026
1027
                        if (!empty($correctAnswerList) && !empty($studentAnswerList)) {
1028
                            $answer = '';
1029
                            for ($i = 0; $i < count($listAnswerInfo['common_words']) - 1; $i++) {
1030
                                // display the common word
1031
                                $answer .= $listAnswerInfo['common_words'][$i];
1032
                                // display the blank word
1033
                                $correctItem = $listAnswerInfo['words'][$i];
1034
                                if (isset($studentAnswerList[$i])) {
1035
                                    // If student already started this test and answered this question,
1036
                                    // fill the blank with his previous answers
1037
                                    // may be "" if student viewed the question, but did not fill the blanks
1038
                                    $correctItem = $studentAnswerList[$i];
1039
                                }
1040
                                $attributes['style'] = 'width:'.$listAnswerInfo['input_size'][$i].'px';
1041
                                $answer .= FillBlanks::getFillTheBlankHtml(
1042
                                    $current_item,
1043
                                    $questionId,
1044
                                    $correctItem,
1045
                                    $attributes,
1046
                                    $answer,
1047
                                    $listAnswerInfo,
1048
                                    $displayForStudent,
1049
                                    $i
1050
                                );
1051
                            }
1052
                            // display the last common word
1053
                            $answer .= $listAnswerInfo['common_words'][$i];
1054
                        } else {
1055
                            // display empty [input] with the right width for student to fill it
1056
                            $answer = '';
1057
                            for ($i = 0; $i < count($listAnswerInfo['common_words']) - 1; $i++) {
1058
                                // display the common words
1059
                                $answer .= $listAnswerInfo['common_words'][$i];
1060
                                // display the blank word
1061
                                $attributes['style'] = 'width:'.$listAnswerInfo['input_size'][$i].'px';
1062
                                $answer .= FillBlanks::getFillTheBlankHtml(
1063
                                    $current_item,
1064
                                    $questionId,
1065
                                    '',
1066
                                    $attributes,
1067
                                    $answer,
1068
                                    $listAnswerInfo,
1069
                                    $displayForStudent,
1070
                                    $i
1071
                                );
1072
                            }
1073
                            // display the last common word
1074
                            $answer .= $listAnswerInfo['common_words'][$i];
1075
                        }
1076
                        $s .= $answer;
1077
                        break;
1078
                    case CALCULATED_ANSWER:
1079
                        /*
1080
                         * In the CALCULATED_ANSWER test
1081
                         * you mustn't have [ and ] in the textarea
1082
                         * you mustn't have @@ in the textarea
1083
                         * the text to find mustn't be empty or contains only spaces
1084
                         * the text to find mustn't contains HTML tags
1085
                         * the text to find mustn't contains char "
1086
                         */
1087
                        if (null !== $origin) {
1088
                            global $exe_id;
1089
                            $exe_id = (int) $exe_id;
1090
                            $trackAttempts = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT);
1091
                            $sql = "SELECT answer FROM $trackAttempts
1092
                                    WHERE exe_id = $exe_id AND question_id= $questionId";
1093
                            $rsLastAttempt = Database::query($sql);
1094
                            $rowLastAttempt = Database::fetch_array($rsLastAttempt);
1095
1096
                            $answer = null;
1097
                            if (isset($rowLastAttempt['answer'])) {
1098
                                $answer = $rowLastAttempt['answer'];
1099
                            }
1100
1101
                            if (empty($answer)) {
1102
                                $_SESSION['calculatedAnswerId'][$questionId] = mt_rand(
1103
                                    1,
1104
                                    $nbrAnswers
1105
                                );
1106
                                $answer = $objAnswerTmp->selectAnswer(
1107
                                    $_SESSION['calculatedAnswerId'][$questionId]
1108
                                );
1109
                            }
1110
                        }
1111
1112
                        [$answer] = explode('@@', $answer);
1113
                        // $correctAnswerList array of array with correct anwsers 0=> [0=>[\p] 1=>[plop]]
1114
                        api_preg_match_all(
1115
                            '/\[[^]]+\]/',
1116
                            $answer,
1117
                            $correctAnswerList
1118
                        );
1119
1120
                        // get student answer to display it if student go back
1121
                        // to previous calculated answer question in a test
1122
                        if (isset($user_choice[0]['answer'])) {
1123
                            api_preg_match_all(
1124
                                '/\[[^]]+\]/',
1125
                                $answer,
1126
                                $studentAnswerList
1127
                            );
1128
                            $studentAnswerListToClean = $studentAnswerList[0];
1129
                            $studentAnswerList = [];
1130
1131
                            $maxStudents = count($studentAnswerListToClean);
1132
                            for ($i = 0; $i < $maxStudents; $i++) {
1133
                                $answerCorrected = $studentAnswerListToClean[$i];
1134
                                $answerCorrected = api_preg_replace(
1135
                                    '| / <font color="green"><b>.*$|',
1136
                                    '',
1137
                                    $answerCorrected
1138
                                );
1139
                                $answerCorrected = api_preg_replace(
1140
                                    '/^\[/',
1141
                                    '',
1142
                                    $answerCorrected
1143
                                );
1144
                                $answerCorrected = api_preg_replace(
1145
                                    '|^<font color="red"><s>|',
1146
                                    '',
1147
                                    $answerCorrected
1148
                                );
1149
                                $answerCorrected = api_preg_replace(
1150
                                    '|</s></font>$|',
1151
                                    '',
1152
                                    $answerCorrected
1153
                                );
1154
                                $answerCorrected = '['.$answerCorrected.']';
1155
                                $studentAnswerList[] = $answerCorrected;
1156
                            }
1157
                        }
1158
1159
                        // If display preview of answer in test view for exemple,
1160
                        // set the student answer to the correct answers
1161
                        if ($debug_mark_answer) {
1162
                            // contain the rights answers surronded with brackets
1163
                            $studentAnswerList = $correctAnswerList[0];
1164
                        }
1165
1166
                        /*
1167
                        Split the response by bracket
1168
                        tabComments is an array with text surrounding the text to find
1169
                        we add a space before and after the answerQuestion to be sure to
1170
                        have a block of text before and after [xxx] patterns
1171
                        so we have n text to find ([xxx]) and n+1 block of texts before,
1172
                        between and after the text to find
1173
                        */
1174
                        $tabComments = api_preg_split(
1175
                            '/\[[^]]+\]/',
1176
                            ' '.$answer.' '
1177
                        );
1178
                        if (!empty($correctAnswerList) && !empty($studentAnswerList)) {
1179
                            $answer = '';
1180
                            $i = 0;
1181
                            foreach ($studentAnswerList as $studentItem) {
1182
                                // Remove surronding brackets
1183
                                $studentResponse = api_substr(
1184
                                    $studentItem,
1185
                                    1,
1186
                                    api_strlen($studentItem) - 2
1187
                                );
1188
                                $size = strlen($studentItem);
1189
                                $attributes['class'] = self::detectInputAppropriateClass($size);
1190
                                $answer .= $tabComments[$i].
1191
                                    Display::input(
1192
                                        'text',
1193
                                        "choice[$questionId][]",
1194
                                        $studentResponse,
1195
                                        $attributes
1196
                                    );
1197
                                $i++;
1198
                            }
1199
                            $answer .= $tabComments[$i];
1200
                        } else {
1201
                            // display exercise with empty input fields
1202
                            // every [xxx] are replaced with an empty input field
1203
                            foreach ($correctAnswerList[0] as $item) {
1204
                                $size = strlen($item);
1205
                                $attributes['class'] = self::detectInputAppropriateClass($size);
1206
                                if (EXERCISE_FEEDBACK_TYPE_POPUP == $exercise->getFeedbackType()) {
1207
                                    $attributes['id'] = "question_$questionId";
1208
                                    $attributes['class'] .= ' checkCalculatedQuestionOnEnter ';
1209
                                }
1210
1211
                                $answer = str_replace(
1212
                                    $item,
1213
                                    Display::input(
1214
                                        'text',
1215
                                        "choice[$questionId][]",
1216
                                        '',
1217
                                        $attributes
1218
                                    ),
1219
                                    $answer
1220
                                );
1221
                            }
1222
                        }
1223
                        if (null !== $origin) {
1224
                            $s = $answer;
1225
                            break;
1226
                        } else {
1227
                            $s .= $answer;
1228
                        }
1229
                        break;
1230
                    case MATCHING:
1231
                    case MATCHING_COMBINATION:
1232
                        // matching type, showing suggestions and answers
1233
                        // TODO: replace $answerId by $numAnswer
1234
                        if (0 != $answerCorrect) {
1235
                            // only show elements to be answered (not the contents of
1236
                            // the select boxes, who are correct = 0)
1237
                            $s .= '<tr><td width="45%" valign="top">';
1238
                            $parsed_answer = $answer;
1239
                            // Left part questions
1240
                            $s .= '<p class="indent">'.$lines_count.'.&nbsp;'.$parsed_answer.'</p></td>';
1241
                            // Middle part (matches selects)
1242
                            // Id of select is # question + # of option
1243
                            $s .= '<td width="10%" valign="top" align="center">
1244
                                <div class="select-matching">
1245
                                <select
1246
                                    class="form-control"
1247
                                    id="choice_id_'.$current_item.'_'.$lines_count.'"
1248
                                    name="choice['.$questionId.']['.$numAnswer.']">';
1249
1250
                            // fills the list-box
1251
                            foreach ($select_items as $key => $val) {
1252
                                // set $debug_mark_answer to true at function start to
1253
                                // show the correct answer with a suffix '-x'
1254
                                $selected = '';
1255
                                if ($debug_mark_answer) {
1256
                                    if ($val['id'] == $answerCorrect) {
1257
                                        $selected = 'selected="selected"';
1258
                                    }
1259
                                }
1260
                                //$user_choice_array_position
1261
                                if (isset($user_choice_array_position[$numAnswer]) &&
1262
                                    $val['id'] == $user_choice_array_position[$numAnswer]
1263
                                ) {
1264
                                    $selected = 'selected="selected"';
1265
                                }
1266
                                $s .= '<option value="'.$val['id'].'" '.$selected.'>'.$val['letter'].'</option>';
1267
                            }
1268
1269
                            $s .= '</select></div></td><td width="5%" class="separate">&nbsp;</td>';
1270
                            $s .= '<td width="40%" valign="top" >';
1271
                            if (isset($select_items[$lines_count])) {
1272
                                $s .= '<div class="text-right">
1273
                                        <p class="indent">'.
1274
                                    $select_items[$lines_count]['letter'].'.&nbsp; '.
1275
                                    $select_items[$lines_count]['answer'].'
1276
                                        </p>
1277
                                        </div>';
1278
                            } else {
1279
                                $s .= '&nbsp;';
1280
                            }
1281
                            $s .= '</td>';
1282
                            $s .= '</tr>';
1283
                            $lines_count++;
1284
                            // If the left side of the "matching" has been completely
1285
                            // shown but the right side still has values to show...
1286
                            if (($lines_count - 1) == $num_suggestions) {
1287
                                // if it remains answers to shown at the right side
1288
                                while (isset($select_items[$lines_count])) {
1289
                                    $s .= '<tr>
1290
                                      <td colspan="2"></td>
1291
                                      <td valign="top">';
1292
                                    $s .= '<b>'.$select_items[$lines_count]['letter'].'.</b> '.
1293
                                        $select_items[$lines_count]['answer'];
1294
                                    $s .= "</td>
1295
                                </tr>";
1296
                                    $lines_count++;
1297
                                }
1298
                            }
1299
                            $matching_correct_answer++;
1300
                        }
1301
                        break;
1302
                    case DRAGGABLE:
1303
                        if ($answerCorrect) {
1304
                            $windowId = $questionId.'_'.$lines_count;
1305
                            $s .= '<li class="touch-items" id="'.$windowId.'">';
1306
                            $s .= Display::div(
1307
                                $answer,
1308
                                [
1309
                                    'id' => "window_$windowId",
1310
                                    'class' => "window{$questionId}_question_draggable exercise-draggable-answer-option",
1311
                                ]
1312
                            );
1313
1314
                            $draggableSelectOptions = [];
1315
                            $selectedValue = 0;
1316
                            $selectedIndex = 0;
1317
                            if ($user_choice) {
1318
                                foreach ($user_choice as $userChoiceKey => $chosen) {
1319
                                    $userChoiceKey++;
1320
                                    if ($lines_count != $userChoiceKey) {
1321
                                        continue;
1322
                                    }
1323
                                    /*if ($answerCorrect != $chosen['answer']) {
1324
                                        continue;
1325
                                    }*/
1326
                                    $selectedValue = $chosen['answer'];
1327
                                }
1328
                            }
1329
                            foreach ($select_items as $key => $select_item) {
1330
                                $draggableSelectOptions[$select_item['id']] = $select_item['letter'];
1331
                            }
1332
1333
                            foreach ($draggableSelectOptions as $value => $text) {
1334
                                if ($value == $selectedValue) {
1335
                                    break;
1336
                                }
1337
                                $selectedIndex++;
1338
                            }
1339
1340
                            $s .= Display::select(
1341
                                "choice[$questionId][$numAnswer]",
1342
                                $draggableSelectOptions,
1343
                                $selectedValue,
1344
                                [
1345
                                    'id' => "window_{$windowId}_select",
1346
                                    'class' => 'select_option hidden',
1347
                                ],
1348
                                false
1349
                            );
1350
1351
                            if ($selectedValue && $selectedIndex) {
1352
                                $s .= "
1353
                                    <script>
1354
                                        $(function() {
1355
                                            DraggableAnswer.deleteItem(
1356
                                                $('#{$questionId}_$lines_count'),
1357
                                                $('#drop_{$questionId}_{$selectedIndex}')
1358
                                            );
1359
                                        });
1360
                                    </script>
1361
                                ";
1362
                            }
1363
1364
                            if (isset($select_items[$lines_count])) {
1365
                                $s .= Display::div(
1366
                                    Display::tag(
1367
                                        'b',
1368
                                        $select_items[$lines_count]['letter']
1369
                                    ).$select_items[$lines_count]['answer'],
1370
                                    [
1371
                                        'id' => "window_{$windowId}_answer",
1372
                                        'class' => 'hidden',
1373
                                    ]
1374
                                );
1375
                            } else {
1376
                                $s .= '&nbsp;';
1377
                            }
1378
1379
                            $lines_count++;
1380
                            if (($lines_count - 1) == $num_suggestions) {
1381
                                while (isset($select_items[$lines_count])) {
1382
                                    $s .= Display::tag('b', $select_items[$lines_count]['letter']);
1383
                                    $s .= $select_items[$lines_count]['answer'];
1384
                                    $lines_count++;
1385
                                }
1386
                            }
1387
1388
                            $matching_correct_answer++;
1389
                            $s .= '</li>';
1390
                        }
1391
                        break;
1392
                    case MATCHING_DRAGGABLE:
1393
                    case MATCHING_DRAGGABLE_COMBINATION:
1394
                        if (1 == $answerId) {
1395
                            echo $objAnswerTmp->getJs();
1396
                        }
1397
                        if (0 != $answerCorrect) {
1398
                            $windowId = "{$questionId}_{$lines_count}";
1399
                            $s .= <<<HTML
1400
                            <tr>
1401
                                <td width="45%">
1402
                                    <div id="window_{$windowId}"
1403
                                        class="window window_left_question window{$questionId}_question">
1404
                                        <strong>$lines_count.</strong>
1405
                                        $answer
1406
                                    </div>
1407
                                </td>
1408
                                <td width="10%">
1409
HTML;
1410
1411
                            $draggableSelectOptions = [];
1412
                            $selectedValue = 0;
1413
                            $selectedIndex = 0;
1414
1415
                            if ($user_choice) {
1416
                                foreach ($user_choice as $chosen) {
1417
                                    if ($numAnswer == $chosen['position']) {
1418
                                        $selectedValue = $chosen['answer'];
1419
                                        break;
1420
                                    }
1421
                                }
1422
                            }
1423
1424
                            foreach ($select_items as $key => $selectItem) {
1425
                                $draggableSelectOptions[$selectItem['id']] = $selectItem['letter'];
1426
                            }
1427
1428
                            foreach ($draggableSelectOptions as $value => $text) {
1429
                                if ($value == $selectedValue) {
1430
                                    break;
1431
                                }
1432
                                $selectedIndex++;
1433
                            }
1434
1435
                            $s .= Display::select(
1436
                                "choice[$questionId][$numAnswer]",
1437
                                $draggableSelectOptions,
1438
                                $selectedValue,
1439
                                [
1440
                                    'id' => "window_{$windowId}_select",
1441
                                    'class' => 'hidden',
1442
                                ],
1443
                                false
1444
                            );
1445
1446
                            if (!empty($answerCorrect) && !empty($selectedValue)) {
1447
                                // Show connect if is not freeze (question preview)
1448
                                if (!$freeze) {
1449
                                    $s .= "
1450
                                        <script>
1451
                                            $(function() {
1452
                                                MatchingDraggable.instances['$questionId'].connect({
1453
                                                    source: 'window_$windowId',
1454
                                                    target: 'window_{$questionId}_{$selectedIndex}_answer',
1455
                                                    endpoint: ['Dot', {radius: 12}],
1456
                                                    anchors: ['RightMiddle', 'LeftMiddle'],
1457
                                                    paintStyle: {stroke: '#8A8888', strokeWidth: 8},
1458
                                                    connector: [
1459
                                                        MatchingDraggable.connectorType,
1460
                                                        {curvines: MatchingDraggable.curviness}
1461
                                                    ]
1462
                                                });
1463
                                            });
1464
                                        </script>
1465
                                    ";
1466
                                }
1467
                            }
1468
1469
                            $s .= '</td><td width="45%">';
1470
                            if (isset($select_items[$lines_count])) {
1471
                                $s .= <<<HTML
1472
                                <div id="window_{$windowId}_answer" class="window window_right_question">
1473
                                    <strong>{$select_items[$lines_count]['letter']}.</strong>
1474
                                    {$select_items[$lines_count]['answer']}
1475
                                </div>
1476
HTML;
1477
                            } else {
1478
                                $s .= '&nbsp;';
1479
                            }
1480
1481
                            $s .= '</td></tr>';
1482
                            $lines_count++;
1483
                            if (($lines_count - 1) == $num_suggestions) {
1484
                                while (isset($select_items[$lines_count])) {
1485
                                    $s .= <<<HTML
1486
                                    <tr>
1487
                                        <td colspan="2"></td>
1488
                                        <td>
1489
                                            <strong>{$select_items[$lines_count]['letter']}</strong>
1490
                                            {$select_items[$lines_count]['answer']}
1491
                                        </td>
1492
                                    </tr>
1493
HTML;
1494
                                    $lines_count++;
1495
                                }
1496
                            }
1497
                            $matching_correct_answer++;
1498
                        }
1499
                        break;
1500
                    case MULTIPLE_ANSWER_DROPDOWN:
1501
                    case MULTIPLE_ANSWER_DROPDOWN_COMBINATION:
1502
                        if ($debug_mark_answer && $answerCorrect) {
1503
                            $s .= '<p>'
1504
                                .(
1505
                                    MULTIPLE_ANSWER_DROPDOWN == $answerType
1506
                                        ? '<span class="pull-right">'.$objAnswerTmp->weighting[$answerId].'</span>'
1507
                                        : ''
1508
                                )
1509
                                .Display::returnFontAwesomeIcon('check-square-o', '', true);
1510
                            $s .= Security::remove_XSS($objAnswerTmp->answer[$answerId]).'</p>';
1511
                        }
1512
                        break;
1513
                }
1514
            }
1515
1516
            if (in_array($answerType, [MULTIPLE_ANSWER_DROPDOWN, MULTIPLE_ANSWER_DROPDOWN_COMBINATION]) && !$debug_mark_answer) {
1517
                $userChoiceList = array_unique($userChoiceList);
1518
                $input_id = "choice-$questionId";
1519
                $clear_id = "clear-$questionId";
1520
1521
                $s .= Display::input('hidden', "choice2[$questionId]", '0')
1522
                    .'<div class="mb-4">'
1523
                    .'<div class="flex items-center justify-between mb-2">'
1524
                    .'<label for="'.$input_id.'" class="text-sm font-medium text-gray-90">'
1525
                    .get_lang('Please select an option')
1526
                    .'</label>'
1527
                    .'<button type="button" id="'.$clear_id.'" '
1528
                    .'class="inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium '
1529
                    .'bg-primary text-white hover:opacity-90 border border-primary">'
1530
                    .'<span class="fa fa-times" aria-hidden="true"></span>'
1531
                    .'<span>'.get_lang('Clear').'</span>'
1532
                    .'</button>'
1533
                    .'</div>'
1534
                    .Display::select(
1535
                        "choice[$questionId][]",
1536
                        $selectableOptions,
1537
                        $userChoiceList,
1538
                        [
1539
                            'id'       => $input_id,
1540
                            'multiple' => 'multiple',
1541
                            'class'    => 'w-full', // full width before Select2 mounts
1542
                        ],
1543
                        false
1544
                    )
1545
                    .'</div>'
1546
                    .'<script>
1547
            $(function () {
1548
                var $el = $("#'.$input_id.'");
1549
                if (!$.fn.select2) return;
1550
1551
                $el.select2({
1552
                    width: "100%",
1553
                    placeholder: { id: "-2", text: "'.get_lang('None').'" },
1554
                    allowClear: true,
1555
                    selectOnClose: false,
1556
                    containerCssClass: "select2-tw",
1557
                    selectionCssClass: "select2-tw",
1558
                    dropdownCssClass: "select2-tw-dd"
1559
                });
1560
1561
                $("#'.$clear_id.'").on("click", function(e){
1562
                    e.preventDefault();
1563
                    $el.val(null).trigger("change");
1564
                });
1565
            });
1566
        </script>';
1567
            }
1568
1569
            if ($show_comment) {
1570
                $s .= '</table>';
1571
            } elseif (in_array(
1572
                $answerType,
1573
                [
1574
                    MATCHING,
1575
                    MATCHING_COMBINATION,
1576
                    MATCHING_DRAGGABLE,
1577
                    MATCHING_DRAGGABLE_COMBINATION,
1578
                    UNIQUE_ANSWER_NO_OPTION,
1579
                    MULTIPLE_ANSWER_TRUE_FALSE,
1580
                    MULTIPLE_ANSWER_COMBINATION_TRUE_FALSE,
1581
                    MULTIPLE_ANSWER_TRUE_FALSE_DEGREE_CERTAINTY,
1582
                ]
1583
            )) {
1584
                $s .= '</table>';
1585
            }
1586
1587
            if (DRAGGABLE == $answerType) {
1588
                $isVertical = 'v' == $objQuestionTmp->extra;
1589
                $s .= "</ul></div>";
1590
                $counterAnswer = 1;
1591
                $s .= '<div class="question-answer__items question-answer__items--'.($isVertical ? 'vertical' : 'horizontal').'">';
1592
                for ($answerId = 1; $answerId <= $nbrAnswers; $answerId++) {
1593
                    $answerCorrect = $objAnswerTmp->isCorrect($answerId);
1594
                    $windowId = $questionId.'_'.$counterAnswer;
1595
                    if ($answerCorrect) {
1596
                        $s .= '<div class="droppable-item '.($isVertical ? 'w-full' : '').' flex items-center justify-between p-4 mb-4 bg-gray-200 rounded-md">';
1597
                        $s .= '<span class="number text-lg font-bold">'.$counterAnswer.'</span>';
1598
                        $s .= '<div id="drop_'.$windowId.'" class="droppable border-2 border-dashed border-gray-400 p-4 bg-white rounded-md"></div>';
1599
                        $s .= '</div>';
1600
                        $counterAnswer++;
1601
                    }
1602
                }
1603
1604
                $s .= '</div>';
1605
//                $s .= '</div>';
1606
            }
1607
1608
            if (in_array($answerType, [MATCHING, MATCHING_COMBINATION, MATCHING_DRAGGABLE, MATCHING_DRAGGABLE_COMBINATION])) {
1609
                $s .= '</div>'; //drag_question
1610
            }
1611
1612
            $s .= '</div>'; //question_options row
1613
1614
            // destruction of the Answer object
1615
            unset($objAnswerTmp);
1616
            // destruction of the Question object
1617
            unset($objQuestionTmp);
1618
            if ('export' == $origin) {
1619
                return $s;
1620
            }
1621
            echo $s;
1622
        } elseif (in_array($answerType, [HOT_SPOT, HOT_SPOT_DELINEATION, HOT_SPOT_COMBINATION])) {
1623
            global $exe_id;
1624
            $questionDescription = $objQuestionTmp->selectDescription();
1625
            // Get the answers, make a list
1626
            $objAnswerTmp = new Answer($questionId, $course_id);
1627
            $nbrAnswers = $objAnswerTmp->selectNbrAnswers();
1628
1629
            // get answers of hotpost
1630
            $answers_hotspot = [];
1631
            for ($answerId = 1; $answerId <= $nbrAnswers; $answerId++) {
1632
                $answers = $objAnswerTmp->selectAnswerByAutoId(
1633
                    $objAnswerTmp->selectAutoId($answerId)
1634
                );
1635
                $answers_hotspot[$answers['iid']] = $objAnswerTmp->selectAnswer(
1636
                    $answerId
1637
                );
1638
            }
1639
1640
            $answerList = '';
1641
            $hotspotColor = 0;
1642
            if (HOT_SPOT_DELINEATION != $answerType) {
1643
                $answerList = '
1644
        <div class="card p-4 rounded-md border border-gray-25">
1645
            <h5 class="font-bold text-lg mb-2 text-primary">'.get_lang('Image zones').'</h5>
1646
            <ol class="list-decimal ml-6 space-y-2 text-primary">
1647
        ';
1648
1649
                if (!empty($answers_hotspot)) {
1650
                    Session::write("hotspot_ordered$questionId", array_keys($answers_hotspot));
1651
                    foreach ($answers_hotspot as $value) {
1652
                        $answerList .= '<li class="flex items-center space-x-2">';
1653
                        if ($freeze) {
1654
                            $answerList .= '<span class="text-support-5 fa fa-square" aria-hidden="true"></span>';
1655
                        }
1656
                        $answerList .= '<span>'.$value.'</span>';
1657
                        $answerList .= '</li>';
1658
                        $hotspotColor++;
1659
                    }
1660
                }
1661
1662
                $answerList .= '
1663
                        </ol>
1664
                    </div>
1665
                ';
1666
            }
1667
            if ($freeze) {
1668
                $relPath = api_get_path(WEB_CODE_PATH);
1669
                echo "
1670
        <div class=\"w-100\">
1671
                $answerList
1672
            </div>
1673
        <div class=\"flex space-x-4\">
1674
            <div class=\"w-100\">
1675
                <div id=\"hotspot-preview-$questionId\" class=\"bg-gray-10 w-full bg-center bg-no-repeat bg-contain border border-gray-25\"></div>
1676
            </div>
1677
        </div>
1678
        <script>
1679
            new ".(in_array($answerType, [HOT_SPOT, HOT_SPOT_COMBINATION]) ? "HotspotQuestion" : "DelineationQuestion")."({
1680
                questionId: $questionId,
1681
                exerciseId: $exerciseId,
1682
                exeId: 0,
1683
                selector: '#hotspot-preview-$questionId',
1684
                for: 'preview',
1685
                relPath: '$relPath'
1686
            });
1687
        </script>
1688
    ";
1689
                return;
1690
            }
1691
1692
            if (!$only_questions) {
1693
                if ($show_title) {
1694
                    if ($exercise->display_category_name) {
1695
                        TestCategory::displayCategoryAndTitle($objQuestionTmp->id);
1696
                    }
1697
                    echo $objQuestionTmp->getTitleToDisplay($exercise, $current_item);
1698
                }
1699
1700
                //@todo I need to the get the feedback type
1701
                echo <<<HOTSPOT
1702
        <input type="hidden" name="hidden_hotspot_id" value="$questionId" />
1703
        <div class="exercise_questions">
1704
            $questionDescription
1705
            <div class="mb-4">
1706
              $answerList
1707
            </div>
1708
            <div class="flex space-x-4">
1709
HOTSPOT;
1710
            }
1711
1712
            $relPath = api_get_path(WEB_CODE_PATH);
1713
            $s .= "<div>
1714
           <div class=\"hotspot-image bg-gray-10 border border-gray-25 bg-center bg-no-repeat bg-contain\"></div>
1715
            <script>
1716
                $(function() {
1717
                    new ".(HOT_SPOT_DELINEATION == $answerType ? 'DelineationQuestion' : 'HotspotQuestion')."({
1718
                        questionId: $questionId,
1719
                        exerciseId: $exerciseId,
1720
                        exeId: 0,
1721
                        selector: '#question_div_' + $questionId + ' .hotspot-image',
1722
                        for: 'user',
1723
                        relPath: '$relPath'
1724
                    });
1725
                });
1726
            </script>
1727
        </div>
1728
    ";
1729
1730
            echo <<<HOTSPOT
1731
        $s
1732
    </div>
1733
</div>
1734
HOTSPOT;
1735
        } elseif (ANNOTATION == $answerType) {
1736
            global $exe_id;
1737
            $relPath = api_get_path(WEB_CODE_PATH);
1738
            if (api_is_platform_admin() || api_is_course_admin()) {
1739
                $questionRepo = Container::getQuestionRepository();
1740
                $questionEntity = $questionRepo->find($questionId);
1741
                if ($freeze) {
1742
                    echo '
1743
            <div id="annotation-canvas-'.$questionId.'" class="annotation-canvas center-block"></div>
1744
            <script>
1745
                AnnotationQuestion({
1746
                    questionId: '.(int)$questionId.',
1747
                    exerciseId: 0,
1748
                    relPath: \''.$relPath.'\',
1749
                    courseId: '.(int)$course_id.',
1750
                    mode: "preview"
1751
                });
1752
            </script>
1753
        ';
1754
                    return 0;
1755
                }
1756
            }
1757
1758
            if (!$only_questions) {
1759
                if ($show_title) {
1760
                    if ($exercise->display_category_name) {
1761
                        TestCategory::displayCategoryAndTitle($objQuestionTmp->id);
1762
                    }
1763
                    echo $objQuestionTmp->getTitleToDisplay($exercise, $current_item);
1764
                }
1765
1766
                echo '
1767
                    <input type="hidden" name="hidden_hotspot_id" value="'.$questionId.'" />
1768
                    <div class="exercise_questions">
1769
                        '.$objQuestionTmp->selectDescription().'
1770
                        <div class="row">
1771
                            <div class="col-sm-8 col-md-9">
1772
                                <div id="annotation-canvas-'.$questionId.'" class="annotation-canvas center-block">
1773
                                </div>
1774
                                <script>
1775
                                    AnnotationQuestion({
1776
                                        questionId: '.$questionId.',
1777
                                        exerciseId: '.$exerciseId.',
1778
                                        relPath: \''.$relPath.'\',
1779
                                        courseId: '.$course_id.',
1780
                                    });
1781
                                </script>
1782
                            </div>
1783
                            <div class="col-sm-4 col-md-3">
1784
                                <div class="well well-sm" id="annotation-toolbar-'.$questionId.'">
1785
                                    <div class="btn-toolbar">
1786
                                        <div class="btn-group" data-toggle="buttons">
1787
                                            <label class="btn btn--plain active"
1788
                                                aria-label="'.get_lang('Add annotation path').'">
1789
                                                <input
1790
                                                    type="radio" value="0"
1791
                                                    name="'.$questionId.'-options" autocomplete="off" checked>
1792
                                                <span class="fas fa-pencil-alt" aria-hidden="true"></span>
1793
                                            </label>
1794
                                            <label class="btn btn--plain"
1795
                                                aria-label="'.get_lang('Add annotation text').'">
1796
                                                <input
1797
                                                    type="radio" value="1"
1798
                                                    name="'.$questionId.'-options" autocomplete="off">
1799
                                                <span class="fa fa-font fa-fw" aria-hidden="true"></span>
1800
                                            </label>
1801
                                        </div>
1802
                                    </div>
1803
                                    <ul class="list-unstyled"></ul>
1804
                                </div>
1805
                            </div>
1806
                        </div>
1807
                    </div>
1808
                ';
1809
            }
1810
            $objAnswerTmp = new Answer($questionId);
1811
            $nbrAnswers = $objAnswerTmp->selectNbrAnswers();
1812
            unset($objAnswerTmp, $objQuestionTmp);
1813
        }
1814
1815
            return $nbrAnswers;
1816
    }
1817
1818
    /**
1819
     * Displays a table listing the quizzes where a question is used.
1820
     */
1821
    public static function showTestsWhereQuestionIsUsed(int $questionId, int $excludeTestId = 0): void
1822
    {
1823
        $em = Database::getManager();
1824
        $quizRepo = $em->getRepository(CQuiz::class);
1825
        $quizzes = $quizRepo->findQuizzesUsingQuestion($questionId, $excludeTestId);
1826
1827
        if (empty($quizzes)) {
1828
            echo '';
1829
            return;
1830
        }
1831
1832
        $result = [];
1833
1834
        foreach ($quizzes as $quiz) {
1835
            $link = $quiz->getFirstResourceLink();
1836
            $course = $link?->getCourse();
1837
            $session = $link?->getSession();
1838
            $courseId = $course?->getId() ?? 0;
1839
            $sessionId = $session?->getId() ?? 0;
1840
1841
            $url = api_get_path(WEB_CODE_PATH).'exercise/admin.php?'.
1842
                'cid='.$courseId.'&sid='.$sessionId.'&gid=0&gradebook=0&origin='.
1843
                '&exerciseId='.$quiz->getIid().'&r=1';
1844
1845
1846
            $result[] = [
1847
                $course?->getTitle() ?? '-',
1848
                $session?->getTitle() ?? '-',
1849
                $quiz->getTitle(),
1850
                '<a href="'.$url.'">'.Display::getMdiIcon(
1851
                    'order-bool-ascending-variant',
1852
                    'ch-tool-icon',
1853
                    null,
1854
                    ICON_SIZE_SMALL,
1855
                    get_lang('Edit')
1856
                ).'</a>',
1857
            ];
1858
        }
1859
1860
        $headers = [
1861
            get_lang('Course'),
1862
            get_lang('Session'),
1863
            get_lang('Test'),
1864
            get_lang('Link to test edition'),
1865
        ];
1866
1867
        $title = Display::div(
1868
            get_lang('Question also used in the following tests'),
1869
            ['class' => 'section-title', 'style' => 'margin-top: 25px; border-bottom: none']
1870
        );
1871
1872
        echo $title.Display::table($headers, $result);
1873
    }
1874
1875
    /**
1876
     * @param int $exeId
1877
     *
1878
     * @return array
1879
     */
1880
    public static function get_exercise_track_exercise_info($exeId)
1881
    {
1882
        $quizTable = Database::get_course_table(TABLE_QUIZ_TEST);
1883
        $trackExerciseTable = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
1884
        $courseTable = Database::get_main_table(TABLE_MAIN_COURSE);
1885
        $exeId = (int) $exeId;
1886
        $result = [];
1887
        if (!empty($exeId)) {
1888
            $sql = " SELECT q.*, tee.*
1889
                FROM $quizTable as q
1890
                INNER JOIN $trackExerciseTable as tee
1891
                ON q.iid = tee.exe_exo_id
1892
                WHERE
1893
                    tee.exe_id = $exeId";
1894
1895
            $sqlResult = Database::query($sql);
1896
            if (Database::num_rows($sqlResult)) {
1897
                $result = Database::fetch_assoc($sqlResult);
1898
                $result['duration_formatted'] = '';
1899
                if (!empty($result['exe_duration'])) {
1900
                    $time = api_format_time($result['exe_duration'], 'js');
1901
                    $result['duration_formatted'] = $time;
1902
                }
1903
            }
1904
        }
1905
1906
        return $result;
1907
    }
1908
1909
    /**
1910
     * Validates the time control key.
1911
     *
1912
     * @param int $lp_id
1913
     * @param int $lp_item_id
1914
     *
1915
     * @return bool
1916
     */
1917
    public static function exercise_time_control_is_valid(Exercise $exercise, $lp_id = 0, $lp_item_id = 0)
1918
    {
1919
        $exercise_id = $exercise->getId();
1920
        $expiredTime = $exercise->expired_time;
1921
1922
        // If the exercise has no time control configured, it's valid.
1923
        if (empty($expiredTime)) {
1924
            return true;
1925
        }
1926
1927
        // Build a stable session key for the LP/exercise context
1928
        $current_expired_time_key = self::get_time_control_key(
1929
            $exercise_id,
1930
            $lp_id,
1931
            $lp_item_id
1932
        );
1933
1934
        // If the key isn't present, time control cannot be validated -> not valid
1935
        if (!isset($_SESSION['expired_time'][$current_expired_time_key])) {
1936
            return false;
1937
        }
1938
1939
        // Normalize the stored value (can be DateTime, unix timestamp, or string)
1940
        $raw = $_SESSION['expired_time'][$current_expired_time_key];
1941
        if ($raw instanceof \DateTimeInterface) {
1942
            $expiredAtStr = $raw->format('Y-m-d H:i:s');
1943
        } elseif (is_int($raw) || ctype_digit((string) $raw)) {
1944
            // Treat numeric as unix timestamp (UTC)
1945
            $expiredAtStr = gmdate('Y-m-d H:i:s', (int) $raw);
1946
        } else {
1947
            // Assume parsable datetime string
1948
            $expiredAtStr = (string) $raw;
1949
        }
1950
1951
        // Compute remaining time (defensive: handle parse failure as already expired)
1952
        $expired_time = api_strtotime($expiredAtStr, 'UTC');
1953
        if ($expired_time === false || $expired_time === null) {
1954
            return false;
1955
        }
1956
1957
        $current_time = time();
1958
        $total_time_allowed = $expired_time + 30; // small grace period
1959
1960
        if ($total_time_allowed < $current_time) {
1961
            return false;
1962
        }
1963
1964
        return true;
1965
    }
1966
1967
    /**
1968
     * Deletes the time control token.
1969
     *
1970
     * @param int $exercise_id
1971
     * @param int $lp_id
1972
     * @param int $lp_item_id
1973
     */
1974
    public static function exercise_time_control_delete(
1975
        $exercise_id,
1976
        $lp_id = 0,
1977
        $lp_item_id = 0
1978
    ) {
1979
        $current_expired_time_key = self::get_time_control_key(
1980
            $exercise_id,
1981
            $lp_id,
1982
            $lp_item_id
1983
        );
1984
        unset($_SESSION['expired_time'][$current_expired_time_key]);
1985
    }
1986
1987
    /**
1988
     * Generates the time control key.
1989
     *
1990
     * @param int $exercise_id
1991
     * @param int $lp_id
1992
     * @param int $lp_item_id
1993
     *
1994
     * @return string
1995
     */
1996
    public static function get_time_control_key(
1997
        $exercise_id,
1998
        $lp_id = 0,
1999
        $lp_item_id = 0
2000
    ) {
2001
        $exercise_id = (int) $exercise_id;
2002
        $lp_id = (int) $lp_id;
2003
        $lp_item_id = (int) $lp_item_id;
2004
2005
        return
2006
            api_get_course_int_id().'_'.
2007
            api_get_session_id().'_'.
2008
            $exercise_id.'_'.
2009
            api_get_user_id().'_'.
2010
            $lp_id.'_'.
2011
            $lp_item_id;
2012
    }
2013
2014
    /**
2015
     * Get session time control.
2016
     *
2017
     * @param int $exercise_id
2018
     * @param int $lp_id
2019
     * @param int $lp_item_id
2020
     *
2021
     * @return int
2022
     */
2023
    public static function get_session_time_control_key(
2024
        $exercise_id,
2025
        $lp_id = 0,
2026
        $lp_item_id = 0
2027
    ) {
2028
        $return_value = 0;
2029
        $time_control_key = self::get_time_control_key(
2030
            $exercise_id,
2031
            $lp_id,
2032
            $lp_item_id
2033
        );
2034
	if (isset($_SESSION['expired_time']) && isset($_SESSION['expired_time'][$time_control_key])) {
2035
            if ($_SESSION['expired_time'][$time_control_key] instanceof DateTimeInterface) {
2036
                $return_value = $_SESSION['expired_time'][$time_control_key]->format('Y-m-d H:i:s');
2037
            } else {
2038
                $return_value = $_SESSION['expired_time'][$time_control_key];
2039
            }
2040
        }
2041
2042
        return $return_value;
2043
    }
2044
2045
    /**
2046
     * Gets count of exam results.
2047
     *
2048
     * @param int   $exerciseId
2049
     * @param array $conditions
2050
     * @param int   $courseId
2051
     * @param bool  $showSession
2052
     *
2053
     * @return array
2054
     */
2055
    public static function get_count_exam_results($exerciseId, $conditions, $courseId, $showSession = false)
2056
    {
2057
        $count = self::get_exam_results_data(
2058
            null,
2059
            null,
2060
            null,
2061
            null,
2062
            $exerciseId,
2063
            $conditions,
2064
            true,
2065
            $courseId,
2066
            $showSession
2067
        );
2068
2069
        return $count;
2070
    }
2071
2072
    /**
2073
     * Gets the exam'data results.
2074
     *
2075
     * @todo this function should be moved in a library  + no global calls
2076
     *
2077
     * @param int    $from
2078
     * @param int    $number_of_items
2079
     * @param int    $column
2080
     * @param string $direction
2081
     * @param int    $exercise_id
2082
     * @param null   $extra_where_conditions
0 ignored issues
show
Documentation Bug introduced by
Are you sure the doc-type for parameter $extra_where_conditions is correct as it would always require null to be passed?
Loading history...
2083
     * @param bool   $get_count
2084
     * @param int    $courseId
2085
     * @param bool   $showSessionField
2086
     * @param bool   $showExerciseCategories
2087
     * @param array  $userExtraFieldsToAdd
2088
     * @param bool   $useCommaAsDecimalPoint
2089
     * @param bool   $roundValues
2090
     * @param bool   $getOnyIds
2091
     *
2092
     * @return array
2093
     */
2094
    public static function get_exam_results_data(
2095
        $from,
2096
        $number_of_items,
2097
        $column,
2098
        $direction,
2099
        $exercise_id,
2100
        $extra_where_conditions = null,
2101
        $get_count = false,
2102
        $courseId = null,
2103
        $showSessionField = false,
2104
        $showExerciseCategories = false,
2105
        $userExtraFieldsToAdd = [],
2106
        $useCommaAsDecimalPoint = false,
2107
        $roundValues = false,
2108
        $getOnyIds = false
2109
    ) {
2110
        //@todo replace all this globals
2111
        global $filter;
2112
        $courseId = (int) $courseId;
2113
        $course = api_get_course_entity($courseId);
2114
        if (null === $course) {
2115
            return [];
2116
        }
2117
2118
        $sessionId = api_get_session_id();
2119
        $exercise_id = (int) $exercise_id;
2120
2121
        $is_allowedToEdit =
2122
            api_is_allowed_to_edit(null, true) ||
2123
            api_is_allowed_to_edit(true) ||
2124
            api_is_drh() ||
2125
            api_is_student_boss() ||
2126
            api_is_session_admin();
2127
        $TBL_USER = Database::get_main_table(TABLE_MAIN_USER);
2128
        $TBL_EXERCISES = Database::get_course_table(TABLE_QUIZ_TEST);
2129
        $TBL_GROUP_REL_USER = Database::get_course_table(TABLE_GROUP_USER);
2130
        $TBL_GROUP = Database::get_course_table(TABLE_GROUP);
2131
        $TBL_TRACK_EXERCISES = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
2132
        $tblTrackAttemptQualify = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT_QUALIFY);
2133
2134
        $session_id_and = '';
2135
        $sessionCondition = '';
2136
        if (!$showSessionField) {
2137
            $session_id_and = api_get_session_condition($sessionId, true, false, 'te.session_id');
2138
            $sessionCondition = api_get_session_condition($sessionId, true, false, 'ttte.session_id');
2139
        }
2140
2141
        $exercise_where = '';
2142
        if (!empty($exercise_id)) {
2143
            $exercise_where .= ' AND te.exe_exo_id = '.$exercise_id.'  ';
2144
        }
2145
2146
        // sql for chamilo-type tests for teacher / tutor view
2147
        $sql_inner_join_tbl_track_exercices = "
2148
        (
2149
            SELECT DISTINCT ttte.*, if(tr.exe_id,1, 0) as revised
2150
            FROM $TBL_TRACK_EXERCISES ttte
2151
            LEFT JOIN $tblTrackAttemptQualify tr
2152
            ON (ttte.exe_id = tr.exe_id) AND tr.author > 0
2153
            WHERE
2154
                c_id = $courseId AND
2155
                exe_exo_id = $exercise_id
2156
                $sessionCondition
2157
        )";
2158
2159
        if ($is_allowedToEdit) {
2160
            //@todo fix to work with COURSE_RELATION_TYPE_RRHH in both queries
2161
            // Hack in order to filter groups
2162
            $sql_inner_join_tbl_user = '';
2163
            if (strpos($extra_where_conditions, 'group_id')) {
2164
                $sql_inner_join_tbl_user = "
2165
                (
2166
                    SELECT
2167
                        u.id as user_id,
2168
                        firstname,
2169
                        lastname,
2170
                        official_code,
2171
                        email,
2172
                        username,
2173
                        g.name as group_name,
2174
                        g.id as group_id
2175
                    FROM $TBL_USER u
2176
                    INNER JOIN $TBL_GROUP_REL_USER gru
2177
                    ON (gru.user_id = u.id AND gru.c_id= $courseId )
2178
                    INNER JOIN $TBL_GROUP g
2179
                    ON (gru.group_id = g.id AND g.c_id= $courseId )
2180
                    WHERE u.active <> ".USER_SOFT_DELETED."
2181
                )";
2182
            }
2183
2184
            if (strpos($extra_where_conditions, 'group_all')) {
2185
                $extra_where_conditions = str_replace(
2186
                    "AND (  group_id = 'group_all'  )",
2187
                    '',
2188
                    $extra_where_conditions
2189
                );
2190
                $extra_where_conditions = str_replace(
2191
                    "AND group_id = 'group_all'",
2192
                    '',
2193
                    $extra_where_conditions
2194
                );
2195
                $extra_where_conditions = str_replace(
2196
                    "group_id = 'group_all' AND",
2197
                    '',
2198
                    $extra_where_conditions
2199
                );
2200
2201
                $sql_inner_join_tbl_user = "
2202
                (
2203
                    SELECT
2204
                        u.id as user_id,
2205
                        firstname,
2206
                        lastname,
2207
                        official_code,
2208
                        email,
2209
                        username,
2210
                        '' as group_name,
2211
                        '' as group_id
2212
                    FROM $TBL_USER u
2213
                    WHERE u.active <> ".USER_SOFT_DELETED."
2214
                )";
2215
                $sql_inner_join_tbl_user = null;
2216
            }
2217
2218
            if (strpos($extra_where_conditions, 'group_none')) {
2219
                $extra_where_conditions = str_replace(
2220
                    "AND (  group_id = 'group_none'  )",
2221
                    "AND (  group_id is null  )",
2222
                    $extra_where_conditions
2223
                );
2224
                $extra_where_conditions = str_replace(
2225
                    "AND group_id = 'group_none'",
2226
                    "AND (  group_id is null  )",
2227
                    $extra_where_conditions
2228
                );
2229
                $sql_inner_join_tbl_user = "
2230
            (
2231
                SELECT
2232
                    u.id as user_id,
2233
                    firstname,
2234
                    lastname,
2235
                    official_code,
2236
                    email,
2237
                    username,
2238
                    g.name as group_name,
2239
                    g.iid as group_id
2240
                FROM $TBL_USER u
2241
                LEFT OUTER JOIN $TBL_GROUP_REL_USER gru
2242
                ON (gru.user_id = u.id AND gru.c_id= $courseId )
2243
                LEFT OUTER JOIN $TBL_GROUP g
2244
                ON (gru.group_id = g.id AND g.c_id = $courseId )
2245
                WHERE u.active <> ".USER_SOFT_DELETED."
2246
            )";
2247
            }
2248
2249
            // All
2250
            $is_empty_sql_inner_join_tbl_user = false;
2251
            if (empty($sql_inner_join_tbl_user)) {
2252
                $is_empty_sql_inner_join_tbl_user = true;
2253
                $sql_inner_join_tbl_user = "
2254
            (
2255
                SELECT u.id as user_id, firstname, lastname, email, username, ' ' as group_name, '' as group_id, official_code
2256
                FROM $TBL_USER u
2257
                WHERE u.active <> ".USER_SOFT_DELETED." AND u.status NOT IN(".api_get_users_status_ignored_in_reports('string').")
2258
            )";
2259
            }
2260
2261
            $sqlFromOption = " , $TBL_GROUP_REL_USER AS gru ";
2262
            $sqlWhereOption = "  AND gru.c_id = $courseId AND gru.user_id = user.id ";
2263
            $first_and_last_name = api_is_western_name_order() ? "firstname, lastname" : "lastname, firstname";
2264
2265
            if ($get_count) {
2266
                $sql_select = 'SELECT count(te.exe_id) ';
2267
            } else {
2268
                $sql_select = "SELECT DISTINCT
2269
                user.user_id,
2270
                $first_and_last_name,
2271
                official_code,
2272
                ce.title,
2273
                username,
2274
                te.score,
2275
                te.max_score,
2276
                te.exe_date,
2277
                te.exe_id,
2278
                te.session_id,
2279
                email as exemail,
2280
                te.start_date,
2281
                ce.expired_time,
2282
                steps_counter,
2283
                exe_user_id,
2284
                te.exe_duration,
2285
                te.status as completion_status,
2286
                propagate_neg,
2287
                revised,
2288
                group_name,
2289
                user.group_id AS group_id,
2290
                orig_lp_id,
2291
                te.user_ip";
2292
            }
2293
2294
            $sql = " $sql_select
2295
            FROM $TBL_EXERCISES AS ce
2296
            INNER JOIN $sql_inner_join_tbl_track_exercices AS te
2297
            ON (te.exe_exo_id = ce.iid)
2298
            INNER JOIN $sql_inner_join_tbl_user AS user
2299
            ON (user.user_id = exe_user_id)
2300
            INNER JOIN resource_node rn
2301
                ON rn.id = ce.resource_node_id
2302
            INNER JOIN resource_link rl
2303
                ON rl.resource_node_id = rn.id
2304
            WHERE
2305
                te.c_id = $courseId $session_id_and AND
2306
                rl.deleted_at IS NULL
2307
                $exercise_where
2308
                $extra_where_conditions
2309
            ";
2310
        }
2311
2312
        if (empty($sql)) {
2313
            return false;
2314
        }
2315
2316
        if ($get_count) {
2317
            $resx = Database::query($sql);
2318
            $rowx = Database::fetch_row($resx, 'ASSOC');
2319
2320
            return $rowx[0];
2321
        }
2322
2323
        $teacher_list = CourseManager::get_teacher_list_from_course_code($course->getCode());
2324
        $teacher_id_list = [];
2325
        if (!empty($teacher_list)) {
2326
            foreach ($teacher_list as $teacher) {
2327
                $teacher_id_list[] = $teacher['user_id'];
2328
            }
2329
        }
2330
2331
        $scoreDisplay = new ScoreDisplay();
2332
        $decimalSeparator = '.';
2333
        $thousandSeparator = ',';
2334
2335
        if ($useCommaAsDecimalPoint) {
2336
            $decimalSeparator = ',';
2337
            $thousandSeparator = '';
2338
        }
2339
2340
        $listInfo = [];
2341
        $column = !empty($column) ? Database::escape_string($column) : null;
2342
        $from = (int) $from;
2343
        $number_of_items = (int) $number_of_items;
2344
        $direction = !in_array(strtolower(trim($direction)), ['asc', 'desc']) ? 'asc' : $direction;
2345
2346
        if (!empty($column)) {
2347
            $sql .= " ORDER BY `$column` $direction ";
2348
        }
2349
2350
        if (!$getOnyIds) {
2351
            $sql .= " LIMIT $from, $number_of_items";
2352
        }
2353
2354
        $results = [];
2355
        $resx = Database::query($sql);
2356
        while ($rowx = Database::fetch_assoc($resx)) {
2357
            $results[] = $rowx;
2358
        }
2359
2360
        $group_list = GroupManager::get_group_list(null, $course);
2361
        $clean_group_list = [];
2362
        if (!empty($group_list)) {
2363
            foreach ($group_list as $group) {
2364
                $clean_group_list[$group['iid']] = $group['title'];
2365
            }
2366
        }
2367
2368
        $lp_list_obj = new LearnpathList(api_get_user_id());
2369
        $lp_list = $lp_list_obj->get_flat_list();
2370
        $oldIds = array_column($lp_list, 'lp_old_id', 'iid');
2371
2372
        if (is_array($results)) {
2373
            $users_array_id = [];
2374
            $from_gradebook = false;
2375
            if (isset($_GET['gradebook']) && 'view' === $_GET['gradebook']) {
2376
                $from_gradebook = true;
2377
            }
2378
            $sizeof = count($results);
2379
            $locked = api_resource_is_locked_by_gradebook(
2380
                $exercise_id,
2381
                LINK_EXERCISE
2382
            );
2383
2384
            $timeNow = strtotime(api_get_utc_datetime());
2385
            // Looping results
2386
            for ($i = 0; $i < $sizeof; $i++) {
2387
                $revised = $results[$i]['revised'];
2388
                if ('incomplete' === $results[$i]['completion_status']) {
2389
                    // If the exercise was incomplete, we need to determine
2390
                    // if it is still into the time allowed, or if its
2391
                    // allowed time has expired and it can be closed
2392
                    // (it's "unclosed")
2393
                    $minutes = $results[$i]['expired_time'];
2394
                    if (0 == $minutes) {
2395
                        // There's no time limit, so obviously the attempt
2396
                        // can still be "ongoing", but the teacher should
2397
                        // be able to choose to close it, so mark it as
2398
                        // "unclosed" instead of "ongoing"
2399
                        $revised = 2;
2400
                    } else {
2401
                        $allowedSeconds = $minutes * 60;
2402
                        $timeAttemptStarted = strtotime($results[$i]['start_date']);
2403
                        $secondsSinceStart = $timeNow - $timeAttemptStarted;
2404
                        if ($secondsSinceStart > $allowedSeconds) {
2405
                            $revised = 2; // mark as "unclosed"
2406
                        } else {
2407
                            $revised = 3; // mark as "ongoing"
2408
                        }
2409
                    }
2410
                }
2411
2412
                if ($from_gradebook && ($is_allowedToEdit)) {
2413
                    if (in_array(
2414
                        $results[$i]['username'].$results[$i]['firstname'].$results[$i]['lastname'],
2415
                        $users_array_id
2416
                    )) {
2417
                        continue;
2418
                    }
2419
                    $users_array_id[] = $results[$i]['username'].$results[$i]['firstname'].$results[$i]['lastname'];
2420
                }
2421
2422
                $lp_obj = isset($results[$i]['orig_lp_id']) && isset($lp_list[$results[$i]['orig_lp_id']]) ? $lp_list[$results[$i]['orig_lp_id']] : null;
2423
                if (empty($lp_obj)) {
2424
                    // Try to get the old id (id instead of iid)
2425
                    $lpNewId = isset($results[$i]['orig_lp_id']) && isset($oldIds[$results[$i]['orig_lp_id']]) ? $oldIds[$results[$i]['orig_lp_id']] : null;
2426
                    if ($lpNewId) {
2427
                        $lp_obj = isset($lp_list[$lpNewId]) ? $lp_list[$lpNewId] : null;
2428
                    }
2429
                }
2430
                $lp_name = null;
2431
                if ($lp_obj) {
2432
                    $url = api_get_path(WEB_CODE_PATH).
2433
                        'lp/lp_controller.php?'.api_get_cidreq().'&action=view&lp_id='.$results[$i]['orig_lp_id'];
2434
                    $lp_name = Display::url(
2435
                        $lp_obj['lp_name'],
2436
                        $url,
2437
                        ['target' => '_blank']
2438
                    );
2439
                }
2440
2441
                // Add all groups by user
2442
                $group_name_list = '';
2443
                if ($is_empty_sql_inner_join_tbl_user) {
2444
                    $group_list = GroupManager::get_group_ids(
2445
                        api_get_course_int_id(),
2446
                        $results[$i]['user_id']
2447
                    );
2448
2449
                    foreach ($group_list as $id) {
2450
                        if (isset($clean_group_list[$id])) {
2451
                            $group_name_list .= $clean_group_list[$id].'<br/>';
2452
                        }
2453
                    }
2454
                    $results[$i]['group_name'] = $group_name_list;
2455
                }
2456
2457
                $results[$i]['exe_duration'] = !empty($results[$i]['exe_duration']) ? round($results[$i]['exe_duration'] / 60) : 0;
2458
                $id = $results[$i]['exe_id'];
2459
                $dt = api_convert_and_format_date($results[$i]['max_score']);
2460
2461
                // we filter the results if we have the permission to
2462
                $result_disabled = 0;
2463
                if (isset($results[$i]['results_disabled'])) {
2464
                    $result_disabled = (int) $results[$i]['results_disabled'];
2465
                }
2466
                if (0 == $result_disabled) {
2467
                    $my_res = $results[$i]['score'];
2468
                    $my_total = $results[$i]['max_score'];
2469
                    $results[$i]['start_date'] = api_get_local_time($results[$i]['start_date']);
2470
                    $results[$i]['exe_date'] = api_get_local_time($results[$i]['exe_date']);
2471
2472
                    if (!$results[$i]['propagate_neg'] && $my_res < 0) {
2473
                        $my_res = 0;
2474
                    }
2475
2476
                    $score = self::show_score(
2477
                        $my_res,
2478
                        $my_total,
2479
                        true,
2480
                        true,
2481
                        false,
2482
                        false,
2483
                        $decimalSeparator,
2484
                        $thousandSeparator,
2485
                        $roundValues
2486
                    );
2487
2488
                    $actions = '<div class="pull-right">';
2489
                    if ($is_allowedToEdit) {
2490
                        if (isset($teacher_id_list)) {
2491
                            if (in_array(
2492
                                $results[$i]['exe_user_id'],
2493
                                $teacher_id_list
2494
                            )) {
2495
                                $actions .= Display::getMdiIcon('human-male-board', 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Trainer'));
2496
                            }
2497
                        }
2498
                        $revisedLabel = '';
2499
                        switch ($revised) {
2500
                            case 0:
2501
                                $actions .= "<a href='exercise_show.php?".api_get_cidreq()."&action=qualify&id=$id'>".
2502
                                    Display::getMdiIcon(ActionIcon::GRADE, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Grade activity')
2503
                                    );
2504
                                $actions .= '</a>';
2505
                                $revisedLabel = Display::label(
2506
                                    get_lang('Not validated'),
2507
                                    'info'
2508
                                );
2509
                                break;
2510
                            case 1:
2511
                                $actions .= "<a href='exercise_show.php?".api_get_cidreq()."&action=edit&id=$id'>".
2512
                                    Display::getMdiIcon(ActionIcon::EDIT, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Edit'));
2513
                                $actions .= '</a>';
2514
                                $revisedLabel = Display::label(
2515
                                    get_lang('Validated'),
2516
                                    'success'
2517
                                );
2518
                                break;
2519
                            case 2: //finished but not marked as such
2520
                                $actions .= '<a href="exercise_report.php?'
2521
                                    .api_get_cidreq()
2522
                                    .'&exerciseId='
2523
                                    .$exercise_id
2524
                                    .'&a=close&id='
2525
                                    .$id
2526
                                    .'">'.
2527
                                    Display::getMdiIcon(ActionIcon::LOCK, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Mark attempt as closed'));
2528
                                $actions .= '</a>';
2529
                                $revisedLabel = Display::label(
2530
                                    get_lang('Unclosed'),
2531
                                    'warning'
2532
                                );
2533
                                break;
2534
                            case 3: //still ongoing
2535
                                $actions .= Display::getMdiIcon('clock', 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Attempt still going on. Please wait.'));
2536
                                $actions .= '';
2537
                                $revisedLabel = Display::label(
2538
                                    get_lang('Ongoing'),
2539
                                    'danger'
2540
                                );
2541
                                break;
2542
                        }
2543
2544
                        if (2 == $filter) {
2545
                            $actions .= ' <a href="exercise_history.php?'.api_get_cidreq().'&exe_id='.$id.'">'.
2546
                                Display::getMdiIcon('clipboard-text-clock', 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('View changes history')
2547
                                ).'</a>';
2548
                        }
2549
2550
                        // Admin can always delete the attempt
2551
                        if ((false == $locked || api_is_platform_admin()) && !api_is_student_boss()) {
2552
                            $ip = Tracking::get_ip_from_user_event(
2553
                                $results[$i]['exe_user_id'],
2554
                                api_get_utc_datetime(),
2555
                                false
2556
                            );
2557
                            $actions .= '<a href="http://www.whatsmyip.org/ip-geo-location/?ip='.$ip.'" target="_blank">'
2558
                                .Display::getMdiIcon('information', 'ch-tool-icon', null, ICON_SIZE_SMALL, $ip)
2559
                                .'</a>';
2560
2561
                            $recalculateUrl = api_get_path(WEB_CODE_PATH).'exercise/recalculate.php?'.
2562
                                api_get_cidreq().'&'.
2563
                                http_build_query([
2564
                                    'id' => $id,
2565
                                    'exercise' => $exercise_id,
2566
                                    'user' => $results[$i]['exe_user_id'],
2567
                                ]);
2568
                            $actions .= Display::url(
2569
                                Display::getMdiIcon(ActionIcon::REFRESH, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Recalculate results')),
2570
                                $recalculateUrl,
2571
                                [
2572
                                    'data-exercise' => $exercise_id,
2573
                                    'data-user' => $results[$i]['exe_user_id'],
2574
                                    'data-id' => $id,
2575
                                    'class' => 'exercise-recalculate',
2576
                                ]
2577
                            );
2578
2579
                            $exportPdfUrl = api_get_path(WEB_CODE_PATH).'exercise/exercise_report.php?'.
2580
                                api_get_cidreq().'&exerciseId='.$exercise_id.'&action=export_pdf&attemptId='.$id.'&userId='.(int) $results[$i]['exe_user_id'];
2581
                            $actions .= '<a href="'.$exportPdfUrl.'" target="_blank">'
2582
                                .Display::getMdiIcon(ActionIcon::EXPORT_PDF, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Export to PDF'))
2583
                                .'</a>';
2584
2585
                            $sendMailUrl =  api_get_path(WEB_CODE_PATH).'exercise/exercise_report.php?'.api_get_cidreq().'&action=send_email&exerciseId='.$exercise_id.'&attemptId='.$results[$i]['exe_id'];
2586
                            $emailLink = '<a href="'.$sendMailUrl.'">'
2587
                                .Display::getMdiIcon(ActionIcon::SEND_SINGLE_EMAIL, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Send by e-mail'))
2588
                                .'</a>';
2589
2590
                            $filterByUser = isset($_GET['filter_by_user']) ? (int) $_GET['filter_by_user'] : 0;
2591
                            $delete_link = '<a
2592
                            href="exercise_report.php?'.api_get_cidreq().'&filter_by_user='.$filterByUser.'&filter='.$filter.'&exerciseId='.$exercise_id.'&delete=delete&did='.$id.'"
2593
                            onclick=
2594
                            "javascript:if(!confirm(\''.sprintf(addslashes(get_lang('Delete attempt?')), $results[$i]['username'], $dt).'\')) return false;"
2595
                            >';
2596
                            $delete_link .= Display::getMdiIcon(ActionIcon::DELETE, 'ch-tool-icon', null, ICON_SIZE_SMALL, addslashes(get_lang('Delete'))).'</a>';
2597
2598
                            if (api_is_drh() && !api_is_platform_admin()) {
2599
                                $delete_link = null;
2600
                            }
2601
                            if (api_is_session_admin()) {
2602
                                $delete_link = '';
2603
                            }
2604
                            if (3 == $revised) {
2605
                                $delete_link = null;
2606
                            }
2607
                            if (1 !== $revised) {
2608
                                $emailLink = '';
2609
                            }
2610
                            $actions .= $delete_link;
2611
                            $actions .= $emailLink;
2612
                        }
2613
                    } else {
2614
                        $attempt_url = api_get_path(WEB_CODE_PATH).'exercise/result.php?'.api_get_cidreq().'&id='.$results[$i]['exe_id'].'&sid='.$sessionId;
2615
                        $attempt_link = Display::url(
2616
                            get_lang('Show'),
2617
                            $attempt_url,
2618
                            [
2619
                                'class' => 'ajax btn btn--plain',
2620
                                'data-title' => get_lang('Show'),
2621
                            ]
2622
                        );
2623
                        $actions .= $attempt_link;
2624
                    }
2625
                    $actions .= '</div>';
2626
2627
                    if (!empty($userExtraFieldsToAdd)) {
2628
                        foreach ($userExtraFieldsToAdd as $variable) {
2629
                            $extraFieldValue = new ExtraFieldValue('user');
2630
                            $values = $extraFieldValue->get_values_by_handler_and_field_variable(
2631
                                $results[$i]['user_id'],
2632
                                $variable
2633
                            );
2634
                            if (isset($values['value'])) {
2635
                                $results[$i][$variable] = $values['value'];
2636
                            }
2637
                        }
2638
                    }
2639
2640
                    $exeId = $results[$i]['exe_id'];
2641
                    $results[$i]['id'] = $exeId;
2642
                    $category_list = [];
2643
                    if ($is_allowedToEdit) {
2644
                        $sessionName = '';
2645
                        $sessionStartAccessDate = '';
2646
                        if (!empty($results[$i]['session_id'])) {
2647
                            $sessionInfo = api_get_session_info($results[$i]['session_id']);
2648
                            if (!empty($sessionInfo)) {
2649
                                $sessionName = $sessionInfo['title'];
2650
                                $sessionStartAccessDate = api_get_local_time($sessionInfo['access_start_date']);
2651
                            }
2652
                        }
2653
2654
                        $objExercise = new Exercise($courseId);
2655
                        if ($showExerciseCategories) {
2656
                            // Getting attempt info
2657
                            $exercise_stat_info = $objExercise->get_stat_track_exercise_info_by_exe_id($exeId);
2658
                            if (!empty($exercise_stat_info['data_tracking'])) {
2659
                                $question_list = explode(',', $exercise_stat_info['data_tracking']);
2660
                                if (!empty($question_list)) {
2661
                                    foreach ($question_list as $questionId) {
2662
                                        $objQuestionTmp = Question::read($questionId, $objExercise->course);
2663
                                        // We're inside *one* question. Go through each possible answer for this question
2664
                                        $result = $objExercise->manage_answer(
2665
                                            $exeId,
2666
                                            $questionId,
2667
                                            null,
2668
                                            'exercise_result',
2669
                                            false,
2670
                                            false,
2671
                                            true,
2672
                                            false,
2673
                                            $objExercise->selectPropagateNeg(),
2674
                                            null,
2675
                                            true
2676
                                        );
2677
2678
                                        $my_total_score = $result['score'];
2679
                                        $my_total_weight = $result['weight'];
2680
2681
                                        // Category report
2682
                                        $category_was_added_for_this_test = false;
2683
                                        if (isset($objQuestionTmp->category) && !empty($objQuestionTmp->category)) {
2684
                                            if (!isset($category_list[$objQuestionTmp->category]['score'])) {
2685
                                                $category_list[$objQuestionTmp->category]['score'] = 0;
2686
                                            }
2687
                                            if (!isset($category_list[$objQuestionTmp->category]['total'])) {
2688
                                                $category_list[$objQuestionTmp->category]['total'] = 0;
2689
                                            }
2690
                                            $category_list[$objQuestionTmp->category]['score'] += $my_total_score;
2691
                                            $category_list[$objQuestionTmp->category]['total'] += $my_total_weight;
2692
                                            $category_was_added_for_this_test = true;
2693
                                        }
2694
2695
                                        if (isset($objQuestionTmp->category_list) &&
2696
                                            !empty($objQuestionTmp->category_list)
2697
                                        ) {
2698
                                            foreach ($objQuestionTmp->category_list as $category_id) {
2699
                                                $category_list[$category_id]['score'] += $my_total_score;
2700
                                                $category_list[$category_id]['total'] += $my_total_weight;
2701
                                                $category_was_added_for_this_test = true;
2702
                                            }
2703
                                        }
2704
2705
                                        // No category for this question!
2706
                                        if (false == $category_was_added_for_this_test) {
2707
                                            if (!isset($category_list['none']['score'])) {
2708
                                                $category_list['none']['score'] = 0;
2709
                                            }
2710
                                            if (!isset($category_list['none']['total'])) {
2711
                                                $category_list['none']['total'] = 0;
2712
                                            }
2713
2714
                                            $category_list['none']['score'] += $my_total_score;
2715
                                            $category_list['none']['total'] += $my_total_weight;
2716
                                        }
2717
                                    }
2718
                                }
2719
                            }
2720
                        }
2721
2722
                        foreach ($category_list as $categoryId => $result) {
2723
                            $scoreToDisplay = self::show_score(
2724
                                $result['score'],
2725
                                $result['total'],
2726
                                true,
2727
                                true,
2728
                                false,
2729
                                false,
2730
                                $decimalSeparator,
2731
                                $thousandSeparator,
2732
                                $roundValues
2733
                            );
2734
                            $results[$i]['category_'.$categoryId] = $scoreToDisplay;
2735
                            $results[$i]['category_'.$categoryId.'_score_percentage'] = self::show_score(
2736
                                $result['score'],
2737
                                $result['total'],
2738
                                true,
2739
                                true,
2740
                                true, // $show_only_percentage = false
2741
                                true, // hide % sign
2742
                                $decimalSeparator,
2743
                                $thousandSeparator,
2744
                                $roundValues
2745
                            );
2746
                            $results[$i]['category_'.$categoryId.'_only_score'] = $result['score'];
2747
                            $results[$i]['category_'.$categoryId.'_total'] = $result['total'];
2748
                        }
2749
                        $results[$i]['session'] = $sessionName;
2750
                        $results[$i]['session_access_start_date'] = $sessionStartAccessDate;
2751
                        $results[$i]['status'] = $revisedLabel;
2752
                        $results[$i]['score'] = $score;
2753
                        $results[$i]['score_percentage'] = self::show_score(
2754
                            $my_res,
2755
                            $my_total,
2756
                            true,
2757
                            true,
2758
                            true,
2759
                            true,
2760
                            $decimalSeparator,
2761
                            $thousandSeparator,
2762
                            $roundValues
2763
                        );
2764
2765
                        if ($roundValues) {
2766
                            $whole = floor($my_res); // 1
2767
                            $fraction = $my_res - $whole; // .25
2768
                            if ($fraction >= 0.5) {
2769
                                $onlyScore = ceil($my_res);
2770
                            } else {
2771
                                $onlyScore = round($my_res);
2772
                            }
2773
                        } else {
2774
                            $onlyScore = $scoreDisplay->format_score(
2775
                                $my_res,
2776
                                false,
2777
                                $decimalSeparator,
2778
                                $thousandSeparator
2779
                            );
2780
                        }
2781
2782
                        $results[$i]['only_score'] = $onlyScore;
2783
2784
                        if ($roundValues) {
2785
                            $whole = floor($my_total); // 1
2786
                            $fraction = $my_total - $whole; // .25
2787
                            if ($fraction >= 0.5) {
2788
                                $onlyTotal = ceil($my_total);
2789
                            } else {
2790
                                $onlyTotal = round($my_total);
2791
                            }
2792
                        } else {
2793
                            $onlyTotal = $scoreDisplay->format_score(
2794
                                $my_total,
2795
                                false,
2796
                                $decimalSeparator,
2797
                                $thousandSeparator
2798
                            );
2799
                        }
2800
                        $results[$i]['total'] = $onlyTotal;
2801
                        $results[$i]['lp'] = $lp_name;
2802
                        $results[$i]['actions'] = $actions;
2803
                        $listInfo[] = $results[$i];
2804
                    } else {
2805
                        $results[$i]['status'] = $revisedLabel;
2806
                        $results[$i]['score'] = $score;
2807
                        $results[$i]['actions'] = $actions;
2808
                        $listInfo[] = $results[$i];
2809
                    }
2810
                }
2811
            }
2812
        }
2813
2814
        return $listInfo;
2815
    }
2816
2817
    /**
2818
     * Returns email content for a specific attempt.
2819
     */
2820
    public static function getEmailContentForAttempt(int $attemptId): array
2821
    {
2822
        $trackExerciseInfo = self::get_exercise_track_exercise_info($attemptId);
2823
2824
        if (empty($trackExerciseInfo)) {
2825
            return [
2826
                'to' => '',
2827
                'subject' => 'No exercise info found',
2828
                'message' => 'Attempt ID not found or invalid.',
2829
            ];
2830
        }
2831
2832
        $studentId = $trackExerciseInfo['exe_user_id'];
2833
        $courseInfo = api_get_course_info();
2834
        $teacherId = api_get_user_id();
2835
2836
        if (
2837
            empty($trackExerciseInfo['orig_lp_id']) ||
2838
            empty($trackExerciseInfo['orig_lp_item_id'])
2839
        ) {
2840
            $url = api_get_path(WEB_CODE_PATH).'exercise/result.php?id='.$trackExerciseInfo['exe_id'].'&'.api_get_cidreq()
2841
                .'&show_headers=1&id_session='.api_get_session_id();
2842
        } else {
2843
            $url = api_get_path(WEB_CODE_PATH).'lp/lp_controller.php?action=view&item_id='
2844
                .$trackExerciseInfo['orig_lp_item_id'].'&lp_id='.$trackExerciseInfo['orig_lp_id'].'&'.api_get_cidreq()
2845
                .'&id_session='.api_get_session_id();
2846
        }
2847
2848
        $message = self::getEmailNotification(
2849
            $teacherId,
2850
            $courseInfo,
2851
            $trackExerciseInfo['title'],
2852
            $url
2853
        );
2854
2855
        return [
2856
            'to' => $studentId,
2857
            'subject' => get_lang('Corrected test result'),
2858
            'message' => $message,
2859
        ];
2860
    }
2861
2862
    /**
2863
     * Sends the exercise result email to the student.
2864
     */
2865
    public static function sendExerciseResultByEmail(int $attemptId): void
2866
    {
2867
        $content = self::getEmailContentForAttempt($attemptId);
2868
2869
        if (empty($content['to'])) {
2870
            return;
2871
        }
2872
2873
        MessageManager::send_message_simple(
2874
            $content['to'],
2875
            $content['subject'],
2876
            $content['message'],
2877
            api_get_user_id()
2878
        );
2879
    }
2880
2881
    /**
2882
     * Returns all reviewed attempts for a given exercise and session.
2883
     */
2884
    public static function getReviewedAttemptsInfo(int $exerciseId, int $sessionId): array
2885
    {
2886
        $courseId = api_get_course_int_id();
2887
        $trackTable = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
2888
        $qualifyTable = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT_QUALIFY);
2889
2890
        $sessionCondition = api_get_session_condition($sessionId, true, false, 't.session_id');
2891
2892
        $sql = "
2893
            SELECT DISTINCT t.exe_id
2894
            FROM $trackTable t
2895
            INNER JOIN $qualifyTable q ON (t.exe_id = q.exe_id AND q.author > 0)
2896
            WHERE
2897
                t.c_id = $courseId AND
2898
                t.exe_exo_id = $exerciseId
2899
                $sessionCondition
2900
        ";
2901
2902
        return Database::store_result(Database::query($sql));
2903
    }
2904
2905
    /**
2906
     * @param $score
2907
     * @param $weight
2908
     *
2909
     * @return array
2910
     */
2911
    public static function convertScoreToPlatformSetting($score, $weight)
2912
    {
2913
        $maxNote = api_get_setting('exercise_max_score');
2914
        $minNote = api_get_setting('exercise_min_score');
2915
2916
        if ('' != $maxNote && '' != $minNote) {
2917
            if (!empty($weight) && (float) $weight !== (float) 0) {
2918
                $score = $minNote + ($maxNote - $minNote) * $score / $weight;
2919
            } else {
2920
                $score = $minNote;
2921
            }
2922
            $weight = $maxNote;
2923
        }
2924
2925
        return ['score' => $score, 'weight' => $weight];
2926
    }
2927
2928
    /**
2929
     * Converts the score with the exercise_max_note and exercise_min_score
2930
     * the platform settings + formats the results using the float_format function.
2931
     *
2932
     * @param float  $score
2933
     * @param float  $weight
2934
     * @param bool   $show_percentage       show percentage or not
2935
     * @param bool   $use_platform_settings use or not the platform settings
2936
     * @param bool   $show_only_percentage
2937
     * @param bool   $hidePercentageSign    hide "%" sign
2938
     * @param string $decimalSeparator
2939
     * @param string $thousandSeparator
2940
     * @param bool   $roundValues           This option rounds the float values into a int using ceil()
2941
     * @param bool   $removeEmptyDecimals
2942
     *
2943
     * @return string an html with the score modified
2944
     */
2945
    public static function show_score(
2946
        $score,
2947
        $weight,
2948
        $show_percentage = true,
2949
        $use_platform_settings = true,
2950
        $show_only_percentage = false,
2951
        $hidePercentageSign = false,
2952
        $decimalSeparator = '.',
2953
        $thousandSeparator = ',',
2954
        $roundValues = false,
2955
        $removeEmptyDecimals = false
2956
    ) {
2957
        if (is_null($score) && is_null($weight)) {
2958
            return '-';
2959
        }
2960
2961
        $decimalSeparator = empty($decimalSeparator) ? '.' : $decimalSeparator;
2962
        $thousandSeparator = empty($thousandSeparator) ? ',' : $thousandSeparator;
2963
2964
        if ($use_platform_settings) {
2965
            $result = self::convertScoreToPlatformSetting($score, $weight);
2966
            $score = $result['score'];
2967
            $weight = $result['weight'];
2968
        }
2969
2970
        // Keep a raw numeric percentage for model mapping BEFORE string formatting
2971
        $percentageRaw = (100 * (float) $score) / ((0 != (float) $weight) ? (float) $weight : 1);
2972
2973
        // Formats values
2974
        $percentage = float_format($percentageRaw, 1);
2975
        $score      = float_format($score, 1);
2976
        $weight     = float_format($weight, 1);
2977
2978
        if ($roundValues) {
2979
            $whole = floor($percentage);
2980
            $fraction = $percentage - $whole;
2981
            $percentage = ($fraction >= 0.5) ? ceil($percentage) : round($percentage);
2982
2983
            $whole = floor($score);
2984
            $fraction = $score - $whole;
2985
            $score = ($fraction >= 0.5) ? ceil($score) : round($score);
2986
2987
            $whole = floor($weight);
2988
            $fraction = $weight - $whole;
2989
            $weight = ($fraction >= 0.5) ? ceil($weight) : round($weight);
2990
        } else {
2991
            $percentage = float_format($percentage, 1, $decimalSeparator, $thousandSeparator);
2992
            $score      = float_format($score, 1, $decimalSeparator, $thousandSeparator);
2993
            $weight     = float_format($weight, 1, $decimalSeparator, $thousandSeparator);
2994
        }
2995
2996
        // Build base HTML (percentage or score/weight)
2997
        if ($show_percentage) {
2998
            $percentageSign = $hidePercentageSign ? '' : ' %';
2999
            $html = $show_only_percentage
3000
                ? ($percentage . $percentageSign)
3001
                : ($percentage . $percentageSign . ' (' . $score . ' / ' . $weight . ')');
3002
        } else {
3003
            if ($removeEmptyDecimals && ScoreDisplay::hasEmptyDecimals($weight)) {
3004
                $weight = round($weight);
3005
            }
3006
            $html = $score . ' / ' . $weight;
3007
        }
3008
3009
        $bucket = self::convertScoreToModel($percentageRaw);
3010
        if ($bucket !== null) {
3011
            $html = self::getModelStyle($bucket, $percentageRaw);
3012
        }
3013
3014
        // If the platform forces a format, it overrides everything (including the model badge)
3015
        $format = (int) api_get_setting('exercise.exercise_score_format');
3016
        if (!empty($format)) {
3017
            $html = ScoreDisplay::instance()->display_score([$score, $weight], $format);
3018
        }
3019
3020
        return Display::span($html, ['class' => 'score_exercise']);
3021
    }
3022
3023
    /**
3024
     * @param array $model
3025
     * @param float $percentage
3026
     *
3027
     * @return string
3028
     */
3029
    public static function getModelStyle($bucket, $percentage)
3030
    {
3031
        $rawClass = (string) ($bucket['css_class'] ?? '');
3032
        $twClass  = self::mapScoreCssClass($rawClass);
3033
3034
        // Accept both 'name' and 'variable'
3035
        $key   = isset($bucket['name']) ? 'name' : (isset($bucket['variable']) ? 'variable' : null);
3036
        $raw   = $key ? (string) $bucket[$key] : '';
3037
        $label = $raw !== '' ? get_lang($raw) : '';
3038
        $show  = (int) ($bucket['display_score_name'] ?? 0) === 1;
3039
3040
        $base = 'inline-block px-2 py-1 rounded';
3041
3042
        if ($show && $label !== '') {
3043
            return '<span class="' . htmlspecialchars($base . ' ' . $twClass) . '">' .
3044
                htmlspecialchars($label) . '</span>';
3045
        }
3046
3047
        return '<span class="' . htmlspecialchars($base . ' ' . $twClass) . '" ' .
3048
            'title="' . htmlspecialchars($label) . '" aria-label="' . htmlspecialchars($label) . '">' .
3049
            '&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;' .
3050
            '</span>';
3051
    }
3052
3053
    /**
3054
     * Map legacy css_class (e.g., "btn-danger") to Tailwind utility classes
3055
     * defined in Chamilo 2's theme (danger/success/warning/info).
3056
     * If a Tailwind class list is already provided, pass-through.
3057
     */
3058
    private static function mapScoreCssClass(string $cssClass): string
3059
    {
3060
        $cssClass = trim($cssClass);
3061
3062
        // Legacy → Tailwind mapping
3063
        $map = [
3064
            'btn-success' => 'bg-success text-success-button-text',
3065
            'btn-warning' => 'bg-warning text-warning-button-text',
3066
            'btn-danger'  => 'bg-danger text-danger-button-text',
3067
            'btn-info'    => 'bg-info text-info-button-text',
3068
3069
            // Also accept short tokens if someone uses "success" directly
3070
            'success' => 'bg-success text-success-button-text',
3071
            'warning' => 'bg-warning text-warning-button-text',
3072
            'danger'  => 'bg-danger text-danger-button-text',
3073
            'info'    => 'bg-info text-info-button-text',
3074
        ];
3075
3076
        if (isset($map[$cssClass])) {
3077
            return $map[$cssClass];
3078
        }
3079
3080
        // If it already looks like Tailwind utility classes, keep as-is
3081
        if (strpos($cssClass, ' ') !== false || preg_match('/[a-z]+-[a-z0-9\-]+/i', $cssClass)) {
3082
            return $cssClass;
3083
        }
3084
3085
        // Neutral fallback
3086
        return 'bg-gray-20 text-gray-90';
3087
    }
3088
3089
    /**
3090
     * @param float $percentage value between 0 and 100
3091
     *
3092
     * @return string
3093
     */
3094
    public static function convertScoreToModel($percentage): ?array
3095
    {
3096
        $model = self::getCourseScoreModel();
3097
        if (empty($model) || empty($model['score_list'])) {
3098
            return null;
3099
        }
3100
3101
        foreach ($model['score_list'] as $bucket) {
3102
            $min = (float) ($bucket['min'] ?? 0);
3103
            $max = (float) ($bucket['max'] ?? 0);
3104
3105
            if ($percentage >= $min && $percentage <= $max) {
3106
                // Propagate the model flag to the bucket
3107
                $bucket['display_score_name'] = (int) ($model['display_score_name'] ?? 0);
3108
                // Precompute label for convenience (optional)
3109
                $bucket['label'] = self::scoreLabel($bucket);
3110
                return $bucket;
3111
            }
3112
        }
3113
3114
        return null;
3115
    }
3116
3117
    private static function scoreLabel(array $row): string
3118
    {
3119
        $key = isset($row['name']) ? 'name' : (isset($row['variable']) ? 'variable' : null);
3120
        if (!$key) {
3121
            return '';
3122
        }
3123
        $value = (string) $row[$key];
3124
        return get_lang($value);
3125
    }
3126
3127
    /**
3128
     * @return array
3129
     */
3130
    public static function getCourseScoreModel(): array
3131
    {
3132
        $modelList = self::getScoreModels();
3133
        if (empty($modelList) || empty($modelList['models'])) {
3134
            return [];
3135
        }
3136
3137
        // Read the configured model id from course settings
3138
        $scoreModelId = (int) api_get_course_setting('score_model_id');
3139
3140
        // first available model
3141
        $selected = $modelList['models'][0];
3142
3143
        if ($scoreModelId !== -1) {
3144
            foreach ($modelList['models'] as $m) {
3145
                if ((int) ($m['id'] ?? 0) === $scoreModelId) {
3146
                    $selected = $m;
3147
                    break;
3148
                }
3149
            }
3150
        }
3151
3152
        // do NOT show name unless explicitly enabled
3153
        $selected['display_score_name'] = (int) ($selected['display_score_name'] ?? 0);
3154
3155
        return $selected;
3156
    }
3157
3158
    /**
3159
     * @return array
3160
     */
3161
    public static function getScoreModels()
3162
    {
3163
        return api_get_setting('exercise.score_grade_model', true);
3164
    }
3165
3166
    /**
3167
     * @param float  $score
3168
     * @param float  $weight
3169
     * @param string $passPercentage
3170
     *
3171
     * @return bool
3172
     */
3173
    public static function isSuccessExerciseResult($score, $weight, $passPercentage)
3174
    {
3175
        $percentage = float_format(
3176
            ($score / (0 != $weight ? $weight : 1)) * 100,
3177
            1
3178
        );
3179
        if (isset($passPercentage) && !empty($passPercentage)) {
3180
            if ($percentage >= $passPercentage) {
3181
                return true;
3182
            }
3183
        }
3184
3185
        return false;
3186
    }
3187
3188
    /**
3189
     * @param string $name
3190
     * @param $weight
3191
     * @param $selected
3192
     *
3193
     * @return bool
3194
     */
3195
    public static function addScoreModelInput(
3196
        FormValidator $form,
3197
        $name,
3198
        $weight,
3199
        $selected
3200
    ) {
3201
        $model = self::getCourseScoreModel();
3202
        if (empty($model)) {
3203
            return false;
3204
        }
3205
3206
        /** @var HTML_QuickForm_select $element */
3207
        $element = $form->createElement(
3208
            'select',
3209
            $name,
3210
            get_lang('Score'),
3211
            [],
3212
            ['class' => 'exercise_mark_select']
3213
        );
3214
3215
        foreach ($model['score_list'] as $item) {
3216
            $i = api_number_format($item['score_to_qualify'] / 100 * $weight, 2);
3217
            $label = self::getModelStyle($item, $i);
3218
            $attributes = [
3219
                'class' => $item['css_class'],
3220
            ];
3221
            if ($selected == $i) {
3222
                $attributes['selected'] = 'selected';
3223
            }
3224
            $element->addOption($label, $i, $attributes);
3225
        }
3226
        $form->addElement($element);
3227
    }
3228
3229
    /**
3230
     * @return string
3231
     */
3232
    public static function getJsCode()
3233
    {
3234
        // Filling the scores with the right colors.
3235
        $models = self::getCourseScoreModel();
3236
        $cssListToString = '';
3237
        if (!empty($models)) {
3238
            $cssList = array_column($models['score_list'], 'css_class');
3239
            $cssListToString = implode(' ', $cssList);
3240
        }
3241
3242
        if (empty($cssListToString)) {
3243
            return '';
3244
        }
3245
        $js = <<<EOT
3246
3247
        function updateSelect(element) {
3248
            var spanTag = element.parent().find('span.filter-option');
3249
            var value = element.val();
3250
            var selectId = element.attr('id');
3251
            var optionClass = $('#' + selectId + ' option[value="'+value+'"]').attr('class');
3252
            spanTag.removeClass('$cssListToString');
3253
            spanTag.addClass(optionClass);
3254
        }
3255
3256
        $(function() {
3257
            // Loading values
3258
            $('.exercise_mark_select').on('loaded.bs.select', function() {
3259
                updateSelect($(this));
3260
            });
3261
            // On change
3262
            $('.exercise_mark_select').on('changed.bs.select', function() {
3263
                updateSelect($(this));
3264
            });
3265
        });
3266
EOT;
3267
3268
        return $js;
3269
    }
3270
3271
    /**
3272
     * @param float  $score
3273
     * @param float  $weight
3274
     * @param string $pass_percentage
3275
     *
3276
     * @return string
3277
     */
3278
    public static function showSuccessMessage($score, $weight, $pass_percentage)
3279
    {
3280
        $res = '';
3281
        if (self::isPassPercentageEnabled($pass_percentage)) {
3282
            $isSuccess = self::isSuccessExerciseResult(
3283
                $score,
3284
                $weight,
3285
                $pass_percentage
3286
            );
3287
3288
            if ($isSuccess) {
3289
                $html = get_lang('Congratulations you passed the test!');
3290
                $icon = Display::getMdiIcon('check-circle', 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Correct'));
3291
            } else {
3292
                $html = get_lang('You didn\'t reach the minimum score');
3293
                $icon = Display::getMdiIcon('alert', 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Wrong'));
3294
            }
3295
            $html = Display::tag('h4', $html);
3296
            $html .= Display::tag(
3297
                'h5',
3298
                $icon,
3299
                ['style' => 'width:40px; padding:2px 10px 0px 0px']
3300
            );
3301
            $res = $html;
3302
        }
3303
3304
        return $res;
3305
    }
3306
3307
    /**
3308
     * Return true if pass_pourcentage activated (we use the pass pourcentage feature
3309
     * return false if pass_percentage = 0 (we don't use the pass pourcentage feature.
3310
     *
3311
     * @param $value
3312
     *
3313
     * @return bool
3314
     *              In this version, pass_percentage and show_success_message are disabled if
3315
     *              pass_percentage is set to 0
3316
     */
3317
    public static function isPassPercentageEnabled($value)
3318
    {
3319
        return $value > 0;
3320
    }
3321
3322
    /**
3323
     * Converts a numeric value in a percentage example 0.66666 to 66.67 %.
3324
     *
3325
     * @param $value
3326
     *
3327
     * @return float Converted number
3328
     */
3329
    public static function convert_to_percentage($value)
3330
    {
3331
        $return = '-';
3332
        if ('' != $value) {
3333
            $return = float_format($value * 100, 1).' %';
3334
        }
3335
3336
        return $return;
3337
    }
3338
3339
    /**
3340
     * Getting all active exercises from a course from a session
3341
     * (if a session_id is provided we will show all the exercises in the course +
3342
     * all exercises in the session).
3343
     *
3344
     * @param array  $course_info
3345
     * @param int    $session_id
3346
     * @param bool   $check_publication_dates
3347
     * @param string $search                  Search exercise name
3348
     * @param bool   $search_all_sessions     Search exercises in all sessions
3349
     * @param   int     0 = only inactive exercises
0 ignored issues
show
Documentation Bug introduced by
The doc comment 0 at position 0 could not be parsed: Unknown type name '0' at position 0 in 0.
Loading history...
3350
     *                  1 = only active exercises,
3351
     *                  2 = all exercises
3352
     *                  3 = active <> -1
3353
     *
3354
     * @return CQuiz[]
3355
     */
3356
    public static function get_all_exercises(
3357
        $course_info = null,
3358
        $session_id = 0,
3359
        $check_publication_dates = false,
3360
        $search = '',
3361
        $search_all_sessions = false,
3362
        $active = 2
3363
    ) {
3364
        $course_id = api_get_course_int_id();
3365
        if (!empty($course_info) && !empty($course_info['real_id'])) {
3366
            $course_id = $course_info['real_id'];
3367
        }
3368
3369
        if (-1 == $session_id) {
3370
            $session_id = 0;
3371
        }
3372
        $course = api_get_course_entity($course_id);
3373
        $session = api_get_session_entity($session_id);
3374
3375
        if (null === $course) {
3376
            return [];
3377
        }
3378
3379
        $repo = Container::getQuizRepository();
3380
3381
        return $repo->findAllByCourse($course, $session, (string) $search, $active)
3382
            ->getQuery()
3383
            ->getResult();
3384
    }
3385
3386
    /**
3387
     * Getting all exercises (active only or all)
3388
     * from a course from a session
3389
     * (if a session_id is provided we will show all the exercises in the
3390
     * course + all exercises in the session).
3391
     */
3392
    public static function get_all_exercises_for_course_id(
3393
        int $courseId,
3394
        int $sessionId = 0,
3395
        bool $onlyActiveExercises = true
3396
    ): array {
3397
        if ($courseId <= 0) {
3398
            return [];
3399
        }
3400
3401
        $course  = api_get_course_entity($courseId);
3402
        $session = api_get_session_entity($sessionId);
3403
3404
        $repo = Container::getQuizRepository();
3405
3406
        $qb = $repo->getResourcesByCourse($course, $session);
3407
3408
        $qb->andWhere('links.deletedAt IS NULL');
3409
        $qb->andWhere('links.endVisibilityAt IS NULL');
3410
        if ($onlyActiveExercises) {
3411
            $qb->andWhere('links.visibility = 2');
3412
        } else {
3413
            $qb->andWhere('links.visibility IN (0,2)');
3414
        }
3415
3416
        $qb->orderBy('resource.title', 'ASC');
3417
3418
        $exercises = $qb->getQuery()->getResult();
3419
3420
        $exerciseList = [];
3421
        foreach ($exercises as $exercise) {
3422
            $exerciseList[] = [
3423
                'iid' => $exercise->getIid(),
3424
                'title' => $exercise->getTitle(),
3425
            ];
3426
        }
3427
3428
        return $exerciseList;
3429
    }
3430
3431
    /**
3432
     * Gets the position of the score based in a given score (result/weight)
3433
     * and the exe_id based in the user list
3434
     * (NO Exercises in LPs ).
3435
     *
3436
     * @param float  $my_score      user score to be compared *attention*
3437
     *                              $my_score = score/weight and not just the score
3438
     * @param int    $my_exe_id     exe id of the exercise
3439
     *                              (this is necessary because if 2 students have the same score the one
3440
     *                              with the minor exe_id will have a best position, just to be fair and FIFO)
3441
     * @param int    $exercise_id
3442
     * @param string $course_code
3443
     * @param int    $session_id
3444
     * @param array  $user_list
3445
     * @param bool   $return_string
3446
     *
3447
     * @return int the position of the user between his friends in a course
3448
     *             (or course within a session)
3449
     */
3450
    public static function get_exercise_result_ranking(
3451
        $my_score,
3452
        $my_exe_id,
3453
        $exercise_id,
3454
        $course_code,
3455
        $session_id = 0,
3456
        $user_list = [],
3457
        $return_string = true
3458
    ) {
3459
        //No score given we return
3460
        if (is_null($my_score)) {
3461
            return '-';
3462
        }
3463
        if (empty($user_list)) {
3464
            return '-';
3465
        }
3466
3467
        $best_attempts = [];
3468
        foreach ($user_list as $user_data) {
3469
            $user_id = $user_data['user_id'];
3470
            $best_attempts[$user_id] = self::get_best_attempt_by_user(
3471
                $user_id,
3472
                $exercise_id,
3473
                $course_code,
3474
                $session_id
3475
            );
3476
        }
3477
3478
        if (empty($best_attempts)) {
3479
            return 1;
3480
        } else {
3481
            $position = 1;
3482
            $my_ranking = [];
3483
            foreach ($best_attempts as $user_id => $result) {
3484
                if (!empty($result['max_score']) && 0 != intval($result['max_score'])) {
3485
                    $my_ranking[$user_id] = $result['score'] / $result['max_score'];
3486
                } else {
3487
                    $my_ranking[$user_id] = 0;
3488
                }
3489
            }
3490
            //if (!empty($my_ranking)) {
3491
            asort($my_ranking);
3492
            $position = count($my_ranking);
3493
            if (!empty($my_ranking)) {
3494
                foreach ($my_ranking as $user_id => $ranking) {
3495
                    if ($my_score >= $ranking) {
3496
                        if ($my_score == $ranking && isset($best_attempts[$user_id]['exe_id'])) {
3497
                            $exe_id = $best_attempts[$user_id]['exe_id'];
3498
                            if ($my_exe_id < $exe_id) {
3499
                                $position--;
3500
                            }
3501
                        } else {
3502
                            $position--;
3503
                        }
3504
                    }
3505
                }
3506
            }
3507
            //}
3508
            $return_value = [
3509
                'position' => $position,
3510
                'count' => count($my_ranking),
3511
            ];
3512
3513
            if ($return_string) {
3514
                if (!empty($position) && !empty($my_ranking)) {
3515
                    $return_value = $position.'/'.count($my_ranking);
3516
                } else {
3517
                    $return_value = '-';
3518
                }
3519
            }
3520
3521
            return $return_value;
3522
        }
3523
    }
3524
3525
    /**
3526
     * Gets the position of the score based in a given score (result/weight) and the exe_id based in all attempts
3527
     * (NO Exercises in LPs ) old functionality by attempt.
3528
     *
3529
     * @param   float   user score to be compared attention => score/weight
3530
     * @param   int     exe id of the exercise
3531
     * (this is necessary because if 2 students have the same score the one
3532
     * with the minor exe_id will have a best position, just to be fair and FIFO)
3533
     * @param   int     exercise id
3534
     * @param   string  course code
3535
     * @param   int     session id
3536
     * @param bool $return_string
3537
     *
3538
     * @return int the position of the user between his friends in a course (or course within a session)
3539
     */
3540
    public static function get_exercise_result_ranking_by_attempt(
3541
        $my_score,
3542
        $my_exe_id,
3543
        $exercise_id,
3544
        $courseId,
3545
        $session_id = 0,
3546
        $return_string = true
3547
    ) {
3548
        if (empty($session_id)) {
3549
            $session_id = 0;
3550
        }
3551
        if (is_null($my_score)) {
3552
            return '-';
3553
        }
3554
        $user_results = Event::get_all_exercise_results(
3555
            $exercise_id,
3556
            $courseId,
3557
            $session_id,
3558
            false
3559
        );
3560
        $position_data = [];
3561
        if (empty($user_results)) {
3562
            return 1;
3563
        } else {
3564
            $position = 1;
3565
            $my_ranking = [];
3566
            foreach ($user_results as $result) {
3567
                if (!empty($result['max_score']) && 0 != intval($result['max_score'])) {
3568
                    $my_ranking[$result['exe_id']] = $result['score'] / $result['max_score'];
3569
                } else {
3570
                    $my_ranking[$result['exe_id']] = 0;
3571
                }
3572
            }
3573
            asort($my_ranking);
3574
            $position = count($my_ranking);
3575
            if (!empty($my_ranking)) {
3576
                foreach ($my_ranking as $exe_id => $ranking) {
3577
                    if ($my_score >= $ranking) {
3578
                        if ($my_score == $ranking) {
3579
                            if ($my_exe_id < $exe_id) {
3580
                                $position--;
3581
                            }
3582
                        } else {
3583
                            $position--;
3584
                        }
3585
                    }
3586
                }
3587
            }
3588
            $return_value = [
3589
                'position' => $position,
3590
                'count' => count($my_ranking),
3591
            ];
3592
3593
            if ($return_string) {
3594
                if (!empty($position) && !empty($my_ranking)) {
3595
                    return $position.'/'.count($my_ranking);
3596
                }
3597
            }
3598
3599
            return $return_value;
3600
        }
3601
    }
3602
3603
    /**
3604
     * Get the best attempt in a exercise (NO Exercises in LPs ).
3605
     *
3606
     * @param int $exercise_id
3607
     * @param int $courseId
3608
     * @param int $session_id
3609
     *
3610
     * @return array
3611
     */
3612
    public static function get_best_attempt_in_course($exercise_id, $courseId, $session_id)
3613
    {
3614
        $user_results = Event::get_all_exercise_results(
3615
            $exercise_id,
3616
            $courseId,
3617
            $session_id,
3618
            false
3619
        );
3620
3621
        $best_score_data = [];
3622
        $best_score = 0;
3623
        if (!empty($user_results)) {
3624
            foreach ($user_results as $result) {
3625
                if (!empty($result['max_score']) &&
3626
                    0 != intval($result['max_score'])
3627
                ) {
3628
                    $score = $result['score'] / $result['max_score'];
3629
                    if ($score >= $best_score) {
3630
                        $best_score = $score;
3631
                        $best_score_data = $result;
3632
                    }
3633
                }
3634
            }
3635
        }
3636
3637
        return $best_score_data;
3638
    }
3639
3640
    /**
3641
     * Get the best score in a exercise (NO Exercises in LPs ).
3642
     *
3643
     * @param int $user_id
3644
     * @param int $exercise_id
3645
     * @param int $courseId
3646
     * @param int $session_id
3647
     *
3648
     * @return array
3649
     */
3650
    public static function get_best_attempt_by_user(
3651
        $user_id,
3652
        $exercise_id,
3653
        $courseId,
3654
        $session_id
3655
    ) {
3656
        $user_results = Event::get_all_exercise_results(
3657
            $exercise_id,
3658
            $courseId,
3659
            $session_id,
3660
            false,
3661
            $user_id
3662
        );
3663
        $best_score_data = [];
3664
        $best_score = 0;
3665
        if (!empty($user_results)) {
3666
            foreach ($user_results as $result) {
3667
                if (!empty($result['max_score']) && 0 != (float) $result['max_score']) {
3668
                    $score = $result['score'] / $result['max_score'];
3669
                    if ($score >= $best_score) {
3670
                        $best_score = $score;
3671
                        $best_score_data = $result;
3672
                    }
3673
                }
3674
            }
3675
        }
3676
3677
        return $best_score_data;
3678
    }
3679
3680
    /**
3681
     * Get average score (NO Exercises in LPs ).
3682
     *
3683
     * @param    int    exercise id
3684
     * @param int $courseId
3685
     * @param    int    session id
3686
     *
3687
     * @return float Average score
3688
     */
3689
    public static function get_average_score($exercise_id, $courseId, $session_id)
3690
    {
3691
        $user_results = Event::get_all_exercise_results(
3692
            $exercise_id,
3693
            $courseId,
3694
            $session_id
3695
        );
3696
        $avg_score = 0;
3697
        if (!empty($user_results)) {
3698
            foreach ($user_results as $result) {
3699
                if (!empty($result['max_score']) && 0 != intval($result['max_score'])) {
3700
                    $score = $result['score'] / $result['max_score'];
3701
                    $avg_score += $score;
3702
                }
3703
            }
3704
            $avg_score = float_format($avg_score / count($user_results), 1);
3705
        }
3706
3707
        return $avg_score;
3708
    }
3709
3710
    /**
3711
     * Get average score by score (NO Exercises in LPs ).
3712
     *
3713
     * @param int $courseId
3714
     * @param    int    session id
3715
     *
3716
     * @return float Average score
3717
     */
3718
    public static function get_average_score_by_course($courseId, $session_id)
3719
    {
3720
        $user_results = Event::get_all_exercise_results_by_course(
3721
            $courseId,
3722
            $session_id,
3723
            false
3724
        );
3725
        $avg_score = 0;
3726
        if (!empty($user_results)) {
3727
            foreach ($user_results as $result) {
3728
                if (!empty($result['max_score']) && 0 != intval(
3729
                        $result['max_score']
3730
                    )
3731
                ) {
3732
                    $score = $result['score'] / $result['max_score'];
3733
                    $avg_score += $score;
3734
                }
3735
            }
3736
            // We assume that all max_score
3737
            $avg_score = $avg_score / count($user_results);
3738
        }
3739
3740
        return $avg_score;
3741
    }
3742
3743
    /**
3744
     * @param int $user_id
3745
     * @param int $courseId
3746
     * @param int $session_id
3747
     *
3748
     * @return float|int
3749
     */
3750
    public static function get_average_score_by_course_by_user(
3751
        $user_id,
3752
        $courseId,
3753
        $session_id
3754
    ) {
3755
        $user_results = Event::get_all_exercise_results_by_user(
3756
            $user_id,
3757
            $courseId,
3758
            $session_id
3759
        );
3760
        $avg_score = 0;
3761
        if (!empty($user_results)) {
3762
            foreach ($user_results as $result) {
3763
                if (!empty($result['max_score']) && 0 != intval($result['max_score'])) {
3764
                    $score = $result['score'] / $result['max_score'];
3765
                    $avg_score += $score;
3766
                }
3767
            }
3768
            // We assume that all max_score
3769
            $avg_score = ($avg_score / count($user_results));
3770
        }
3771
3772
        return $avg_score;
3773
    }
3774
3775
    /**
3776
     * Get average score by score (NO Exercises in LPs ).
3777
     *
3778
     * @param int $exercise_id
3779
     * @param int $courseId
3780
     * @param int $session_id
3781
     * @param int $user_count
3782
     *
3783
     * @return float Best average score
3784
     */
3785
    public static function get_best_average_score_by_exercise(
3786
        $exercise_id,
3787
        $courseId,
3788
        $session_id,
3789
        $user_count
3790
    ) {
3791
        $user_results = Event::get_best_exercise_results_by_user(
3792
            $exercise_id,
3793
            $courseId,
3794
            $session_id
3795
        );
3796
        $avg_score = 0;
3797
        if (!empty($user_results)) {
3798
            foreach ($user_results as $result) {
3799
                if (!empty($result['max_score']) && 0 != intval($result['max_score'])) {
3800
                    $score = $result['score'] / $result['max_score'];
3801
                    $avg_score += $score;
3802
                }
3803
            }
3804
            // We asumme that all max_score
3805
            if (!empty($user_count)) {
3806
                $avg_score = float_format($avg_score / $user_count, 1) * 100;
3807
            } else {
3808
                $avg_score = 0;
3809
            }
3810
        }
3811
3812
        return $avg_score;
3813
    }
3814
3815
    /**
3816
     * Get average score by score (NO Exercises in LPs ).
3817
     *
3818
     * @param int $exercise_id
3819
     * @param int $courseId
3820
     * @param int $session_id
3821
     *
3822
     * @return float Best average score
3823
     */
3824
    public static function getBestScoreByExercise(
3825
        $exercise_id,
3826
        $courseId,
3827
        $session_id
3828
    ) {
3829
        $user_results = Event::get_best_exercise_results_by_user(
3830
            $exercise_id,
3831
            $courseId,
3832
            $session_id
3833
        );
3834
        $avg_score = 0;
3835
        if (!empty($user_results)) {
3836
            foreach ($user_results as $result) {
3837
                if (!empty($result['max_score']) && 0 != intval($result['max_score'])) {
3838
                    $score = $result['score'] / $result['max_score'];
3839
                    $avg_score += $score;
3840
                }
3841
            }
3842
        }
3843
3844
        return $avg_score;
3845
    }
3846
3847
    /**
3848
     * Get student results (only in completed exercises) stats by question.
3849
     *
3850
     * @throws \Doctrine\DBAL\Exception
3851
     */
3852
    public static function getStudentStatsByQuestion(
3853
        int $questionId,
3854
        int $exerciseId,
3855
        string $courseCode,
3856
        int $sessionId,
3857
        bool $onlyStudent = false
3858
    ): array
3859
    {
3860
        $trackExercises = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
3861
        $trackAttempt = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT);
3862
        $courseUser = Database::get_main_table(TABLE_MAIN_COURSE_USER);
3863
3864
        $questionId = (int) $questionId;
3865
        $exerciseId = (int) $exerciseId;
3866
        $courseCode = Database::escape_string($courseCode);
3867
        $sessionId = (int) $sessionId;
3868
        $courseId = api_get_course_int_id($courseCode);
3869
3870
        $sql = "SELECT MAX(marks) as max, MIN(marks) as min, AVG(marks) as average
3871
                FROM $trackExercises e ";
3872
        $sessionCondition = api_get_session_condition($sessionId, true, false, 'e.session_id');
3873
        if ($onlyStudent) {
3874
            $courseCondition = '';
3875
            if (empty($sessionId)) {
3876
                $courseCondition = "
3877
                INNER JOIN $courseUser c
3878
                ON (
3879
                    e.exe_user_id = c.user_id AND
3880
                    e.c_id = c.c_id AND
3881
                    c.status = ".STUDENT." AND
3882
                    relation_type <> 2
3883
                )";
3884
            } else {
3885
                $sessionRelCourse = Database::get_main_table(TABLE_MAIN_SESSION_COURSE_USER);
3886
                $courseCondition = "
3887
            INNER JOIN $sessionRelCourse sc
3888
            ON (
3889
                        e.exe_user_id = sc.user_id AND
3890
                        e.c_id = sc.c_id AND
3891
                        e.session_id = sc.session_id AND
3892
                        sc.status = ".SessionEntity::STUDENT."
3893
                )";
3894
            }
3895
            $sql .= $courseCondition;
3896
        }
3897
        $sql .= "
3898
    		INNER JOIN $trackAttempt a
3899
    		ON (
3900
    		    a.exe_id = e.exe_id
3901
            )
3902
    		WHERE
3903
    		    exe_exo_id 	= $exerciseId AND
3904
                e.c_id = $courseId AND
3905
                question_id = $questionId AND
3906
                e.status = ''
3907
                $sessionCondition
3908
            LIMIT 1";
3909
        $result = Database::query($sql);
3910
        $return = [];
3911
        if ($result) {
3912
            $return = Database::fetch_assoc($result);
3913
        }
3914
3915
        return $return;
3916
    }
3917
3918
    /**
3919
     * Get the correct answer count for a fill blanks question.
3920
     *
3921
     * @param int $question_id
3922
     * @param int $exercise_id
3923
     *
3924
     * @return array
3925
     */
3926
    public static function getNumberStudentsFillBlanksAnswerCount(
3927
        $question_id,
3928
        $exercise_id
3929
    ) {
3930
        $listStudentsId = [];
3931
        $listAllStudentInfo = CourseManager::get_student_list_from_course_code(
3932
            api_get_course_id(),
3933
            true
3934
        );
3935
        foreach ($listAllStudentInfo as $i => $listStudentInfo) {
3936
            $listStudentsId[] = $listStudentInfo['user_id'];
3937
        }
3938
3939
        $listFillTheBlankResult = FillBlanks::getFillTheBlankResult(
3940
            $exercise_id,
3941
            $question_id,
3942
            $listStudentsId,
3943
            '1970-01-01',
3944
            '3000-01-01'
3945
        );
3946
3947
        $arrayCount = [];
3948
3949
        foreach ($listFillTheBlankResult as $resultCount) {
3950
            foreach ($resultCount as $index => $count) {
3951
                //this is only for declare the array index per answer
3952
                $arrayCount[$index] = 0;
3953
            }
3954
        }
3955
3956
        foreach ($listFillTheBlankResult as $resultCount) {
3957
            foreach ($resultCount as $index => $count) {
3958
                $count = (0 === $count) ? 1 : 0;
3959
                $arrayCount[$index] += $count;
3960
            }
3961
        }
3962
3963
        return $arrayCount;
3964
    }
3965
3966
    /**
3967
     * Get the number of questions with answers.
3968
     *
3969
     * @param int    $question_id
3970
     * @param int    $exercise_id
3971
     * @param string $course_code
3972
     * @param int    $session_id
3973
     * @param string $questionType
3974
     *
3975
     * @return int
3976
     */
3977
    public static function get_number_students_question_with_answer_count(
3978
        $question_id,
3979
        $exercise_id,
3980
        $course_code,
3981
        $session_id,
3982
        $questionType = ''
3983
    ) {
3984
        $track_exercises   = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
3985
        $track_attempt     = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT);
3986
        $courseUser        = Database::get_main_table(TABLE_MAIN_COURSE_USER);
3987
        $courseTable       = Database::get_main_table(TABLE_MAIN_COURSE);
3988
        $courseUserSession = Database::get_main_table(TABLE_MAIN_SESSION_COURSE_USER);
3989
3990
        $question_id = (int) $question_id;
3991
        $exercise_id = (int) $exercise_id;
3992
        $courseId    = (int) api_get_course_int_id($course_code);
3993
        $session_id  = (int) $session_id;
3994
3995
        if (in_array($questionType, [FILL_IN_BLANKS, FILL_IN_BLANKS_COMBINATION], true)) {
3996
            $listStudentsId     = [];
3997
            $listAllStudentInfo = CourseManager::get_student_list_from_course_code(api_get_course_id(), true);
3998
            foreach ($listAllStudentInfo as $listStudentInfo) {
3999
                $listStudentsId[] = (int) $listStudentInfo['user_id'];
4000
            }
4001
4002
            $listFillTheBlankResult = FillBlanks::getFillTheBlankResult(
4003
                $exercise_id,
4004
                $question_id,
4005
                $listStudentsId,
4006
                '1970-01-01',
4007
                '3000-01-01'
4008
            );
4009
4010
            return FillBlanks::getNbResultFillBlankAll($listFillTheBlankResult);
4011
        }
4012
4013
        if (empty($session_id)) {
4014
            $courseCondition = "
4015
            INNER JOIN $courseUser cu
4016
                ON cu.c_id = c.id AND cu.user_id = exe_user_id";
4017
            $courseConditionWhere = " AND relation_type <> 2 AND cu.status = ".STUDENT;
4018
        } else {
4019
            $courseCondition = "
4020
            INNER JOIN $courseUserSession cu
4021
                ON (cu.c_id = c.id AND cu.user_id = e.exe_user_id AND e.session_id = cu.session_id)";
4022
            $courseConditionWhere = ' AND cu.status = '.SessionEntity::STUDENT;
4023
        }
4024
4025
        $sessionCondition = api_get_session_condition($session_id, true, false, 'e.session_id');
4026
        $sql = "SELECT DISTINCT exe_user_id
4027
            FROM $track_exercises e
4028
            INNER JOIN $track_attempt a
4029
                ON (a.exe_id = e.exe_id AND a.c_id = e.c_id)
4030
            INNER JOIN $courseTable c
4031
                ON c.id = e.c_id
4032
            $courseCondition
4033
            WHERE
4034
                exe_exo_id  = $exercise_id AND
4035
                e.c_id      = $courseId AND
4036
                question_id = $question_id AND
4037
                answer <> '0' AND
4038
                e.status = ''
4039
                $courseConditionWhere
4040
                $sessionCondition
4041
    ";
4042
4043
        $result = Database::query($sql);
4044
4045
        return $result ? (int) Database::num_rows($result) : 0;
4046
    }
4047
4048
    /**
4049
     * Get number of answers to hotspot questions.
4050
     */
4051
    public static function getNumberStudentsAnswerHotspotCount(
4052
        int    $answerId,
4053
        int    $questionId,
4054
        int    $exerciseId,
4055
        string $courseCode,
4056
        int $sessionId
4057
    ): int
4058
    {
4059
        $trackExercises = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
4060
        $trackHotspot = Database::get_main_table(TABLE_STATISTIC_TRACK_E_HOTSPOT);
4061
        $courseUser = Database::get_main_table(TABLE_MAIN_COURSE_USER);
4062
        $courseTable = Database::get_main_table(TABLE_MAIN_COURSE);
4063
        $courseUserSession = Database::get_main_table(TABLE_MAIN_SESSION_COURSE_USER);
4064
4065
        $questionId = (int) $questionId;
4066
        $answerId = (int) $answerId;
4067
        $exerciseId = (int) $exerciseId;
4068
        $courseId = api_get_course_int_id($courseCode);
4069
        $sessionId = (int) $sessionId;
4070
4071
        if (empty($sessionId)) {
4072
            $courseCondition = "
4073
            INNER JOIN $courseUser cu
4074
            ON cu.c_id = c.id AND cu.user_id  = exe_user_id";
4075
            $courseConditionWhere = " AND relation_type <> 2 AND cu.status = ".STUDENT;
4076
        } else {
4077
            $courseCondition = "
4078
            INNER JOIN $courseUserSession cu
4079
            ON (cu.c_id = c.id AND cu.user_id = e.exe_user_id AND e.session_id = cu.session_id)";
4080
            $courseConditionWhere = ' AND cu.status = '.SessionEntity::STUDENT;
4081
        }
4082
4083
        $sessionCondition = api_get_session_condition($sessionId, true, false, 'e.session_id');
4084
        $sql = "SELECT DISTINCT exe_user_id
4085
                FROM $trackExercises e
4086
                INNER JOIN $trackHotspot a
4087
                ON (a.hotspot_exe_id = e.exe_id)
4088
                INNER JOIN $courseTable c
4089
                ON (a.c_id = c.id)
4090
                $courseCondition
4091
                WHERE
4092
                    exe_exo_id              = $exerciseId AND
4093
                    a.c_id 	= $courseId AND
4094
                    hotspot_answer_id       = $answerId AND
4095
                    hotspot_question_id     = $questionId AND
4096
                    hotspot_correct         =  1 AND
4097
                    e.status                = ''
4098
                    $courseConditionWhere
4099
                    $sessionCondition
4100
            ";
4101
        $result = Database::query($sql);
4102
        $return = 0;
4103
        if ($result) {
4104
            $return = Database::num_rows($result);
4105
        }
4106
4107
        return $return;
4108
    }
4109
4110
    /**
4111
     * @param int    $answer_id
4112
     * @param int    $question_id
4113
     * @param int    $exercise_id
4114
     * @param string $course_code
4115
     * @param int    $session_id
4116
     * @param string $question_type
4117
     * @param string $correct_answer
4118
     * @param string $current_answer
4119
     *
4120
     * @return int
4121
     */
4122
    public static function get_number_students_answer_count(
4123
        $answer_id,
4124
        $question_id,
4125
        $exercise_id,
4126
        $course_code,
4127
        $session_id,
4128
        $question_type = null,
4129
        $correct_answer = null,
4130
        $current_answer = null
4131
    ) {
4132
        $track_exercises = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
4133
        $track_attempt   = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT);
4134
        $courseTable     = Database::get_main_table(TABLE_MAIN_COURSE);
4135
        $courseUser      = Database::get_main_table(TABLE_MAIN_COURSE_USER);
4136
        $courseUserSession = Database::get_main_table(TABLE_MAIN_SESSION_COURSE_USER);
4137
4138
        $question_id = (int) $question_id;
4139
        $answer_id   = (int) $answer_id;
4140
        $exercise_id = (int) $exercise_id;
4141
        $courseId    = (int) api_get_course_int_id($course_code);
4142
        $session_id  = (int) $session_id;
4143
4144
        switch ($question_type) {
4145
            case FILL_IN_BLANKS:
4146
            case FILL_IN_BLANKS_COMBINATION:
4147
                $answer_condition = '';
4148
                $select_condition = ' e.exe_id, answer ';
4149
                break;
4150
            case MATCHING:
4151
            case MATCHING_COMBINATION:
4152
            case MATCHING_DRAGGABLE:
4153
            case MATCHING_DRAGGABLE_COMBINATION:
4154
            default:
4155
                $answer_condition = " answer = $answer_id AND ";
4156
                $select_condition = ' DISTINCT exe_user_id ';
4157
        }
4158
4159
        if (empty($session_id)) {
4160
            $courseCondition = "
4161
            INNER JOIN $courseUser cu
4162
                ON cu.c_id = e.c_id AND cu.user_id = e.exe_user_id";
4163
            $courseConditionWhere = " AND relation_type <> 2 AND cu.status = ".STUDENT;
4164
        } else {
4165
            $courseCondition = "
4166
            INNER JOIN $courseUserSession cu
4167
                ON (cu.c_id = e.c_id AND cu.user_id = e.exe_user_id AND e.session_id = cu.session_id)";
4168
            $courseConditionWhere = ' AND cu.status = '.SessionEntity::STUDENT;
4169
        }
4170
4171
        $sessionCondition = api_get_session_condition($session_id, true, false, 'e.session_id');
4172
        $sql = "SELECT $select_condition
4173
            FROM $track_exercises e
4174
            INNER JOIN $track_attempt a
4175
                ON (a.exe_id = e.exe_id AND a.c_id = e.c_id)
4176
            INNER JOIN $courseTable c
4177
                ON c.id = e.c_id
4178
            $courseCondition
4179
            WHERE
4180
                exe_exo_id = $exercise_id AND
4181
                e.c_id = $courseId AND
4182
                $answer_condition
4183
                question_id = $question_id AND
4184
                e.status = ''
4185
                $courseConditionWhere
4186
                $sessionCondition
4187
    ";
4188
4189
        $result = Database::query($sql);
4190
        $return = 0;
4191
        if ($result) {
4192
            switch ($question_type) {
4193
                case FILL_IN_BLANKS:
4194
                case FILL_IN_BLANKS_COMBINATION:
4195
                    $good_answers = 0;
4196
                    while ($row = Database::fetch_assoc($result)) {
4197
                        $fill_blank = self::check_fill_in_blanks(
4198
                            $correct_answer,
4199
                            $row['answer'],
4200
                            $current_answer
4201
                        );
4202
                        if (isset($fill_blank[$current_answer]) && 1 == (int) $fill_blank[$current_answer]) {
4203
                            $good_answers++;
4204
                        }
4205
                    }
4206
4207
                    return $good_answers;
4208
4209
                case MATCHING:
4210
                case MATCHING_COMBINATION:
4211
                case MATCHING_DRAGGABLE:
4212
                case MATCHING_DRAGGABLE_COMBINATION:
4213
                default:
4214
                    $return = Database::num_rows($result);
4215
            }
4216
        }
4217
4218
        return $return;
4219
    }
4220
4221
    /**
4222
     * Get the number of times an answer was selected.
4223
     */
4224
    public static function getCountOfAnswers(
4225
        int $answerId,
4226
        int $questionId,
4227
        int $exerciseId,
4228
        string $courseCode,
4229
        int $sessionId,
4230
        $questionType = null,
4231
    ): int
4232
    {
4233
        $trackExercises = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
4234
        $trackAttempt = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT);
4235
        $courseTable = Database::get_main_table(TABLE_MAIN_COURSE);
4236
        $courseUser = Database::get_main_table(TABLE_MAIN_COURSE_USER);
4237
        $courseUserSession = Database::get_main_table(TABLE_MAIN_SESSION_COURSE_USER);
4238
4239
        $answerId = (int) $answerId;
4240
        $questionId = (int) $questionId;
4241
        $exerciseId = (int) $exerciseId;
4242
        $courseId = api_get_course_int_id($courseCode);
4243
        $sessionId = (int) $sessionId;
4244
        $return = 0;
4245
4246
        $answerCondition = match ($questionType) {
4247
            FILL_IN_BLANKS => '',
4248
            default => " answer = $answerId AND ",
4249
        };
4250
4251
        if (empty($sessionId)) {
4252
            $courseCondition = "
4253
            INNER JOIN $courseUser cu
4254
            ON cu.c_id = c.id AND cu.user_id = exe_user_id";
4255
            $courseConditionWhere = " AND relation_type <> 2 AND cu.status = ".STUDENT;
4256
        } else {
4257
            $courseCondition = "
4258
            INNER JOIN $courseUserSession cu
4259
            ON (cu.c_id = a.c_id AND cu.user_id = e.exe_user_id AND e.session_id = cu.session_id)";
4260
            $courseConditionWhere = ' AND cu.status = '.SessionEntity::STUDENT;
4261
        }
4262
4263
        $sessionCondition = api_get_session_condition($sessionId, true, false, 'e.session_id');
4264
        $sql = "SELECT count(a.answer) as total
4265
                FROM $trackExercises e
4266
                INNER JOIN $trackAttempt a
4267
                ON (
4268
                    a.exe_id = e.exe_id
4269
                )
4270
                INNER JOIN $courseTable c
4271
                ON c.id = e.c_id
4272
                $courseCondition
4273
                WHERE
4274
                    exe_exo_id = $exerciseId AND
4275
                    e.c_id = $courseId AND
4276
                    $answerCondition
4277
                    question_id = $questionId AND
4278
                    e.status = ''
4279
                    $courseConditionWhere
4280
                    $sessionCondition
4281
            ";
4282
        $result = Database::query($sql);
4283
        if ($result) {
4284
            $count = Database::fetch_array($result);
4285
            $return = (int) $count['total'];
4286
        }
4287
        return $return;
4288
    }
4289
4290
    /**
4291
     * @param array  $answer
4292
     * @param string $user_answer
4293
     *
4294
     * @return array
4295
     */
4296
    public static function check_fill_in_blanks($answer, $user_answer, $current_answer)
4297
    {
4298
        // the question is encoded like this
4299
        // [A] B [C] D [E] F::10,10,10@1
4300
        // number 1 before the "@" means that is a switchable fill in blank question
4301
        // [A] B [C] D [E] F::10,10,10@ or  [A] B [C] D [E] F::10,10,10
4302
        // means that is a normal fill blank question
4303
        // first we explode the "::"
4304
        $pre_array = explode('::', $answer);
4305
        // is switchable fill blank or not
4306
        $last = count($pre_array) - 1;
4307
        $is_set_switchable = explode('@', $pre_array[$last]);
4308
        $switchable_answer_set = false;
4309
        if (isset($is_set_switchable[1]) && 1 == $is_set_switchable[1]) {
4310
            $switchable_answer_set = true;
4311
        }
4312
        $answer = '';
4313
        for ($k = 0; $k < $last; $k++) {
4314
            $answer .= $pre_array[$k];
4315
        }
4316
        // splits weightings that are joined with a comma
4317
        $answerWeighting = explode(',', $is_set_switchable[0]);
4318
4319
        // we save the answer because it will be modified
4320
        //$temp = $answer;
4321
        $temp = $answer;
4322
4323
        $answer = '';
4324
        $j = 0;
4325
        //initialise answer tags
4326
        $user_tags = $correct_tags = $real_text = [];
4327
        // the loop will stop at the end of the text
4328
        while (1) {
4329
            // quits the loop if there are no more blanks (detect '[')
4330
            if (false === ($pos = api_strpos($temp, '['))) {
4331
                // adds the end of the text
4332
                $answer = $temp;
4333
                $real_text[] = $answer;
4334
                break; //no more "blanks", quit the loop
4335
            }
4336
            // adds the piece of text that is before the blank
4337
            //and ends with '[' into a general storage array
4338
            $real_text[] = api_substr($temp, 0, $pos + 1);
4339
            $answer .= api_substr($temp, 0, $pos + 1);
4340
            //take the string remaining (after the last "[" we found)
4341
            $temp = api_substr($temp, $pos + 1);
4342
            // quit the loop if there are no more blanks, and update $pos to the position of next ']'
4343
            if (false === ($pos = api_strpos($temp, ']'))) {
4344
                // adds the end of the text
4345
                $answer .= $temp;
4346
                break;
4347
            }
4348
4349
            $str = $user_answer;
4350
4351
            preg_match_all('#\[([^[]*)\]#', $str, $arr);
4352
            $str = str_replace('\r\n', '', $str);
4353
            $choices = $arr[1];
4354
            $choice = [];
4355
            $check = false;
4356
            $i = 0;
4357
            foreach ($choices as $item) {
4358
                if ($current_answer === $item) {
4359
                    $check = true;
4360
                }
4361
                if ($check) {
4362
                    $choice[] = $item;
4363
                    $i++;
4364
                }
4365
                if (3 == $i) {
4366
                    break;
4367
                }
4368
            }
4369
            $tmp = api_strrpos($choice[$j], ' / ');
4370
4371
            if (false !== $tmp) {
4372
                $choice[$j] = api_substr($choice[$j], 0, $tmp);
4373
            }
4374
4375
            $choice[$j] = trim($choice[$j]);
4376
4377
            //Needed to let characters ' and " to work as part of an answer
4378
            $choice[$j] = stripslashes($choice[$j]);
4379
4380
            $user_tags[] = api_strtolower($choice[$j]);
4381
            //put the contents of the [] answer tag into correct_tags[]
4382
            $correct_tags[] = api_strtolower(api_substr($temp, 0, $pos));
4383
            $j++;
4384
            $temp = api_substr($temp, $pos + 1);
4385
        }
4386
4387
        $answer = '';
4388
        $real_correct_tags = $correct_tags;
4389
        $chosen_list = [];
4390
        $good_answer = [];
4391
4392
        for ($i = 0; $i < count($real_correct_tags); $i++) {
0 ignored issues
show
Performance Best Practice introduced by
It seems like you are calling the size function count() as part of the test condition. You might want to compute the size beforehand, and not on each iteration.

If the size of the collection does not change during the iteration, it is generally a good practice to compute it beforehand, and not on each iteration:

for ($i=0; $i<count($array); $i++) { // calls count() on each iteration
}

// Better
for ($i=0, $c=count($array); $i<$c; $i++) { // calls count() just once
}
Loading history...
4393
            if (!$switchable_answer_set) {
4394
                //needed to parse ' and " characters
4395
                $user_tags[$i] = stripslashes($user_tags[$i]);
4396
                if ($correct_tags[$i] == $user_tags[$i]) {
4397
                    $good_answer[$correct_tags[$i]] = 1;
4398
                } elseif (!empty($user_tags[$i])) {
4399
                    $good_answer[$correct_tags[$i]] = 0;
4400
                } else {
4401
                    $good_answer[$correct_tags[$i]] = 0;
4402
                }
4403
            } else {
4404
                // switchable fill in the blanks
4405
                if (in_array($user_tags[$i], $correct_tags)) {
4406
                    $correct_tags = array_diff($correct_tags, $chosen_list);
4407
                    $good_answer[$correct_tags[$i]] = 1;
4408
                } elseif (!empty($user_tags[$i])) {
4409
                    $good_answer[$correct_tags[$i]] = 0;
4410
                } else {
4411
                    $good_answer[$correct_tags[$i]] = 0;
4412
                }
4413
            }
4414
            // adds the correct word, followed by ] to close the blank
4415
            $answer .= ' / <font color="green"><b>'.$real_correct_tags[$i].'</b></font>]';
4416
            if (isset($real_text[$i + 1])) {
4417
                $answer .= $real_text[$i + 1];
4418
            }
4419
        }
4420
4421
        return $good_answer;
4422
    }
4423
4424
    /**
4425
     * Return an HTML select menu with the student groups.
4426
     *
4427
     * @param string $name     is the name and the id of the <select>
4428
     * @param string $default  default value for option
4429
     * @param string $onchange
4430
     *
4431
     * @return string the html code of the <select>
4432
     */
4433
    public static function displayGroupMenu($name, $default, $onchange = "")
4434
    {
4435
        // check the default value of option
4436
        $tabSelected = [$default => " selected='selected' "];
4437
        $res = "<select name='$name' id='$name' onchange='".$onchange."' >";
4438
        $res .= "<option value='-1'".$tabSelected["-1"].">-- ".get_lang('All groups')." --</option>";
4439
        $res .= "<option value='0'".$tabSelected["0"].">- ".get_lang('Not in a group')." -</option>";
4440
        $groups = GroupManager::get_group_list();
4441
        $currentCatId = 0;
4442
        $countGroups = count($groups);
4443
        for ($i = 0; $i < $countGroups; $i++) {
4444
            $category = GroupManager::get_category_from_group($groups[$i]['iid']);
4445
            if ($category['id'] != $currentCatId) {
4446
                $res .= "<option value='-1' disabled='disabled'>".$category['title']."</option>";
4447
                $currentCatId = $category['id'];
4448
            }
4449
            $res .= "<option ".$tabSelected[$groups[$i]['id']]."style='margin-left:40px' value='".
4450
                $groups[$i]["iid"]."'>".
4451
                $groups[$i]["name"].
4452
                "</option>";
4453
        }
4454
        $res .= "</select>";
4455
4456
        return $res;
4457
    }
4458
4459
    public static function create_chat_exercise_session($exe_id)
4460
    {
4461
        $exeId = (int) $exe_id;
4462
        if ($exeId <= 0) {
4463
            return;
4464
        }
4465
4466
        if (!isset($_SESSION['current_exercises']) || !is_array($_SESSION['current_exercises'])) {
4467
            $_SESSION['current_exercises'] = [];
4468
        }
4469
        $_SESSION['current_exercises'][$exeId] = true;
4470
4471
        try {
4472
            /** @var AiHelper $aiHelper */
4473
            $aiHelper = Container::$container->get(AiHelper::class);
4474
            $aiHelper->markUserInTest((int) $exeId);
4475
        } catch (\Throwable $e) {
4476
            // Ignore on legacy context (no hard dependency).
4477
        }
4478
    }
4479
4480
    public static function delete_chat_exercise_session($exe_id)
4481
    {
4482
        $exeId = (int) $exe_id;
4483
        if ($exeId <= 0) {
4484
            return;
4485
        }
4486
4487
        if (isset($_SESSION['current_exercises']) && is_array($_SESSION['current_exercises'])) {
4488
            unset($_SESSION['current_exercises'][$exeId]);
4489
4490
            if (empty($_SESSION['current_exercises'])) {
4491
                unset($_SESSION['current_exercises']);
4492
            }
4493
        }
4494
4495
        try {
4496
            /** @var AiHelper $aiHelper */
4497
            $aiHelper = Container::$container->get(AiHelper::class);
4498
            $aiHelper->clearUserInTest((int) $exeId);
4499
        } catch (\Throwable $e) {
4500
            // Ignore on legacy context (no hard dependency).
4501
        }
4502
    }
4503
4504
    /**
4505
     * Display the exercise results.
4506
     *
4507
     * @param Exercise $objExercise
4508
     * @param int      $exeId
4509
     * @param bool     $save_user_result save users results (true) or just show the results (false)
4510
     * @param string   $remainingMessage
4511
     * @param bool     $allowSignature
4512
     * @param bool     $allowExportPdf
4513
     * @param bool     $isExport
4514
     */
4515
    public static function displayQuestionListByAttempt(
4516
        $objExercise,
4517
        $exeId,
4518
        $save_user_result = false,
4519
        $remainingMessage = '',
4520
        $allowSignature = false,
4521
        $allowExportPdf = false,
4522
        $isExport = false
4523
    ) {
4524
        $origin = api_get_origin();
4525
        $courseId = api_get_course_int_id();
4526
        $courseCode = api_get_course_id();
4527
        $sessionId = api_get_session_id();
4528
4529
        // Getting attempt info
4530
        $exercise_stat_info = $objExercise->get_stat_track_exercise_info_by_exe_id($exeId);
4531
4532
        // Getting question list
4533
        $question_list = [];
4534
        $studentInfo = [];
4535
        if (!empty($exercise_stat_info['data_tracking'])) {
4536
            $studentInfo = api_get_user_info($exercise_stat_info['exe_user_id']);
4537
            $question_list = explode(',', $exercise_stat_info['data_tracking']);
4538
        } else {
4539
            // Try getting the question list only if save result is off
4540
            if (false == $save_user_result) {
4541
                $question_list = $objExercise->get_validated_question_list();
4542
            }
4543
            if (in_array(
4544
                $objExercise->getFeedbackType(),
4545
                [EXERCISE_FEEDBACK_TYPE_DIRECT, EXERCISE_FEEDBACK_TYPE_POPUP]
4546
            )) {
4547
                $question_list = $objExercise->get_validated_question_list();
4548
            }
4549
        }
4550
4551
        if ($objExercise->getResultAccess()) {
4552
            if (false === $objExercise->hasResultsAccess($exercise_stat_info)) {
4553
                echo Display::return_message(
4554
                    sprintf(get_lang('You have passed the %s minutes limit to see the results.'), $objExercise->getResultsAccess())
4555
                );
4556
4557
                return false;
4558
            }
4559
4560
            if (!empty($objExercise->getResultAccess())) {
4561
                $url = api_get_path(WEB_CODE_PATH).'exercise/overview.php?'.api_get_cidreq().'&exerciseId='.$objExercise->id;
4562
                echo $objExercise->returnTimeLeftDiv();
4563
                echo $objExercise->showSimpleTimeControl(
4564
                    $objExercise->getResultAccessTimeDiff($exercise_stat_info),
4565
                    $url
4566
                );
4567
            }
4568
        }
4569
4570
        $counter = 1;
4571
        $total_score = $total_weight = 0;
4572
        $exerciseContent = null;
4573
4574
        // Hide results
4575
        $show_results = false;
4576
        $show_only_score = false;
4577
        if (in_array($objExercise->results_disabled,
4578
            [
4579
                RESULT_DISABLE_SHOW_ONLY_IN_CORRECT_ANSWER,
4580
                RESULT_DISABLE_SHOW_SCORE_AND_EXPECTED_ANSWERS,
4581
                RESULT_DISABLE_SHOW_SCORE_AND_EXPECTED_ANSWERS_AND_RANKING,
4582
            ]
4583
        )) {
4584
            $show_results = true;
4585
        }
4586
4587
        if (in_array(
4588
            $objExercise->results_disabled,
4589
            [
4590
                RESULT_DISABLE_SHOW_SCORE_ONLY,
4591
                RESULT_DISABLE_SHOW_FINAL_SCORE_ONLY_WITH_CATEGORIES,
4592
                RESULT_DISABLE_RANKING,
4593
            ]
4594
        )
4595
        ) {
4596
            $show_only_score = true;
4597
        }
4598
4599
        // Not display expected answer, but score, and feedback
4600
        $show_all_but_expected_answer = false;
4601
        if (RESULT_DISABLE_SHOW_SCORE_ONLY == $objExercise->results_disabled &&
4602
            EXERCISE_FEEDBACK_TYPE_END == $objExercise->getFeedbackType()
4603
        ) {
4604
            $show_all_but_expected_answer = true;
4605
            $show_results = true;
4606
            $show_only_score = false;
4607
        }
4608
4609
        $showTotalScoreAndUserChoicesInLastAttempt = true;
4610
        $showTotalScore = true;
4611
        $showQuestionScore = true;
4612
        $attemptResult = [];
4613
4614
        if (in_array(
4615
            $objExercise->results_disabled,
4616
            [
4617
                RESULT_DISABLE_SHOW_SCORE_ATTEMPT_SHOW_ANSWERS_LAST_ATTEMPT,
4618
                RESULT_DISABLE_SHOW_SCORE_ATTEMPT_SHOW_ANSWERS_LAST_ATTEMPT_NO_FEEDBACK,
4619
                RESULT_DISABLE_DONT_SHOW_SCORE_ONLY_IF_USER_FINISHES_ATTEMPTS_SHOW_ALWAYS_FEEDBACK,
4620
            ])
4621
        ) {
4622
            $show_only_score = true;
4623
            $show_results = true;
4624
            $numberAttempts = 0;
4625
            if ($objExercise->attempts > 0) {
4626
                $attempts = Event::getExerciseResultsByUser(
4627
                    api_get_user_id(),
4628
                    $objExercise->id,
4629
                    $courseId,
4630
                    $sessionId,
4631
                    $exercise_stat_info['orig_lp_id'],
4632
                    $exercise_stat_info['orig_lp_item_id'],
4633
                    'desc'
4634
                );
4635
                if ($attempts) {
4636
                    $numberAttempts = count($attempts);
4637
                }
4638
4639
                if ($save_user_result) {
4640
                    $numberAttempts++;
4641
                }
4642
4643
                $showTotalScore = false;
4644
                if (RESULT_DISABLE_SHOW_SCORE_ATTEMPT_SHOW_ANSWERS_LAST_ATTEMPT == $objExercise->results_disabled) {
4645
                    $showTotalScore = true;
4646
                }
4647
                $showTotalScoreAndUserChoicesInLastAttempt = false;
4648
                if ($numberAttempts >= $objExercise->attempts) {
4649
                    $showTotalScore = true;
4650
                    $show_results = true;
4651
                    $show_only_score = false;
4652
                    $showTotalScoreAndUserChoicesInLastAttempt = true;
4653
                }
4654
4655
                if (RESULT_DISABLE_SHOW_SCORE_ATTEMPT_SHOW_ANSWERS_LAST_ATTEMPT_NO_FEEDBACK == $objExercise->results_disabled) {
4656
                    $showTotalScore = true;
4657
                    $show_results = true;
4658
                    $show_only_score = false;
4659
                    $showTotalScoreAndUserChoicesInLastAttempt = false;
4660
                    if ($numberAttempts >= $objExercise->attempts) {
4661
                        $showTotalScoreAndUserChoicesInLastAttempt = true;
4662
                    }
4663
4664
                    // Check if the current attempt is the last.
4665
                    if (false === $save_user_result && !empty($attempts)) {
4666
                        $showTotalScoreAndUserChoicesInLastAttempt = false;
4667
                        $position = 1;
4668
                        foreach ($attempts as $attempt) {
4669
                            if ($exeId == $attempt['exe_id']) {
4670
                                break;
4671
                            }
4672
                            $position++;
4673
                        }
4674
4675
                        if ($position == $objExercise->attempts) {
4676
                            $showTotalScoreAndUserChoicesInLastAttempt = true;
4677
                        }
4678
                    }
4679
                }
4680
            }
4681
4682
            if (RESULT_DISABLE_DONT_SHOW_SCORE_ONLY_IF_USER_FINISHES_ATTEMPTS_SHOW_ALWAYS_FEEDBACK ==
4683
                $objExercise->results_disabled
4684
            ) {
4685
                $show_only_score = false;
4686
                $show_results = true;
4687
                $show_all_but_expected_answer = false;
4688
                $showTotalScore = false;
4689
                $showQuestionScore = false;
4690
                if ($numberAttempts >= $objExercise->attempts) {
4691
                    $showTotalScore = true;
4692
                    $showQuestionScore = true;
4693
                }
4694
            }
4695
        }
4696
4697
        // When exporting to PDF hide feedback/comment/score show warning in hotspot.
4698
        if ($allowExportPdf && $isExport) {
4699
            $showTotalScore = false;
4700
            $showQuestionScore = false;
4701
            $objExercise->feedback_type = 2;
4702
            $objExercise->hideComment = true;
4703
            $objExercise->hideNoAnswer = true;
4704
            $objExercise->results_disabled = 0;
4705
            $objExercise->hideExpectedAnswer = true;
4706
            $show_results = true;
4707
        }
4708
4709
        if ('embeddable' !== $origin &&
4710
            !empty($exercise_stat_info['exe_user_id']) &&
4711
            !empty($studentInfo)
4712
        ) {
4713
            // Shows exercise header.
4714
            echo $objExercise->showExerciseResultHeader(
4715
                $studentInfo,
4716
                $exercise_stat_info,
4717
                $save_user_result,
4718
                $allowSignature,
4719
                $allowExportPdf
4720
            );
4721
        }
4722
4723
        $question_list_answers = [];
4724
        $category_list = [];
4725
        $loadChoiceFromSession = false;
4726
        $fromDatabase = true;
4727
        $exerciseResult = null;
4728
        $exerciseResultCoordinates = null;
4729
        $delineationResults = null;
4730
        if (true === $save_user_result && in_array(
4731
            $objExercise->getFeedbackType(),
4732
            [EXERCISE_FEEDBACK_TYPE_DIRECT, EXERCISE_FEEDBACK_TYPE_POPUP]
4733
        )) {
4734
            $loadChoiceFromSession = true;
4735
            $fromDatabase = false;
4736
            $exerciseResult = Session::read('exerciseResult');
4737
            $exerciseResultCoordinates = Session::read('exerciseResultCoordinates');
4738
            $delineationResults = Session::read('hotspot_delineation_result');
4739
            $delineationResults = isset($delineationResults[$objExercise->id]) ? $delineationResults[$objExercise->id] : null;
4740
        }
4741
4742
        $countPendingQuestions = 0;
4743
        $result = [];
4744
        $panelsByParent = [];
4745
        $finalOrder = [];
4746
        if (!empty($question_list)) {
4747
            $parentMap = [];
4748
            $mediaChildren = []; // pid => ['first_idx'=>int, 'children'=>int[]]
4749
            foreach ($question_list as $idx => $qid) {
4750
                $q = Question::read($qid, $objExercise->course);
4751
                $pid = (int) ($q->parent_id ?: 0);
4752
                $parentMap[$qid] = $pid;
4753
                if ($pid > 0) {
4754
                    if (!isset($mediaChildren[$pid])) {
4755
                        $mediaChildren[$pid] = ['first_idx' => $idx, 'children' => []];
4756
                    }
4757
                    $mediaChildren[$pid]['children'][] = $qid;
4758
                }
4759
            }
4760
4761
            // build finalOrder, emitting each media group once.
4762
            $groupEmitted = [];
4763
            foreach ($question_list as $idx => $qid) {
4764
                $pid = $parentMap[$qid] ?? 0;
4765
                if ($pid === 0) {
4766
                    $finalOrder[] = ['type' => 'single', 'qid' => $qid];
4767
                } else {
4768
                    if (empty($groupEmitted[$pid])) {
4769
                        $groupEmitted[$pid] = true;
4770
                        $finalOrder[] = [
4771
                            'type'     => 'group',
4772
                            'parent'   => $pid,
4773
                            'children' => $mediaChildren[$pid]['children'] ?? [$qid],
4774
                        ];
4775
                    }
4776
                    // If already emitted, skip the child here (it will be in the group).
4777
                }
4778
            }
4779
        }
4780
4781
        $orderedOutputHtml = '';
4782
        $renderSingle = function (int $questionId) use (
4783
            &$objExercise,
4784
            $exeId,
4785
            $loadChoiceFromSession,
4786
            &$exerciseResult,
4787
            &$delineationResults,
4788
            &$exerciseResultCoordinates,
4789
            &$save_user_result,
4790
            &$fromDatabase,
4791
            &$show_results,
4792
            &$total_score,
4793
            &$total_weight,
4794
            &$question_list_answers,
4795
            &$showQuestionScore,
4796
            &$counter,
4797
            &$attemptResult,
4798
            &$category_list
4799
        ) {
4800
            // Start buffering rendering for this question
4801
            ob_start();
4802
4803
            // Load choices from session if needed
4804
            $choice = null;
4805
            $delineationChoice = null;
4806
            if ($loadChoiceFromSession) {
4807
                $choice = $exerciseResult[$questionId] ?? null;
4808
                $delineationChoice = $delineationResults[$questionId] ?? null;
4809
            }
4810
4811
            // Compute result for the given question
4812
            $result = $objExercise->manage_answer(
4813
                $exeId,
4814
                $questionId,
4815
                $choice,
4816
                'exercise_result',
4817
                $exerciseResultCoordinates,
4818
                $save_user_result,
4819
                $fromDatabase,
4820
                $show_results,
4821
                $objExercise->selectPropagateNeg(),
4822
                $delineationChoice,
4823
                true // keep user choices in last attempt when applicable
4824
            );
4825
4826
            if (empty($result)) {
4827
                ob_end_clean();
4828
                return [null, null]; // nothing to add
4829
            }
4830
4831
            $total_score  += $result['score'];
4832
            $total_weight += $result['weight'];
4833
4834
            $question_list_answers[] = [
4835
                'question'            => $result['open_question'],
4836
                'answer'              => $result['open_answer'],
4837
                'answer_type'         => $result['answer_type'],
4838
                'generated_oral_file' => $result['generated_oral_file'],
4839
            ];
4840
4841
            $my_total_score  = $result['score'];
4842
            $my_total_weight = $result['weight'];
4843
            $scorePassed     = self::scorePassed($my_total_score, $my_total_weight);
4844
4845
            // Category aggregation
4846
            $objQuestionTmp = Question::read($questionId, $objExercise->course);
4847
            $category_was_added_for_this_test = false;
4848
            if (isset($objQuestionTmp->category) && !empty($objQuestionTmp->category)) {
4849
                $cid = $objQuestionTmp->category;
4850
                $category_list[$cid]['score']           = ($category_list[$cid]['score'] ?? 0) + $my_total_score;
4851
                $category_list[$cid]['total']           = ($category_list[$cid]['total'] ?? 0) + $my_total_weight;
4852
                $category_list[$cid]['total_questions'] = ($category_list[$cid]['total_questions'] ?? 0) + 1;
4853
                if ($scorePassed) {
4854
                    if (!empty($my_total_score)) {
4855
                        $category_list[$cid]['passed'] = ($category_list[$cid]['passed'] ?? 0) + 1;
4856
                    }
4857
                } else {
4858
                    if ($result['user_answered']) {
4859
                        $category_list[$cid]['wrong'] = ($category_list[$cid]['wrong'] ?? 0) + 1;
4860
                    } else {
4861
                        $category_list[$cid]['no_answer'] = ($category_list[$cid]['no_answer'] ?? 0) + 1;
4862
                    }
4863
                }
4864
                $category_was_added_for_this_test = true;
4865
            }
4866
            if (!empty($objQuestionTmp->category_list)) {
4867
                foreach ($objQuestionTmp->category_list as $cid) {
4868
                    $category_list[$cid]['score'] = ($category_list[$cid]['score'] ?? 0) + $my_total_score;
4869
                    $category_list[$cid]['total'] = ($category_list[$cid]['total'] ?? 0) + $my_total_weight;
4870
                    $category_was_added_for_this_test = true;
4871
                }
4872
            }
4873
            if (!$category_was_added_for_this_test) {
4874
                $category_list['none']['score'] = ($category_list['none']['score'] ?? 0) + $my_total_score;
4875
                $category_list['none']['total'] = ($category_list['none']['total'] ?? 0) + $my_total_weight;
4876
            }
4877
4878
            if (0 == $objExercise->selectPropagateNeg() && $my_total_score < 0) {
4879
                $my_total_score = 0;
4880
            }
4881
4882
            $comnt = null;
4883
            if ($show_results) {
4884
                $comnt = Event::get_comments($exeId, $questionId);
4885
                $teacherAudio = self::getOralFeedbackAudio($exeId, $questionId);
4886
4887
                if (!empty($comnt) || $teacherAudio) {
4888
                    echo '<b>'.get_lang('Feedback').'</b>';
4889
                }
4890
                if (!empty($comnt)) {
4891
                    echo self::getFeedbackText($comnt);
4892
                }
4893
                if ($teacherAudio) {
4894
                    echo $teacherAudio;
4895
                }
4896
            }
4897
4898
            $calculatedScore = [
4899
                'result'        => self::show_score($my_total_score, $my_total_weight, false),
4900
                'pass'          => $scorePassed,
4901
                'score'         => $my_total_score,
4902
                'weight'        => $my_total_weight,
4903
                'comments'      => $comnt,
4904
                'user_answered' => $result['user_answered'],
4905
            ];
4906
4907
            $scoreCol = $show_results ? $calculatedScore : [];
4908
4909
            $contents = ob_get_clean();
4910
            $questionContent = '';
4911
            if ($show_results) {
4912
                $questionContent = '<div class="question-answer-result">';
4913
                if (false === $showQuestionScore) {
4914
                    $scoreCol = [];
4915
                }
4916
4917
                // Numbered header (media parents are not rendered here)
4918
                $questionContent .= $objQuestionTmp->return_header(
4919
                    $objExercise,
4920
                    $counter,
4921
                    $scoreCol
4922
                );
4923
            }
4924
            // Count only real questions
4925
            $counter++;
4926
            $questionContent .= $contents;
4927
            if ($show_results) {
4928
                $questionContent .= '</div>';
4929
            }
4930
4931
            $calculatedScore['question_content'] = $questionContent;
4932
            $attemptResult[] = $calculatedScore;
4933
4934
            return [$questionContent, $result];
4935
        };
4936
4937
        // Render entries
4938
        if (!empty($finalOrder)) {
4939
            foreach ($finalOrder as $entry) {
4940
                if ($entry['type'] === 'single') {
4941
                    [$html, $resultLast] = $renderSingle((int)$entry['qid']);
4942
                    if ($html) {
4943
                        $panelsByParent[0][] = Display::panel($html);
4944
                        if ($show_results) {
4945
                            $orderedOutputHtml .= Display::panel($html);
4946
                        }
4947
                        if ($resultLast) {
4948
                            $result = $resultLast; // keep last result for later checks (like chart)
4949
                        }
4950
                    }
4951
                } else {
4952
                    $pid = (int)$entry['parent'];
4953
                    $children = (array)$entry['children'];
4954
4955
                    if ($show_results) {
4956
                        // Open media wrapper
4957
                        $orderedOutputHtml .= '<div class="media-group">';
4958
4959
                        // Render media stem (no numbering)
4960
                        $orderedOutputHtml .= '<div class="media-content">';
4961
                        ob_start();
4962
                        $objExercise->manage_answer(
4963
                            $exeId,
4964
                            $pid,
4965
                            null,
4966
                            'exercise_show',
4967
                            [],
4968
                            false,
4969
                            true,
4970
                            $show_results,
4971
                            $objExercise->selectPropagateNeg()
4972
                        );
4973
                        $orderedOutputHtml .= ob_get_clean();
4974
                        $orderedOutputHtml .= '</div>';
4975
4976
                        $mediaQ = Question::read($pid, $objExercise->course);
4977
                        if (!empty($mediaQ->description)) {
4978
                            $orderedOutputHtml .= '<div class="media-description">'.$mediaQ->description.'</div>';
4979
                        }
4980
4981
                        $orderedOutputHtml .= '<div class="media-children">';
4982
                    }
4983
4984
                    // Render all children contiguously
4985
                    foreach ($children as $cid) {
4986
                        [$html, $resultLast] = $renderSingle((int)$cid);
4987
                        if ($html) {
4988
                            $panelsByParent[$pid][] = Display::panel($html);
4989
                            if ($show_results) {
4990
                                $orderedOutputHtml .= Display::panel($html);
4991
                            }
4992
                            if ($resultLast) {
4993
                                $result = $resultLast;
4994
                            }
4995
                        }
4996
                    }
4997
4998
                    if ($show_results) {
4999
                        // Close media wrapper
5000
                        $orderedOutputHtml .= '</div></div>';
5001
                    }
5002
                }
5003
            }
5004
        }
5005
5006
        // Print output
5007
        if ($show_results) {
5008
            echo $orderedOutputHtml;
5009
        } else {
5010
            // Fallback (no wrappers when results are not shown)
5011
            foreach ($panelsByParent as $pid => $panels) {
5012
                foreach ($panels as $panelHtml) {
5013
                    echo $panelHtml;
5014
                }
5015
            }
5016
        }
5017
5018
        // Display text when test is finished #4074 and for LP #4227
5019
        $endOfMessage = $objExercise->getFinishText($total_score, $total_weight);
5020
        if (!empty($endOfMessage)) {
5021
            echo Display::div(
5022
                $endOfMessage,
5023
                ['id' => 'quiz_end_message']
5024
            );
5025
        }
5026
5027
        $totalScoreText = null;
5028
        $certificateBlock = '';
5029
        if (($show_results || $show_only_score) && $showTotalScore) {
5030
            if (MULTIPLE_ANSWER_TRUE_FALSE_DEGREE_CERTAINTY == ($result['answer_type'] ?? null)) {
5031
                echo '<h1 style="text-align : center; margin : 20px 0;">'.get_lang('Your results').'</h1><br />';
5032
            }
5033
            $totalScoreText .= '<div class="question_row_score">';
5034
            if (!empty($result) && MULTIPLE_ANSWER_TRUE_FALSE_DEGREE_CERTAINTY == ($result['answer_type'] ?? null)) {
5035
                $totalScoreText .= self::getQuestionDiagnosisRibbon(
5036
                    $objExercise,
5037
                    $total_score,
5038
                    $total_weight,
5039
                    true
5040
                );
5041
            } else {
5042
                $pluginEvaluation = QuestionOptionsEvaluationPlugin::create();
5043
                if ('true' === $pluginEvaluation->get(QuestionOptionsEvaluationPlugin::SETTING_ENABLE)) {
5044
                    $formula = $pluginEvaluation->getFormulaForExercise($objExercise->getId());
5045
5046
                    if (!empty($formula)) {
5047
                        $total_score = $pluginEvaluation->getResultWithFormula($exeId, $formula);
5048
                        $total_weight = $pluginEvaluation->getMaxScore();
5049
                    }
5050
                }
5051
5052
                $totalScoreText .= self::getTotalScoreRibbon(
5053
                    $objExercise,
5054
                    $total_score,
5055
                    $total_weight,
5056
                    true,
5057
                    $countPendingQuestions
5058
                );
5059
            }
5060
            $totalScoreText .= '</div>';
5061
5062
            if (!empty($studentInfo)) {
5063
                $certificateBlock = self::generateAndShowCertificateBlock(
5064
                    $total_score,
5065
                    $total_weight,
5066
                    $objExercise,
5067
                    $studentInfo['id'],
5068
                    $courseId,
5069
                    $sessionId
5070
                );
5071
            }
5072
        }
5073
5074
        if (MULTIPLE_ANSWER_TRUE_FALSE_DEGREE_CERTAINTY == ($result['answer_type'] ?? null)) {
5075
            $chartMultiAnswer = MultipleAnswerTrueFalseDegreeCertainty::displayStudentsChartResults(
5076
                $exeId,
5077
                $objExercise
5078
            );
5079
            echo $chartMultiAnswer;
5080
        }
5081
5082
        if (!empty($category_list) &&
5083
            ($show_results || $show_only_score || RESULT_DISABLE_RADAR == $objExercise->results_disabled)
5084
        ) {
5085
            // Adding total
5086
            $category_list['total'] = [
5087
                'score' => $total_score,
5088
                'total' => $total_weight,
5089
            ];
5090
            echo TestCategory::get_stats_table_by_attempt($objExercise, $category_list);
5091
        }
5092
5093
        if ($show_all_but_expected_answer) {
5094
            $exerciseContent .= Display::return_message(get_lang('Note: This test has been setup to hide the expected answers.'));
5095
        }
5096
5097
        // Remove audio auto play from questions on results page - refs BT#7939
5098
        $exerciseContent = preg_replace(
5099
            ['/autoplay[\=\".+\"]+/', '/autostart[\=\".+\"]+/'],
5100
            '',
5101
            $exerciseContent
5102
        );
5103
5104
        echo $certificateBlock;
5105
5106
        // Ofaj change BT#11784
5107
        if (('true' === api_get_setting('exercise.quiz_show_description_on_results_page')) &&
5108
            !empty($objExercise->description)
5109
        ) {
5110
            echo Display::div($objExercise->description, ['class' => 'exercise_description']);
5111
        }
5112
5113
        echo $exerciseContent;
5114
        if (!$show_only_score) {
5115
            echo $totalScoreText;
5116
        }
5117
5118
        if ($save_user_result) {
5119
            // Tracking of results
5120
            if ($exercise_stat_info) {
5121
                $learnpath_id = $exercise_stat_info['orig_lp_id'];
5122
                $learnpath_item_id = $exercise_stat_info['orig_lp_item_id'];
5123
                $learnpath_item_view_id = $exercise_stat_info['orig_lp_item_view_id'];
5124
5125
                if (api_is_allowed_to_session_edit()) {
5126
                    Event::updateEventExercise(
5127
                        $exercise_stat_info['exe_id'],
5128
                        $objExercise->getId(),
5129
                        $total_score,
5130
                        $total_weight,
5131
                        $sessionId,
5132
                        $learnpath_id,
5133
                        $learnpath_item_id,
5134
                        $learnpath_item_view_id,
5135
                        $exercise_stat_info['exe_duration'],
5136
                        $question_list
5137
                    );
5138
5139
                    $allowStats = ('true' === api_get_setting('gradebook.allow_gradebook_stats'));
5140
                    if ($allowStats) {
5141
                        $objExercise->generateStats(
5142
                            $objExercise->getId(),
5143
                            api_get_course_info(),
5144
                            $sessionId
5145
                        );
5146
                    }
5147
                }
5148
            }
5149
5150
            // Send notification at the end
5151
            if (!api_is_allowed_to_edit(null, true) &&
5152
                !api_is_excluded_user_type()
5153
            ) {
5154
                $objExercise->send_mail_notification_for_exam(
5155
                    'end',
5156
                    $question_list_answers,
5157
                    $origin,
5158
                    $exeId,
5159
                    $total_score,
5160
                    $total_weight
5161
                );
5162
            }
5163
        }
5164
5165
        if (in_array(
5166
            $objExercise->selectResultsDisabled(),
5167
            [RESULT_DISABLE_RANKING, RESULT_DISABLE_SHOW_SCORE_AND_EXPECTED_ANSWERS_AND_RANKING]
5168
        )) {
5169
            echo Display::page_header(get_lang('Ranking'), null, 'h4');
5170
            echo self::displayResultsInRanking(
5171
                $objExercise,
5172
                api_get_user_id(),
5173
                $courseId,
5174
                $sessionId
5175
            );
5176
        }
5177
5178
        if (!empty($remainingMessage)) {
5179
            echo Display::return_message($remainingMessage, 'normal', false);
5180
        }
5181
5182
        $failedAnswersCount = 0;
5183
        $wrongQuestionHtml = '';
5184
        $all = '';
5185
        foreach ($attemptResult as $item) {
5186
            if (false === $item['pass']) {
5187
                $failedAnswersCount++;
5188
                $wrongQuestionHtml .= $item['question_content'].'<br />';
5189
            }
5190
            $all .= $item['question_content'].'<br />';
5191
        }
5192
5193
        $passed = self::isPassPercentageAttemptPassed(
5194
            $objExercise,
5195
            $total_score,
5196
            $total_weight
5197
        );
5198
5199
        $percentage = 0;
5200
        if (!empty($total_weight)) {
5201
            $percentage = ($total_score / $total_weight) * 100;
5202
        }
5203
5204
        return [
5205
            'category_list' => $category_list,
5206
            'attempts_result_list' => $attemptResult, // array of results
5207
            'exercise_passed' => $passed, // boolean
5208
            'total_answers_count' => count($attemptResult), // int
5209
            'failed_answers_count' => $failedAnswersCount, // int
5210
            'failed_answers_html' => $wrongQuestionHtml,
5211
            'all_answers_html' => $all,
5212
            'total_score' => $total_score,
5213
            'total_weight' => $total_weight,
5214
            'total_percentage' => $percentage,
5215
            'count_pending_questions' => $countPendingQuestions,
5216
        ];
5217
    }
5218
5219
    /**
5220
     * Display the ranking of results in a exercise.
5221
     *
5222
     * @param Exercise $exercise
5223
     * @param int      $currentUserId
5224
     * @param int      $courseId
5225
     * @param int      $sessionId
5226
     *
5227
     * @return string
5228
     */
5229
    public static function displayResultsInRanking($exercise, $currentUserId, $courseId, $sessionId = 0)
5230
    {
5231
        $exerciseId = $exercise->iId;
5232
        $data = self::exerciseResultsInRanking($exerciseId, $courseId, $sessionId);
5233
5234
        $table = new HTML_Table(['class' => 'table table-hover table-striped table-bordered']);
5235
        $table->setHeaderContents(0, 0, get_lang('Position'), ['class' => 'text-right']);
5236
        $table->setHeaderContents(0, 1, get_lang('Username'));
5237
        $table->setHeaderContents(0, 2, get_lang('Score'), ['class' => 'text-right']);
5238
        $table->setHeaderContents(0, 3, get_lang('Date'), ['class' => 'text-center']);
5239
5240
        foreach ($data as $r => $item) {
5241
            if (!isset($item[1])) {
5242
                continue;
5243
            }
5244
            $selected = $item[1]->getId() == $currentUserId;
5245
5246
            foreach ($item as $c => $value) {
5247
                $table->setCellContents($r + 1, $c, $value);
5248
5249
                $attrClass = '';
5250
5251
                if (in_array($c, [0, 2])) {
5252
                    $attrClass = 'text-right';
5253
                } elseif (3 == $c) {
5254
                    $attrClass = 'text-center';
5255
                }
5256
5257
                if ($selected) {
5258
                    $attrClass .= ' warning';
5259
                }
5260
5261
                $table->setCellAttributes($r + 1, $c, ['class' => $attrClass]);
5262
            }
5263
        }
5264
5265
        return $table->toHtml();
5266
    }
5267
5268
    /**
5269
     * Get the ranking for results in a exercise.
5270
     * Function used internally by ExerciseLib::displayResultsInRanking.
5271
     *
5272
     * @param int $exerciseId
5273
     * @param int $courseId
5274
     * @param int $sessionId
5275
     *
5276
     * @return array
5277
     */
5278
    public static function exerciseResultsInRanking($exerciseId, $courseId, $sessionId = 0)
5279
    {
5280
        $em = Database::getManager();
5281
5282
        $dql = 'SELECT DISTINCT u.id FROM ChamiloCoreBundle:TrackEExercise te JOIN te.user u WHERE te.quiz = :id AND te.course = :cId';
5283
        $dql .= api_get_session_condition($sessionId, true, false, 'te.session');
5284
5285
        $result = $em
5286
            ->createQuery($dql)
5287
            ->setParameters(['id' => $exerciseId, 'cId' => $courseId])
5288
            ->getScalarResult();
5289
5290
        $data = [];
5291
5292
        foreach ($result as $item) {
5293
            $attempt = self::get_best_attempt_by_user($item['id'], $exerciseId, $courseId, $sessionId);
5294
            if (!empty($attempt) && isset($attempt['score']) && isset($attempt['exe_date'])) {
5295
                $data[] = $attempt;
5296
            }
5297
        }
5298
5299
        if (empty($data)) {
5300
            return [];
5301
        }
5302
5303
        usort(
5304
            $data,
5305
            function ($a, $b) {
5306
                if ($a['score'] != $b['score']) {
5307
                    return $a['score'] > $b['score'] ? -1 : 1;
5308
                }
5309
5310
                if ($a['exe_date'] != $b['exe_date']) {
5311
                    return $a['exe_date'] < $b['exe_date'] ? -1 : 1;
5312
                }
5313
5314
                return 0;
5315
            }
5316
        );
5317
5318
        // flags to display the same position in case of tie
5319
        $lastScore = $data[0]['score'];
5320
        $position = 1;
5321
        $data = array_map(
5322
            function ($item) use (&$lastScore, &$position) {
5323
                if ($item['score'] < $lastScore) {
5324
                    $position++;
5325
                }
5326
5327
                $lastScore = $item['score'];
5328
5329
                return [
5330
                    $position,
5331
                    api_get_user_entity($item['exe_user_id']),
5332
                    self::show_score($item['score'], $item['max_score'], true, true, true),
5333
                    api_convert_and_format_date($item['exe_date'], DATE_TIME_FORMAT_SHORT),
5334
                ];
5335
            },
5336
            $data
5337
        );
5338
5339
        return $data;
5340
    }
5341
5342
    /**
5343
     * Get a special ribbon on top of "degree of certainty" questions (
5344
     * variation from getTotalScoreRibbon() for other question types).
5345
     *
5346
     * @param Exercise $objExercise
5347
     * @param float    $score
5348
     * @param float    $weight
5349
     * @param bool     $checkPassPercentage
5350
     *
5351
     * @return string
5352
     */
5353
    public static function getQuestionDiagnosisRibbon($objExercise, $score, $weight, $checkPassPercentage = false)
5354
    {
5355
        $displayChartDegree = true;
5356
        $ribbon = $displayChartDegree ? '<div class="ribbon">' : '';
5357
5358
        if ($checkPassPercentage) {
5359
            $passPercentage = $objExercise->selectPassPercentage();
5360
            $isSuccess = self::isSuccessExerciseResult($score, $weight, $passPercentage);
5361
            // Color the final test score if pass_percentage activated
5362
            $ribbonTotalSuccessOrError = '';
5363
            if (self::isPassPercentageEnabled($passPercentage)) {
5364
                if ($isSuccess) {
5365
                    $ribbonTotalSuccessOrError = ' ribbon-total-success';
5366
                } else {
5367
                    $ribbonTotalSuccessOrError = ' ribbon-total-error';
5368
                }
5369
            }
5370
            $ribbon .= $displayChartDegree ? '<div class="rib rib-total '.$ribbonTotalSuccessOrError.'">' : '';
5371
        } else {
5372
            $ribbon .= $displayChartDegree ? '<div class="rib rib-total">' : '';
5373
        }
5374
5375
        if ($displayChartDegree) {
5376
            $ribbon .= '<h3>'.get_lang('Score for the test').':&nbsp;';
5377
            $ribbon .= self::show_score($score, $weight, false, true);
5378
            $ribbon .= '</h3>';
5379
            $ribbon .= '</div>';
5380
        }
5381
5382
        if ($checkPassPercentage) {
5383
            $ribbon .= self::showSuccessMessage(
5384
                $score,
5385
                $weight,
5386
                $objExercise->selectPassPercentage()
5387
            );
5388
        }
5389
5390
        $ribbon .= $displayChartDegree ? '</div>' : '';
5391
5392
        return $ribbon;
5393
    }
5394
5395
    public static function isPassPercentageAttemptPassed($objExercise, $score, $weight)
5396
    {
5397
        $passPercentage = $objExercise->selectPassPercentage();
5398
5399
        return self::isSuccessExerciseResult($score, $weight, $passPercentage);
5400
    }
5401
5402
    /**
5403
     * @param float $score
5404
     * @param float $weight
5405
     * @param bool  $checkPassPercentage
5406
     * @param int   $countPendingQuestions
5407
     *
5408
     * @return string
5409
     */
5410
    public static function getTotalScoreRibbon(
5411
        Exercise $objExercise,
5412
        $score,
5413
        $weight,
5414
        $checkPassPercentage = false,
5415
        $countPendingQuestions = 0
5416
    ) {
5417
        $hide = (int) $objExercise->getPageConfigurationAttribute('hide_total_score');
5418
        if (1 === $hide) {
5419
            return '';
5420
        }
5421
5422
        $passPercentage = $objExercise->selectPassPercentage();
5423
        $ribbon = '<div class="title-score">';
5424
        if ($checkPassPercentage) {
5425
            $isSuccess = self::isSuccessExerciseResult(
5426
                $score,
5427
                $weight,
5428
                $passPercentage
5429
            );
5430
            // Color the final test score if pass_percentage activated
5431
            $class = '';
5432
            if (self::isPassPercentageEnabled($passPercentage)) {
5433
                if ($isSuccess) {
5434
                    $class = ' ribbon-total-success';
5435
                } else {
5436
                    $class = ' ribbon-total-error';
5437
                }
5438
            }
5439
            $ribbon .= '<div class="total '.$class.'">';
5440
        } else {
5441
            $ribbon .= '<div class="total">';
5442
        }
5443
        $ribbon .= '<h3>'.get_lang('Score for the test').':&nbsp;';
5444
        $ribbon .= self::show_score($score, $weight, false, true);
5445
        $ribbon .= '</h3>';
5446
        $ribbon .= '</div>';
5447
        if ($checkPassPercentage) {
5448
            $ribbon .= self::showSuccessMessage(
5449
                $score,
5450
                $weight,
5451
                $passPercentage
5452
            );
5453
        }
5454
        $ribbon .= '</div>';
5455
5456
        if (!empty($countPendingQuestions)) {
5457
            $ribbon .= '<br />';
5458
            $ribbon .= Display::return_message(
5459
                sprintf(
5460
                    get_lang('Temporary score: %s open question(s) not corrected yet.'),
5461
                    $countPendingQuestions
5462
                ),
5463
                'warning'
5464
            );
5465
        }
5466
5467
        return $ribbon;
5468
    }
5469
5470
    /**
5471
     * @param int $countLetter
5472
     *
5473
     * @return mixed
5474
     */
5475
    public static function detectInputAppropriateClass($countLetter)
5476
    {
5477
        $limits = [
5478
            0 => 'input-mini',
5479
            10 => 'input-mini',
5480
            15 => 'input-medium',
5481
            20 => 'input-xlarge',
5482
            40 => 'input-xlarge',
5483
            60 => 'input-xxlarge',
5484
            100 => 'input-xxlarge',
5485
            200 => 'input-xxlarge',
5486
        ];
5487
5488
        foreach ($limits as $size => $item) {
5489
            if ($countLetter <= $size) {
5490
                return $item;
5491
            }
5492
        }
5493
5494
        return $limits[0];
5495
    }
5496
5497
    /**
5498
     * @param int    $senderId
5499
     * @param array  $course_info
5500
     * @param string $test
5501
     * @param string $url
5502
     *
5503
     * @return string
5504
     */
5505
    public static function getEmailNotification($senderId, $course_info, $test, $url)
5506
    {
5507
        $teacher_info = api_get_user_info($senderId);
5508
        $fromName = api_get_person_name(
5509
            $teacher_info['firstname'],
5510
            $teacher_info['lastname'],
5511
            null,
5512
            PERSON_NAME_EMAIL_ADDRESS
5513
        );
5514
5515
        $params = [
5516
            'course_title' => Security::remove_XSS($course_info['name']),
5517
            'test_title' => Security::remove_XSS($test),
5518
            'url' => $url,
5519
            'teacher_name' => $fromName,
5520
        ];
5521
5522
        return Container::getTwig()->render(
5523
            '@ChamiloCore/Mailer/Exercise/result_alert_body.html.twig',
5524
            $params
5525
        );
5526
    }
5527
5528
    /**
5529
     * @return string
5530
     */
5531
    public static function getNotCorrectedYetText()
5532
    {
5533
        return Display::return_message(get_lang('This answer has not yet been corrected. Meanwhile, your score for this question is set to 0, affecting the total score.'), 'warning');
5534
    }
5535
5536
    /**
5537
     * @param string $message
5538
     *
5539
     * @return string
5540
     */
5541
    public static function getFeedbackText($message)
5542
    {
5543
        return Display::return_message($message, 'warning', false);
5544
    }
5545
5546
    /**
5547
     * Get the recorder audio component for save a teacher audio feedback.
5548
     *
5549
     * @param int $attemptId
5550
     * @param int $questionId
5551
     *
5552
     * @return string
5553
     */
5554
    public static function getOralFeedbackForm($attemptId, $questionId)
5555
    {
5556
        $view = new Template('', false, false, false, false, false, false);
5557
5558
        $view->assign('type', OralExpression::RECORDING_TYPE_FEEDBACK);
5559
        $view->assign('question_id', $questionId);
5560
        $view->assign('t_exercise_id', $attemptId);
5561
5562
        $template = $view->get_template('exercise/oral_expression.html.twig');
5563
5564
        return $view->fetch($template);
5565
    }
5566
5567
    /**
5568
     * Get oral file audio for a given exercise attempt and question.
5569
     *
5570
     * If $returnUrls is true, returns an array of URLs.
5571
     * Otherwise returns the HTML string with <audio> players.
5572
     *
5573
     * @param int  $trackExerciseId
5574
     * @param int  $questionId
5575
     * @param bool $returnUrls
5576
     *
5577
     * @return array|string
5578
     */
5579
    public static function getOralFileAudio(
5580
        int $trackExerciseId,
5581
        int $questionId,
5582
        bool $returnUrls = false
5583
    ) {
5584
        /** @var TrackEExercise|null $trackExercise */
5585
        $trackExercise = Container::getTrackEExerciseRepository()->find($trackExerciseId);
5586
5587
        if (null === $trackExercise) {
5588
            return $returnUrls ? [] : '';
5589
        }
5590
5591
        $questionAttempt = $trackExercise->getAttemptByQuestionId($questionId);
5592
5593
        if (null === $questionAttempt) {
5594
            return $returnUrls ? [] : '';
5595
        }
5596
5597
        $attemptId = method_exists($questionAttempt, 'getId')
5598
            ? (int) $questionAttempt->getId()
5599
            : 0;
5600
5601
        // Collect feedback ResourceNode IDs to avoid duplicate players
5602
        $feedbackNodeIds = [];
5603
        if (method_exists($questionAttempt, 'getAttemptFeedbacks')) {
5604
            foreach ($questionAttempt->getAttemptFeedbacks() as $feedback) {
5605
                if (null === $feedback) {
5606
                    continue;
5607
                }
5608
5609
                $feedbackNode = method_exists($feedback, 'getResourceNode')
5610
                    ? $feedback->getResourceNode()
5611
                    : null;
5612
5613
                if (null === $feedbackNode) {
5614
                    continue;
5615
                }
5616
5617
                if (method_exists($feedbackNode, 'getId')) {
5618
                    $feedbackNodeIds[] = (int) $feedbackNode->getId();
5619
                }
5620
            }
5621
5622
            $feedbackNodeIds = array_unique($feedbackNodeIds);
5623
        }
5624
5625
        $filesCollection = $questionAttempt->getAttemptFiles();
5626
        $filesCount = is_countable($filesCollection) ? count($filesCollection) : 0;
5627
5628
        if (0 === $filesCount) {
5629
            return $returnUrls ? [] : '';
5630
        }
5631
5632
        $urls = [];
5633
5634
        foreach ($filesCollection as $attemptFile) {
5635
            if (!$attemptFile) {
5636
                continue;
5637
            }
5638
5639
            $attemptFileId = method_exists($attemptFile, 'getId')
5640
                ? (string) $attemptFile->getId()
5641
                : 'n/a';
5642
5643
            $resourceNode = method_exists($attemptFile, 'getResourceNode')
5644
                ? $attemptFile->getResourceNode()
5645
                : null;
5646
5647
            if (null === $resourceNode) {
5648
                continue;
5649
            }
5650
5651
            $nodeId = method_exists($resourceNode, 'getId')
5652
                ? (int) $resourceNode->getId()
5653
                : 0;
5654
5655
            // Skip files whose ResourceNode is used by feedback (avoid duplicate players)
5656
            if (!empty($feedbackNodeIds) && in_array($nodeId, $feedbackNodeIds, true)) {
5657
                continue;
5658
            }
5659
5660
            $url = self::getPublicUrlForResourceNode($resourceNode);
5661
            if (empty($url)) {
5662
                continue;
5663
            }
5664
5665
            $urls[] = $url;
5666
        }
5667
5668
        if (empty($urls)) {
5669
            return $returnUrls ? [] : '';
5670
        }
5671
5672
        if ($returnUrls) {
5673
            return $urls;
5674
        }
5675
5676
        // Build HTML <audio> tags using the resolved URLs (student attempts only)
5677
        $html = '';
5678
5679
        foreach ($urls as $url) {
5680
            $html .= Display::tag(
5681
                'audio',
5682
                '',
5683
                [
5684
                    'src' => $url,
5685
                    'controls' => '',
5686
                ]
5687
            );
5688
        }
5689
5690
        return $html;
5691
    }
5692
5693
    /**
5694
     * Returns the HTML audio player for the latest oral feedback
5695
     * of a given question attempt.
5696
     *
5697
     * @param int  $attemptId   TrackEExercise id (exercise attempt)
5698
     * @param int  $questionId  Question id inside the attempt
5699
     * @param bool $wrap        Kept for backward compatibility (currently unused)
5700
     *
5701
     * @return string           HTML <audio> tag or empty string if none
5702
     */
5703
    public static function getOralFeedbackAudio(
5704
        int $attemptId,
5705
        int $questionId,
5706
        bool $wrap = true
5707
    ): string {
5708
        /** @var TrackEExercise|null $exercise */
5709
        $exercise = Container::getTrackEExerciseRepository()->find($attemptId);
5710
5711
        if (null === $exercise) {
5712
            return '';
5713
        }
5714
5715
        $attempt = $exercise->getAttemptByQuestionId($questionId);
5716
        if (null === $attempt) {
5717
            return '';
5718
        }
5719
5720
        $html = '';
5721
5722
        // We keep only the latest feedback to avoid duplicated players.
5723
        foreach ($attempt->getAttemptFeedbacks() as $feedback) {
5724
            $node = $feedback->getResourceNode();
5725
5726
            if (null === $node) {
5727
                // Old data might still be asset-based; migration can handle that later.
5728
                continue;
5729
            }
5730
5731
            $url = self::getPublicUrlForResourceNode($node);
5732
5733
            if ('' === $url) {
5734
                // URL could not be generated (missing file or routing issue).
5735
                continue;
5736
            }
5737
5738
            // Override previous HTML so that only the last feedback is rendered.
5739
            $html = Display::tag(
5740
                'audio',
5741
                '',
5742
                [
5743
                    'src' => $url,
5744
                    'controls' => '',
5745
                ]
5746
            );
5747
        }
5748
5749
        return $html;
5750
    }
5751
5752
    /**
5753
     * Get uploaded answer files (resource-based) for a given attempt/question.
5754
     *
5755
     * If $returnUrls is true, returns an array of URLs.
5756
     * Otherwise returns a simple HTML list of links.
5757
     *
5758
     * @param int  $trackExerciseId
5759
     * @param int  $questionId
5760
     * @param bool $returnUrls
5761
     *
5762
     * @return array|string
5763
     */
5764
    public static function getUploadAnswerFiles(int $trackExerciseId, int $questionId, bool $returnUrls = false)
5765
    {
5766
        /** @var TrackEExercise|null $trackExercise */
5767
        $trackExercise = Container::getTrackEExerciseRepository()->find($trackExerciseId);
5768
5769
        if (null === $trackExercise) {
5770
            return $returnUrls ? [] : '';
5771
        }
5772
5773
        $attempt = $trackExercise->getAttemptByQuestionId($questionId);
5774
5775
        if (null === $attempt) {
5776
            return $returnUrls ? [] : '';
5777
        }
5778
5779
        $urls = [];
5780
5781
        // Loop over AttemptFile and use their ResourceNode to get public URLs
5782
        foreach ($attempt->getAttemptFiles() as $attemptFile) {
5783
            $resourceNode = $attemptFile->getResourceNode();
5784
            $url = self::getPublicUrlForResourceNode($resourceNode);
5785
5786
            if (!empty($url)) {
5787
                $urls[] = $url;
5788
            }
5789
        }
5790
5791
        if ($returnUrls) {
5792
            return $urls;
5793
        }
5794
5795
        // Legacy simple HTML (used by some views)
5796
        $html = '';
5797
5798
        foreach ($urls as $url) {
5799
            $path = parse_url($url, PHP_URL_PATH);
5800
            $name = $path ? basename($path) : $url;
5801
5802
            $html .= Display::url($name, $url, ['target' => '_blank']).'<br />';
5803
        }
5804
5805
        return $html;
5806
    }
5807
5808
    public static function getNotificationSettings(): array
5809
    {
5810
        return [
5811
            2 => get_lang('Paranoid: E-mail teacher when a student starts an exercise'),
5812
            1 => get_lang('Aware: E-mail teacher when a student ends an exercise'), // default
5813
            3 => get_lang('Relaxed open: E-mail teacher when a student ends an exercise, only if an open question is answered'),
5814
            4 => get_lang('Relaxed audio: E-mail teacher when a student ends an exercise, only if an oral question is answered'),
5815
        ];
5816
    }
5817
5818
    /**
5819
     * Get the additional actions added in exercise_additional_teacher_modify_actions configuration.
5820
     *
5821
     * @param int $exerciseId
5822
     * @param int $iconSize
5823
     *
5824
     * @return string
5825
     */
5826
    public static function getAdditionalTeacherActions($exerciseId, $iconSize = ICON_SIZE_SMALL)
5827
    {
5828
        $additionalActions = api_get_setting('exercise.exercise_additional_teacher_modify_actions', true) ?: [];
5829
        $actions = [];
5830
5831
        if (is_array($additionalActions)) {
5832
            foreach ($additionalActions as $additionalAction) {
5833
                $actions[] = call_user_func(
5834
                    $additionalAction,
5835
                    $exerciseId,
5836
                    $iconSize
5837
                );
5838
            }
5839
        }
5840
5841
        return implode(PHP_EOL, $actions);
5842
    }
5843
5844
    /**
5845
     * @param int $userId
5846
     * @param int $courseId
5847
     * @param int $sessionId
5848
     *
5849
     * @return int
5850
     */
5851
    public static function countAnsweredQuestionsByUserAfterTime(DateTime $time, $userId, $courseId, $sessionId)
5852
    {
5853
        $em = Database::getManager();
5854
5855
        if (empty($sessionId)) {
5856
            $sessionId = null;
5857
        }
5858
5859
        $time = api_get_utc_datetime($time->format('Y-m-d H:i:s'), false, true);
5860
5861
        $result = $em
5862
            ->createQuery('
5863
                SELECT COUNT(ea) FROM ChamiloCoreBundle:TrackEAttempt ea
5864
                WHERE ea.userId = :user AND ea.cId = :course AND ea.sessionId = :session
5865
                    AND ea.tms > :time
5866
            ')
5867
            ->setParameters(['user' => $userId, 'course' => $courseId, 'session' => $sessionId, 'time' => $time])
5868
            ->getSingleScalarResult();
5869
5870
        return $result;
5871
    }
5872
5873
    /**
5874
     * @param int $userId
5875
     * @param int $numberOfQuestions
5876
     * @param int $courseId
5877
     * @param int $sessionId
5878
     *
5879
     * @throws \Doctrine\ORM\Query\QueryException
5880
     *
5881
     * @return bool
5882
     */
5883
    public static function isQuestionsLimitPerDayReached($userId, $numberOfQuestions, $courseId, $sessionId)
5884
    {
5885
        $questionsLimitPerDay = (int) api_get_course_setting('quiz_question_limit_per_day');
5886
5887
        if ($questionsLimitPerDay <= 0) {
5888
            return false;
5889
        }
5890
5891
        $midnightTime = ChamiloHelper::getServerMidnightTime();
5892
5893
        $answeredQuestionsCount = self::countAnsweredQuestionsByUserAfterTime(
5894
            $midnightTime,
5895
            $userId,
5896
            $courseId,
5897
            $sessionId
5898
        );
5899
5900
        return ($answeredQuestionsCount + $numberOfQuestions) > $questionsLimitPerDay;
5901
    }
5902
5903
    /**
5904
     * Check if an exercise complies with the requirements to be embedded in the mobile app or a video.
5905
     * By making sure it is set on one question per page and it only contains unique-answer or multiple-answer questions
5906
     * or unique-answer image. And that the exam does not have immediate feedback.
5907
     *
5908
     * @return bool
5909
     */
5910
    public static function isQuizEmbeddable(CQuiz $exercise)
5911
    {
5912
        $em = Database::getManager();
5913
5914
        if (ONE_PER_PAGE != $exercise->getType() ||
5915
            in_array($exercise->getFeedbackType(), [EXERCISE_FEEDBACK_TYPE_DIRECT, EXERCISE_FEEDBACK_TYPE_POPUP])
5916
        ) {
5917
            return false;
5918
        }
5919
5920
        $countAll = $em
5921
            ->createQuery('SELECT COUNT(qq)
5922
                FROM ChamiloCourseBundle:CQuizQuestion qq
5923
                INNER JOIN ChamiloCourseBundle:CQuizRelQuestion qrq
5924
                   WITH qq.iid = qrq.question
5925
                WHERE qrq.quiz = :id'
5926
            )
5927
            ->setParameter('id', $exercise->getIid())
5928
            ->getSingleScalarResult();
5929
5930
        $countOfAllowed = $em
5931
            ->createQuery('SELECT COUNT(qq)
5932
                FROM ChamiloCourseBundle:CQuizQuestion qq
5933
                INNER JOIN ChamiloCourseBundle:CQuizRelQuestion qrq
5934
                   WITH qq.iid = qrq.question
5935
                WHERE qrq.quiz = :id AND qq.type IN (:types)'
5936
            )
5937
            ->setParameters(
5938
                [
5939
                    'id' => $exercise->getIid(),
5940
                    'types' => [UNIQUE_ANSWER, MULTIPLE_ANSWER, UNIQUE_ANSWER_IMAGE],
5941
                ]
5942
            )
5943
            ->getSingleScalarResult();
5944
5945
        return $countAll === $countOfAllowed;
5946
    }
5947
5948
    /**
5949
     * Generate a certificate linked to current quiz and.
5950
     * Return the HTML block with links to download and view the certificate.
5951
     *
5952
     * @param float $totalScore
5953
     * @param float $totalWeight
5954
     * @param int   $studentId
5955
     * @param int   $courseId
5956
     * @param int   $sessionId
5957
     *
5958
     * @return string
5959
     */
5960
    public static function generateAndShowCertificateBlock(
5961
        $totalScore,
5962
        $totalWeight,
5963
        Exercise $objExercise,
5964
        $studentId,
5965
        $courseId,
5966
        $sessionId = 0
5967
    ) {
5968
        if (('true' !== api_get_setting('exercise.quiz_generate_certificate_ending')) ||
5969
            !self::isSuccessExerciseResult($totalScore, $totalWeight, $objExercise->selectPassPercentage())
5970
        ) {
5971
            return '';
5972
        }
5973
5974
        $repo = Container::getGradeBookCategoryRepository();
5975
        /** @var GradebookCategory $category */
5976
        $category = $repo->findOneBy(
5977
            ['course' => $courseId, 'session' => $sessionId]
5978
        );
5979
5980
        if (null === $category) {
5981
            return '';
5982
        }
5983
5984
        /*$category = Category::load(null, null, $courseCode, null, null, $sessionId, 'ORDER By id');
5985
        if (empty($category)) {
5986
            return '';
5987
        }*/
5988
        $categoryId = $category->getId();
5989
        /*$link = LinkFactory::load(
5990
            null,
5991
            null,
5992
            $objExercise->getId(),
5993
            null,
5994
            $courseCode,
5995
            $categoryId
5996
        );*/
5997
5998
        if (empty($category->getLinks()->count())) {
5999
            return '';
6000
        }
6001
6002
        $resourceDeletedMessage = Category::show_message_resource_delete($courseId);
6003
        if (!empty($resourceDeletedMessage) || api_is_allowed_to_edit() || api_is_excluded_user_type()) {
6004
            return '';
6005
        }
6006
6007
        $certificate = Category::generateUserCertificate($category, $studentId);
6008
        if (!is_array($certificate)) {
6009
            return '';
6010
        }
6011
6012
        return Category::getDownloadCertificateBlock($certificate);
6013
    }
6014
6015
    /**
6016
     * @param int $exerciseId
6017
     */
6018
    public static function getExerciseTitleById($exerciseId)
6019
    {
6020
        $em = Database::getManager();
6021
6022
        return $em
6023
            ->createQuery('SELECT cq.title
6024
                FROM ChamiloCourseBundle:CQuiz cq
6025
                WHERE cq.iid = :iid'
6026
            )
6027
            ->setParameter('iid', $exerciseId)
6028
            ->getSingleScalarResult();
6029
    }
6030
6031
    /**
6032
     * @param int $exeId      ID from track_e_exercises
6033
     * @param int $userId     User ID
6034
     * @param int $exerciseId Exercise ID
6035
     * @param int $courseId   Optional. Coure ID.
6036
     *
6037
     * @return TrackEExercise|null
6038
     */
6039
    public static function recalculateResult($exeId, $userId, $exerciseId, $courseId = 0)
6040
    {
6041
        if (empty($userId) || empty($exerciseId)) {
6042
            return null;
6043
        }
6044
6045
        $em = Database::getManager();
6046
        /** @var TrackEExercise $trackedExercise */
6047
        $trackedExercise = $em->getRepository(TrackEExercise::class)->find($exeId);
6048
6049
        if (empty($trackedExercise)) {
6050
            return null;
6051
        }
6052
6053
        if ($trackedExercise->getUser()->getId() != $userId ||
6054
            $trackedExercise->getQuiz()?->getIid() != $exerciseId
6055
        ) {
6056
            return null;
6057
        }
6058
6059
        $questionList = $trackedExercise->getDataTracking();
6060
6061
        if (empty($questionList)) {
6062
            return null;
6063
        }
6064
6065
        $questionList = explode(',', $questionList);
6066
6067
        $exercise = new Exercise($courseId);
6068
        $courseInfo = $courseId ? api_get_course_info_by_id($courseId) : [];
6069
6070
        if (false === $exercise->read($exerciseId)) {
6071
            return null;
6072
        }
6073
6074
        $totalScore = 0;
6075
        $totalWeight = 0;
6076
6077
        $pluginEvaluation = QuestionOptionsEvaluationPlugin::create();
6078
6079
        $formula = 'true' === $pluginEvaluation->get(QuestionOptionsEvaluationPlugin::SETTING_ENABLE)
6080
            ? $pluginEvaluation->getFormulaForExercise($exerciseId)
6081
            : 0;
6082
6083
        if (empty($formula)) {
6084
            foreach ($questionList as $questionId) {
6085
                $question = Question::read($questionId, $courseInfo);
6086
6087
                if (false === $question) {
6088
                    continue;
6089
                }
6090
6091
                $totalWeight += $question->selectWeighting();
6092
6093
                // We're inside *one* question. Go through each possible answer for this question
6094
                $result = $exercise->manage_answer(
6095
                    $exeId,
6096
                    $questionId,
6097
                    [],
6098
                    'exercise_result',
6099
                    [],
6100
                    false,
6101
                    true,
6102
                    false,
6103
                    $exercise->selectPropagateNeg(),
6104
                    [],
6105
                    [],
6106
                    true
6107
                );
6108
6109
                //  Adding the new score.
6110
                $totalScore += $result['score'];
6111
            }
6112
        } else {
6113
            $totalScore = $pluginEvaluation->getResultWithFormula($exeId, $formula);
6114
            $totalWeight = $pluginEvaluation->getMaxScore();
6115
        }
6116
6117
        $trackedExercise
6118
            ->setScore($totalScore)
6119
            ->setMaxScore($totalWeight);
6120
6121
        $em->persist($trackedExercise);
6122
        $em->flush();
6123
        $lpItemId = $trackedExercise->getOrigLpItemId();
6124
        $lpId = $trackedExercise->getOrigLpId();
6125
        $lpItemViewId = $trackedExercise->getOrigLpItemViewId();
6126
        if ($lpId && $lpItemId && $lpItemViewId) {
6127
            $lpItem = $em->getRepository(CLpItem::class)->find($lpItemId);
6128
            if ($lpItem && 'quiz' === $lpItem->getItemType()) {
6129
                $lpItemView = $em->getRepository(CLpItemView::class)->find($lpItemViewId);
6130
                if ($lpItemView) {
6131
                    $lpItemView->setScore($totalScore);
6132
                    $em->persist($lpItemView);
6133
                    $em->flush();
6134
                }
6135
            }
6136
        }
6137
6138
        return $trackedExercise;
6139
    }
6140
6141
    public static function getTotalQuestionAnswered($courseId, $exerciseId, $questionId, $onlyStudents = false): int
6142
    {
6143
        $courseId = (int) $courseId;
6144
        $exerciseId = (int) $exerciseId;
6145
        $questionId = (int) $questionId;
6146
6147
        $attemptTable = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT);
6148
        $trackTable = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
6149
        $courseUser = Database::get_main_table(TABLE_MAIN_COURSE_USER);
6150
        $courseUserJoin = "";
6151
        $studentsWhere = "";
6152
        if ($onlyStudents) {
6153
            $courseUserJoin = "
6154
            INNER JOIN $courseUser cu
6155
            ON cu.c_id = te.c_id AND cu.user_id = exe_user_id";
6156
            $studentsWhere = " AND relation_type <> 2 AND cu.status = ".STUDENT;
6157
        }
6158
6159
        $sql = "SELECT count(distinct (te.exe_id)) total
6160
            FROM $attemptTable t
6161
            INNER JOIN $trackTable te
6162
            ON (t.exe_id = te.exe_id)
6163
            $courseUserJoin
6164
            WHERE
6165
                te.c_id = $courseId AND
6166
                exe_exo_id = $exerciseId AND
6167
                t.question_id = $questionId AND
6168
                te.status != 'incomplete'
6169
                $studentsWhere
6170
        ";
6171
        $queryTotal = Database::query($sql);
6172
        $totalRow = Database::fetch_assoc($queryTotal);
6173
        $total = 0;
6174
        if ($totalRow) {
6175
            $total = (int) $totalRow['total'];
6176
        }
6177
6178
        return $total;
6179
    }
6180
6181
    public static function getWrongQuestionResults($courseId, $exerciseId, $sessionId = 0, $limit = 10)
6182
    {
6183
        $courseId = (int) $courseId;
6184
        $exerciseId = (int) $exerciseId;
6185
        $limit = (int) $limit;
6186
6187
        $questionTable = Database::get_course_table(TABLE_QUIZ_QUESTION);
6188
        $attemptTable = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT);
6189
        $trackTable = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
6190
6191
        $sessionCondition = '';
6192
        if (!empty($sessionId)) {
6193
            $sessionCondition = api_get_session_condition($sessionId, true, false, 'te.session_id');
6194
        }
6195
6196
        $sql = "SELECT q.question, question_id, count(q.iid) count
6197
                FROM $attemptTable t
6198
                INNER JOIN $questionTable q
6199
                ON (q.iid = t.question_id)
6200
                INNER JOIN $trackTable te
6201
                ON (t.exe_id = te.exe_id)
6202
                WHERE
6203
                    te.c_id = $courseId AND
6204
                    t.marks != q.ponderation AND
6205
                    exe_exo_id = $exerciseId AND
6206
                    status != 'incomplete'
6207
                    $sessionCondition
6208
                GROUP BY q.iid
6209
                ORDER BY count DESC
6210
                LIMIT $limit
6211
        ";
6212
6213
        $result = Database::query($sql);
6214
6215
        return Database::store_result($result, 'ASSOC');
6216
    }
6217
6218
    public static function getExerciseResultsCount($type, $courseId, $exerciseId, $sessionId = 0)
6219
    {
6220
        $courseId = (int) $courseId;
6221
        $exerciseId = (int) $exerciseId;
6222
6223
        $trackTable = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
6224
6225
        $sessionCondition = '';
6226
        if (!empty($sessionId)) {
6227
            $sessionCondition = api_get_session_condition($sessionId, true, false, 'te.session_id');
6228
        }
6229
6230
        $selectCount = 'count(DISTINCT te.exe_id)';
6231
        $scoreCondition = '';
6232
        switch ($type) {
6233
            case 'correct_student':
6234
                $selectCount = 'count(DISTINCT te.exe_user_id)';
6235
                $scoreCondition = ' AND score = max_score ';
6236
                break;
6237
            case 'wrong_student':
6238
                $selectCount = 'count(DISTINCT te.exe_user_id)';
6239
                $scoreCondition = ' AND score != max_score ';
6240
                break;
6241
            case 'correct':
6242
                $scoreCondition = ' AND score = max_score ';
6243
                break;
6244
            case 'wrong':
6245
                $scoreCondition = ' AND score != max_score ';
6246
                break;
6247
        }
6248
6249
        $sql = "SELECT $selectCount count
6250
                FROM $trackTable te
6251
                WHERE
6252
                    c_id = $courseId AND
6253
                    exe_exo_id = $exerciseId AND
6254
                    status != 'incomplete'
6255
                    $scoreCondition
6256
                    $sessionCondition
6257
        ";
6258
        $result = Database::query($sql);
6259
        $totalRow = Database::fetch_assoc($result);
6260
        $total = 0;
6261
        if ($totalRow) {
6262
            $total = (int) $totalRow['count'];
6263
        }
6264
6265
        return $total;
6266
    }
6267
6268
    public static function parseContent($content, $stats, Exercise $exercise, $trackInfo, $currentUserId = 0)
6269
    {
6270
        $wrongAnswersCount = $stats['failed_answers_count'];
6271
        $attemptDate = substr($trackInfo['exe_date'], 0, 10);
6272
        $exerciseId = $exercise->iId;
6273
        $resultsStudentUrl = api_get_path(WEB_CODE_PATH).
6274
            'exercise/result.php?id='.$exerciseId.'&'.api_get_cidreq();
6275
        $resultsTeacherUrl = api_get_path(WEB_CODE_PATH).
6276
            'exercise/exercise_show.php?action=edit&id='.$exerciseId.'&'.api_get_cidreq();
6277
6278
        $content = str_replace(
6279
            [
6280
                '((exercise_error_count))',
6281
                '((all_answers_html))',
6282
                '((all_answers_teacher_html))',
6283
                '((exercise_title))',
6284
                '((exercise_attempt_date))',
6285
                '((link_to_test_result_page_student))',
6286
                '((link_to_test_result_page_teacher))',
6287
            ],
6288
            [
6289
                $wrongAnswersCount,
6290
                $stats['all_answers_html'],
6291
                $stats['all_answers_teacher_html'],
6292
                $exercise->get_formated_title(),
6293
                $attemptDate,
6294
                $resultsStudentUrl,
6295
                $resultsTeacherUrl,
6296
            ],
6297
            $content
6298
        );
6299
6300
        $currentUserId = empty($currentUserId) ? api_get_user_id() : (int) $currentUserId;
6301
6302
        $content = AnnouncementManager::parseContent(
6303
            $currentUserId,
6304
            $content,
6305
            api_get_course_id(),
6306
            api_get_session_id()
6307
        );
6308
6309
        return $content;
6310
    }
6311
6312
    public static function sendNotification(
6313
        $currentUserId,
6314
        $objExercise,
6315
        $exercise_stat_info,
6316
        $courseInfo,
6317
        $attemptCountToSend,
6318
        $stats,
6319
        $statsTeacher
6320
    ) {
6321
        $notifications = api_get_configuration_value('exercise_finished_notification_settings');
6322
        if (empty($notifications)) {
6323
            return false;
6324
        }
6325
6326
        $studentId = $exercise_stat_info['exe_user_id'];
6327
        $exerciseExtraFieldValue = new ExtraFieldValue('exercise');
6328
        $wrongAnswersCount = $stats['failed_answers_count'];
6329
        $exercisePassed = $stats['exercise_passed'];
6330
        $countPendingQuestions = $stats['count_pending_questions'];
6331
        $stats['all_answers_teacher_html'] = $statsTeacher['all_answers_html'];
6332
6333
        // If there are no pending questions (Open questions).
6334
        if (0 === $countPendingQuestions) {
6335
            /*$extraFieldData = $exerciseExtraFieldValue->get_values_by_handler_and_field_variable(
6336
                $objExercise->iId,
6337
                'signature_mandatory'
6338
            );
6339
6340
            if ($extraFieldData && isset($extraFieldData['value']) && 1 === (int) $extraFieldData['value']) {
6341
                if (ExerciseSignaturePlugin::exerciseHasSignatureActivated($objExercise)) {
6342
                    $signature = ExerciseSignaturePlugin::getSignature($studentId, $exercise_stat_info);
6343
                    if (false !== $signature) {
6344
                        //return false;
6345
                    }
6346
                }
6347
            }*/
6348
6349
            // Notifications.
6350
            $extraFieldData = $exerciseExtraFieldValue->get_values_by_handler_and_field_variable(
6351
                $objExercise->iId,
6352
                'notifications'
6353
            );
6354
            $exerciseNotification = '';
6355
            if ($extraFieldData && isset($extraFieldData['value'])) {
6356
                $exerciseNotification = $extraFieldData['value'];
6357
            }
6358
6359
            $subject = sprintf(get_lang('Failure on attempt %s at %s'), $attemptCountToSend, $courseInfo['title']);
6360
            if ($exercisePassed) {
6361
                $subject = sprintf(get_lang('Validation of exercise at %s'), $courseInfo['title']);
6362
            }
6363
6364
            if ($exercisePassed) {
6365
                $extraFieldData = $exerciseExtraFieldValue->get_values_by_handler_and_field_variable(
6366
                    $objExercise->iId,
6367
                    'MailSuccess'
6368
                );
6369
            } else {
6370
                $extraFieldData = $exerciseExtraFieldValue->get_values_by_handler_and_field_variable(
6371
                    $objExercise->iId,
6372
                    'MailAttempt'.$attemptCountToSend
6373
                );
6374
            }
6375
6376
            // Blocking exercise.
6377
            $blockPercentageExtra = $exerciseExtraFieldValue->get_values_by_handler_and_field_variable(
6378
                $objExercise->iId,
6379
                'blocking_percentage'
6380
            );
6381
            $blockPercentage = false;
6382
            if ($blockPercentageExtra && isset($blockPercentageExtra['value']) && $blockPercentageExtra['value']) {
6383
                $blockPercentage = $blockPercentageExtra['value'];
6384
            }
6385
            if ($blockPercentage) {
6386
                $passBlock = $stats['total_percentage'] > $blockPercentage;
6387
                if (false === $passBlock) {
6388
                    $extraFieldData = $exerciseExtraFieldValue->get_values_by_handler_and_field_variable(
6389
                        $objExercise->iId,
6390
                        'MailIsBlockByPercentage'
6391
                    );
6392
                }
6393
            }
6394
6395
            $extraFieldValueUser = new ExtraFieldValue('user');
6396
6397
            if ($extraFieldData && isset($extraFieldData['value'])) {
6398
                $content = $extraFieldData['value'];
6399
                $content = self::parseContent($content, $stats, $objExercise, $exercise_stat_info, $studentId);
6400
                //if (false === $exercisePassed) {
6401
                if (0 !== $wrongAnswersCount) {
6402
                    $content .= $stats['failed_answers_html'];
6403
                }
6404
6405
                $sendMessage = true;
6406
                if (!empty($exerciseNotification)) {
6407
                    foreach ($notifications as $name => $notificationList) {
6408
                        if ($exerciseNotification !== $name) {
6409
                            continue;
6410
                        }
6411
                        foreach ($notificationList as $notificationName => $attemptData) {
6412
                            if ('student_check' === $notificationName) {
6413
                                $sendMsgIfInList = isset($attemptData['send_notification_if_user_in_extra_field']) ? $attemptData['send_notification_if_user_in_extra_field'] : '';
6414
                                if (!empty($sendMsgIfInList)) {
6415
                                    foreach ($sendMsgIfInList as $skipVariable => $skipValues) {
6416
                                        $userExtraFieldValue = $extraFieldValueUser->get_values_by_handler_and_field_variable(
6417
                                            $studentId,
6418
                                            $skipVariable
6419
                                        );
6420
6421
                                        if (empty($userExtraFieldValue)) {
6422
                                            $sendMessage = false;
6423
                                            break;
6424
                                        } else {
6425
                                            $sendMessage = false;
6426
                                            if (isset($userExtraFieldValue['value']) &&
6427
                                                in_array($userExtraFieldValue['value'], $skipValues)
6428
                                            ) {
6429
                                                $sendMessage = true;
6430
                                                break;
6431
                                            }
6432
                                        }
6433
                                    }
6434
                                }
6435
                                break;
6436
                            }
6437
                        }
6438
                    }
6439
                }
6440
6441
                // Send to student.
6442
                if ($sendMessage) {
6443
                    MessageManager::send_message($currentUserId, $subject, $content);
6444
                }
6445
            }
6446
6447
            if (!empty($exerciseNotification)) {
6448
                foreach ($notifications as $name => $notificationList) {
6449
                    if ($exerciseNotification !== $name) {
6450
                        continue;
6451
                    }
6452
                    foreach ($notificationList as $attemptData) {
6453
                        $skipNotification = false;
6454
                        $skipNotificationList = isset($attemptData['send_notification_if_user_in_extra_field']) ? $attemptData['send_notification_if_user_in_extra_field'] : [];
6455
                        if (!empty($skipNotificationList)) {
6456
                            foreach ($skipNotificationList as $skipVariable => $skipValues) {
6457
                                $userExtraFieldValue = $extraFieldValueUser->get_values_by_handler_and_field_variable(
6458
                                    $studentId,
6459
                                    $skipVariable
6460
                                );
6461
6462
                                if (empty($userExtraFieldValue)) {
6463
                                    $skipNotification = true;
6464
                                    break;
6465
                                } else {
6466
                                    if (isset($userExtraFieldValue['value'])) {
6467
                                        if (!in_array($userExtraFieldValue['value'], $skipValues)) {
6468
                                            $skipNotification = true;
6469
                                            break;
6470
                                        }
6471
                                    } else {
6472
                                        $skipNotification = true;
6473
                                        break;
6474
                                    }
6475
                                }
6476
                            }
6477
                        }
6478
6479
                        if ($skipNotification) {
6480
                            continue;
6481
                        }
6482
6483
                        $email = isset($attemptData['email']) ? $attemptData['email'] : '';
6484
                        $emailList = explode(',', $email);
6485
                        if (empty($emailList)) {
6486
                            continue;
6487
                        }
6488
                        $attempts = isset($attemptData['attempts']) ? $attemptData['attempts'] : [];
6489
                        foreach ($attempts as $attempt) {
6490
                            $sendMessage = false;
6491
                            if (isset($attempt['attempt']) && $attemptCountToSend !== (int) $attempt['attempt']) {
6492
                                continue;
6493
                            }
6494
6495
                            if (!isset($attempt['status'])) {
6496
                                continue;
6497
                            }
6498
6499
                            if ($blockPercentage && isset($attempt['is_block_by_percentage'])) {
6500
                                if ($attempt['is_block_by_percentage']) {
6501
                                    if ($passBlock) {
6502
                                        continue;
6503
                                    }
6504
                                } else {
6505
                                    if (false === $passBlock) {
6506
                                        continue;
6507
                                    }
6508
                                }
6509
                            }
6510
6511
                            switch ($attempt['status']) {
6512
                                case 'passed':
6513
                                    if ($exercisePassed) {
6514
                                        $sendMessage = true;
6515
                                    }
6516
                                    break;
6517
                                case 'failed':
6518
                                    if (false === $exercisePassed) {
6519
                                        $sendMessage = true;
6520
                                    }
6521
                                    break;
6522
                                case 'all':
6523
                                    $sendMessage = true;
6524
                                    break;
6525
                            }
6526
6527
                            if ($sendMessage) {
6528
                                $attachments = [];
6529
                                if (isset($attempt['add_pdf']) && $attempt['add_pdf']) {
6530
                                    // Get pdf content
6531
                                    $pdfExtraData = $exerciseExtraFieldValue->get_values_by_handler_and_field_variable(
6532
                                        $objExercise->iId,
6533
                                        $attempt['add_pdf']
6534
                                    );
6535
6536
                                    if ($pdfExtraData && isset($pdfExtraData['value'])) {
6537
                                        $pdfContent = self::parseContent(
6538
                                            $pdfExtraData['value'],
6539
                                            $stats,
6540
                                            $objExercise,
6541
                                            $exercise_stat_info,
6542
                                            $studentId
6543
                                        );
6544
6545
                                        @$pdf = new PDF();
6546
                                        $filename = get_lang('Test');
6547
                                        $pdfPath = @$pdf->content_to_pdf(
6548
                                            "<html><body>$pdfContent</body></html>",
6549
                                            null,
6550
                                            $filename,
6551
                                            api_get_course_id(),
6552
                                            'F',
6553
                                            false,
6554
                                            null,
6555
                                            false,
6556
                                            true
6557
                                        );
6558
                                        $attachments[] = ['filename' => $filename, 'path' => $pdfPath];
6559
                                    }
6560
                                }
6561
6562
                                $content = isset($attempt['content_default']) ? $attempt['content_default'] : '';
6563
                                if (isset($attempt['content'])) {
6564
                                    $extraFieldData = $exerciseExtraFieldValue->get_values_by_handler_and_field_variable(
6565
                                        $objExercise->iId,
6566
                                        $attempt['content']
6567
                                    );
6568
                                    if ($extraFieldData && isset($extraFieldData['value']) && !empty($extraFieldData['value'])) {
6569
                                        $content = $extraFieldData['value'];
6570
                                    }
6571
                                }
6572
6573
                                if (!empty($content)) {
6574
                                    $content = self::parseContent(
6575
                                        $content,
6576
                                        $stats,
6577
                                        $objExercise,
6578
                                        $exercise_stat_info,
6579
                                        $studentId
6580
                                    );
6581
                                    foreach ($emailList as $email) {
6582
                                        if (empty($email)) {
6583
                                            continue;
6584
                                        }
6585
                                        api_mail_html(
6586
                                            null,
6587
                                            $email,
6588
                                            $subject,
6589
                                            $content,
6590
                                            null,
6591
                                            null,
6592
                                            [],
6593
                                            $attachments
6594
                                        );
6595
                                    }
6596
                                }
6597
6598
                                if (isset($attempt['post_actions'])) {
6599
                                    foreach ($attempt['post_actions'] as $action => $params) {
6600
                                        switch ($action) {
6601
                                            case 'subscribe_student_to_courses':
6602
                                                foreach ($params as $code) {
6603
                                                    $courseInfo = api_get_course_info($code);
6604
                                                    CourseManager::subscribeUser(
6605
                                                        $currentUserId,
6606
                                                        $courseInfo['real_id']
6607
                                                    );
6608
                                                    break;
6609
                                                }
6610
                                                break;
6611
                                        }
6612
                                    }
6613
                                }
6614
                            }
6615
                        }
6616
                    }
6617
                }
6618
            }
6619
        }
6620
    }
6621
6622
    /**
6623
     * Delete an exercise attempt.
6624
     *
6625
     * Log the exe_id deleted with the exe_user_id related.
6626
     *
6627
     * @param int $exeId
6628
     */
6629
    public static function deleteExerciseAttempt($exeId)
6630
    {
6631
        $exeId = (int) $exeId;
6632
6633
        $trackExerciseInfo = self::get_exercise_track_exercise_info($exeId);
6634
6635
        if (empty($trackExerciseInfo)) {
6636
            return;
6637
        }
6638
6639
        $tblTrackExercises = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
6640
        $tblTrackAttempt = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT);
6641
6642
        Database::query("DELETE FROM $tblTrackAttempt WHERE exe_id = $exeId");
6643
        Database::query("DELETE FROM $tblTrackExercises WHERE exe_id = $exeId");
6644
6645
        Event::addEvent(
6646
            LOG_EXERCISE_ATTEMPT_DELETE,
6647
            LOG_EXERCISE_ATTEMPT,
6648
            $exeId,
6649
            api_get_utc_datetime()
6650
        );
6651
        Event::addEvent(
6652
            LOG_EXERCISE_ATTEMPT_DELETE,
6653
            LOG_EXERCISE_AND_USER_ID,
6654
            $exeId.'-'.$trackExerciseInfo['exe_user_id'],
6655
            api_get_utc_datetime()
6656
        );
6657
    }
6658
6659
    public static function scorePassed($score, $total)
6660
    {
6661
        $compareResult = bccomp($score, $total, 3);
6662
        $scorePassed = 1 === $compareResult || 0 === $compareResult;
6663
        if (false === $scorePassed) {
6664
            $epsilon = 0.00001;
6665
            if (abs($score - $total) < $epsilon) {
6666
                $scorePassed = true;
6667
            }
6668
        }
6669
6670
        return $scorePassed;
6671
    }
6672
6673
    /**
6674
     * Returns the HTML for a specific exercise attempt, ready for PDF generation.
6675
     */
6676
    public static function getAttemptPdfHtml(int $exeId, int $courseId, int $sessionId): string
6677
    {
6678
        $_GET = [
6679
            'id'           => $exeId,
6680
            'action'       => 'export',
6681
            'export_type'  => 'all_results',
6682
            'cid'          => $courseId,
6683
            'sid'          => $sessionId,
6684
            'gid'          => 0,
6685
            'gradebook'    => 0,
6686
            'origin'       => '',
6687
        ];
6688
        $_REQUEST = $_GET + $_REQUEST;
6689
6690
        ob_start();
6691
        include __DIR__ . '/../../exercise/exercise_show.php';
6692
        return ob_get_clean();
6693
    }
6694
6695
    /**
6696
     * Generates and saves a PDF for a single exercise attempt
6697
     */
6698
    public static function saveFileExerciseResultPdfDirect(
6699
        int    $exeId,
6700
        int    $courseId,
6701
        int    $sessionId,
6702
        string $exportFolderPath
6703
    ): void {
6704
        // Retrieve the HTML for this attempt and convert it to PDF
6705
        $html = self::getAttemptPdfHtml($exeId, $courseId, $sessionId);
6706
6707
        // Determine filename and path based on user information
6708
        $track   = self::get_exercise_track_exercise_info($exeId);
6709
        $userId  = $track['exe_user_id'] ?? 0;
6710
        $user    = api_get_user_info($userId);
6711
        $pdfName = api_replace_dangerous_char(
6712
            ($user['firstname'] ?? 'user') . '_' .
6713
            ($user['lastname']  ?? 'unknown') .
6714
            '_attemptId' . $exeId . '.pdf'
6715
        );
6716
        $filePath = rtrim($exportFolderPath, '/') . '/' . $pdfName;
6717
6718
        if (file_exists($filePath)) {
6719
            return;
6720
        }
6721
6722
        // Ensure the directory exists
6723
        $dir = dirname($filePath);
6724
        if (!is_dir($dir)) {
6725
            mkdir($dir, 0755, true);
6726
        }
6727
6728
        // Use Chamilo's PDF class to generate and save the file
6729
        $params = [
6730
            'filename'    => $pdfName,
6731
            'course_code' => api_get_course_id(),
6732
        ];
6733
        $pdf = new PDF('A4', 'P', $params);
6734
        $pdf->html_to_pdf_with_template(
6735
            $html,
6736
            true,
6737
            false,
6738
            true,
6739
            [],
6740
            'F',
6741
            $filePath
6742
        );
6743
    }
6744
6745
    /**
6746
     * Exports all results of an exercise to a ZIP archive by generating PDFs on disk and then sending the ZIP to the browser.
6747
     */
6748
    public static function exportExerciseAllResultsZip(
6749
        int   $sessionId,
6750
        int   $courseId,
6751
        int   $exerciseId,
6752
        array $filterDates = [],
6753
        string $mainPath    = ''
6754
    ) {
6755
        $em = Container::getEntityManager();
6756
6757
        /** @var CourseEntity|null $course */
6758
        $course = $em->getRepository(CourseEntity::class)->find($courseId);
6759
        /** @var CQuiz|null $quiz */
6760
        $quiz   = $em->getRepository(CQuiz::class)->findOneBy(['iid' => $exerciseId]);
6761
        $session = null;
6762
6763
        if (!$course) {
6764
            Display::addFlash(Display::return_message(get_lang('Course not found'), 'warning', false));
6765
            return false;
6766
        }
6767
        if (!$quiz) {
6768
            Display::addFlash(Display::return_message(get_lang('Test not found or not visible'), 'warning', false));
6769
            return false;
6770
        }
6771
        if ($sessionId > 0) {
6772
            $session = $em->getRepository(SessionEntity::class)->find($sessionId);
6773
            if (!$session) {
6774
                Display::addFlash(Display::return_message(get_lang('Session not found'), 'warning', false));
6775
                return false;
6776
            }
6777
        }
6778
6779
        // Fetch exe_ids with Doctrine, accepting NULL/0 session when $sessionId == 0
6780
        $exeIds = self::findAttemptExeIdsForExport($course, $quiz, $session, $filterDates);
6781
6782
        // Optional: hard fallback with native SQL to catch legacy session_id=0 rows if needed
6783
        if (empty($exeIds) && $sessionId === 0) {
6784
            $exeIds = self::findAttemptExeIdsFallbackSql($courseId, $exerciseId, $filterDates);
6785
        }
6786
6787
        if (empty($exeIds)) {
6788
            Display::addFlash(
6789
                Display::return_message(
6790
                    get_lang('No result found for export in this test.'),
6791
                    'warning',
6792
                    false
6793
                )
6794
            );
6795
            return false;
6796
        }
6797
6798
        // Prepare a temporary folder for the PDFs
6799
        $exportName       = 'S' . (int)($sessionId) . '-C' . (int)($courseId) . '-T' . (int)($exerciseId);
6800
        $baseDir          = api_get_path(SYS_ARCHIVE_PATH);
6801
        $exportFolderPath = $baseDir . 'pdfexport-' . $exportName;
6802
        if (is_dir($exportFolderPath)) {
6803
            rmdirr($exportFolderPath);
6804
        }
6805
        mkdir($exportFolderPath, 0755, true);
6806
6807
        // Generate a PDF for each attempt
6808
        foreach ($exeIds as $exeId) {
6809
            self::saveFileExerciseResultPdfDirect(
6810
                (int)$exeId,
6811
                (int)$courseId,
6812
                (int)$sessionId,
6813
                $exportFolderPath
6814
            );
6815
        }
6816
6817
        // Create the ZIP archive containing all generated PDFs
6818
        $zipFilePath = $baseDir . 'pdfexport-' . $exportName . '.zip';
6819
        $zip = new \ZipArchive();
6820
        if ($zip->open($zipFilePath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
6821
            throw new \Exception('Failed to create ZIP file');
6822
        }
6823
        $files = new RecursiveIteratorIterator(
6824
            new RecursiveDirectoryIterator($exportFolderPath),
6825
            RecursiveIteratorIterator::LEAVES_ONLY
6826
        );
6827
        foreach ($files as $file) {
6828
            if (!$file->isDir()) {
6829
                $filePath     = $file->getRealPath();
6830
                $relativePath = substr($filePath, strlen($exportFolderPath) + 1);
6831
                $zip->addFile($filePath, $relativePath);
6832
            }
6833
        }
6834
        $zip->close();
6835
        rmdirr($exportFolderPath);
6836
6837
        // Send the ZIP file to the browser or move it to mainPath
6838
        if (!empty($mainPath)) {
6839
            @rename($zipFilePath, $mainPath . '/pdfexport-' . $exportName . '.zip');
6840
            return true;
6841
        }
6842
6843
        session_write_close();
6844
        while (ob_get_level()) {
6845
            @ob_end_clean();
6846
        }
6847
6848
        header('Content-Description: File Transfer');
6849
        header('Content-Type: application/zip');
6850
        header('Content-Disposition: attachment; filename="pdfexport-' . $exportName . '.zip"');
6851
        header('Content-Transfer-Encoding: binary');
6852
        header('Expires: 0');
6853
        header('Cache-Control: must-revalidate');
6854
        header('Pragma: public');
6855
        header('Content-Length: ' . filesize($zipFilePath));
6856
6857
        readfile($zipFilePath);
6858
        @unlink($zipFilePath);
6859
        exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
6860
    }
6861
6862
    /**
6863
     * Return exe_ids for export using Doctrine (handles NULL/0 sessions safely).
6864
     */
6865
    private static function findAttemptExeIdsForExport(
6866
        CourseEntity $course,
6867
        CQuiz $quiz,
6868
        ?SessionEntity $session,
6869
        array $filterDates
6870
    ): array {
6871
        $em = Container::getEntityManager();
6872
6873
        $qb = $em->createQueryBuilder()
6874
            ->select('te.exeId AS exeId')
6875
            ->from(TrackEExercise::class, 'te')
6876
            ->where('te.course = :course')
6877
            ->andWhere('te.quiz = :quiz')
6878
            ->setParameter('course', $course)
6879
            ->setParameter('quiz', $quiz);
6880
6881
        // Session filter:
6882
        if ($session) {
6883
            $qb->andWhere('te.session = :session')->setParameter('session', $session);
6884
        } else {
6885
            // Accept both NULL and legacy "0" values
6886
            // IDENTITY() extracts the FK raw value to match 0 if present
6887
            $qb->andWhere('(te.session IS NULL OR IDENTITY(te.session) = 0)');
6888
        }
6889
6890
        // Date filters on exeDate
6891
        if (!empty($filterDates['start_date'])) {
6892
            $qb->andWhere('te.exeDate >= :start')
6893
                ->setParameter('start', new DateTime($filterDates['start_date']));
6894
        }
6895
        if (!empty($filterDates['end_date'])) {
6896
            $qb->andWhere('te.exeDate <= :end')
6897
                ->setParameter('end', new DateTime($filterDates['end_date']));
6898
        }
6899
6900
        $qb->orderBy('te.exeDate', 'DESC')->setMaxResults(5000);
6901
6902
        $rows = $qb->getQuery()->getScalarResult();
6903
        $exeIds = array_map(static fn($r) => (int)$r['exeId'], $rows);
6904
6905
6906
        return array_values(array_unique($exeIds));
6907
    }
6908
6909
    /**
6910
     * Fallback with native SQL for very legacy rows (session_id=0 and column names).
6911
     */
6912
    private static function findAttemptExeIdsFallbackSql(
6913
        int $courseId,
6914
        int $quizIid,
6915
        array $filterDates
6916
    ): array {
6917
        $conn = Container::getEntityManager()->getConnection();
6918
6919
        $sql = 'SELECT te.exe_id
6920
            FROM track_e_exercises te
6921
            WHERE te.c_id = :cid
6922
              AND te.exe_exo_id = :iid
6923
              AND (te.session_id IS NULL OR te.session_id = 0)';
6924
6925
        $params = ['cid' => $courseId, 'iid' => $quizIid];
6926
        $types  = [];
6927
6928
        if (!empty($filterDates['start_date'])) {
6929
            $sql .= ' AND te.exe_date >= :start';
6930
            $params['start'] = $filterDates['start_date'];
6931
        }
6932
        if (!empty($filterDates['end_date'])) {
6933
            $sql .= ' AND te.exe_date <= :end';
6934
            $params['end'] = $filterDates['end_date'];
6935
        }
6936
6937
        $sql .= ' ORDER BY te.exe_date DESC LIMIT 5000';
6938
6939
        $rows = $conn->fetchAllAssociative($sql, $params, $types);
6940
        $exeIds = array_map(static fn($r) => (int)$r['exe_id'], $rows);
6941
6942
        return $exeIds;
6943
    }
6944
6945
    /**
6946
     * Calculates the overall score for Combination-type questions.
6947
     */
6948
    public static function getUserQuestionScoreGlobal(
6949
        int   $answerType,
6950
        array $listCorrectAnswers,
6951
        int   $exeId,
6952
        int   $questionId,
6953
        float $questionWeighting,
6954
        array $choice = [],
6955
        int $nbrAnswers = 0
6956
    ): float
6957
    {
6958
        $nbrCorrect = 0;
6959
        $nbrOptions = 0;
6960
        $choice = is_array($choice) ? $choice : [];
6961
        switch ($answerType) {
6962
            case FILL_IN_BLANKS_COMBINATION:
6963
                if (!empty($listCorrectAnswers)) {
6964
                    if (!empty($listCorrectAnswers['student_score']) && is_array($listCorrectAnswers['student_score'])) {
6965
                        foreach ($listCorrectAnswers['student_score'] as $val) {
6966
                            if ((int) $val === 1) {
6967
                                $nbrCorrect++;
6968
                            }
6969
                        }
6970
                    }
6971
                    if (!empty($listCorrectAnswers['words_count'])) {
6972
                        $nbrOptions = (int) $listCorrectAnswers['words_count'];
6973
                    } elseif (!empty($listCorrectAnswers['words']) && is_array($listCorrectAnswers['words'])) {
6974
                        $nbrOptions = count($listCorrectAnswers['words']);
6975
                    }
6976
                }
6977
                break;
6978
6979
            case HOT_SPOT_COMBINATION:
6980
                if (!empty($listCorrectAnswers) && is_array($listCorrectAnswers) && is_array($choice)) {
6981
                    foreach ($listCorrectAnswers as $idx => $val) {
6982
                        if (isset($choice[$idx]) && (int) $choice[$idx] === 1) {
6983
                            $nbrCorrect++;
6984
                        }
6985
                    }
6986
                } else {
6987
                    $TBL_TRACK_HOTSPOT = Database::get_main_table(TABLE_STATISTIC_TRACK_E_HOTSPOT);
6988
                    $exeIdEsc = Database::escape_string($exeId);
6989
                    $qIdEsc   = Database::escape_string($questionId);
6990
                    $sql = "SELECT COUNT(hotspot_id) AS ct
6991
                        FROM $TBL_TRACK_HOTSPOT
6992
                        WHERE hotspot_exe_id = '$exeIdEsc'
6993
                          AND hotspot_question_id = '$qIdEsc'
6994
                          AND hotspot_correct = 1";
6995
                    $result = Database::query($sql);
6996
                    $nbrCorrect = (int) Database::result($result, 0, 0);
6997
                }
6998
                $nbrOptions = (int) $nbrAnswers;
6999
                break;
7000
7001
            case MATCHING_COMBINATION:
7002
            case MATCHING_DRAGGABLE_COMBINATION:
7003
                if (isset($listCorrectAnswers['form_values'])) {
7004
                    if (isset($listCorrectAnswers['form_values']['correct'])) {
7005
                        $nbrCorrect = count($listCorrectAnswers['form_values']['correct']);
7006
                        $nbrOptions = (int) $listCorrectAnswers['form_values']['count_options'];
7007
                    }
7008
                } else {
7009
                    if (isset($listCorrectAnswers['from_database'])) {
7010
                        if (isset($listCorrectAnswers['from_database']['correct'])) {
7011
                            $nbrCorrect = count($listCorrectAnswers['from_database']['correct']);
7012
                            $nbrOptions = (int) $listCorrectAnswers['from_database']['count_options'];
7013
                        }
7014
                    }
7015
                }
7016
                break;
7017
        }
7018
7019
        $questionScore = 0.0;
7020
        if ($nbrOptions > 0 && $nbrCorrect === $nbrOptions) {
7021
            $questionScore = (float) $questionWeighting;
7022
        }
7023
7024
        return $questionScore;
7025
    }
7026
7027
    /**
7028
     * Build a public URL for a ResourceNode file used in exercises.
7029
     * Returns an empty string when the node is null or when the underlying
7030
     * file/route cannot be resolved (we do not want to break the exercise view).
7031
     *
7032
     * @param ResourceNode|null $resourceNode
7033
     * @param array                                        $extraParams
7034
     *
7035
     * @return string
7036
     */
7037
    public static function getPublicUrlForResourceNode(?ResourceNode $resourceNode): string
7038
    {
7039
        if (null === $resourceNode) {
7040
            return '';
7041
        }
7042
7043
        try {
7044
            /** @var ResourceNodeRepository $resourceNodeRepo */
7045
            $resourceNodeRepo = Container::getResourceNodeRepository();
7046
            $resourceType = $resourceNode->getResourceType();
7047
            $tool         = $resourceType?->getTool();
7048
            $url = $resourceNodeRepo->getResourceFileUrl($resourceNode);
7049
7050
            return $url;
7051
        } catch (Throwable $e) {
7052
            error_log(sprintf(
7053
                '[ORAL_FILE_AUDIO][node=%s] Exception in getPublicUrlForResourceNode(): %s (%s) at %s:%d',
7054
                $resourceNode?->getId() ?? 'null',
7055
                $e->getMessage(),
7056
                get_class($e),
7057
                $e->getFile(),
7058
                $e->getLine()
7059
            ));
7060
7061
            return '';
7062
        }
7063
    }
7064
7065
    /**
7066
     * Normalize the attempt question list:
7067
     * - Media questions are containers and must NOT be counted as real questions.
7068
     * - When random questions are enabled ($objExercise->random > 0),
7069
     *   ensure we have exactly N answerable questions by topping up from the exercise pool.
7070
     *
7071
     * @param Exercise $objExercise
7072
     * @param int[]    $ids
7073
     *
7074
     * @return int[]
7075
     */
7076
    public static function normalizeAttemptQuestionList(Exercise $objExercise, array $ids): array
7077
    {
7078
        $ids = array_values(array_unique(array_map('intval', $ids)));
7079
7080
        $randomCount = isset($objExercise->random) ? (int) $objExercise->random : 0;
7081
7082
        // Remove media questions from the list (and page breaks only in random mode).
7083
        $normalized = [];
7084
        foreach ($ids as $qid) {
7085
            $q = Question::read((int) $qid);
7086
            if (!$q) {
7087
                continue;
7088
            }
7089
7090
            // Media questions are not answerable.
7091
            if ((int) $q->type === MEDIA_QUESTION) {
7092
                continue;
7093
            }
7094
7095
            // Random selection applies to answerable questions, not structural breaks.
7096
            if ($randomCount > 0 && (int) $q->type === PAGE_BREAK) {
7097
                continue;
7098
            }
7099
7100
            $normalized[] = (int) $qid;
7101
        }
7102
7103
        if ($randomCount <= 0) {
7104
            return $normalized;
7105
        }
7106
7107
        // Trim if we somehow have too many.
7108
        if (count($normalized) > $randomCount) {
7109
            return array_slice($normalized, 0, $randomCount);
7110
        }
7111
7112
        // Top up if we have too few after removing media questions.
7113
        if (count($normalized) < $randomCount) {
7114
            $pool = [];
7115
            foreach ((array) $objExercise->getQuestionOrderedList() as $qid) {
7116
                $qid = (int) $qid;
7117
                $q = Question::read($qid);
7118
                if (!$q) {
7119
                    continue;
7120
                }
7121
7122
                if ((int) $q->type === MEDIA_QUESTION || (int) $q->type === PAGE_BREAK) {
7123
                    continue;
7124
                }
7125
7126
                $pool[] = $qid;
7127
            }
7128
7129
            // Remove already selected.
7130
            $pool = array_values(array_diff($pool, $normalized));
7131
7132
            if (!empty($pool)) {
7133
                shuffle($pool);
7134
            }
7135
7136
            $needed = $randomCount - count($normalized);
7137
            if ($needed > 0) {
7138
                $normalized = array_merge($normalized, array_slice($pool, 0, $needed));
7139
            }
7140
        }
7141
7142
        return $normalized;
7143
    }
7144
7145
    /**
7146
     * Persist corrected data_tracking back to DB so the attempt stays stable.
7147
     *
7148
     * @param int   $exeId
7149
     * @param int[] $questionList
7150
     * @param array $exerciseStatInfo
7151
     */
7152
    public static function updateAttemptDataTrackingIfNeeded(int $exeId, array $questionList, array &$exerciseStatInfo): void
7153
    {
7154
        if ($exeId <= 0) {
7155
            return;
7156
        }
7157
7158
        $newTracking = implode(',', array_map('intval', $questionList));
7159
        $oldTracking = isset($exerciseStatInfo['data_tracking']) ? (string) $exerciseStatInfo['data_tracking'] : '';
7160
7161
        if ($newTracking === $oldTracking) {
7162
            return;
7163
        }
7164
7165
        $table = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
7166
        $sql = "UPDATE $table
7167
            SET data_tracking = '".Database::escape_string($newTracking)."'
7168
            WHERE exe_id = ".(int) $exeId;
7169
        Database::query($sql);
7170
7171
        $exerciseStatInfo['data_tracking'] = $newTracking;
7172
    }
7173
}
7174