ExerciseLib::exerciseResultsInRanking()   B
last analyzed

Complexity

Conditions 11

Size

Total Lines 62
Code Lines 36

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 11
eloc 36
nop 3
dl 0
loc 62
rs 7.3166
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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_configuration_value('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
                            $attributes['class'] = 'checkradios';
739
                            $answer_input = '<label class="checkbox">';
740
                            $answer_input .= Display::input(
741
                                'checkbox',
742
                                'choice['.$questionId.']['.$numAnswer.']',
743
                                $numAnswer,
744
                                $attributes
745
                            );
746
                            $answer_input .= $answer;
747
                            $answer_input .= '</label>';
748
749
                            if ($show_comment) {
750
                                $s .= '<tr><td>';
751
                                $s .= $answer_input;
752
                                $s .= '</td>';
753
                                $s .= '<td>';
754
                                $s .= $comment;
755
                                $s .= '</td>';
756
                                $s .= '</tr>';
757
                            } else {
758
                                $s .= $answer_input;
759
                            }
760
                        } elseif (MULTIPLE_ANSWER_TRUE_FALSE == $answerType) {
761
                            $myChoice = [];
762
                            if (!empty($userChoiceList)) {
763
                                foreach ($userChoiceList as $item) {
764
                                    $item = explode(':', $item);
765
                                    if (!empty($item)) {
766
                                        $myChoice[$item[0]] = isset($item[1]) ? $item[1] : '';
767
                                    }
768
                                }
769
                            }
770
771
                            $s .= '<tr>';
772
                            $s .= Display::tag('td', $answer);
773
774
                            if (!empty($quizQuestionOptions)) {
775
                                $j = 1;
776
                                foreach ($quizQuestionOptions as $id => $item) {
777
                                    if (isset($myChoice[$numAnswer]) && $item['iid'] == $myChoice[$numAnswer]) {
778
                                        $attributes = [
779
                                            'checked' => 1,
780
                                            'selected' => 1,
781
                                        ];
782
                                    } else {
783
                                        $attributes = [];
784
                                    }
785
786
                                    if ($debug_mark_answer) {
787
                                        if ($j == $answerCorrect) {
788
                                            $attributes['checked'] = 1;
789
                                            $attributes['selected'] = 1;
790
                                        }
791
                                    }
792
                                    $s .= Display::tag(
793
                                        'td',
794
                                        Display::input(
795
                                            'radio',
796
                                            'choice['.$questionId.']['.$numAnswer.']',
797
                                            $item['iid'],
798
                                            $attributes
799
                                        ),
800
                                        ['style' => '']
801
                                    );
802
                                    $j++;
803
                                }
804
                            }
805
806
                            if ($show_comment) {
807
                                $s .= '<td>';
808
                                $s .= $comment;
809
                                $s .= '</td>';
810
                            }
811
                            $s .= '</tr>';
812
                        } elseif (MULTIPLE_ANSWER_TRUE_FALSE_DEGREE_CERTAINTY == $answerType) {
813
                            $myChoice = [];
814
                            if (!empty($userChoiceList)) {
815
                                foreach ($userChoiceList as $item) {
816
                                    $item = explode(':', $item);
817
                                    $myChoice[$item[0]] = $item[1];
818
                                }
819
                            }
820
                            $myChoiceDegreeCertainty = [];
821
                            if (!empty($userChoiceList)) {
822
                                foreach ($userChoiceList as $item) {
823
                                    $item = explode(':', $item);
824
                                    $myChoiceDegreeCertainty[$item[0]] = $item[2];
825
                                }
826
                            }
827
                            $s .= '<tr>';
828
                            $s .= Display::tag('td', $answer);
829
830
                            if (!empty($quizQuestionOptions)) {
831
                                $j = 1;
832
                                foreach ($quizQuestionOptions as $id => $item) {
833
                                    if (isset($myChoice[$numAnswer]) && $id == $myChoice[$numAnswer]) {
834
                                        $attributes = ['checked' => 1, 'selected' => 1];
835
                                    } else {
836
                                        $attributes = [];
837
                                    }
838
                                    $attributes['onChange'] = 'RadioValidator('.$questionId.', '.$numAnswer.')';
839
840
                                    // radio button selection
841
                                    if (isset($myChoiceDegreeCertainty[$numAnswer]) &&
842
                                        $id == $myChoiceDegreeCertainty[$numAnswer]
843
                                    ) {
844
                                        $attributes1 = ['checked' => 1, 'selected' => 1];
845
                                    } else {
846
                                        $attributes1 = [];
847
                                    }
848
849
                                    $attributes1['onChange'] = 'RadioValidator('.$questionId.', '.$numAnswer.')';
850
851
                                    if ($debug_mark_answer) {
852
                                        if ($j == $answerCorrect) {
853
                                            $attributes['checked'] = 1;
854
                                            $attributes['selected'] = 1;
855
                                        }
856
                                    }
857
858
                                    if ('True' == $item['name'] || 'False' == $item['name']) {
859
                                        $s .= Display::tag('td',
860
                                            Display::input('radio',
861
                                                'choice['.$questionId.']['.$numAnswer.']',
862
                                                $id,
863
                                                $attributes
864
                                            ),
865
                                            ['style' => 'text-align:center; background-color:#F7E1D7;',
866
                                                'onclick' => 'handleRadioRow(event, '.
867
                                                    $questionId.', '.
868
                                                    $numAnswer.')',
869
                                            ]
870
                                        );
871
                                    } else {
872
                                        $s .= Display::tag('td',
873
                                            Display::input('radio',
874
                                                'choiceDegreeCertainty['.$questionId.']['.$numAnswer.']',
875
                                                $id,
876
                                                $attributes1
877
                                            ),
878
                                            ['style' => 'text-align:center; background-color:#EFEFFC;',
879
                                                'onclick' => 'handleRadioRow(event, '.
880
                                                    $questionId.', '.
881
                                                    $numAnswer.')',
882
                                            ]
883
                                        );
884
                                    }
885
                                    $j++;
886
                                }
887
                            }
888
889
                            if ($show_comment) {
890
                                $s .= '<td>';
891
                                $s .= $comment;
892
                                $s .= '</td>';
893
                            }
894
                            $s .= '</tr>';
895
                        }
896
                        break;
897
                    case MULTIPLE_ANSWER_COMBINATION:
898
                        // multiple answers
899
                        $input_id = 'choice-'.$questionId.'-'.$answerId;
900
901
                        if (in_array($numAnswer, $userChoiceList)) {
902
                            $attributes = [
903
                                'id' => $input_id,
904
                                'checked' => 1,
905
                                'selected' => 1,
906
                            ];
907
                        } else {
908
                            $attributes = ['id' => $input_id];
909
                        }
910
911
                        if ($debug_mark_answer) {
912
                            if ($answerCorrect) {
913
                                $attributes['checked'] = 1;
914
                                $attributes['selected'] = 1;
915
                            }
916
                        }
917
918
                        $userStatus = STUDENT;
919
                        // Allows to do a remove_XSS in question of exersice with user status COURSEMANAGER
920
                        // see BT#18242
921
                        if (api_get_configuration_value('question_exercise_html_strict_filtering')) {
922
                            $userStatus = COURSEMANAGERLOWSECURITY;
923
                        }
924
                        $answer = Security::remove_XSS($answer, $userStatus);
925
                        $answer_input = '<input type="hidden" name="choice2['.$questionId.']" value="0" />';
926
                        $answer_input .= '<label class="checkbox">';
927
                        $answer_input .= Display::input(
928
                            'checkbox',
929
                            'choice['.$questionId.']['.$numAnswer.']',
930
                            1,
931
                            $attributes
932
                        );
933
                        $answer_input .= $answer;
934
                        $answer_input .= '</label>';
935
936
                        if ($show_comment) {
937
                            $s .= '<tr>';
938
                            $s .= '<td>';
939
                            $s .= $answer_input;
940
                            $s .= '</td>';
941
                            $s .= '<td>';
942
                            $s .= $comment;
943
                            $s .= '</td>';
944
                            $s .= '</tr>';
945
                        } else {
946
                            $s .= $answer_input;
947
                        }
948
                        break;
949
                    case MULTIPLE_ANSWER_COMBINATION_TRUE_FALSE:
950
                        $s .= '<input type="hidden" name="choice2['.$questionId.']" value="0" />';
951
                        $myChoice = [];
952
                        if (!empty($userChoiceList)) {
953
                            foreach ($userChoiceList as $item) {
954
                                $item = explode(':', $item);
955
                                if (isset($item[1]) && isset($item[0])) {
956
                                    $myChoice[$item[0]] = $item[1];
957
                                }
958
                            }
959
                        }
960
                        $userStatus = STUDENT;
961
                        // Allows to do a remove_XSS in question of exersice with user status COURSEMANAGER
962
                        // see BT#18242
963
                        if (api_get_configuration_value('question_exercise_html_strict_filtering')) {
964
                            $userStatus = COURSEMANAGERLOWSECURITY;
965
                        }
966
                        $answer = Security::remove_XSS($answer, $userStatus);
967
                        $s .= '<tr>';
968
                        $s .= Display::tag('td', $answer);
969
                        foreach ($objQuestionTmp->options as $key => $item) {
970
                            if (isset($myChoice[$numAnswer]) && $key == $myChoice[$numAnswer]) {
971
                                $attributes = [
972
                                    'checked' => 1,
973
                                    'selected' => 1,
974
                                ];
975
                            } else {
976
                                $attributes = [];
977
                            }
978
979
                            if ($debug_mark_answer) {
980
                                if ($key == $answerCorrect) {
981
                                    $attributes['checked'] = 1;
982
                                    $attributes['selected'] = 1;
983
                                }
984
                            }
985
                            $s .= Display::tag(
986
                                'td',
987
                                Display::input(
988
                                    'radio',
989
                                    'choice['.$questionId.']['.$numAnswer.']',
990
                                    $key,
991
                                    $attributes
992
                                )
993
                            );
994
                        }
995
996
                        if ($show_comment) {
997
                            $s .= '<td>';
998
                            $s .= $comment;
999
                            $s .= '</td>';
1000
                        }
1001
                        $s .= '</tr>';
1002
                        break;
1003
                    case FILL_IN_BLANKS:
1004
                    case FILL_IN_BLANKS_COMBINATION:
1005
                        // display the question, with field empty, for student to fill it,
1006
                        // or filled to display the answer in the Question preview of the exercise/admin.php page
1007
                        $displayForStudent = true;
1008
                        $listAnswerInfo = FillBlanks::getAnswerInfo($answer);
1009
                        // Correct answers
1010
                        $correctAnswerList = $listAnswerInfo['words'];
1011
                        // Student's answer
1012
                        $studentAnswerList = [];
1013
                        if (isset($user_choice[0]['answer'])) {
1014
                            $arrayStudentAnswer = FillBlanks::getAnswerInfo(
1015
                                $user_choice[0]['answer'],
1016
                                true
1017
                            );
1018
                            $studentAnswerList = $arrayStudentAnswer['student_answer'];
1019
                        }
1020
1021
                        // If the question must be shown with the answer (in page exercise/admin.php)
1022
                        // for teacher preview set the student-answer to the correct answer
1023
                        if ($debug_mark_answer) {
1024
                            $studentAnswerList = $correctAnswerList;
1025
                            $displayForStudent = false;
1026
                        }
1027
1028
                        if (!empty($correctAnswerList) && !empty($studentAnswerList)) {
1029
                            $answer = '';
1030
                            for ($i = 0; $i < count($listAnswerInfo['common_words']) - 1; $i++) {
1031
                                // display the common word
1032
                                $answer .= $listAnswerInfo['common_words'][$i];
1033
                                // display the blank word
1034
                                $correctItem = $listAnswerInfo['words'][$i];
1035
                                if (isset($studentAnswerList[$i])) {
1036
                                    // If student already started this test and answered this question,
1037
                                    // fill the blank with his previous answers
1038
                                    // may be "" if student viewed the question, but did not fill the blanks
1039
                                    $correctItem = $studentAnswerList[$i];
1040
                                }
1041
                                $attributes['style'] = 'width:'.$listAnswerInfo['input_size'][$i].'px';
1042
                                $answer .= FillBlanks::getFillTheBlankHtml(
1043
                                    $current_item,
1044
                                    $questionId,
1045
                                    $correctItem,
1046
                                    $attributes,
1047
                                    $answer,
1048
                                    $listAnswerInfo,
1049
                                    $displayForStudent,
1050
                                    $i
1051
                                );
1052
                            }
1053
                            // display the last common word
1054
                            $answer .= $listAnswerInfo['common_words'][$i];
1055
                        } else {
1056
                            // display empty [input] with the right width for student to fill it
1057
                            $answer = '';
1058
                            for ($i = 0; $i < count($listAnswerInfo['common_words']) - 1; $i++) {
1059
                                // display the common words
1060
                                $answer .= $listAnswerInfo['common_words'][$i];
1061
                                // display the blank word
1062
                                $attributes['style'] = 'width:'.$listAnswerInfo['input_size'][$i].'px';
1063
                                $answer .= FillBlanks::getFillTheBlankHtml(
1064
                                    $current_item,
1065
                                    $questionId,
1066
                                    '',
1067
                                    $attributes,
1068
                                    $answer,
1069
                                    $listAnswerInfo,
1070
                                    $displayForStudent,
1071
                                    $i
1072
                                );
1073
                            }
1074
                            // display the last common word
1075
                            $answer .= $listAnswerInfo['common_words'][$i];
1076
                        }
1077
                        $s .= $answer;
1078
                        break;
1079
                    case CALCULATED_ANSWER:
1080
                        /*
1081
                         * In the CALCULATED_ANSWER test
1082
                         * you mustn't have [ and ] in the textarea
1083
                         * you mustn't have @@ in the textarea
1084
                         * the text to find mustn't be empty or contains only spaces
1085
                         * the text to find mustn't contains HTML tags
1086
                         * the text to find mustn't contains char "
1087
                         */
1088
                        if (null !== $origin) {
1089
                            global $exe_id;
1090
                            $exe_id = (int) $exe_id;
1091
                            $trackAttempts = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT);
1092
                            $sql = "SELECT answer FROM $trackAttempts
1093
                                    WHERE exe_id = $exe_id AND question_id= $questionId";
1094
                            $rsLastAttempt = Database::query($sql);
1095
                            $rowLastAttempt = Database::fetch_array($rsLastAttempt);
1096
1097
                            $answer = null;
1098
                            if (isset($rowLastAttempt['answer'])) {
1099
                                $answer = $rowLastAttempt['answer'];
1100
                            }
1101
1102
                            if (empty($answer)) {
1103
                                $_SESSION['calculatedAnswerId'][$questionId] = mt_rand(
1104
                                    1,
1105
                                    $nbrAnswers
1106
                                );
1107
                                $answer = $objAnswerTmp->selectAnswer(
1108
                                    $_SESSION['calculatedAnswerId'][$questionId]
1109
                                );
1110
                            }
1111
                        }
1112
1113
                        [$answer] = explode('@@', $answer);
1114
                        // $correctAnswerList array of array with correct anwsers 0=> [0=>[\p] 1=>[plop]]
1115
                        api_preg_match_all(
1116
                            '/\[[^]]+\]/',
1117
                            $answer,
1118
                            $correctAnswerList
1119
                        );
1120
1121
                        // get student answer to display it if student go back
1122
                        // to previous calculated answer question in a test
1123
                        if (isset($user_choice[0]['answer'])) {
1124
                            api_preg_match_all(
1125
                                '/\[[^]]+\]/',
1126
                                $answer,
1127
                                $studentAnswerList
1128
                            );
1129
                            $studentAnswerListToClean = $studentAnswerList[0];
1130
                            $studentAnswerList = [];
1131
1132
                            $maxStudents = count($studentAnswerListToClean);
1133
                            for ($i = 0; $i < $maxStudents; $i++) {
1134
                                $answerCorrected = $studentAnswerListToClean[$i];
1135
                                $answerCorrected = api_preg_replace(
1136
                                    '| / <font color="green"><b>.*$|',
1137
                                    '',
1138
                                    $answerCorrected
1139
                                );
1140
                                $answerCorrected = api_preg_replace(
1141
                                    '/^\[/',
1142
                                    '',
1143
                                    $answerCorrected
1144
                                );
1145
                                $answerCorrected = api_preg_replace(
1146
                                    '|^<font color="red"><s>|',
1147
                                    '',
1148
                                    $answerCorrected
1149
                                );
1150
                                $answerCorrected = api_preg_replace(
1151
                                    '|</s></font>$|',
1152
                                    '',
1153
                                    $answerCorrected
1154
                                );
1155
                                $answerCorrected = '['.$answerCorrected.']';
1156
                                $studentAnswerList[] = $answerCorrected;
1157
                            }
1158
                        }
1159
1160
                        // If display preview of answer in test view for exemple,
1161
                        // set the student answer to the correct answers
1162
                        if ($debug_mark_answer) {
1163
                            // contain the rights answers surronded with brackets
1164
                            $studentAnswerList = $correctAnswerList[0];
1165
                        }
1166
1167
                        /*
1168
                        Split the response by bracket
1169
                        tabComments is an array with text surrounding the text to find
1170
                        we add a space before and after the answerQuestion to be sure to
1171
                        have a block of text before and after [xxx] patterns
1172
                        so we have n text to find ([xxx]) and n+1 block of texts before,
1173
                        between and after the text to find
1174
                        */
1175
                        $tabComments = api_preg_split(
1176
                            '/\[[^]]+\]/',
1177
                            ' '.$answer.' '
1178
                        );
1179
                        if (!empty($correctAnswerList) && !empty($studentAnswerList)) {
1180
                            $answer = '';
1181
                            $i = 0;
1182
                            foreach ($studentAnswerList as $studentItem) {
1183
                                // Remove surronding brackets
1184
                                $studentResponse = api_substr(
1185
                                    $studentItem,
1186
                                    1,
1187
                                    api_strlen($studentItem) - 2
1188
                                );
1189
                                $size = strlen($studentItem);
1190
                                $attributes['class'] = self::detectInputAppropriateClass($size);
1191
                                $answer .= $tabComments[$i].
1192
                                    Display::input(
1193
                                        'text',
1194
                                        "choice[$questionId][]",
1195
                                        $studentResponse,
1196
                                        $attributes
1197
                                    );
1198
                                $i++;
1199
                            }
1200
                            $answer .= $tabComments[$i];
1201
                        } else {
1202
                            // display exercise with empty input fields
1203
                            // every [xxx] are replaced with an empty input field
1204
                            foreach ($correctAnswerList[0] as $item) {
1205
                                $size = strlen($item);
1206
                                $attributes['class'] = self::detectInputAppropriateClass($size);
1207
                                if (EXERCISE_FEEDBACK_TYPE_POPUP == $exercise->getFeedbackType()) {
1208
                                    $attributes['id'] = "question_$questionId";
1209
                                    $attributes['class'] .= ' checkCalculatedQuestionOnEnter ';
1210
                                }
1211
1212
                                $answer = str_replace(
1213
                                    $item,
1214
                                    Display::input(
1215
                                        'text',
1216
                                        "choice[$questionId][]",
1217
                                        '',
1218
                                        $attributes
1219
                                    ),
1220
                                    $answer
1221
                                );
1222
                            }
1223
                        }
1224
                        if (null !== $origin) {
1225
                            $s = $answer;
1226
                            break;
1227
                        } else {
1228
                            $s .= $answer;
1229
                        }
1230
                        break;
1231
                    case MATCHING:
1232
                    case MATCHING_COMBINATION:
1233
                        // matching type, showing suggestions and answers
1234
                        // TODO: replace $answerId by $numAnswer
1235
                        if (0 != $answerCorrect) {
1236
                            // only show elements to be answered (not the contents of
1237
                            // the select boxes, who are correct = 0)
1238
                            $s .= '<tr><td width="45%" valign="top">';
1239
                            $parsed_answer = $answer;
1240
                            // Left part questions
1241
                            $s .= '<p class="indent">'.$lines_count.'.&nbsp;'.$parsed_answer.'</p></td>';
1242
                            // Middle part (matches selects)
1243
                            // Id of select is # question + # of option
1244
                            $s .= '<td width="10%" valign="top" align="center">
1245
                                <div class="select-matching">
1246
                                <select
1247
                                    class="form-control"
1248
                                    id="choice_id_'.$current_item.'_'.$lines_count.'"
1249
                                    name="choice['.$questionId.']['.$numAnswer.']">';
1250
1251
                            // fills the list-box
1252
                            foreach ($select_items as $key => $val) {
1253
                                // set $debug_mark_answer to true at function start to
1254
                                // show the correct answer with a suffix '-x'
1255
                                $selected = '';
1256
                                if ($debug_mark_answer) {
1257
                                    if ($val['id'] == $answerCorrect) {
1258
                                        $selected = 'selected="selected"';
1259
                                    }
1260
                                }
1261
                                //$user_choice_array_position
1262
                                if (isset($user_choice_array_position[$numAnswer]) &&
1263
                                    $val['id'] == $user_choice_array_position[$numAnswer]
1264
                                ) {
1265
                                    $selected = 'selected="selected"';
1266
                                }
1267
                                $s .= '<option value="'.$val['id'].'" '.$selected.'>'.$val['letter'].'</option>';
1268
                            }
1269
1270
                            $s .= '</select></div></td><td width="5%" class="separate">&nbsp;</td>';
1271
                            $s .= '<td width="40%" valign="top" >';
1272
                            if (isset($select_items[$lines_count])) {
1273
                                $s .= '<div class="text-right">
1274
                                        <p class="indent">'.
1275
                                    $select_items[$lines_count]['letter'].'.&nbsp; '.
1276
                                    $select_items[$lines_count]['answer'].'
1277
                                        </p>
1278
                                        </div>';
1279
                            } else {
1280
                                $s .= '&nbsp;';
1281
                            }
1282
                            $s .= '</td>';
1283
                            $s .= '</tr>';
1284
                            $lines_count++;
1285
                            // If the left side of the "matching" has been completely
1286
                            // shown but the right side still has values to show...
1287
                            if (($lines_count - 1) == $num_suggestions) {
1288
                                // if it remains answers to shown at the right side
1289
                                while (isset($select_items[$lines_count])) {
1290
                                    $s .= '<tr>
1291
                                      <td colspan="2"></td>
1292
                                      <td valign="top">';
1293
                                    $s .= '<b>'.$select_items[$lines_count]['letter'].'.</b> '.
1294
                                        $select_items[$lines_count]['answer'];
1295
                                    $s .= "</td>
1296
                                </tr>";
1297
                                    $lines_count++;
1298
                                }
1299
                            }
1300
                            $matching_correct_answer++;
1301
                        }
1302
                        break;
1303
                    case DRAGGABLE:
1304
                        if ($answerCorrect) {
1305
                            $windowId = $questionId.'_'.$lines_count;
1306
                            $s .= '<li class="touch-items" id="'.$windowId.'">';
1307
                            $s .= Display::div(
1308
                                $answer,
1309
                                [
1310
                                    'id' => "window_$windowId",
1311
                                    'class' => "window{$questionId}_question_draggable exercise-draggable-answer-option",
1312
                                ]
1313
                            );
1314
1315
                            $draggableSelectOptions = [];
1316
                            $selectedValue = 0;
1317
                            $selectedIndex = 0;
1318
                            if ($user_choice) {
1319
                                foreach ($user_choice as $userChoiceKey => $chosen) {
1320
                                    $userChoiceKey++;
1321
                                    if ($lines_count != $userChoiceKey) {
1322
                                        continue;
1323
                                    }
1324
                                    /*if ($answerCorrect != $chosen['answer']) {
1325
                                        continue;
1326
                                    }*/
1327
                                    $selectedValue = $chosen['answer'];
1328
                                }
1329
                            }
1330
                            foreach ($select_items as $key => $select_item) {
1331
                                $draggableSelectOptions[$select_item['id']] = $select_item['letter'];
1332
                            }
1333
1334
                            foreach ($draggableSelectOptions as $value => $text) {
1335
                                if ($value == $selectedValue) {
1336
                                    break;
1337
                                }
1338
                                $selectedIndex++;
1339
                            }
1340
1341
                            $s .= Display::select(
1342
                                "choice[$questionId][$numAnswer]",
1343
                                $draggableSelectOptions,
1344
                                $selectedValue,
1345
                                [
1346
                                    'id' => "window_{$windowId}_select",
1347
                                    'class' => 'select_option hidden',
1348
                                ],
1349
                                false
1350
                            );
1351
1352
                            if ($selectedValue && $selectedIndex) {
1353
                                $s .= "
1354
                                    <script>
1355
                                        $(function() {
1356
                                            DraggableAnswer.deleteItem(
1357
                                                $('#{$questionId}_$lines_count'),
1358
                                                $('#drop_{$questionId}_{$selectedIndex}')
1359
                                            );
1360
                                        });
1361
                                    </script>
1362
                                ";
1363
                            }
1364
1365
                            if (isset($select_items[$lines_count])) {
1366
                                $s .= Display::div(
1367
                                    Display::tag(
1368
                                        'b',
1369
                                        $select_items[$lines_count]['letter']
1370
                                    ).$select_items[$lines_count]['answer'],
1371
                                    [
1372
                                        'id' => "window_{$windowId}_answer",
1373
                                        'class' => 'hidden',
1374
                                    ]
1375
                                );
1376
                            } else {
1377
                                $s .= '&nbsp;';
1378
                            }
1379
1380
                            $lines_count++;
1381
                            if (($lines_count - 1) == $num_suggestions) {
1382
                                while (isset($select_items[$lines_count])) {
1383
                                    $s .= Display::tag('b', $select_items[$lines_count]['letter']);
1384
                                    $s .= $select_items[$lines_count]['answer'];
1385
                                    $lines_count++;
1386
                                }
1387
                            }
1388
1389
                            $matching_correct_answer++;
1390
                            $s .= '</li>';
1391
                        }
1392
                        break;
1393
                    case MATCHING_DRAGGABLE:
1394
                    case MATCHING_DRAGGABLE_COMBINATION:
1395
                        if (1 == $answerId) {
1396
                            echo $objAnswerTmp->getJs();
1397
                        }
1398
                        if (0 != $answerCorrect) {
1399
                            $windowId = "{$questionId}_{$lines_count}";
1400
                            $s .= <<<HTML
1401
                            <tr>
1402
                                <td width="45%">
1403
                                    <div id="window_{$windowId}"
1404
                                        class="window window_left_question window{$questionId}_question">
1405
                                        <strong>$lines_count.</strong>
1406
                                        $answer
1407
                                    </div>
1408
                                </td>
1409
                                <td width="10%">
1410
HTML;
1411
1412
                            $draggableSelectOptions = [];
1413
                            $selectedValue = 0;
1414
                            $selectedIndex = 0;
1415
1416
                            if ($user_choice) {
1417
                                foreach ($user_choice as $chosen) {
1418
                                    if ($numAnswer == $chosen['position']) {
1419
                                        $selectedValue = $chosen['answer'];
1420
                                        break;
1421
                                    }
1422
                                }
1423
                            }
1424
1425
                            foreach ($select_items as $key => $selectItem) {
1426
                                $draggableSelectOptions[$selectItem['id']] = $selectItem['letter'];
1427
                            }
1428
1429
                            foreach ($draggableSelectOptions as $value => $text) {
1430
                                if ($value == $selectedValue) {
1431
                                    break;
1432
                                }
1433
                                $selectedIndex++;
1434
                            }
1435
1436
                            $s .= Display::select(
1437
                                "choice[$questionId][$numAnswer]",
1438
                                $draggableSelectOptions,
1439
                                $selectedValue,
1440
                                [
1441
                                    'id' => "window_{$windowId}_select",
1442
                                    'class' => 'hidden',
1443
                                ],
1444
                                false
1445
                            );
1446
1447
                            if (!empty($answerCorrect) && !empty($selectedValue)) {
1448
                                // Show connect if is not freeze (question preview)
1449
                                if (!$freeze) {
1450
                                    $s .= "
1451
                                        <script>
1452
                                            $(function() {
1453
                                                MatchingDraggable.instances['$questionId'].connect({
1454
                                                    source: 'window_$windowId',
1455
                                                    target: 'window_{$questionId}_{$selectedIndex}_answer',
1456
                                                    endpoint: ['Dot', {radius: 12}],
1457
                                                    anchors: ['RightMiddle', 'LeftMiddle'],
1458
                                                    paintStyle: {stroke: '#8A8888', strokeWidth: 8},
1459
                                                    connector: [
1460
                                                        MatchingDraggable.connectorType,
1461
                                                        {curvines: MatchingDraggable.curviness}
1462
                                                    ]
1463
                                                });
1464
                                            });
1465
                                        </script>
1466
                                    ";
1467
                                }
1468
                            }
1469
1470
                            $s .= '</td><td width="45%">';
1471
                            if (isset($select_items[$lines_count])) {
1472
                                $s .= <<<HTML
1473
                                <div id="window_{$windowId}_answer" class="window window_right_question">
1474
                                    <strong>{$select_items[$lines_count]['letter']}.</strong>
1475
                                    {$select_items[$lines_count]['answer']}
1476
                                </div>
1477
HTML;
1478
                            } else {
1479
                                $s .= '&nbsp;';
1480
                            }
1481
1482
                            $s .= '</td></tr>';
1483
                            $lines_count++;
1484
                            if (($lines_count - 1) == $num_suggestions) {
1485
                                while (isset($select_items[$lines_count])) {
1486
                                    $s .= <<<HTML
1487
                                    <tr>
1488
                                        <td colspan="2"></td>
1489
                                        <td>
1490
                                            <strong>{$select_items[$lines_count]['letter']}</strong>
1491
                                            {$select_items[$lines_count]['answer']}
1492
                                        </td>
1493
                                    </tr>
1494
HTML;
1495
                                    $lines_count++;
1496
                                }
1497
                            }
1498
                            $matching_correct_answer++;
1499
                        }
1500
                        break;
1501
                    case MULTIPLE_ANSWER_DROPDOWN:
1502
                    case MULTIPLE_ANSWER_DROPDOWN_COMBINATION:
1503
                        if ($debug_mark_answer && $answerCorrect) {
1504
                            $s .= '<p>'
1505
                                .(
1506
                                    MULTIPLE_ANSWER_DROPDOWN == $answerType
1507
                                        ? '<span class="pull-right">'.$objAnswerTmp->weighting[$answerId].'</span>'
1508
                                        : ''
1509
                                )
1510
                                .Display::returnFontAwesomeIcon('check-square-o', '', true);
1511
                            $s .= Security::remove_XSS($objAnswerTmp->answer[$answerId]).'</p>';
1512
                        }
1513
                        break;
1514
                }
1515
            }
1516
1517
            if (in_array($answerType, [MULTIPLE_ANSWER_DROPDOWN, MULTIPLE_ANSWER_DROPDOWN_COMBINATION]) && !$debug_mark_answer) {
1518
                $userChoiceList = array_unique($userChoiceList);
1519
                $input_id = "choice-$questionId";
1520
                $clear_id = "clear-$questionId";
1521
1522
                $s .= Display::input('hidden', "choice2[$questionId]", '0')
1523
                    .'<div class="mb-4">'
1524
                    .'<div class="flex items-center justify-between mb-2">'
1525
                    .'<label for="'.$input_id.'" class="text-sm font-medium text-gray-90">'
1526
                    .get_lang('Please select an option')
1527
                    .'</label>'
1528
                    .'<button type="button" id="'.$clear_id.'" '
1529
                    .'class="inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium '
1530
                    .'bg-primary text-white hover:opacity-90 border border-primary">'
1531
                    .'<span class="fa fa-times" aria-hidden="true"></span>'
1532
                    .'<span>'.get_lang('Clear').'</span>'
1533
                    .'</button>'
1534
                    .'</div>'
1535
                    .Display::select(
1536
                        "choice[$questionId][]",
1537
                        $selectableOptions,
1538
                        $userChoiceList,
1539
                        [
1540
                            'id'       => $input_id,
1541
                            'multiple' => 'multiple',
1542
                            'class'    => 'w-full', // full width before Select2 mounts
1543
                        ],
1544
                        false
1545
                    )
1546
                    .'</div>'
1547
                    .'<script>
1548
            $(function () {
1549
                var $el = $("#'.$input_id.'");
1550
                if (!$.fn.select2) return;
1551
1552
                $el.select2({
1553
                    width: "100%",
1554
                    placeholder: { id: "-2", text: "'.get_lang('None').'" },
1555
                    allowClear: true,
1556
                    selectOnClose: false,
1557
                    containerCssClass: "select2-tw",
1558
                    selectionCssClass: "select2-tw",
1559
                    dropdownCssClass: "select2-tw-dd"
1560
                });
1561
1562
                $("#'.$clear_id.'").on("click", function(e){
1563
                    e.preventDefault();
1564
                    $el.val(null).trigger("change");
1565
                });
1566
            });
1567
        </script>';
1568
            }
1569
1570
            if ($show_comment) {
1571
                $s .= '</table>';
1572
            } elseif (in_array(
1573
                $answerType,
1574
                [
1575
                    MATCHING,
1576
                    MATCHING_COMBINATION,
1577
                    MATCHING_DRAGGABLE,
1578
                    MATCHING_DRAGGABLE_COMBINATION,
1579
                    UNIQUE_ANSWER_NO_OPTION,
1580
                    MULTIPLE_ANSWER_TRUE_FALSE,
1581
                    MULTIPLE_ANSWER_COMBINATION_TRUE_FALSE,
1582
                    MULTIPLE_ANSWER_TRUE_FALSE_DEGREE_CERTAINTY,
1583
                ]
1584
            )) {
1585
                $s .= '</table>';
1586
            }
1587
1588
            if (DRAGGABLE == $answerType) {
1589
                $isVertical = 'v' == $objQuestionTmp->extra;
1590
                $s .= "</ul></div>";
1591
                $counterAnswer = 1;
1592
                $s .= '<div class="question-answer__items question-answer__items--'.($isVertical ? 'vertical' : 'horizontal').'">';
1593
                for ($answerId = 1; $answerId <= $nbrAnswers; $answerId++) {
1594
                    $answerCorrect = $objAnswerTmp->isCorrect($answerId);
1595
                    $windowId = $questionId.'_'.$counterAnswer;
1596
                    if ($answerCorrect) {
1597
                        $s .= '<div class="droppable-item '.($isVertical ? 'w-full' : '').' flex items-center justify-between p-4 mb-4 bg-gray-200 rounded-md">';
1598
                        $s .= '<span class="number text-lg font-bold">'.$counterAnswer.'</span>';
1599
                        $s .= '<div id="drop_'.$windowId.'" class="droppable border-2 border-dashed border-gray-400 p-4 bg-white rounded-md"></div>';
1600
                        $s .= '</div>';
1601
                        $counterAnswer++;
1602
                    }
1603
                }
1604
1605
                $s .= '</div>';
1606
//                $s .= '</div>';
1607
            }
1608
1609
            if (in_array($answerType, [MATCHING, MATCHING_COMBINATION, MATCHING_DRAGGABLE, MATCHING_DRAGGABLE_COMBINATION])) {
1610
                $s .= '</div>'; //drag_question
1611
            }
1612
1613
            $s .= '</div>'; //question_options row
1614
1615
            // destruction of the Answer object
1616
            unset($objAnswerTmp);
1617
            // destruction of the Question object
1618
            unset($objQuestionTmp);
1619
            if ('export' == $origin) {
1620
                return $s;
1621
            }
1622
            echo $s;
1623
        } elseif (in_array($answerType, [HOT_SPOT, HOT_SPOT_DELINEATION, HOT_SPOT_COMBINATION])) {
1624
            global $exe_id;
1625
            $questionDescription = $objQuestionTmp->selectDescription();
1626
            // Get the answers, make a list
1627
            $objAnswerTmp = new Answer($questionId, $course_id);
1628
            $nbrAnswers = $objAnswerTmp->selectNbrAnswers();
1629
1630
            // get answers of hotpost
1631
            $answers_hotspot = [];
1632
            for ($answerId = 1; $answerId <= $nbrAnswers; $answerId++) {
1633
                $answers = $objAnswerTmp->selectAnswerByAutoId(
1634
                    $objAnswerTmp->selectAutoId($answerId)
1635
                );
1636
                $answers_hotspot[$answers['iid']] = $objAnswerTmp->selectAnswer(
1637
                    $answerId
1638
                );
1639
            }
1640
1641
            $answerList = '';
1642
            $hotspotColor = 0;
1643
            if (HOT_SPOT_DELINEATION != $answerType) {
1644
                $answerList = '
1645
        <div class="card p-4 rounded-md border border-gray-25">
1646
            <h5 class="font-bold text-lg mb-2 text-primary">'.get_lang('Image zones').'</h5>
1647
            <ol class="list-decimal ml-6 space-y-2 text-primary">
1648
        ';
1649
1650
                if (!empty($answers_hotspot)) {
1651
                    Session::write("hotspot_ordered$questionId", array_keys($answers_hotspot));
1652
                    foreach ($answers_hotspot as $value) {
1653
                        $answerList .= '<li class="flex items-center space-x-2">';
1654
                        if ($freeze) {
1655
                            $answerList .= '<span class="text-support-5 fa fa-square" aria-hidden="true"></span>';
1656
                        }
1657
                        $answerList .= '<span>'.$value.'</span>';
1658
                        $answerList .= '</li>';
1659
                        $hotspotColor++;
1660
                    }
1661
                }
1662
1663
                $answerList .= '
1664
                        </ol>
1665
                    </div>
1666
                ';
1667
            }
1668
            if ($freeze) {
1669
                $relPath = api_get_path(WEB_CODE_PATH);
1670
                echo "
1671
        <div class=\"w-100\">
1672
                $answerList
1673
            </div>
1674
        <div class=\"flex space-x-4\">
1675
            <div class=\"w-100\">
1676
                <div id=\"hotspot-preview-$questionId\" class=\"bg-gray-10 w-full bg-center bg-no-repeat bg-contain border border-gray-25\"></div>
1677
            </div>
1678
        </div>
1679
        <script>
1680
            new ".(in_array($answerType, [HOT_SPOT, HOT_SPOT_COMBINATION]) ? "HotspotQuestion" : "DelineationQuestion")."({
1681
                questionId: $questionId,
1682
                exerciseId: $exerciseId,
1683
                exeId: 0,
1684
                selector: '#hotspot-preview-$questionId',
1685
                for: 'preview',
1686
                relPath: '$relPath'
1687
            });
1688
        </script>
1689
    ";
1690
                return;
1691
            }
1692
1693
            if (!$only_questions) {
1694
                if ($show_title) {
1695
                    if ($exercise->display_category_name) {
1696
                        TestCategory::displayCategoryAndTitle($objQuestionTmp->id);
1697
                    }
1698
                    echo $objQuestionTmp->getTitleToDisplay($exercise, $current_item);
1699
                }
1700
1701
                //@todo I need to the get the feedback type
1702
                echo <<<HOTSPOT
1703
        <input type="hidden" name="hidden_hotspot_id" value="$questionId" />
1704
        <div class="exercise_questions">
1705
            $questionDescription
1706
            <div class="mb-4">
1707
              $answerList
1708
            </div>
1709
            <div class="flex space-x-4">
1710
HOTSPOT;
1711
            }
1712
1713
            $relPath = api_get_path(WEB_CODE_PATH);
1714
            $s .= "<div>
1715
           <div class=\"hotspot-image bg-gray-10 border border-gray-25 bg-center bg-no-repeat bg-contain\"></div>
1716
            <script>
1717
                $(function() {
1718
                    new ".(HOT_SPOT_DELINEATION == $answerType ? 'DelineationQuestion' : 'HotspotQuestion')."({
1719
                        questionId: $questionId,
1720
                        exerciseId: $exerciseId,
1721
                        exeId: 0,
1722
                        selector: '#question_div_' + $questionId + ' .hotspot-image',
1723
                        for: 'user',
1724
                        relPath: '$relPath'
1725
                    });
1726
                });
1727
            </script>
1728
        </div>
1729
    ";
1730
1731
            echo <<<HOTSPOT
1732
        $s
1733
    </div>
1734
</div>
1735
HOTSPOT;
1736
        } elseif (ANNOTATION == $answerType) {
1737
            global $exe_id;
1738
            $relPath = api_get_path(WEB_CODE_PATH);
1739
            if (api_is_platform_admin() || api_is_course_admin()) {
1740
                $questionRepo = Container::getQuestionRepository();
1741
                $questionEntity = $questionRepo->find($questionId);
1742
                if ($freeze) {
1743
                    echo '
1744
            <div id="annotation-canvas-'.$questionId.'" class="annotation-canvas center-block"></div>
1745
            <script>
1746
                AnnotationQuestion({
1747
                    questionId: '.(int)$questionId.',
1748
                    exerciseId: 0,
1749
                    relPath: \''.$relPath.'\',
1750
                    courseId: '.(int)$course_id.',
1751
                    mode: "preview"
1752
                });
1753
            </script>
1754
        ';
1755
                    return 0;
1756
                }
1757
            }
1758
1759
            if (!$only_questions) {
1760
                if ($show_title) {
1761
                    if ($exercise->display_category_name) {
1762
                        TestCategory::displayCategoryAndTitle($objQuestionTmp->id);
1763
                    }
1764
                    echo $objQuestionTmp->getTitleToDisplay($exercise, $current_item);
1765
                }
1766
1767
                echo '
1768
                    <input type="hidden" name="hidden_hotspot_id" value="'.$questionId.'" />
1769
                    <div class="exercise_questions">
1770
                        '.$objQuestionTmp->selectDescription().'
1771
                        <div class="row">
1772
                            <div class="col-sm-8 col-md-9">
1773
                                <div id="annotation-canvas-'.$questionId.'" class="annotation-canvas center-block">
1774
                                </div>
1775
                                <script>
1776
                                    AnnotationQuestion({
1777
                                        questionId: '.$questionId.',
1778
                                        exerciseId: '.$exerciseId.',
1779
                                        relPath: \''.$relPath.'\',
1780
                                        courseId: '.$course_id.',
1781
                                    });
1782
                                </script>
1783
                            </div>
1784
                            <div class="col-sm-4 col-md-3">
1785
                                <div class="well well-sm" id="annotation-toolbar-'.$questionId.'">
1786
                                    <div class="btn-toolbar">
1787
                                        <div class="btn-group" data-toggle="buttons">
1788
                                            <label class="btn btn--plain active"
1789
                                                aria-label="'.get_lang('Add annotation path').'">
1790
                                                <input
1791
                                                    type="radio" value="0"
1792
                                                    name="'.$questionId.'-options" autocomplete="off" checked>
1793
                                                <span class="fas fa-pencil-alt" aria-hidden="true"></span>
1794
                                            </label>
1795
                                            <label class="btn btn--plain"
1796
                                                aria-label="'.get_lang('Add annotation text').'">
1797
                                                <input
1798
                                                    type="radio" value="1"
1799
                                                    name="'.$questionId.'-options" autocomplete="off">
1800
                                                <span class="fa fa-font fa-fw" aria-hidden="true"></span>
1801
                                            </label>
1802
                                        </div>
1803
                                    </div>
1804
                                    <ul class="list-unstyled"></ul>
1805
                                </div>
1806
                            </div>
1807
                        </div>
1808
                    </div>
1809
                ';
1810
            }
1811
            $objAnswerTmp = new Answer($questionId);
1812
            $nbrAnswers = $objAnswerTmp->selectNbrAnswers();
1813
            unset($objAnswerTmp, $objQuestionTmp);
1814
        }
1815
1816
            return $nbrAnswers;
1817
    }
1818
1819
    /**
1820
     * Displays a table listing the quizzes where a question is used.
1821
     */
1822
    public static function showTestsWhereQuestionIsUsed(int $questionId, int $excludeTestId = 0): void
1823
    {
1824
        $em = Database::getManager();
1825
        $quizRepo = $em->getRepository(CQuiz::class);
1826
        $quizzes = $quizRepo->findQuizzesUsingQuestion($questionId, $excludeTestId);
1827
1828
        if (empty($quizzes)) {
1829
            echo '';
1830
            return;
1831
        }
1832
1833
        $result = [];
1834
1835
        foreach ($quizzes as $quiz) {
1836
            $link = $quiz->getFirstResourceLink();
1837
            $course = $link?->getCourse();
1838
            $session = $link?->getSession();
1839
            $courseId = $course?->getId() ?? 0;
1840
            $sessionId = $session?->getId() ?? 0;
1841
1842
            $url = api_get_path(WEB_CODE_PATH).'exercise/admin.php?'.
1843
                'cid='.$courseId.'&sid='.$sessionId.'&gid=0&gradebook=0&origin='.
1844
                '&exerciseId='.$quiz->getIid().'&r=1';
1845
1846
1847
            $result[] = [
1848
                $course?->getTitle() ?? '-',
1849
                $session?->getTitle() ?? '-',
1850
                $quiz->getTitle(),
1851
                '<a href="'.$url.'">'.Display::getMdiIcon(
1852
                    'order-bool-ascending-variant',
1853
                    'ch-tool-icon',
1854
                    null,
1855
                    ICON_SIZE_SMALL,
1856
                    get_lang('Edit')
1857
                ).'</a>',
1858
            ];
1859
        }
1860
1861
        $headers = [
1862
            get_lang('Course'),
1863
            get_lang('Session'),
1864
            get_lang('Test'),
1865
            get_lang('Link to test edition'),
1866
        ];
1867
1868
        $title = Display::div(
1869
            get_lang('Question also used in the following tests'),
1870
            ['class' => 'section-title', 'style' => 'margin-top: 25px; border-bottom: none']
1871
        );
1872
1873
        echo $title.Display::table($headers, $result);
1874
    }
1875
1876
    /**
1877
     * @param int $exeId
1878
     *
1879
     * @return array
1880
     */
1881
    public static function get_exercise_track_exercise_info($exeId)
1882
    {
1883
        $quizTable = Database::get_course_table(TABLE_QUIZ_TEST);
1884
        $trackExerciseTable = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
1885
        $courseTable = Database::get_main_table(TABLE_MAIN_COURSE);
1886
        $exeId = (int) $exeId;
1887
        $result = [];
1888
        if (!empty($exeId)) {
1889
            $sql = " SELECT q.*, tee.*
1890
                FROM $quizTable as q
1891
                INNER JOIN $trackExerciseTable as tee
1892
                ON q.iid = tee.exe_exo_id
1893
                WHERE
1894
                    tee.exe_id = $exeId";
1895
1896
            $sqlResult = Database::query($sql);
1897
            if (Database::num_rows($sqlResult)) {
1898
                $result = Database::fetch_assoc($sqlResult);
1899
                $result['duration_formatted'] = '';
1900
                if (!empty($result['exe_duration'])) {
1901
                    $time = api_format_time($result['exe_duration'], 'js');
1902
                    $result['duration_formatted'] = $time;
1903
                }
1904
            }
1905
        }
1906
1907
        return $result;
1908
    }
1909
1910
    /**
1911
     * Validates the time control key.
1912
     *
1913
     * @param int $lp_id
1914
     * @param int $lp_item_id
1915
     *
1916
     * @return bool
1917
     */
1918
    public static function exercise_time_control_is_valid(Exercise $exercise, $lp_id = 0, $lp_item_id = 0)
1919
    {
1920
        $exercise_id = $exercise->getId();
1921
        $expiredTime = $exercise->expired_time;
1922
1923
        // If the exercise has no time control configured, it's valid.
1924
        if (empty($expiredTime)) {
1925
            return true;
1926
        }
1927
1928
        // Build a stable session key for the LP/exercise context
1929
        $current_expired_time_key = self::get_time_control_key(
1930
            $exercise_id,
1931
            $lp_id,
1932
            $lp_item_id
1933
        );
1934
1935
        // If the key isn't present, time control cannot be validated -> not valid
1936
        if (!isset($_SESSION['expired_time'][$current_expired_time_key])) {
1937
            return false;
1938
        }
1939
1940
        // Normalize the stored value (can be DateTime, unix timestamp, or string)
1941
        $raw = $_SESSION['expired_time'][$current_expired_time_key];
1942
        if ($raw instanceof \DateTimeInterface) {
1943
            $expiredAtStr = $raw->format('Y-m-d H:i:s');
1944
        } elseif (is_int($raw) || ctype_digit((string) $raw)) {
1945
            // Treat numeric as unix timestamp (UTC)
1946
            $expiredAtStr = gmdate('Y-m-d H:i:s', (int) $raw);
1947
        } else {
1948
            // Assume parsable datetime string
1949
            $expiredAtStr = (string) $raw;
1950
        }
1951
1952
        // Compute remaining time (defensive: handle parse failure as already expired)
1953
        $expired_time = api_strtotime($expiredAtStr, 'UTC');
1954
        if ($expired_time === false || $expired_time === null) {
1955
            return false;
1956
        }
1957
1958
        $current_time = time();
1959
        $total_time_allowed = $expired_time + 30; // small grace period
1960
1961
        if ($total_time_allowed < $current_time) {
1962
            return false;
1963
        }
1964
1965
        return true;
1966
    }
1967
1968
    /**
1969
     * Deletes the time control token.
1970
     *
1971
     * @param int $exercise_id
1972
     * @param int $lp_id
1973
     * @param int $lp_item_id
1974
     */
1975
    public static function exercise_time_control_delete(
1976
        $exercise_id,
1977
        $lp_id = 0,
1978
        $lp_item_id = 0
1979
    ) {
1980
        $current_expired_time_key = self::get_time_control_key(
1981
            $exercise_id,
1982
            $lp_id,
1983
            $lp_item_id
1984
        );
1985
        unset($_SESSION['expired_time'][$current_expired_time_key]);
1986
    }
1987
1988
    /**
1989
     * Generates the time control key.
1990
     *
1991
     * @param int $exercise_id
1992
     * @param int $lp_id
1993
     * @param int $lp_item_id
1994
     *
1995
     * @return string
1996
     */
1997
    public static function get_time_control_key(
1998
        $exercise_id,
1999
        $lp_id = 0,
2000
        $lp_item_id = 0
2001
    ) {
2002
        $exercise_id = (int) $exercise_id;
2003
        $lp_id = (int) $lp_id;
2004
        $lp_item_id = (int) $lp_item_id;
2005
2006
        return
2007
            api_get_course_int_id().'_'.
2008
            api_get_session_id().'_'.
2009
            $exercise_id.'_'.
2010
            api_get_user_id().'_'.
2011
            $lp_id.'_'.
2012
            $lp_item_id;
2013
    }
2014
2015
    /**
2016
     * Get session time control.
2017
     *
2018
     * @param int $exercise_id
2019
     * @param int $lp_id
2020
     * @param int $lp_item_id
2021
     *
2022
     * @return int
2023
     */
2024
    public static function get_session_time_control_key(
2025
        $exercise_id,
2026
        $lp_id = 0,
2027
        $lp_item_id = 0
2028
    ) {
2029
        $return_value = 0;
2030
        $time_control_key = self::get_time_control_key(
2031
            $exercise_id,
2032
            $lp_id,
2033
            $lp_item_id
2034
        );
2035
	if (isset($_SESSION['expired_time']) && isset($_SESSION['expired_time'][$time_control_key])) {
2036
            if ($_SESSION['expired_time'][$time_control_key] instanceof DateTimeInterface) {
2037
                $return_value = $_SESSION['expired_time'][$time_control_key]->format('Y-m-d H:i:s');
2038
            } else {
2039
                $return_value = $_SESSION['expired_time'][$time_control_key];
2040
            }
2041
        }
2042
2043
        return $return_value;
2044
    }
2045
2046
    /**
2047
     * Gets count of exam results.
2048
     *
2049
     * @param int   $exerciseId
2050
     * @param array $conditions
2051
     * @param int   $courseId
2052
     * @param bool  $showSession
2053
     *
2054
     * @return array
2055
     */
2056
    public static function get_count_exam_results($exerciseId, $conditions, $courseId, $showSession = false)
2057
    {
2058
        $count = self::get_exam_results_data(
2059
            null,
2060
            null,
2061
            null,
2062
            null,
2063
            $exerciseId,
2064
            $conditions,
2065
            true,
2066
            $courseId,
2067
            $showSession
2068
        );
2069
2070
        return $count;
2071
    }
2072
2073
    /**
2074
     * Gets the exam'data results.
2075
     *
2076
     * @todo this function should be moved in a library  + no global calls
2077
     *
2078
     * @param int    $from
2079
     * @param int    $number_of_items
2080
     * @param int    $column
2081
     * @param string $direction
2082
     * @param int    $exercise_id
2083
     * @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...
2084
     * @param bool   $get_count
2085
     * @param int    $courseId
2086
     * @param bool   $showSessionField
2087
     * @param bool   $showExerciseCategories
2088
     * @param array  $userExtraFieldsToAdd
2089
     * @param bool   $useCommaAsDecimalPoint
2090
     * @param bool   $roundValues
2091
     * @param bool   $getOnyIds
2092
     *
2093
     * @return array
2094
     */
2095
    public static function get_exam_results_data(
2096
        $from,
2097
        $number_of_items,
2098
        $column,
2099
        $direction,
2100
        $exercise_id,
2101
        $extra_where_conditions = null,
2102
        $get_count = false,
2103
        $courseId = null,
2104
        $showSessionField = false,
2105
        $showExerciseCategories = false,
2106
        $userExtraFieldsToAdd = [],
2107
        $useCommaAsDecimalPoint = false,
2108
        $roundValues = false,
2109
        $getOnyIds = false
2110
    ) {
2111
        //@todo replace all this globals
2112
        global $filter;
2113
        $courseId = (int) $courseId;
2114
        $course = api_get_course_entity($courseId);
2115
        if (null === $course) {
2116
            return [];
2117
        }
2118
2119
        $sessionId = api_get_session_id();
2120
        $exercise_id = (int) $exercise_id;
2121
2122
        $is_allowedToEdit =
2123
            api_is_allowed_to_edit(null, true) ||
2124
            api_is_allowed_to_edit(true) ||
2125
            api_is_drh() ||
2126
            api_is_student_boss() ||
2127
            api_is_session_admin();
2128
        $TBL_USER = Database::get_main_table(TABLE_MAIN_USER);
2129
        $TBL_EXERCISES = Database::get_course_table(TABLE_QUIZ_TEST);
2130
        $TBL_GROUP_REL_USER = Database::get_course_table(TABLE_GROUP_USER);
2131
        $TBL_GROUP = Database::get_course_table(TABLE_GROUP);
2132
        $TBL_TRACK_EXERCISES = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
2133
        $tblTrackAttemptQualify = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT_QUALIFY);
2134
2135
        $session_id_and = '';
2136
        $sessionCondition = '';
2137
        if (!$showSessionField) {
2138
            $session_id_and = api_get_session_condition($sessionId, true, false, 'te.session_id');
2139
            $sessionCondition = api_get_session_condition($sessionId, true, false, 'ttte.session_id');
2140
        }
2141
2142
        $exercise_where = '';
2143
        if (!empty($exercise_id)) {
2144
            $exercise_where .= ' AND te.exe_exo_id = '.$exercise_id.'  ';
2145
        }
2146
2147
        // sql for chamilo-type tests for teacher / tutor view
2148
        $sql_inner_join_tbl_track_exercices = "
2149
        (
2150
            SELECT DISTINCT ttte.*, if(tr.exe_id,1, 0) as revised
2151
            FROM $TBL_TRACK_EXERCISES ttte
2152
            LEFT JOIN $tblTrackAttemptQualify tr
2153
            ON (ttte.exe_id = tr.exe_id) AND tr.author > 0
2154
            WHERE
2155
                c_id = $courseId AND
2156
                exe_exo_id = $exercise_id
2157
                $sessionCondition
2158
        )";
2159
2160
        if ($is_allowedToEdit) {
2161
            //@todo fix to work with COURSE_RELATION_TYPE_RRHH in both queries
2162
            // Hack in order to filter groups
2163
            $sql_inner_join_tbl_user = '';
2164
            if (strpos($extra_where_conditions, 'group_id')) {
2165
                $sql_inner_join_tbl_user = "
2166
                (
2167
                    SELECT
2168
                        u.id as user_id,
2169
                        firstname,
2170
                        lastname,
2171
                        official_code,
2172
                        email,
2173
                        username,
2174
                        g.name as group_name,
2175
                        g.id as group_id
2176
                    FROM $TBL_USER u
2177
                    INNER JOIN $TBL_GROUP_REL_USER gru
2178
                    ON (gru.user_id = u.id AND gru.c_id= $courseId )
2179
                    INNER JOIN $TBL_GROUP g
2180
                    ON (gru.group_id = g.id AND g.c_id= $courseId )
2181
                    WHERE u.active <> ".USER_SOFT_DELETED."
2182
                )";
2183
            }
2184
2185
            if (strpos($extra_where_conditions, 'group_all')) {
2186
                $extra_where_conditions = str_replace(
2187
                    "AND (  group_id = 'group_all'  )",
2188
                    '',
2189
                    $extra_where_conditions
2190
                );
2191
                $extra_where_conditions = str_replace(
2192
                    "AND group_id = 'group_all'",
2193
                    '',
2194
                    $extra_where_conditions
2195
                );
2196
                $extra_where_conditions = str_replace(
2197
                    "group_id = 'group_all' AND",
2198
                    '',
2199
                    $extra_where_conditions
2200
                );
2201
2202
                $sql_inner_join_tbl_user = "
2203
                (
2204
                    SELECT
2205
                        u.id as user_id,
2206
                        firstname,
2207
                        lastname,
2208
                        official_code,
2209
                        email,
2210
                        username,
2211
                        '' as group_name,
2212
                        '' as group_id
2213
                    FROM $TBL_USER u
2214
                    WHERE u.active <> ".USER_SOFT_DELETED."
2215
                )";
2216
                $sql_inner_join_tbl_user = null;
2217
            }
2218
2219
            if (strpos($extra_where_conditions, 'group_none')) {
2220
                $extra_where_conditions = str_replace(
2221
                    "AND (  group_id = 'group_none'  )",
2222
                    "AND (  group_id is null  )",
2223
                    $extra_where_conditions
2224
                );
2225
                $extra_where_conditions = str_replace(
2226
                    "AND group_id = 'group_none'",
2227
                    "AND (  group_id is null  )",
2228
                    $extra_where_conditions
2229
                );
2230
                $sql_inner_join_tbl_user = "
2231
            (
2232
                SELECT
2233
                    u.id as user_id,
2234
                    firstname,
2235
                    lastname,
2236
                    official_code,
2237
                    email,
2238
                    username,
2239
                    g.name as group_name,
2240
                    g.iid as group_id
2241
                FROM $TBL_USER u
2242
                LEFT OUTER JOIN $TBL_GROUP_REL_USER gru
2243
                ON (gru.user_id = u.id AND gru.c_id= $courseId )
2244
                LEFT OUTER JOIN $TBL_GROUP g
2245
                ON (gru.group_id = g.id AND g.c_id = $courseId )
2246
                WHERE u.active <> ".USER_SOFT_DELETED."
2247
            )";
2248
            }
2249
2250
            // All
2251
            $is_empty_sql_inner_join_tbl_user = false;
2252
            if (empty($sql_inner_join_tbl_user)) {
2253
                $is_empty_sql_inner_join_tbl_user = true;
2254
                $sql_inner_join_tbl_user = "
2255
            (
2256
                SELECT u.id as user_id, firstname, lastname, email, username, ' ' as group_name, '' as group_id, official_code
2257
                FROM $TBL_USER u
2258
                WHERE u.active <> ".USER_SOFT_DELETED." AND u.status NOT IN(".api_get_users_status_ignored_in_reports('string').")
2259
            )";
2260
            }
2261
2262
            $sqlFromOption = " , $TBL_GROUP_REL_USER AS gru ";
2263
            $sqlWhereOption = "  AND gru.c_id = $courseId AND gru.user_id = user.id ";
2264
            $first_and_last_name = api_is_western_name_order() ? "firstname, lastname" : "lastname, firstname";
2265
2266
            if ($get_count) {
2267
                $sql_select = 'SELECT count(te.exe_id) ';
2268
            } else {
2269
                $sql_select = "SELECT DISTINCT
2270
                user.user_id,
2271
                $first_and_last_name,
2272
                official_code,
2273
                ce.title,
2274
                username,
2275
                te.score,
2276
                te.max_score,
2277
                te.exe_date,
2278
                te.exe_id,
2279
                te.session_id,
2280
                email as exemail,
2281
                te.start_date,
2282
                ce.expired_time,
2283
                steps_counter,
2284
                exe_user_id,
2285
                te.exe_duration,
2286
                te.status as completion_status,
2287
                propagate_neg,
2288
                revised,
2289
                group_name,
2290
                user.group_id AS group_id,
2291
                orig_lp_id,
2292
                te.user_ip";
2293
            }
2294
2295
            $sql = " $sql_select
2296
            FROM $TBL_EXERCISES AS ce
2297
            INNER JOIN $sql_inner_join_tbl_track_exercices AS te
2298
            ON (te.exe_exo_id = ce.iid)
2299
            INNER JOIN $sql_inner_join_tbl_user AS user
2300
            ON (user.user_id = exe_user_id)
2301
            INNER JOIN resource_node rn
2302
                ON rn.id = ce.resource_node_id
2303
            INNER JOIN resource_link rl
2304
                ON rl.resource_node_id = rn.id
2305
            WHERE
2306
                te.c_id = $courseId $session_id_and AND
2307
                rl.deleted_at IS NULL
2308
                $exercise_where
2309
                $extra_where_conditions
2310
            ";
2311
        }
2312
2313
        if (empty($sql)) {
2314
            return false;
2315
        }
2316
2317
        if ($get_count) {
2318
            $resx = Database::query($sql);
2319
            $rowx = Database::fetch_row($resx, 'ASSOC');
2320
2321
            return $rowx[0];
2322
        }
2323
2324
        $teacher_list = CourseManager::get_teacher_list_from_course_code($course->getCode());
2325
        $teacher_id_list = [];
2326
        if (!empty($teacher_list)) {
2327
            foreach ($teacher_list as $teacher) {
2328
                $teacher_id_list[] = $teacher['user_id'];
2329
            }
2330
        }
2331
2332
        $scoreDisplay = new ScoreDisplay();
2333
        $decimalSeparator = '.';
2334
        $thousandSeparator = ',';
2335
2336
        if ($useCommaAsDecimalPoint) {
2337
            $decimalSeparator = ',';
2338
            $thousandSeparator = '';
2339
        }
2340
2341
        $listInfo = [];
2342
        $column = !empty($column) ? Database::escape_string($column) : null;
2343
        $from = (int) $from;
2344
        $number_of_items = (int) $number_of_items;
2345
        $direction = !in_array(strtolower(trim($direction)), ['asc', 'desc']) ? 'asc' : $direction;
2346
2347
        if (!empty($column)) {
2348
            $sql .= " ORDER BY `$column` $direction ";
2349
        }
2350
2351
        if (!$getOnyIds) {
2352
            $sql .= " LIMIT $from, $number_of_items";
2353
        }
2354
2355
        $results = [];
2356
        $resx = Database::query($sql);
2357
        while ($rowx = Database::fetch_assoc($resx)) {
2358
            $results[] = $rowx;
2359
        }
2360
2361
        $group_list = GroupManager::get_group_list(null, $course);
2362
        $clean_group_list = [];
2363
        if (!empty($group_list)) {
2364
            foreach ($group_list as $group) {
2365
                $clean_group_list[$group['iid']] = $group['title'];
2366
            }
2367
        }
2368
2369
        $lp_list_obj = new LearnpathList(api_get_user_id());
2370
        $lp_list = $lp_list_obj->get_flat_list();
2371
        $oldIds = array_column($lp_list, 'lp_old_id', 'iid');
2372
2373
        if (is_array($results)) {
2374
            $users_array_id = [];
2375
            $from_gradebook = false;
2376
            if (isset($_GET['gradebook']) && 'view' === $_GET['gradebook']) {
2377
                $from_gradebook = true;
2378
            }
2379
            $sizeof = count($results);
2380
            $locked = api_resource_is_locked_by_gradebook(
2381
                $exercise_id,
2382
                LINK_EXERCISE
2383
            );
2384
2385
            $timeNow = strtotime(api_get_utc_datetime());
2386
            // Looping results
2387
            for ($i = 0; $i < $sizeof; $i++) {
2388
                $revised = $results[$i]['revised'];
2389
                if ('incomplete' === $results[$i]['completion_status']) {
2390
                    // If the exercise was incomplete, we need to determine
2391
                    // if it is still into the time allowed, or if its
2392
                    // allowed time has expired and it can be closed
2393
                    // (it's "unclosed")
2394
                    $minutes = $results[$i]['expired_time'];
2395
                    if (0 == $minutes) {
2396
                        // There's no time limit, so obviously the attempt
2397
                        // can still be "ongoing", but the teacher should
2398
                        // be able to choose to close it, so mark it as
2399
                        // "unclosed" instead of "ongoing"
2400
                        $revised = 2;
2401
                    } else {
2402
                        $allowedSeconds = $minutes * 60;
2403
                        $timeAttemptStarted = strtotime($results[$i]['start_date']);
2404
                        $secondsSinceStart = $timeNow - $timeAttemptStarted;
2405
                        if ($secondsSinceStart > $allowedSeconds) {
2406
                            $revised = 2; // mark as "unclosed"
2407
                        } else {
2408
                            $revised = 3; // mark as "ongoing"
2409
                        }
2410
                    }
2411
                }
2412
2413
                if ($from_gradebook && ($is_allowedToEdit)) {
2414
                    if (in_array(
2415
                        $results[$i]['username'].$results[$i]['firstname'].$results[$i]['lastname'],
2416
                        $users_array_id
2417
                    )) {
2418
                        continue;
2419
                    }
2420
                    $users_array_id[] = $results[$i]['username'].$results[$i]['firstname'].$results[$i]['lastname'];
2421
                }
2422
2423
                $lp_obj = isset($results[$i]['orig_lp_id']) && isset($lp_list[$results[$i]['orig_lp_id']]) ? $lp_list[$results[$i]['orig_lp_id']] : null;
2424
                if (empty($lp_obj)) {
2425
                    // Try to get the old id (id instead of iid)
2426
                    $lpNewId = isset($results[$i]['orig_lp_id']) && isset($oldIds[$results[$i]['orig_lp_id']]) ? $oldIds[$results[$i]['orig_lp_id']] : null;
2427
                    if ($lpNewId) {
2428
                        $lp_obj = isset($lp_list[$lpNewId]) ? $lp_list[$lpNewId] : null;
2429
                    }
2430
                }
2431
                $lp_name = null;
2432
                if ($lp_obj) {
2433
                    $url = api_get_path(WEB_CODE_PATH).
2434
                        'lp/lp_controller.php?'.api_get_cidreq().'&action=view&lp_id='.$results[$i]['orig_lp_id'];
2435
                    $lp_name = Display::url(
2436
                        $lp_obj['lp_name'],
2437
                        $url,
2438
                        ['target' => '_blank']
2439
                    );
2440
                }
2441
2442
                // Add all groups by user
2443
                $group_name_list = '';
2444
                if ($is_empty_sql_inner_join_tbl_user) {
2445
                    $group_list = GroupManager::get_group_ids(
2446
                        api_get_course_int_id(),
2447
                        $results[$i]['user_id']
2448
                    );
2449
2450
                    foreach ($group_list as $id) {
2451
                        if (isset($clean_group_list[$id])) {
2452
                            $group_name_list .= $clean_group_list[$id].'<br/>';
2453
                        }
2454
                    }
2455
                    $results[$i]['group_name'] = $group_name_list;
2456
                }
2457
2458
                $results[$i]['exe_duration'] = !empty($results[$i]['exe_duration']) ? round($results[$i]['exe_duration'] / 60) : 0;
2459
                $id = $results[$i]['exe_id'];
2460
                $dt = api_convert_and_format_date($results[$i]['max_score']);
2461
2462
                // we filter the results if we have the permission to
2463
                $result_disabled = 0;
2464
                if (isset($results[$i]['results_disabled'])) {
2465
                    $result_disabled = (int) $results[$i]['results_disabled'];
2466
                }
2467
                if (0 == $result_disabled) {
2468
                    $my_res = $results[$i]['score'];
2469
                    $my_total = $results[$i]['max_score'];
2470
                    $results[$i]['start_date'] = api_get_local_time($results[$i]['start_date']);
2471
                    $results[$i]['exe_date'] = api_get_local_time($results[$i]['exe_date']);
2472
2473
                    if (!$results[$i]['propagate_neg'] && $my_res < 0) {
2474
                        $my_res = 0;
2475
                    }
2476
2477
                    $score = self::show_score(
2478
                        $my_res,
2479
                        $my_total,
2480
                        true,
2481
                        true,
2482
                        false,
2483
                        false,
2484
                        $decimalSeparator,
2485
                        $thousandSeparator,
2486
                        $roundValues
2487
                    );
2488
2489
                    $actions = '<div class="pull-right">';
2490
                    if ($is_allowedToEdit) {
2491
                        if (isset($teacher_id_list)) {
2492
                            if (in_array(
2493
                                $results[$i]['exe_user_id'],
2494
                                $teacher_id_list
2495
                            )) {
2496
                                $actions .= Display::getMdiIcon('human-male-board', 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Trainer'));
2497
                            }
2498
                        }
2499
                        $revisedLabel = '';
2500
                        switch ($revised) {
2501
                            case 0:
2502
                                $actions .= "<a href='exercise_show.php?".api_get_cidreq()."&action=qualify&id=$id'>".
2503
                                    Display::getMdiIcon(ActionIcon::GRADE, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Grade activity')
2504
                                    );
2505
                                $actions .= '</a>';
2506
                                $revisedLabel = Display::label(
2507
                                    get_lang('Not validated'),
2508
                                    'info'
2509
                                );
2510
                                break;
2511
                            case 1:
2512
                                $actions .= "<a href='exercise_show.php?".api_get_cidreq()."&action=edit&id=$id'>".
2513
                                    Display::getMdiIcon(ActionIcon::EDIT, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Edit'));
2514
                                $actions .= '</a>';
2515
                                $revisedLabel = Display::label(
2516
                                    get_lang('Validated'),
2517
                                    'success'
2518
                                );
2519
                                break;
2520
                            case 2: //finished but not marked as such
2521
                                $actions .= '<a href="exercise_report.php?'
2522
                                    .api_get_cidreq()
2523
                                    .'&exerciseId='
2524
                                    .$exercise_id
2525
                                    .'&a=close&id='
2526
                                    .$id
2527
                                    .'">'.
2528
                                    Display::getMdiIcon(ActionIcon::LOCK, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Mark attempt as closed'));
2529
                                $actions .= '</a>';
2530
                                $revisedLabel = Display::label(
2531
                                    get_lang('Unclosed'),
2532
                                    'warning'
2533
                                );
2534
                                break;
2535
                            case 3: //still ongoing
2536
                                $actions .= Display::getMdiIcon('clock', 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Attempt still going on. Please wait.'));
2537
                                $actions .= '';
2538
                                $revisedLabel = Display::label(
2539
                                    get_lang('Ongoing'),
2540
                                    'danger'
2541
                                );
2542
                                break;
2543
                        }
2544
2545
                        if (2 == $filter) {
2546
                            $actions .= ' <a href="exercise_history.php?'.api_get_cidreq().'&exe_id='.$id.'">'.
2547
                                Display::getMdiIcon('clipboard-text-clock', 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('View changes history')
2548
                                ).'</a>';
2549
                        }
2550
2551
                        // Admin can always delete the attempt
2552
                        if ((false == $locked || api_is_platform_admin()) && !api_is_student_boss()) {
2553
                            $ip = Tracking::get_ip_from_user_event(
2554
                                $results[$i]['exe_user_id'],
2555
                                api_get_utc_datetime(),
2556
                                false
2557
                            );
2558
                            $actions .= '<a href="http://www.whatsmyip.org/ip-geo-location/?ip='.$ip.'" target="_blank">'
2559
                                .Display::getMdiIcon('information', 'ch-tool-icon', null, ICON_SIZE_SMALL, $ip)
2560
                                .'</a>';
2561
2562
                            $recalculateUrl = api_get_path(WEB_CODE_PATH).'exercise/recalculate.php?'.
2563
                                api_get_cidreq().'&'.
2564
                                http_build_query([
2565
                                    'id' => $id,
2566
                                    'exercise' => $exercise_id,
2567
                                    'user' => $results[$i]['exe_user_id'],
2568
                                ]);
2569
                            $actions .= Display::url(
2570
                                Display::getMdiIcon(ActionIcon::REFRESH, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Recalculate results')),
2571
                                $recalculateUrl,
2572
                                [
2573
                                    'data-exercise' => $exercise_id,
2574
                                    'data-user' => $results[$i]['exe_user_id'],
2575
                                    'data-id' => $id,
2576
                                    'class' => 'exercise-recalculate',
2577
                                ]
2578
                            );
2579
2580
                            $exportPdfUrl = api_get_path(WEB_CODE_PATH).'exercise/exercise_report.php?'.
2581
                                api_get_cidreq().'&exerciseId='.$exercise_id.'&action=export_pdf&attemptId='.$id.'&userId='.(int) $results[$i]['exe_user_id'];
2582
                            $actions .= '<a href="'.$exportPdfUrl.'" target="_blank">'
2583
                                .Display::getMdiIcon(ActionIcon::EXPORT_PDF, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Export to PDF'))
2584
                                .'</a>';
2585
2586
                            $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'];
2587
                            $emailLink = '<a href="'.$sendMailUrl.'">'
2588
                                .Display::getMdiIcon(ActionIcon::SEND_SINGLE_EMAIL, 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Send by e-mail'))
2589
                                .'</a>';
2590
2591
                            $filterByUser = isset($_GET['filter_by_user']) ? (int) $_GET['filter_by_user'] : 0;
2592
                            $delete_link = '<a
2593
                            href="exercise_report.php?'.api_get_cidreq().'&filter_by_user='.$filterByUser.'&filter='.$filter.'&exerciseId='.$exercise_id.'&delete=delete&did='.$id.'"
2594
                            onclick=
2595
                            "javascript:if(!confirm(\''.sprintf(addslashes(get_lang('Delete attempt?')), $results[$i]['username'], $dt).'\')) return false;"
2596
                            >';
2597
                            $delete_link .= Display::getMdiIcon(ActionIcon::DELETE, 'ch-tool-icon', null, ICON_SIZE_SMALL, addslashes(get_lang('Delete'))).'</a>';
2598
2599
                            if (api_is_drh() && !api_is_platform_admin()) {
2600
                                $delete_link = null;
2601
                            }
2602
                            if (api_is_session_admin()) {
2603
                                $delete_link = '';
2604
                            }
2605
                            if (3 == $revised) {
2606
                                $delete_link = null;
2607
                            }
2608
                            if (1 !== $revised) {
2609
                                $emailLink = '';
2610
                            }
2611
                            $actions .= $delete_link;
2612
                            $actions .= $emailLink;
2613
                        }
2614
                    } else {
2615
                        $attempt_url = api_get_path(WEB_CODE_PATH).'exercise/result.php?'.api_get_cidreq().'&id='.$results[$i]['exe_id'].'&sid='.$sessionId;
2616
                        $attempt_link = Display::url(
2617
                            get_lang('Show'),
2618
                            $attempt_url,
2619
                            [
2620
                                'class' => 'ajax btn btn--plain',
2621
                                'data-title' => get_lang('Show'),
2622
                            ]
2623
                        );
2624
                        $actions .= $attempt_link;
2625
                    }
2626
                    $actions .= '</div>';
2627
2628
                    if (!empty($userExtraFieldsToAdd)) {
2629
                        foreach ($userExtraFieldsToAdd as $variable) {
2630
                            $extraFieldValue = new ExtraFieldValue('user');
2631
                            $values = $extraFieldValue->get_values_by_handler_and_field_variable(
2632
                                $results[$i]['user_id'],
2633
                                $variable
2634
                            );
2635
                            if (isset($values['value'])) {
2636
                                $results[$i][$variable] = $values['value'];
2637
                            }
2638
                        }
2639
                    }
2640
2641
                    $exeId = $results[$i]['exe_id'];
2642
                    $results[$i]['id'] = $exeId;
2643
                    $category_list = [];
2644
                    if ($is_allowedToEdit) {
2645
                        $sessionName = '';
2646
                        $sessionStartAccessDate = '';
2647
                        if (!empty($results[$i]['session_id'])) {
2648
                            $sessionInfo = api_get_session_info($results[$i]['session_id']);
2649
                            if (!empty($sessionInfo)) {
2650
                                $sessionName = $sessionInfo['title'];
2651
                                $sessionStartAccessDate = api_get_local_time($sessionInfo['access_start_date']);
2652
                            }
2653
                        }
2654
2655
                        $objExercise = new Exercise($courseId);
2656
                        if ($showExerciseCategories) {
2657
                            // Getting attempt info
2658
                            $exercise_stat_info = $objExercise->get_stat_track_exercise_info_by_exe_id($exeId);
2659
                            if (!empty($exercise_stat_info['data_tracking'])) {
2660
                                $question_list = explode(',', $exercise_stat_info['data_tracking']);
2661
                                if (!empty($question_list)) {
2662
                                    foreach ($question_list as $questionId) {
2663
                                        $objQuestionTmp = Question::read($questionId, $objExercise->course);
2664
                                        // We're inside *one* question. Go through each possible answer for this question
2665
                                        $result = $objExercise->manage_answer(
2666
                                            $exeId,
2667
                                            $questionId,
2668
                                            null,
2669
                                            'exercise_result',
2670
                                            false,
2671
                                            false,
2672
                                            true,
2673
                                            false,
2674
                                            $objExercise->selectPropagateNeg(),
2675
                                            null,
2676
                                            true
2677
                                        );
2678
2679
                                        $my_total_score = $result['score'];
2680
                                        $my_total_weight = $result['weight'];
2681
2682
                                        // Category report
2683
                                        $category_was_added_for_this_test = false;
2684
                                        if (isset($objQuestionTmp->category) && !empty($objQuestionTmp->category)) {
2685
                                            if (!isset($category_list[$objQuestionTmp->category]['score'])) {
2686
                                                $category_list[$objQuestionTmp->category]['score'] = 0;
2687
                                            }
2688
                                            if (!isset($category_list[$objQuestionTmp->category]['total'])) {
2689
                                                $category_list[$objQuestionTmp->category]['total'] = 0;
2690
                                            }
2691
                                            $category_list[$objQuestionTmp->category]['score'] += $my_total_score;
2692
                                            $category_list[$objQuestionTmp->category]['total'] += $my_total_weight;
2693
                                            $category_was_added_for_this_test = true;
2694
                                        }
2695
2696
                                        if (isset($objQuestionTmp->category_list) &&
2697
                                            !empty($objQuestionTmp->category_list)
2698
                                        ) {
2699
                                            foreach ($objQuestionTmp->category_list as $category_id) {
2700
                                                $category_list[$category_id]['score'] += $my_total_score;
2701
                                                $category_list[$category_id]['total'] += $my_total_weight;
2702
                                                $category_was_added_for_this_test = true;
2703
                                            }
2704
                                        }
2705
2706
                                        // No category for this question!
2707
                                        if (false == $category_was_added_for_this_test) {
2708
                                            if (!isset($category_list['none']['score'])) {
2709
                                                $category_list['none']['score'] = 0;
2710
                                            }
2711
                                            if (!isset($category_list['none']['total'])) {
2712
                                                $category_list['none']['total'] = 0;
2713
                                            }
2714
2715
                                            $category_list['none']['score'] += $my_total_score;
2716
                                            $category_list['none']['total'] += $my_total_weight;
2717
                                        }
2718
                                    }
2719
                                }
2720
                            }
2721
                        }
2722
2723
                        foreach ($category_list as $categoryId => $result) {
2724
                            $scoreToDisplay = self::show_score(
2725
                                $result['score'],
2726
                                $result['total'],
2727
                                true,
2728
                                true,
2729
                                false,
2730
                                false,
2731
                                $decimalSeparator,
2732
                                $thousandSeparator,
2733
                                $roundValues
2734
                            );
2735
                            $results[$i]['category_'.$categoryId] = $scoreToDisplay;
2736
                            $results[$i]['category_'.$categoryId.'_score_percentage'] = self::show_score(
2737
                                $result['score'],
2738
                                $result['total'],
2739
                                true,
2740
                                true,
2741
                                true, // $show_only_percentage = false
2742
                                true, // hide % sign
2743
                                $decimalSeparator,
2744
                                $thousandSeparator,
2745
                                $roundValues
2746
                            );
2747
                            $results[$i]['category_'.$categoryId.'_only_score'] = $result['score'];
2748
                            $results[$i]['category_'.$categoryId.'_total'] = $result['total'];
2749
                        }
2750
                        $results[$i]['session'] = $sessionName;
2751
                        $results[$i]['session_access_start_date'] = $sessionStartAccessDate;
2752
                        $results[$i]['status'] = $revisedLabel;
2753
                        $results[$i]['score'] = $score;
2754
                        $results[$i]['score_percentage'] = self::show_score(
2755
                            $my_res,
2756
                            $my_total,
2757
                            true,
2758
                            true,
2759
                            true,
2760
                            true,
2761
                            $decimalSeparator,
2762
                            $thousandSeparator,
2763
                            $roundValues
2764
                        );
2765
2766
                        if ($roundValues) {
2767
                            $whole = floor($my_res); // 1
2768
                            $fraction = $my_res - $whole; // .25
2769
                            if ($fraction >= 0.5) {
2770
                                $onlyScore = ceil($my_res);
2771
                            } else {
2772
                                $onlyScore = round($my_res);
2773
                            }
2774
                        } else {
2775
                            $onlyScore = $scoreDisplay->format_score(
2776
                                $my_res,
2777
                                false,
2778
                                $decimalSeparator,
2779
                                $thousandSeparator
2780
                            );
2781
                        }
2782
2783
                        $results[$i]['only_score'] = $onlyScore;
2784
2785
                        if ($roundValues) {
2786
                            $whole = floor($my_total); // 1
2787
                            $fraction = $my_total - $whole; // .25
2788
                            if ($fraction >= 0.5) {
2789
                                $onlyTotal = ceil($my_total);
2790
                            } else {
2791
                                $onlyTotal = round($my_total);
2792
                            }
2793
                        } else {
2794
                            $onlyTotal = $scoreDisplay->format_score(
2795
                                $my_total,
2796
                                false,
2797
                                $decimalSeparator,
2798
                                $thousandSeparator
2799
                            );
2800
                        }
2801
                        $results[$i]['total'] = $onlyTotal;
2802
                        $results[$i]['lp'] = $lp_name;
2803
                        $results[$i]['actions'] = $actions;
2804
                        $listInfo[] = $results[$i];
2805
                    } else {
2806
                        $results[$i]['status'] = $revisedLabel;
2807
                        $results[$i]['score'] = $score;
2808
                        $results[$i]['actions'] = $actions;
2809
                        $listInfo[] = $results[$i];
2810
                    }
2811
                }
2812
            }
2813
        }
2814
2815
        return $listInfo;
2816
    }
2817
2818
    /**
2819
     * Returns email content for a specific attempt.
2820
     */
2821
    public static function getEmailContentForAttempt(int $attemptId): array
2822
    {
2823
        $trackExerciseInfo = self::get_exercise_track_exercise_info($attemptId);
2824
2825
        if (empty($trackExerciseInfo)) {
2826
            return [
2827
                'to' => '',
2828
                'subject' => 'No exercise info found',
2829
                'message' => 'Attempt ID not found or invalid.',
2830
            ];
2831
        }
2832
2833
        $studentId = $trackExerciseInfo['exe_user_id'];
2834
        $courseInfo = api_get_course_info();
2835
        $teacherId = api_get_user_id();
2836
2837
        if (
2838
            empty($trackExerciseInfo['orig_lp_id']) ||
2839
            empty($trackExerciseInfo['orig_lp_item_id'])
2840
        ) {
2841
            $url = api_get_path(WEB_CODE_PATH).'exercise/result.php?id='.$trackExerciseInfo['exe_id'].'&'.api_get_cidreq()
2842
                .'&show_headers=1&id_session='.api_get_session_id();
2843
        } else {
2844
            $url = api_get_path(WEB_CODE_PATH).'lp/lp_controller.php?action=view&item_id='
2845
                .$trackExerciseInfo['orig_lp_item_id'].'&lp_id='.$trackExerciseInfo['orig_lp_id'].'&'.api_get_cidreq()
2846
                .'&id_session='.api_get_session_id();
2847
        }
2848
2849
        $message = self::getEmailNotification(
2850
            $teacherId,
2851
            $courseInfo,
2852
            $trackExerciseInfo['title'],
2853
            $url
2854
        );
2855
2856
        return [
2857
            'to' => $studentId,
2858
            'subject' => get_lang('Corrected test result'),
2859
            'message' => $message,
2860
        ];
2861
    }
2862
2863
    /**
2864
     * Sends the exercise result email to the student.
2865
     */
2866
    public static function sendExerciseResultByEmail(int $attemptId): void
2867
    {
2868
        $content = self::getEmailContentForAttempt($attemptId);
2869
2870
        if (empty($content['to'])) {
2871
            return;
2872
        }
2873
2874
        MessageManager::send_message_simple(
2875
            $content['to'],
2876
            $content['subject'],
2877
            $content['message'],
2878
            api_get_user_id()
2879
        );
2880
    }
2881
2882
    /**
2883
     * Returns all reviewed attempts for a given exercise and session.
2884
     */
2885
    public static function getReviewedAttemptsInfo(int $exerciseId, int $sessionId): array
2886
    {
2887
        $courseId = api_get_course_int_id();
2888
        $trackTable = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
2889
        $qualifyTable = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT_QUALIFY);
2890
2891
        $sessionCondition = api_get_session_condition($sessionId, true, false, 't.session_id');
2892
2893
        $sql = "
2894
            SELECT DISTINCT t.exe_id
2895
            FROM $trackTable t
2896
            INNER JOIN $qualifyTable q ON (t.exe_id = q.exe_id AND q.author > 0)
2897
            WHERE
2898
                t.c_id = $courseId AND
2899
                t.exe_exo_id = $exerciseId
2900
                $sessionCondition
2901
        ";
2902
2903
        return Database::store_result(Database::query($sql));
2904
    }
2905
2906
    /**
2907
     * @param $score
2908
     * @param $weight
2909
     *
2910
     * @return array
2911
     */
2912
    public static function convertScoreToPlatformSetting($score, $weight)
2913
    {
2914
        $maxNote = api_get_setting('exercise_max_score');
2915
        $minNote = api_get_setting('exercise_min_score');
2916
2917
        if ('' != $maxNote && '' != $minNote) {
2918
            if (!empty($weight) && (float) $weight !== (float) 0) {
2919
                $score = $minNote + ($maxNote - $minNote) * $score / $weight;
2920
            } else {
2921
                $score = $minNote;
2922
            }
2923
            $weight = $maxNote;
2924
        }
2925
2926
        return ['score' => $score, 'weight' => $weight];
2927
    }
2928
2929
    /**
2930
     * Converts the score with the exercise_max_note and exercise_min_score
2931
     * the platform settings + formats the results using the float_format function.
2932
     *
2933
     * @param float  $score
2934
     * @param float  $weight
2935
     * @param bool   $show_percentage       show percentage or not
2936
     * @param bool   $use_platform_settings use or not the platform settings
2937
     * @param bool   $show_only_percentage
2938
     * @param bool   $hidePercentageSign    hide "%" sign
2939
     * @param string $decimalSeparator
2940
     * @param string $thousandSeparator
2941
     * @param bool   $roundValues           This option rounds the float values into a int using ceil()
2942
     * @param bool   $removeEmptyDecimals
2943
     *
2944
     * @return string an html with the score modified
2945
     */
2946
    public static function show_score(
2947
        $score,
2948
        $weight,
2949
        $show_percentage = true,
2950
        $use_platform_settings = true,
2951
        $show_only_percentage = false,
2952
        $hidePercentageSign = false,
2953
        $decimalSeparator = '.',
2954
        $thousandSeparator = ',',
2955
        $roundValues = false,
2956
        $removeEmptyDecimals = false
2957
    ) {
2958
        if (is_null($score) && is_null($weight)) {
2959
            return '-';
2960
        }
2961
2962
        $decimalSeparator = empty($decimalSeparator) ? '.' : $decimalSeparator;
2963
        $thousandSeparator = empty($thousandSeparator) ? ',' : $thousandSeparator;
2964
2965
        if ($use_platform_settings) {
2966
            $result = self::convertScoreToPlatformSetting($score, $weight);
2967
            $score = $result['score'];
2968
            $weight = $result['weight'];
2969
        }
2970
2971
        // Keep a raw numeric percentage for model mapping BEFORE string formatting
2972
        $percentageRaw = (100 * (float) $score) / ((0 != (float) $weight) ? (float) $weight : 1);
2973
2974
        // Formats values
2975
        $percentage = float_format($percentageRaw, 1);
2976
        $score      = float_format($score, 1);
2977
        $weight     = float_format($weight, 1);
2978
2979
        if ($roundValues) {
2980
            $whole = floor($percentage);
2981
            $fraction = $percentage - $whole;
2982
            $percentage = ($fraction >= 0.5) ? ceil($percentage) : round($percentage);
2983
2984
            $whole = floor($score);
2985
            $fraction = $score - $whole;
2986
            $score = ($fraction >= 0.5) ? ceil($score) : round($score);
2987
2988
            $whole = floor($weight);
2989
            $fraction = $weight - $whole;
2990
            $weight = ($fraction >= 0.5) ? ceil($weight) : round($weight);
2991
        } else {
2992
            $percentage = float_format($percentage, 1, $decimalSeparator, $thousandSeparator);
2993
            $score      = float_format($score, 1, $decimalSeparator, $thousandSeparator);
2994
            $weight     = float_format($weight, 1, $decimalSeparator, $thousandSeparator);
2995
        }
2996
2997
        // Build base HTML (percentage or score/weight)
2998
        if ($show_percentage) {
2999
            $percentageSign = $hidePercentageSign ? '' : ' %';
3000
            $html = $show_only_percentage
3001
                ? ($percentage . $percentageSign)
3002
                : ($percentage . $percentageSign . ' (' . $score . ' / ' . $weight . ')');
3003
        } else {
3004
            if ($removeEmptyDecimals && ScoreDisplay::hasEmptyDecimals($weight)) {
3005
                $weight = round($weight);
3006
            }
3007
            $html = $score . ' / ' . $weight;
3008
        }
3009
3010
        $bucket = self::convertScoreToModel($percentageRaw);
3011
        if ($bucket !== null) {
3012
            $html = self::getModelStyle($bucket, $percentageRaw);
3013
        }
3014
3015
        // If the platform forces a format, it overrides everything (including the model badge)
3016
        $format = (int) api_get_setting('exercise.exercise_score_format');
3017
        if (!empty($format)) {
3018
            $html = ScoreDisplay::instance()->display_score([$score, $weight], $format);
3019
        }
3020
3021
        return Display::span($html, ['class' => 'score_exercise']);
3022
    }
3023
3024
    /**
3025
     * @param array $model
3026
     * @param float $percentage
3027
     *
3028
     * @return string
3029
     */
3030
    public static function getModelStyle($bucket, $percentage)
3031
    {
3032
        $rawClass = (string) ($bucket['css_class'] ?? '');
3033
        $twClass  = self::mapScoreCssClass($rawClass);
3034
3035
        // Accept both 'name' and 'variable'
3036
        $key   = isset($bucket['name']) ? 'name' : (isset($bucket['variable']) ? 'variable' : null);
3037
        $raw   = $key ? (string) $bucket[$key] : '';
3038
        $label = $raw !== '' ? get_lang($raw) : '';
3039
        $show  = (int) ($bucket['display_score_name'] ?? 0) === 1;
3040
3041
        $base = 'inline-block px-2 py-1 rounded';
3042
3043
        if ($show && $label !== '') {
3044
            return '<span class="' . htmlspecialchars($base . ' ' . $twClass) . '">' .
3045
                htmlspecialchars($label) . '</span>';
3046
        }
3047
3048
        return '<span class="' . htmlspecialchars($base . ' ' . $twClass) . '" ' .
3049
            'title="' . htmlspecialchars($label) . '" aria-label="' . htmlspecialchars($label) . '">' .
3050
            '&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;' .
3051
            '</span>';
3052
    }
3053
3054
    /**
3055
     * Map legacy css_class (e.g., "btn-danger") to Tailwind utility classes
3056
     * defined in Chamilo 2's theme (danger/success/warning/info).
3057
     * If a Tailwind class list is already provided, pass-through.
3058
     */
3059
    private static function mapScoreCssClass(string $cssClass): string
3060
    {
3061
        $cssClass = trim($cssClass);
3062
3063
        // Legacy → Tailwind mapping
3064
        $map = [
3065
            'btn-success' => 'bg-success text-success-button-text',
3066
            'btn-warning' => 'bg-warning text-warning-button-text',
3067
            'btn-danger'  => 'bg-danger text-danger-button-text',
3068
            'btn-info'    => 'bg-info text-info-button-text',
3069
3070
            // Also accept short tokens if someone uses "success" directly
3071
            'success' => 'bg-success text-success-button-text',
3072
            'warning' => 'bg-warning text-warning-button-text',
3073
            'danger'  => 'bg-danger text-danger-button-text',
3074
            'info'    => 'bg-info text-info-button-text',
3075
        ];
3076
3077
        if (isset($map[$cssClass])) {
3078
            return $map[$cssClass];
3079
        }
3080
3081
        // If it already looks like Tailwind utility classes, keep as-is
3082
        if (strpos($cssClass, ' ') !== false || preg_match('/[a-z]+-[a-z0-9\-]+/i', $cssClass)) {
3083
            return $cssClass;
3084
        }
3085
3086
        // Neutral fallback
3087
        return 'bg-gray-20 text-gray-90';
3088
    }
3089
3090
    /**
3091
     * @param float $percentage value between 0 and 100
3092
     *
3093
     * @return string
3094
     */
3095
    public static function convertScoreToModel($percentage): ?array
3096
    {
3097
        $model = self::getCourseScoreModel();
3098
        if (empty($model) || empty($model['score_list'])) {
3099
            return null;
3100
        }
3101
3102
        foreach ($model['score_list'] as $bucket) {
3103
            $min = (float) ($bucket['min'] ?? 0);
3104
            $max = (float) ($bucket['max'] ?? 0);
3105
3106
            if ($percentage >= $min && $percentage <= $max) {
3107
                // Propagate the model flag to the bucket
3108
                $bucket['display_score_name'] = (int) ($model['display_score_name'] ?? 0);
3109
                // Precompute label for convenience (optional)
3110
                $bucket['label'] = self::scoreLabel($bucket);
3111
                return $bucket;
3112
            }
3113
        }
3114
3115
        return null;
3116
    }
3117
3118
    private static function scoreLabel(array $row): string
3119
    {
3120
        $key = isset($row['name']) ? 'name' : (isset($row['variable']) ? 'variable' : null);
3121
        if (!$key) {
3122
            return '';
3123
        }
3124
        $value = (string) $row[$key];
3125
        return get_lang($value);
3126
    }
3127
3128
    /**
3129
     * @return array
3130
     */
3131
    public static function getCourseScoreModel(): array
3132
    {
3133
        $modelList = self::getScoreModels();
3134
        if (empty($modelList) || empty($modelList['models'])) {
3135
            return [];
3136
        }
3137
3138
        // Read the configured model id from course settings
3139
        $scoreModelId = (int) api_get_course_setting('score_model_id');
3140
3141
        // first available model
3142
        $selected = $modelList['models'][0];
3143
3144
        if ($scoreModelId !== -1) {
3145
            foreach ($modelList['models'] as $m) {
3146
                if ((int) ($m['id'] ?? 0) === $scoreModelId) {
3147
                    $selected = $m;
3148
                    break;
3149
                }
3150
            }
3151
        }
3152
3153
        // do NOT show name unless explicitly enabled
3154
        $selected['display_score_name'] = (int) ($selected['display_score_name'] ?? 0);
3155
3156
        return $selected;
3157
    }
3158
3159
    /**
3160
     * @return array
3161
     */
3162
    public static function getScoreModels()
3163
    {
3164
        return api_get_setting('exercise.score_grade_model', true);
3165
    }
3166
3167
    /**
3168
     * @param float  $score
3169
     * @param float  $weight
3170
     * @param string $passPercentage
3171
     *
3172
     * @return bool
3173
     */
3174
    public static function isSuccessExerciseResult($score, $weight, $passPercentage)
3175
    {
3176
        $percentage = float_format(
3177
            ($score / (0 != $weight ? $weight : 1)) * 100,
3178
            1
3179
        );
3180
        if (isset($passPercentage) && !empty($passPercentage)) {
3181
            if ($percentage >= $passPercentage) {
3182
                return true;
3183
            }
3184
        }
3185
3186
        return false;
3187
    }
3188
3189
    /**
3190
     * @param string $name
3191
     * @param $weight
3192
     * @param $selected
3193
     *
3194
     * @return bool
3195
     */
3196
    public static function addScoreModelInput(
3197
        FormValidator $form,
3198
        $name,
3199
        $weight,
3200
        $selected
3201
    ) {
3202
        $model = self::getCourseScoreModel();
3203
        if (empty($model)) {
3204
            return false;
3205
        }
3206
3207
        /** @var HTML_QuickForm_select $element */
3208
        $element = $form->createElement(
3209
            'select',
3210
            $name,
3211
            get_lang('Score'),
3212
            [],
3213
            ['class' => 'exercise_mark_select']
3214
        );
3215
3216
        foreach ($model['score_list'] as $item) {
3217
            $i = api_number_format($item['score_to_qualify'] / 100 * $weight, 2);
3218
            $label = self::getModelStyle($item, $i);
3219
            $attributes = [
3220
                'class' => $item['css_class'],
3221
            ];
3222
            if ($selected == $i) {
3223
                $attributes['selected'] = 'selected';
3224
            }
3225
            $element->addOption($label, $i, $attributes);
3226
        }
3227
        $form->addElement($element);
3228
    }
3229
3230
    /**
3231
     * @return string
3232
     */
3233
    public static function getJsCode()
3234
    {
3235
        // Filling the scores with the right colors.
3236
        $models = self::getCourseScoreModel();
3237
        $cssListToString = '';
3238
        if (!empty($models)) {
3239
            $cssList = array_column($models['score_list'], 'css_class');
3240
            $cssListToString = implode(' ', $cssList);
3241
        }
3242
3243
        if (empty($cssListToString)) {
3244
            return '';
3245
        }
3246
        $js = <<<EOT
3247
3248
        function updateSelect(element) {
3249
            var spanTag = element.parent().find('span.filter-option');
3250
            var value = element.val();
3251
            var selectId = element.attr('id');
3252
            var optionClass = $('#' + selectId + ' option[value="'+value+'"]').attr('class');
3253
            spanTag.removeClass('$cssListToString');
3254
            spanTag.addClass(optionClass);
3255
        }
3256
3257
        $(function() {
3258
            // Loading values
3259
            $('.exercise_mark_select').on('loaded.bs.select', function() {
3260
                updateSelect($(this));
3261
            });
3262
            // On change
3263
            $('.exercise_mark_select').on('changed.bs.select', function() {
3264
                updateSelect($(this));
3265
            });
3266
        });
3267
EOT;
3268
3269
        return $js;
3270
    }
3271
3272
    /**
3273
     * @param float  $score
3274
     * @param float  $weight
3275
     * @param string $pass_percentage
3276
     *
3277
     * @return string
3278
     */
3279
    public static function showSuccessMessage($score, $weight, $pass_percentage)
3280
    {
3281
        $res = '';
3282
        if (self::isPassPercentageEnabled($pass_percentage)) {
3283
            $isSuccess = self::isSuccessExerciseResult(
3284
                $score,
3285
                $weight,
3286
                $pass_percentage
3287
            );
3288
3289
            if ($isSuccess) {
3290
                $html = get_lang('Congratulations you passed the test!');
3291
                $icon = Display::getMdiIcon('check-circle', 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Correct'));
3292
            } else {
3293
                $html = get_lang('You didn\'t reach the minimum score');
3294
                $icon = Display::getMdiIcon('alert', 'ch-tool-icon', null, ICON_SIZE_SMALL, get_lang('Wrong'));
3295
            }
3296
            $html = Display::tag('h4', $html);
3297
            $html .= Display::tag(
3298
                'h5',
3299
                $icon,
3300
                ['style' => 'width:40px; padding:2px 10px 0px 0px']
3301
            );
3302
            $res = $html;
3303
        }
3304
3305
        return $res;
3306
    }
3307
3308
    /**
3309
     * Return true if pass_pourcentage activated (we use the pass pourcentage feature
3310
     * return false if pass_percentage = 0 (we don't use the pass pourcentage feature.
3311
     *
3312
     * @param $value
3313
     *
3314
     * @return bool
3315
     *              In this version, pass_percentage and show_success_message are disabled if
3316
     *              pass_percentage is set to 0
3317
     */
3318
    public static function isPassPercentageEnabled($value)
3319
    {
3320
        return $value > 0;
3321
    }
3322
3323
    /**
3324
     * Converts a numeric value in a percentage example 0.66666 to 66.67 %.
3325
     *
3326
     * @param $value
3327
     *
3328
     * @return float Converted number
3329
     */
3330
    public static function convert_to_percentage($value)
3331
    {
3332
        $return = '-';
3333
        if ('' != $value) {
3334
            $return = float_format($value * 100, 1).' %';
3335
        }
3336
3337
        return $return;
3338
    }
3339
3340
    /**
3341
     * Getting all active exercises from a course from a session
3342
     * (if a session_id is provided we will show all the exercises in the course +
3343
     * all exercises in the session).
3344
     *
3345
     * @param array  $course_info
3346
     * @param int    $session_id
3347
     * @param bool   $check_publication_dates
3348
     * @param string $search                  Search exercise name
3349
     * @param bool   $search_all_sessions     Search exercises in all sessions
3350
     * @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...
3351
     *                  1 = only active exercises,
3352
     *                  2 = all exercises
3353
     *                  3 = active <> -1
3354
     *
3355
     * @return CQuiz[]
3356
     */
3357
    public static function get_all_exercises(
3358
        $course_info = null,
3359
        $session_id = 0,
3360
        $check_publication_dates = false,
3361
        $search = '',
3362
        $search_all_sessions = false,
3363
        $active = 2
3364
    ) {
3365
        $course_id = api_get_course_int_id();
3366
        if (!empty($course_info) && !empty($course_info['real_id'])) {
3367
            $course_id = $course_info['real_id'];
3368
        }
3369
3370
        if (-1 == $session_id) {
3371
            $session_id = 0;
3372
        }
3373
        $course = api_get_course_entity($course_id);
3374
        $session = api_get_session_entity($session_id);
3375
3376
        if (null === $course) {
3377
            return [];
3378
        }
3379
3380
        $repo = Container::getQuizRepository();
3381
3382
        return $repo->findAllByCourse($course, $session, (string) $search, $active)
3383
            ->getQuery()
3384
            ->getResult();
3385
    }
3386
3387
    /**
3388
     * Getting all exercises (active only or all)
3389
     * from a course from a session
3390
     * (if a session_id is provided we will show all the exercises in the
3391
     * course + all exercises in the session).
3392
     */
3393
    public static function get_all_exercises_for_course_id(
3394
        int $courseId,
3395
        int $sessionId = 0,
3396
        bool $onlyActiveExercises = true
3397
    ): array {
3398
        if ($courseId <= 0) {
3399
            return [];
3400
        }
3401
3402
        $course  = api_get_course_entity($courseId);
3403
        $session = api_get_session_entity($sessionId);
3404
3405
        $repo = Container::getQuizRepository();
3406
3407
        $qb = $repo->getResourcesByCourse($course, $session);
3408
3409
        $qb->andWhere('links.deletedAt IS NULL');
3410
        $qb->andWhere('links.endVisibilityAt IS NULL');
3411
        if ($onlyActiveExercises) {
3412
            $qb->andWhere('links.visibility = 2');
3413
        } else {
3414
            $qb->andWhere('links.visibility IN (0,2)');
3415
        }
3416
3417
        $qb->orderBy('resource.title', 'ASC');
3418
3419
        $exercises = $qb->getQuery()->getResult();
3420
3421
        $exerciseList = [];
3422
        foreach ($exercises as $exercise) {
3423
            $exerciseList[] = [
3424
                'iid' => $exercise->getIid(),
3425
                'title' => $exercise->getTitle(),
3426
            ];
3427
        }
3428
3429
        return $exerciseList;
3430
    }
3431
3432
    /**
3433
     * Gets the position of the score based in a given score (result/weight)
3434
     * and the exe_id based in the user list
3435
     * (NO Exercises in LPs ).
3436
     *
3437
     * @param float  $my_score      user score to be compared *attention*
3438
     *                              $my_score = score/weight and not just the score
3439
     * @param int    $my_exe_id     exe id of the exercise
3440
     *                              (this is necessary because if 2 students have the same score the one
3441
     *                              with the minor exe_id will have a best position, just to be fair and FIFO)
3442
     * @param int    $exercise_id
3443
     * @param string $course_code
3444
     * @param int    $session_id
3445
     * @param array  $user_list
3446
     * @param bool   $return_string
3447
     *
3448
     * @return int the position of the user between his friends in a course
3449
     *             (or course within a session)
3450
     */
3451
    public static function get_exercise_result_ranking(
3452
        $my_score,
3453
        $my_exe_id,
3454
        $exercise_id,
3455
        $course_code,
3456
        $session_id = 0,
3457
        $user_list = [],
3458
        $return_string = true
3459
    ) {
3460
        //No score given we return
3461
        if (is_null($my_score)) {
3462
            return '-';
3463
        }
3464
        if (empty($user_list)) {
3465
            return '-';
3466
        }
3467
3468
        $best_attempts = [];
3469
        foreach ($user_list as $user_data) {
3470
            $user_id = $user_data['user_id'];
3471
            $best_attempts[$user_id] = self::get_best_attempt_by_user(
3472
                $user_id,
3473
                $exercise_id,
3474
                $course_code,
3475
                $session_id
3476
            );
3477
        }
3478
3479
        if (empty($best_attempts)) {
3480
            return 1;
3481
        } else {
3482
            $position = 1;
3483
            $my_ranking = [];
3484
            foreach ($best_attempts as $user_id => $result) {
3485
                if (!empty($result['max_score']) && 0 != intval($result['max_score'])) {
3486
                    $my_ranking[$user_id] = $result['score'] / $result['max_score'];
3487
                } else {
3488
                    $my_ranking[$user_id] = 0;
3489
                }
3490
            }
3491
            //if (!empty($my_ranking)) {
3492
            asort($my_ranking);
3493
            $position = count($my_ranking);
3494
            if (!empty($my_ranking)) {
3495
                foreach ($my_ranking as $user_id => $ranking) {
3496
                    if ($my_score >= $ranking) {
3497
                        if ($my_score == $ranking && isset($best_attempts[$user_id]['exe_id'])) {
3498
                            $exe_id = $best_attempts[$user_id]['exe_id'];
3499
                            if ($my_exe_id < $exe_id) {
3500
                                $position--;
3501
                            }
3502
                        } else {
3503
                            $position--;
3504
                        }
3505
                    }
3506
                }
3507
            }
3508
            //}
3509
            $return_value = [
3510
                'position' => $position,
3511
                'count' => count($my_ranking),
3512
            ];
3513
3514
            if ($return_string) {
3515
                if (!empty($position) && !empty($my_ranking)) {
3516
                    $return_value = $position.'/'.count($my_ranking);
3517
                } else {
3518
                    $return_value = '-';
3519
                }
3520
            }
3521
3522
            return $return_value;
3523
        }
3524
    }
3525
3526
    /**
3527
     * Gets the position of the score based in a given score (result/weight) and the exe_id based in all attempts
3528
     * (NO Exercises in LPs ) old functionality by attempt.
3529
     *
3530
     * @param   float   user score to be compared attention => score/weight
3531
     * @param   int     exe id of the exercise
3532
     * (this is necessary because if 2 students have the same score the one
3533
     * with the minor exe_id will have a best position, just to be fair and FIFO)
3534
     * @param   int     exercise id
3535
     * @param   string  course code
3536
     * @param   int     session id
3537
     * @param bool $return_string
3538
     *
3539
     * @return int the position of the user between his friends in a course (or course within a session)
3540
     */
3541
    public static function get_exercise_result_ranking_by_attempt(
3542
        $my_score,
3543
        $my_exe_id,
3544
        $exercise_id,
3545
        $courseId,
3546
        $session_id = 0,
3547
        $return_string = true
3548
    ) {
3549
        if (empty($session_id)) {
3550
            $session_id = 0;
3551
        }
3552
        if (is_null($my_score)) {
3553
            return '-';
3554
        }
3555
        $user_results = Event::get_all_exercise_results(
3556
            $exercise_id,
3557
            $courseId,
3558
            $session_id,
3559
            false
3560
        );
3561
        $position_data = [];
3562
        if (empty($user_results)) {
3563
            return 1;
3564
        } else {
3565
            $position = 1;
3566
            $my_ranking = [];
3567
            foreach ($user_results as $result) {
3568
                if (!empty($result['max_score']) && 0 != intval($result['max_score'])) {
3569
                    $my_ranking[$result['exe_id']] = $result['score'] / $result['max_score'];
3570
                } else {
3571
                    $my_ranking[$result['exe_id']] = 0;
3572
                }
3573
            }
3574
            asort($my_ranking);
3575
            $position = count($my_ranking);
3576
            if (!empty($my_ranking)) {
3577
                foreach ($my_ranking as $exe_id => $ranking) {
3578
                    if ($my_score >= $ranking) {
3579
                        if ($my_score == $ranking) {
3580
                            if ($my_exe_id < $exe_id) {
3581
                                $position--;
3582
                            }
3583
                        } else {
3584
                            $position--;
3585
                        }
3586
                    }
3587
                }
3588
            }
3589
            $return_value = [
3590
                'position' => $position,
3591
                'count' => count($my_ranking),
3592
            ];
3593
3594
            if ($return_string) {
3595
                if (!empty($position) && !empty($my_ranking)) {
3596
                    return $position.'/'.count($my_ranking);
3597
                }
3598
            }
3599
3600
            return $return_value;
3601
        }
3602
    }
3603
3604
    /**
3605
     * Get the best attempt in a exercise (NO Exercises in LPs ).
3606
     *
3607
     * @param int $exercise_id
3608
     * @param int $courseId
3609
     * @param int $session_id
3610
     *
3611
     * @return array
3612
     */
3613
    public static function get_best_attempt_in_course($exercise_id, $courseId, $session_id)
3614
    {
3615
        $user_results = Event::get_all_exercise_results(
3616
            $exercise_id,
3617
            $courseId,
3618
            $session_id,
3619
            false
3620
        );
3621
3622
        $best_score_data = [];
3623
        $best_score = 0;
3624
        if (!empty($user_results)) {
3625
            foreach ($user_results as $result) {
3626
                if (!empty($result['max_score']) &&
3627
                    0 != intval($result['max_score'])
3628
                ) {
3629
                    $score = $result['score'] / $result['max_score'];
3630
                    if ($score >= $best_score) {
3631
                        $best_score = $score;
3632
                        $best_score_data = $result;
3633
                    }
3634
                }
3635
            }
3636
        }
3637
3638
        return $best_score_data;
3639
    }
3640
3641
    /**
3642
     * Get the best score in a exercise (NO Exercises in LPs ).
3643
     *
3644
     * @param int $user_id
3645
     * @param int $exercise_id
3646
     * @param int $courseId
3647
     * @param int $session_id
3648
     *
3649
     * @return array
3650
     */
3651
    public static function get_best_attempt_by_user(
3652
        $user_id,
3653
        $exercise_id,
3654
        $courseId,
3655
        $session_id
3656
    ) {
3657
        $user_results = Event::get_all_exercise_results(
3658
            $exercise_id,
3659
            $courseId,
3660
            $session_id,
3661
            false,
3662
            $user_id
3663
        );
3664
        $best_score_data = [];
3665
        $best_score = 0;
3666
        if (!empty($user_results)) {
3667
            foreach ($user_results as $result) {
3668
                if (!empty($result['max_score']) && 0 != (float) $result['max_score']) {
3669
                    $score = $result['score'] / $result['max_score'];
3670
                    if ($score >= $best_score) {
3671
                        $best_score = $score;
3672
                        $best_score_data = $result;
3673
                    }
3674
                }
3675
            }
3676
        }
3677
3678
        return $best_score_data;
3679
    }
3680
3681
    /**
3682
     * Get average score (NO Exercises in LPs ).
3683
     *
3684
     * @param    int    exercise id
3685
     * @param int $courseId
3686
     * @param    int    session id
3687
     *
3688
     * @return float Average score
3689
     */
3690
    public static function get_average_score($exercise_id, $courseId, $session_id)
3691
    {
3692
        $user_results = Event::get_all_exercise_results(
3693
            $exercise_id,
3694
            $courseId,
3695
            $session_id
3696
        );
3697
        $avg_score = 0;
3698
        if (!empty($user_results)) {
3699
            foreach ($user_results as $result) {
3700
                if (!empty($result['max_score']) && 0 != intval($result['max_score'])) {
3701
                    $score = $result['score'] / $result['max_score'];
3702
                    $avg_score += $score;
3703
                }
3704
            }
3705
            $avg_score = float_format($avg_score / count($user_results), 1);
3706
        }
3707
3708
        return $avg_score;
3709
    }
3710
3711
    /**
3712
     * Get average score by score (NO Exercises in LPs ).
3713
     *
3714
     * @param int $courseId
3715
     * @param    int    session id
3716
     *
3717
     * @return float Average score
3718
     */
3719
    public static function get_average_score_by_course($courseId, $session_id)
3720
    {
3721
        $user_results = Event::get_all_exercise_results_by_course(
3722
            $courseId,
3723
            $session_id,
3724
            false
3725
        );
3726
        $avg_score = 0;
3727
        if (!empty($user_results)) {
3728
            foreach ($user_results as $result) {
3729
                if (!empty($result['max_score']) && 0 != intval(
3730
                        $result['max_score']
3731
                    )
3732
                ) {
3733
                    $score = $result['score'] / $result['max_score'];
3734
                    $avg_score += $score;
3735
                }
3736
            }
3737
            // We assume that all max_score
3738
            $avg_score = $avg_score / count($user_results);
3739
        }
3740
3741
        return $avg_score;
3742
    }
3743
3744
    /**
3745
     * @param int $user_id
3746
     * @param int $courseId
3747
     * @param int $session_id
3748
     *
3749
     * @return float|int
3750
     */
3751
    public static function get_average_score_by_course_by_user(
3752
        $user_id,
3753
        $courseId,
3754
        $session_id
3755
    ) {
3756
        $user_results = Event::get_all_exercise_results_by_user(
3757
            $user_id,
3758
            $courseId,
3759
            $session_id
3760
        );
3761
        $avg_score = 0;
3762
        if (!empty($user_results)) {
3763
            foreach ($user_results as $result) {
3764
                if (!empty($result['max_score']) && 0 != intval($result['max_score'])) {
3765
                    $score = $result['score'] / $result['max_score'];
3766
                    $avg_score += $score;
3767
                }
3768
            }
3769
            // We assume that all max_score
3770
            $avg_score = ($avg_score / count($user_results));
3771
        }
3772
3773
        return $avg_score;
3774
    }
3775
3776
    /**
3777
     * Get average score by score (NO Exercises in LPs ).
3778
     *
3779
     * @param int $exercise_id
3780
     * @param int $courseId
3781
     * @param int $session_id
3782
     * @param int $user_count
3783
     *
3784
     * @return float Best average score
3785
     */
3786
    public static function get_best_average_score_by_exercise(
3787
        $exercise_id,
3788
        $courseId,
3789
        $session_id,
3790
        $user_count
3791
    ) {
3792
        $user_results = Event::get_best_exercise_results_by_user(
3793
            $exercise_id,
3794
            $courseId,
3795
            $session_id
3796
        );
3797
        $avg_score = 0;
3798
        if (!empty($user_results)) {
3799
            foreach ($user_results as $result) {
3800
                if (!empty($result['max_score']) && 0 != intval($result['max_score'])) {
3801
                    $score = $result['score'] / $result['max_score'];
3802
                    $avg_score += $score;
3803
                }
3804
            }
3805
            // We asumme that all max_score
3806
            if (!empty($user_count)) {
3807
                $avg_score = float_format($avg_score / $user_count, 1) * 100;
3808
            } else {
3809
                $avg_score = 0;
3810
            }
3811
        }
3812
3813
        return $avg_score;
3814
    }
3815
3816
    /**
3817
     * Get average score by score (NO Exercises in LPs ).
3818
     *
3819
     * @param int $exercise_id
3820
     * @param int $courseId
3821
     * @param int $session_id
3822
     *
3823
     * @return float Best average score
3824
     */
3825
    public static function getBestScoreByExercise(
3826
        $exercise_id,
3827
        $courseId,
3828
        $session_id
3829
    ) {
3830
        $user_results = Event::get_best_exercise_results_by_user(
3831
            $exercise_id,
3832
            $courseId,
3833
            $session_id
3834
        );
3835
        $avg_score = 0;
3836
        if (!empty($user_results)) {
3837
            foreach ($user_results as $result) {
3838
                if (!empty($result['max_score']) && 0 != intval($result['max_score'])) {
3839
                    $score = $result['score'] / $result['max_score'];
3840
                    $avg_score += $score;
3841
                }
3842
            }
3843
        }
3844
3845
        return $avg_score;
3846
    }
3847
3848
    /**
3849
     * Get student results (only in completed exercises) stats by question.
3850
     *
3851
     * @throws \Doctrine\DBAL\Exception
3852
     */
3853
    public static function getStudentStatsByQuestion(
3854
        int $questionId,
3855
        int $exerciseId,
3856
        string $courseCode,
3857
        int $sessionId,
3858
        bool $onlyStudent = false
3859
    ): array
3860
    {
3861
        $trackExercises = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
3862
        $trackAttempt = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT);
3863
        $courseUser = Database::get_main_table(TABLE_MAIN_COURSE_USER);
3864
3865
        $questionId = (int) $questionId;
3866
        $exerciseId = (int) $exerciseId;
3867
        $courseCode = Database::escape_string($courseCode);
3868
        $sessionId = (int) $sessionId;
3869
        $courseId = api_get_course_int_id($courseCode);
3870
3871
        $sql = "SELECT MAX(marks) as max, MIN(marks) as min, AVG(marks) as average
3872
                FROM $trackExercises e ";
3873
        $sessionCondition = api_get_session_condition($sessionId, true, false, 'e.session_id');
3874
        if ($onlyStudent) {
3875
            $courseCondition = '';
3876
            if (empty($sessionId)) {
3877
                $courseCondition = "
3878
                INNER JOIN $courseUser c
3879
                ON (
3880
                    e.exe_user_id = c.user_id AND
3881
                    e.c_id = c.c_id AND
3882
                    c.status = ".STUDENT." AND
3883
                    relation_type <> 2
3884
                )";
3885
            } else {
3886
                $sessionRelCourse = Database::get_main_table(TABLE_MAIN_SESSION_COURSE_USER);
3887
                $courseCondition = "
3888
            INNER JOIN $sessionRelCourse sc
3889
            ON (
3890
                        e.exe_user_id = sc.user_id AND
3891
                        e.c_id = sc.c_id AND
3892
                        e.session_id = sc.session_id AND
3893
                        sc.status = ".SessionEntity::STUDENT."
3894
                )";
3895
            }
3896
            $sql .= $courseCondition;
3897
        }
3898
        $sql .= "
3899
    		INNER JOIN $trackAttempt a
3900
    		ON (
3901
    		    a.exe_id = e.exe_id
3902
            )
3903
    		WHERE
3904
    		    exe_exo_id 	= $exerciseId AND
3905
                e.c_id = $courseId AND
3906
                question_id = $questionId AND
3907
                e.status = ''
3908
                $sessionCondition
3909
            LIMIT 1";
3910
        $result = Database::query($sql);
3911
        $return = [];
3912
        if ($result) {
3913
            $return = Database::fetch_assoc($result);
3914
        }
3915
3916
        return $return;
3917
    }
3918
3919
    /**
3920
     * Get the correct answer count for a fill blanks question.
3921
     *
3922
     * @param int $question_id
3923
     * @param int $exercise_id
3924
     *
3925
     * @return array
3926
     */
3927
    public static function getNumberStudentsFillBlanksAnswerCount(
3928
        $question_id,
3929
        $exercise_id
3930
    ) {
3931
        $listStudentsId = [];
3932
        $listAllStudentInfo = CourseManager::get_student_list_from_course_code(
3933
            api_get_course_id(),
3934
            true
3935
        );
3936
        foreach ($listAllStudentInfo as $i => $listStudentInfo) {
3937
            $listStudentsId[] = $listStudentInfo['user_id'];
3938
        }
3939
3940
        $listFillTheBlankResult = FillBlanks::getFillTheBlankResult(
3941
            $exercise_id,
3942
            $question_id,
3943
            $listStudentsId,
3944
            '1970-01-01',
3945
            '3000-01-01'
3946
        );
3947
3948
        $arrayCount = [];
3949
3950
        foreach ($listFillTheBlankResult as $resultCount) {
3951
            foreach ($resultCount as $index => $count) {
3952
                //this is only for declare the array index per answer
3953
                $arrayCount[$index] = 0;
3954
            }
3955
        }
3956
3957
        foreach ($listFillTheBlankResult as $resultCount) {
3958
            foreach ($resultCount as $index => $count) {
3959
                $count = (0 === $count) ? 1 : 0;
3960
                $arrayCount[$index] += $count;
3961
            }
3962
        }
3963
3964
        return $arrayCount;
3965
    }
3966
3967
    /**
3968
     * Get the number of questions with answers.
3969
     *
3970
     * @param int    $question_id
3971
     * @param int    $exercise_id
3972
     * @param string $course_code
3973
     * @param int    $session_id
3974
     * @param string $questionType
3975
     *
3976
     * @return int
3977
     */
3978
    public static function get_number_students_question_with_answer_count(
3979
        $question_id,
3980
        $exercise_id,
3981
        $course_code,
3982
        $session_id,
3983
        $questionType = ''
3984
    ) {
3985
        $track_exercises   = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
3986
        $track_attempt     = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT);
3987
        $courseUser        = Database::get_main_table(TABLE_MAIN_COURSE_USER);
3988
        $courseTable       = Database::get_main_table(TABLE_MAIN_COURSE);
3989
        $courseUserSession = Database::get_main_table(TABLE_MAIN_SESSION_COURSE_USER);
3990
3991
        $question_id = (int) $question_id;
3992
        $exercise_id = (int) $exercise_id;
3993
        $courseId    = (int) api_get_course_int_id($course_code);
3994
        $session_id  = (int) $session_id;
3995
3996
        if (in_array($questionType, [FILL_IN_BLANKS, FILL_IN_BLANKS_COMBINATION], true)) {
3997
            $listStudentsId     = [];
3998
            $listAllStudentInfo = CourseManager::get_student_list_from_course_code(api_get_course_id(), true);
3999
            foreach ($listAllStudentInfo as $listStudentInfo) {
4000
                $listStudentsId[] = (int) $listStudentInfo['user_id'];
4001
            }
4002
4003
            $listFillTheBlankResult = FillBlanks::getFillTheBlankResult(
4004
                $exercise_id,
4005
                $question_id,
4006
                $listStudentsId,
4007
                '1970-01-01',
4008
                '3000-01-01'
4009
            );
4010
4011
            return FillBlanks::getNbResultFillBlankAll($listFillTheBlankResult);
4012
        }
4013
4014
        if (empty($session_id)) {
4015
            $courseCondition = "
4016
            INNER JOIN $courseUser cu
4017
                ON cu.c_id = c.id AND cu.user_id = exe_user_id";
4018
            $courseConditionWhere = " AND relation_type <> 2 AND cu.status = ".STUDENT;
4019
        } else {
4020
            $courseCondition = "
4021
            INNER JOIN $courseUserSession cu
4022
                ON (cu.c_id = c.id AND cu.user_id = e.exe_user_id AND e.session_id = cu.session_id)";
4023
            $courseConditionWhere = ' AND cu.status = '.SessionEntity::STUDENT;
4024
        }
4025
4026
        $sessionCondition = api_get_session_condition($session_id, true, false, 'e.session_id');
4027
        $sql = "SELECT DISTINCT exe_user_id
4028
            FROM $track_exercises e
4029
            INNER JOIN $track_attempt a
4030
                ON (a.exe_id = e.exe_id AND a.c_id = e.c_id)
4031
            INNER JOIN $courseTable c
4032
                ON c.id = e.c_id
4033
            $courseCondition
4034
            WHERE
4035
                exe_exo_id  = $exercise_id AND
4036
                e.c_id      = $courseId AND
4037
                question_id = $question_id AND
4038
                answer <> '0' AND
4039
                e.status = ''
4040
                $courseConditionWhere
4041
                $sessionCondition
4042
    ";
4043
4044
        $result = Database::query($sql);
4045
4046
        return $result ? (int) Database::num_rows($result) : 0;
4047
    }
4048
4049
    /**
4050
     * Get number of answers to hotspot questions.
4051
     */
4052
    public static function getNumberStudentsAnswerHotspotCount(
4053
        int    $answerId,
4054
        int    $questionId,
4055
        int    $exerciseId,
4056
        string $courseCode,
4057
        int $sessionId
4058
    ): int
4059
    {
4060
        $trackExercises = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
4061
        $trackHotspot = Database::get_main_table(TABLE_STATISTIC_TRACK_E_HOTSPOT);
4062
        $courseUser = Database::get_main_table(TABLE_MAIN_COURSE_USER);
4063
        $courseTable = Database::get_main_table(TABLE_MAIN_COURSE);
4064
        $courseUserSession = Database::get_main_table(TABLE_MAIN_SESSION_COURSE_USER);
4065
4066
        $questionId = (int) $questionId;
4067
        $answerId = (int) $answerId;
4068
        $exerciseId = (int) $exerciseId;
4069
        $courseId = api_get_course_int_id($courseCode);
4070
        $sessionId = (int) $sessionId;
4071
4072
        if (empty($sessionId)) {
4073
            $courseCondition = "
4074
            INNER JOIN $courseUser cu
4075
            ON cu.c_id = c.id AND cu.user_id  = exe_user_id";
4076
            $courseConditionWhere = " AND relation_type <> 2 AND cu.status = ".STUDENT;
4077
        } else {
4078
            $courseCondition = "
4079
            INNER JOIN $courseUserSession cu
4080
            ON (cu.c_id = c.id AND cu.user_id = e.exe_user_id AND e.session_id = cu.session_id)";
4081
            $courseConditionWhere = ' AND cu.status = '.SessionEntity::STUDENT;
4082
        }
4083
4084
        $sessionCondition = api_get_session_condition($sessionId, true, false, 'e.session_id');
4085
        $sql = "SELECT DISTINCT exe_user_id
4086
                FROM $trackExercises e
4087
                INNER JOIN $trackHotspot a
4088
                ON (a.hotspot_exe_id = e.exe_id)
4089
                INNER JOIN $courseTable c
4090
                ON (a.c_id = c.id)
4091
                $courseCondition
4092
                WHERE
4093
                    exe_exo_id              = $exerciseId AND
4094
                    a.c_id 	= $courseId AND
4095
                    hotspot_answer_id       = $answerId AND
4096
                    hotspot_question_id     = $questionId AND
4097
                    hotspot_correct         =  1 AND
4098
                    e.status                = ''
4099
                    $courseConditionWhere
4100
                    $sessionCondition
4101
            ";
4102
        $result = Database::query($sql);
4103
        $return = 0;
4104
        if ($result) {
4105
            $return = Database::num_rows($result);
4106
        }
4107
4108
        return $return;
4109
    }
4110
4111
    /**
4112
     * @param int    $answer_id
4113
     * @param int    $question_id
4114
     * @param int    $exercise_id
4115
     * @param string $course_code
4116
     * @param int    $session_id
4117
     * @param string $question_type
4118
     * @param string $correct_answer
4119
     * @param string $current_answer
4120
     *
4121
     * @return int
4122
     */
4123
    public static function get_number_students_answer_count(
4124
        $answer_id,
4125
        $question_id,
4126
        $exercise_id,
4127
        $course_code,
4128
        $session_id,
4129
        $question_type = null,
4130
        $correct_answer = null,
4131
        $current_answer = null
4132
    ) {
4133
        $track_exercises = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
4134
        $track_attempt   = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT);
4135
        $courseTable     = Database::get_main_table(TABLE_MAIN_COURSE);
4136
        $courseUser      = Database::get_main_table(TABLE_MAIN_COURSE_USER);
4137
        $courseUserSession = Database::get_main_table(TABLE_MAIN_SESSION_COURSE_USER);
4138
4139
        $question_id = (int) $question_id;
4140
        $answer_id   = (int) $answer_id;
4141
        $exercise_id = (int) $exercise_id;
4142
        $courseId    = (int) api_get_course_int_id($course_code);
4143
        $session_id  = (int) $session_id;
4144
4145
        switch ($question_type) {
4146
            case FILL_IN_BLANKS:
4147
            case FILL_IN_BLANKS_COMBINATION:
4148
                $answer_condition = '';
4149
                $select_condition = ' e.exe_id, answer ';
4150
                break;
4151
            case MATCHING:
4152
            case MATCHING_COMBINATION:
4153
            case MATCHING_DRAGGABLE:
4154
            case MATCHING_DRAGGABLE_COMBINATION:
4155
            default:
4156
                $answer_condition = " answer = $answer_id AND ";
4157
                $select_condition = ' DISTINCT exe_user_id ';
4158
        }
4159
4160
        if (empty($session_id)) {
4161
            $courseCondition = "
4162
            INNER JOIN $courseUser cu
4163
                ON cu.c_id = e.c_id AND cu.user_id = e.exe_user_id";
4164
            $courseConditionWhere = " AND relation_type <> 2 AND cu.status = ".STUDENT;
4165
        } else {
4166
            $courseCondition = "
4167
            INNER JOIN $courseUserSession cu
4168
                ON (cu.c_id = e.c_id AND cu.user_id = e.exe_user_id AND e.session_id = cu.session_id)";
4169
            $courseConditionWhere = ' AND cu.status = '.SessionEntity::STUDENT;
4170
        }
4171
4172
        $sessionCondition = api_get_session_condition($session_id, true, false, 'e.session_id');
4173
        $sql = "SELECT $select_condition
4174
            FROM $track_exercises e
4175
            INNER JOIN $track_attempt a
4176
                ON (a.exe_id = e.exe_id AND a.c_id = e.c_id)
4177
            INNER JOIN $courseTable c
4178
                ON c.id = e.c_id
4179
            $courseCondition
4180
            WHERE
4181
                exe_exo_id = $exercise_id AND
4182
                e.c_id = $courseId AND
4183
                $answer_condition
4184
                question_id = $question_id AND
4185
                e.status = ''
4186
                $courseConditionWhere
4187
                $sessionCondition
4188
    ";
4189
4190
        $result = Database::query($sql);
4191
        $return = 0;
4192
        if ($result) {
4193
            switch ($question_type) {
4194
                case FILL_IN_BLANKS:
4195
                case FILL_IN_BLANKS_COMBINATION:
4196
                    $good_answers = 0;
4197
                    while ($row = Database::fetch_assoc($result)) {
4198
                        $fill_blank = self::check_fill_in_blanks(
4199
                            $correct_answer,
4200
                            $row['answer'],
4201
                            $current_answer
4202
                        );
4203
                        if (isset($fill_blank[$current_answer]) && 1 == (int) $fill_blank[$current_answer]) {
4204
                            $good_answers++;
4205
                        }
4206
                    }
4207
4208
                    return $good_answers;
4209
4210
                case MATCHING:
4211
                case MATCHING_COMBINATION:
4212
                case MATCHING_DRAGGABLE:
4213
                case MATCHING_DRAGGABLE_COMBINATION:
4214
                default:
4215
                    $return = Database::num_rows($result);
4216
            }
4217
        }
4218
4219
        return $return;
4220
    }
4221
4222
    /**
4223
     * Get the number of times an answer was selected.
4224
     */
4225
    public static function getCountOfAnswers(
4226
        int $answerId,
4227
        int $questionId,
4228
        int $exerciseId,
4229
        string $courseCode,
4230
        int $sessionId,
4231
        $questionType = null,
4232
    ): int
4233
    {
4234
        $trackExercises = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
4235
        $trackAttempt = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT);
4236
        $courseTable = Database::get_main_table(TABLE_MAIN_COURSE);
4237
        $courseUser = Database::get_main_table(TABLE_MAIN_COURSE_USER);
4238
        $courseUserSession = Database::get_main_table(TABLE_MAIN_SESSION_COURSE_USER);
4239
4240
        $answerId = (int) $answerId;
4241
        $questionId = (int) $questionId;
4242
        $exerciseId = (int) $exerciseId;
4243
        $courseId = api_get_course_int_id($courseCode);
4244
        $sessionId = (int) $sessionId;
4245
        $return = 0;
4246
4247
        $answerCondition = match ($questionType) {
4248
            FILL_IN_BLANKS => '',
4249
            default => " answer = $answerId AND ",
4250
        };
4251
4252
        if (empty($sessionId)) {
4253
            $courseCondition = "
4254
            INNER JOIN $courseUser cu
4255
            ON cu.c_id = c.id AND cu.user_id = exe_user_id";
4256
            $courseConditionWhere = " AND relation_type <> 2 AND cu.status = ".STUDENT;
4257
        } else {
4258
            $courseCondition = "
4259
            INNER JOIN $courseUserSession cu
4260
            ON (cu.c_id = a.c_id AND cu.user_id = e.exe_user_id AND e.session_id = cu.session_id)";
4261
            $courseConditionWhere = ' AND cu.status = '.SessionEntity::STUDENT;
4262
        }
4263
4264
        $sessionCondition = api_get_session_condition($sessionId, true, false, 'e.session_id');
4265
        $sql = "SELECT count(a.answer) as total
4266
                FROM $trackExercises e
4267
                INNER JOIN $trackAttempt a
4268
                ON (
4269
                    a.exe_id = e.exe_id
4270
                )
4271
                INNER JOIN $courseTable c
4272
                ON c.id = e.c_id
4273
                $courseCondition
4274
                WHERE
4275
                    exe_exo_id = $exerciseId AND
4276
                    e.c_id = $courseId AND
4277
                    $answerCondition
4278
                    question_id = $questionId AND
4279
                    e.status = ''
4280
                    $courseConditionWhere
4281
                    $sessionCondition
4282
            ";
4283
        $result = Database::query($sql);
4284
        if ($result) {
4285
            $count = Database::fetch_array($result);
4286
            $return = (int) $count['total'];
4287
        }
4288
        return $return;
4289
    }
4290
4291
    /**
4292
     * @param array  $answer
4293
     * @param string $user_answer
4294
     *
4295
     * @return array
4296
     */
4297
    public static function check_fill_in_blanks($answer, $user_answer, $current_answer)
4298
    {
4299
        // the question is encoded like this
4300
        // [A] B [C] D [E] F::10,10,10@1
4301
        // number 1 before the "@" means that is a switchable fill in blank question
4302
        // [A] B [C] D [E] F::10,10,10@ or  [A] B [C] D [E] F::10,10,10
4303
        // means that is a normal fill blank question
4304
        // first we explode the "::"
4305
        $pre_array = explode('::', $answer);
4306
        // is switchable fill blank or not
4307
        $last = count($pre_array) - 1;
4308
        $is_set_switchable = explode('@', $pre_array[$last]);
4309
        $switchable_answer_set = false;
4310
        if (isset($is_set_switchable[1]) && 1 == $is_set_switchable[1]) {
4311
            $switchable_answer_set = true;
4312
        }
4313
        $answer = '';
4314
        for ($k = 0; $k < $last; $k++) {
4315
            $answer .= $pre_array[$k];
4316
        }
4317
        // splits weightings that are joined with a comma
4318
        $answerWeighting = explode(',', $is_set_switchable[0]);
4319
4320
        // we save the answer because it will be modified
4321
        //$temp = $answer;
4322
        $temp = $answer;
4323
4324
        $answer = '';
4325
        $j = 0;
4326
        //initialise answer tags
4327
        $user_tags = $correct_tags = $real_text = [];
4328
        // the loop will stop at the end of the text
4329
        while (1) {
4330
            // quits the loop if there are no more blanks (detect '[')
4331
            if (false === ($pos = api_strpos($temp, '['))) {
4332
                // adds the end of the text
4333
                $answer = $temp;
4334
                $real_text[] = $answer;
4335
                break; //no more "blanks", quit the loop
4336
            }
4337
            // adds the piece of text that is before the blank
4338
            //and ends with '[' into a general storage array
4339
            $real_text[] = api_substr($temp, 0, $pos + 1);
4340
            $answer .= api_substr($temp, 0, $pos + 1);
4341
            //take the string remaining (after the last "[" we found)
4342
            $temp = api_substr($temp, $pos + 1);
4343
            // quit the loop if there are no more blanks, and update $pos to the position of next ']'
4344
            if (false === ($pos = api_strpos($temp, ']'))) {
4345
                // adds the end of the text
4346
                $answer .= $temp;
4347
                break;
4348
            }
4349
4350
            $str = $user_answer;
4351
4352
            preg_match_all('#\[([^[]*)\]#', $str, $arr);
4353
            $str = str_replace('\r\n', '', $str);
4354
            $choices = $arr[1];
4355
            $choice = [];
4356
            $check = false;
4357
            $i = 0;
4358
            foreach ($choices as $item) {
4359
                if ($current_answer === $item) {
4360
                    $check = true;
4361
                }
4362
                if ($check) {
4363
                    $choice[] = $item;
4364
                    $i++;
4365
                }
4366
                if (3 == $i) {
4367
                    break;
4368
                }
4369
            }
4370
            $tmp = api_strrpos($choice[$j], ' / ');
4371
4372
            if (false !== $tmp) {
4373
                $choice[$j] = api_substr($choice[$j], 0, $tmp);
4374
            }
4375
4376
            $choice[$j] = trim($choice[$j]);
4377
4378
            //Needed to let characters ' and " to work as part of an answer
4379
            $choice[$j] = stripslashes($choice[$j]);
4380
4381
            $user_tags[] = api_strtolower($choice[$j]);
4382
            //put the contents of the [] answer tag into correct_tags[]
4383
            $correct_tags[] = api_strtolower(api_substr($temp, 0, $pos));
4384
            $j++;
4385
            $temp = api_substr($temp, $pos + 1);
4386
        }
4387
4388
        $answer = '';
4389
        $real_correct_tags = $correct_tags;
4390
        $chosen_list = [];
4391
        $good_answer = [];
4392
4393
        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...
4394
            if (!$switchable_answer_set) {
4395
                //needed to parse ' and " characters
4396
                $user_tags[$i] = stripslashes($user_tags[$i]);
4397
                if ($correct_tags[$i] == $user_tags[$i]) {
4398
                    $good_answer[$correct_tags[$i]] = 1;
4399
                } elseif (!empty($user_tags[$i])) {
4400
                    $good_answer[$correct_tags[$i]] = 0;
4401
                } else {
4402
                    $good_answer[$correct_tags[$i]] = 0;
4403
                }
4404
            } else {
4405
                // switchable fill in the blanks
4406
                if (in_array($user_tags[$i], $correct_tags)) {
4407
                    $correct_tags = array_diff($correct_tags, $chosen_list);
4408
                    $good_answer[$correct_tags[$i]] = 1;
4409
                } elseif (!empty($user_tags[$i])) {
4410
                    $good_answer[$correct_tags[$i]] = 0;
4411
                } else {
4412
                    $good_answer[$correct_tags[$i]] = 0;
4413
                }
4414
            }
4415
            // adds the correct word, followed by ] to close the blank
4416
            $answer .= ' / <font color="green"><b>'.$real_correct_tags[$i].'</b></font>]';
4417
            if (isset($real_text[$i + 1])) {
4418
                $answer .= $real_text[$i + 1];
4419
            }
4420
        }
4421
4422
        return $good_answer;
4423
    }
4424
4425
    /**
4426
     * Return an HTML select menu with the student groups.
4427
     *
4428
     * @param string $name     is the name and the id of the <select>
4429
     * @param string $default  default value for option
4430
     * @param string $onchange
4431
     *
4432
     * @return string the html code of the <select>
4433
     */
4434
    public static function displayGroupMenu($name, $default, $onchange = "")
4435
    {
4436
        // check the default value of option
4437
        $tabSelected = [$default => " selected='selected' "];
4438
        $res = "<select name='$name' id='$name' onchange='".$onchange."' >";
4439
        $res .= "<option value='-1'".$tabSelected["-1"].">-- ".get_lang('All groups')." --</option>";
4440
        $res .= "<option value='0'".$tabSelected["0"].">- ".get_lang('Not in a group')." -</option>";
4441
        $groups = GroupManager::get_group_list();
4442
        $currentCatId = 0;
4443
        $countGroups = count($groups);
4444
        for ($i = 0; $i < $countGroups; $i++) {
4445
            $category = GroupManager::get_category_from_group($groups[$i]['iid']);
4446
            if ($category['id'] != $currentCatId) {
4447
                $res .= "<option value='-1' disabled='disabled'>".$category['title']."</option>";
4448
                $currentCatId = $category['id'];
4449
            }
4450
            $res .= "<option ".$tabSelected[$groups[$i]['id']]."style='margin-left:40px' value='".
4451
                $groups[$i]["iid"]."'>".
4452
                $groups[$i]["name"].
4453
                "</option>";
4454
        }
4455
        $res .= "</select>";
4456
4457
        return $res;
4458
    }
4459
4460
    public static function create_chat_exercise_session($exe_id)
4461
    {
4462
        $exeId = (int) $exe_id;
4463
        if ($exeId <= 0) {
4464
            return;
4465
        }
4466
4467
        if (!isset($_SESSION['current_exercises']) || !is_array($_SESSION['current_exercises'])) {
4468
            $_SESSION['current_exercises'] = [];
4469
        }
4470
        $_SESSION['current_exercises'][$exeId] = true;
4471
4472
        try {
4473
            /** @var AiHelper $aiHelper */
4474
            $aiHelper = Container::$container->get(AiHelper::class);
4475
            $aiHelper->markUserInTest((int) $exeId);
4476
        } catch (\Throwable $e) {
4477
            // Ignore on legacy context (no hard dependency).
4478
        }
4479
    }
4480
4481
    public static function delete_chat_exercise_session($exe_id)
4482
    {
4483
        $exeId = (int) $exe_id;
4484
        if ($exeId <= 0) {
4485
            return;
4486
        }
4487
4488
        if (isset($_SESSION['current_exercises']) && is_array($_SESSION['current_exercises'])) {
4489
            unset($_SESSION['current_exercises'][$exeId]);
4490
4491
            if (empty($_SESSION['current_exercises'])) {
4492
                unset($_SESSION['current_exercises']);
4493
            }
4494
        }
4495
4496
        try {
4497
            /** @var AiHelper $aiHelper */
4498
            $aiHelper = Container::$container->get(AiHelper::class);
4499
            $aiHelper->clearUserInTest((int) $exeId);
4500
        } catch (\Throwable $e) {
4501
            // Ignore on legacy context (no hard dependency).
4502
        }
4503
    }
4504
4505
    /**
4506
     * Display the exercise results.
4507
     *
4508
     * @param Exercise $objExercise
4509
     * @param int      $exeId
4510
     * @param bool     $save_user_result save users results (true) or just show the results (false)
4511
     * @param string   $remainingMessage
4512
     * @param bool     $allowSignature
4513
     * @param bool     $allowExportPdf
4514
     * @param bool     $isExport
4515
     */
4516
    public static function displayQuestionListByAttempt(
4517
        $objExercise,
4518
        $exeId,
4519
        $save_user_result = false,
4520
        $remainingMessage = '',
4521
        $allowSignature = false,
4522
        $allowExportPdf = false,
4523
        $isExport = false
4524
    ) {
4525
        $origin = api_get_origin();
4526
        $courseId = api_get_course_int_id();
4527
        $courseCode = api_get_course_id();
4528
        $sessionId = api_get_session_id();
4529
4530
        // Getting attempt info
4531
        $exercise_stat_info = $objExercise->get_stat_track_exercise_info_by_exe_id($exeId);
4532
4533
        // Getting question list
4534
        $question_list = [];
4535
        $studentInfo = [];
4536
        if (!empty($exercise_stat_info['data_tracking'])) {
4537
            $studentInfo = api_get_user_info($exercise_stat_info['exe_user_id']);
4538
            $question_list = explode(',', $exercise_stat_info['data_tracking']);
4539
        } else {
4540
            // Try getting the question list only if save result is off
4541
            if (false == $save_user_result) {
4542
                $question_list = $objExercise->get_validated_question_list();
4543
            }
4544
            if (in_array(
4545
                $objExercise->getFeedbackType(),
4546
                [EXERCISE_FEEDBACK_TYPE_DIRECT, EXERCISE_FEEDBACK_TYPE_POPUP]
4547
            )) {
4548
                $question_list = $objExercise->get_validated_question_list();
4549
            }
4550
        }
4551
4552
        if ($objExercise->getResultAccess()) {
4553
            if (false === $objExercise->hasResultsAccess($exercise_stat_info)) {
4554
                echo Display::return_message(
4555
                    sprintf(get_lang('You have passed the %s minutes limit to see the results.'), $objExercise->getResultsAccess())
4556
                );
4557
4558
                return false;
4559
            }
4560
4561
            if (!empty($objExercise->getResultAccess())) {
4562
                $url = api_get_path(WEB_CODE_PATH).'exercise/overview.php?'.api_get_cidreq().'&exerciseId='.$objExercise->id;
4563
                echo $objExercise->returnTimeLeftDiv();
4564
                echo $objExercise->showSimpleTimeControl(
4565
                    $objExercise->getResultAccessTimeDiff($exercise_stat_info),
4566
                    $url
4567
                );
4568
            }
4569
        }
4570
4571
        $counter = 1;
4572
        $total_score = $total_weight = 0;
4573
        $exerciseContent = null;
4574
4575
        // Hide results
4576
        $show_results = false;
4577
        $show_only_score = false;
4578
        if (in_array($objExercise->results_disabled,
4579
            [
4580
                RESULT_DISABLE_SHOW_ONLY_IN_CORRECT_ANSWER,
4581
                RESULT_DISABLE_SHOW_SCORE_AND_EXPECTED_ANSWERS,
4582
                RESULT_DISABLE_SHOW_SCORE_AND_EXPECTED_ANSWERS_AND_RANKING,
4583
            ]
4584
        )) {
4585
            $show_results = true;
4586
        }
4587
4588
        if (in_array(
4589
            $objExercise->results_disabled,
4590
            [
4591
                RESULT_DISABLE_SHOW_SCORE_ONLY,
4592
                RESULT_DISABLE_SHOW_FINAL_SCORE_ONLY_WITH_CATEGORIES,
4593
                RESULT_DISABLE_RANKING,
4594
            ]
4595
        )
4596
        ) {
4597
            $show_only_score = true;
4598
        }
4599
4600
        // Not display expected answer, but score, and feedback
4601
        $show_all_but_expected_answer = false;
4602
        if (RESULT_DISABLE_SHOW_SCORE_ONLY == $objExercise->results_disabled &&
4603
            EXERCISE_FEEDBACK_TYPE_END == $objExercise->getFeedbackType()
4604
        ) {
4605
            $show_all_but_expected_answer = true;
4606
            $show_results = true;
4607
            $show_only_score = false;
4608
        }
4609
4610
        $showTotalScoreAndUserChoicesInLastAttempt = true;
4611
        $showTotalScore = true;
4612
        $showQuestionScore = true;
4613
        $attemptResult = [];
4614
4615
        if (in_array(
4616
            $objExercise->results_disabled,
4617
            [
4618
                RESULT_DISABLE_SHOW_SCORE_ATTEMPT_SHOW_ANSWERS_LAST_ATTEMPT,
4619
                RESULT_DISABLE_SHOW_SCORE_ATTEMPT_SHOW_ANSWERS_LAST_ATTEMPT_NO_FEEDBACK,
4620
                RESULT_DISABLE_DONT_SHOW_SCORE_ONLY_IF_USER_FINISHES_ATTEMPTS_SHOW_ALWAYS_FEEDBACK,
4621
            ])
4622
        ) {
4623
            $show_only_score = true;
4624
            $show_results = true;
4625
            $numberAttempts = 0;
4626
            if ($objExercise->attempts > 0) {
4627
                $attempts = Event::getExerciseResultsByUser(
4628
                    api_get_user_id(),
4629
                    $objExercise->id,
4630
                    $courseId,
4631
                    $sessionId,
4632
                    $exercise_stat_info['orig_lp_id'],
4633
                    $exercise_stat_info['orig_lp_item_id'],
4634
                    'desc'
4635
                );
4636
                if ($attempts) {
4637
                    $numberAttempts = count($attempts);
4638
                }
4639
4640
                if ($save_user_result) {
4641
                    $numberAttempts++;
4642
                }
4643
4644
                $showTotalScore = false;
4645
                if (RESULT_DISABLE_SHOW_SCORE_ATTEMPT_SHOW_ANSWERS_LAST_ATTEMPT == $objExercise->results_disabled) {
4646
                    $showTotalScore = true;
4647
                }
4648
                $showTotalScoreAndUserChoicesInLastAttempt = false;
4649
                if ($numberAttempts >= $objExercise->attempts) {
4650
                    $showTotalScore = true;
4651
                    $show_results = true;
4652
                    $show_only_score = false;
4653
                    $showTotalScoreAndUserChoicesInLastAttempt = true;
4654
                }
4655
4656
                if (RESULT_DISABLE_SHOW_SCORE_ATTEMPT_SHOW_ANSWERS_LAST_ATTEMPT_NO_FEEDBACK == $objExercise->results_disabled) {
4657
                    $showTotalScore = true;
4658
                    $show_results = true;
4659
                    $show_only_score = false;
4660
                    $showTotalScoreAndUserChoicesInLastAttempt = false;
4661
                    if ($numberAttempts >= $objExercise->attempts) {
4662
                        $showTotalScoreAndUserChoicesInLastAttempt = true;
4663
                    }
4664
4665
                    // Check if the current attempt is the last.
4666
                    if (false === $save_user_result && !empty($attempts)) {
4667
                        $showTotalScoreAndUserChoicesInLastAttempt = false;
4668
                        $position = 1;
4669
                        foreach ($attempts as $attempt) {
4670
                            if ($exeId == $attempt['exe_id']) {
4671
                                break;
4672
                            }
4673
                            $position++;
4674
                        }
4675
4676
                        if ($position == $objExercise->attempts) {
4677
                            $showTotalScoreAndUserChoicesInLastAttempt = true;
4678
                        }
4679
                    }
4680
                }
4681
            }
4682
4683
            if (RESULT_DISABLE_DONT_SHOW_SCORE_ONLY_IF_USER_FINISHES_ATTEMPTS_SHOW_ALWAYS_FEEDBACK ==
4684
                $objExercise->results_disabled
4685
            ) {
4686
                $show_only_score = false;
4687
                $show_results = true;
4688
                $show_all_but_expected_answer = false;
4689
                $showTotalScore = false;
4690
                $showQuestionScore = false;
4691
                if ($numberAttempts >= $objExercise->attempts) {
4692
                    $showTotalScore = true;
4693
                    $showQuestionScore = true;
4694
                }
4695
            }
4696
        }
4697
4698
        // When exporting to PDF hide feedback/comment/score show warning in hotspot.
4699
        if ($allowExportPdf && $isExport) {
4700
            $showTotalScore = false;
4701
            $showQuestionScore = false;
4702
            $objExercise->feedback_type = 2;
4703
            $objExercise->hideComment = true;
4704
            $objExercise->hideNoAnswer = true;
4705
            $objExercise->results_disabled = 0;
4706
            $objExercise->hideExpectedAnswer = true;
4707
            $show_results = true;
4708
        }
4709
4710
        if ('embeddable' !== $origin &&
4711
            !empty($exercise_stat_info['exe_user_id']) &&
4712
            !empty($studentInfo)
4713
        ) {
4714
            // Shows exercise header.
4715
            echo $objExercise->showExerciseResultHeader(
4716
                $studentInfo,
4717
                $exercise_stat_info,
4718
                $save_user_result,
4719
                $allowSignature,
4720
                $allowExportPdf
4721
            );
4722
        }
4723
4724
        $question_list_answers = [];
4725
        $category_list = [];
4726
        $loadChoiceFromSession = false;
4727
        $fromDatabase = true;
4728
        $exerciseResult = null;
4729
        $exerciseResultCoordinates = null;
4730
        $delineationResults = null;
4731
        if (true === $save_user_result && in_array(
4732
            $objExercise->getFeedbackType(),
4733
            [EXERCISE_FEEDBACK_TYPE_DIRECT, EXERCISE_FEEDBACK_TYPE_POPUP]
4734
        )) {
4735
            $loadChoiceFromSession = true;
4736
            $fromDatabase = false;
4737
            $exerciseResult = Session::read('exerciseResult');
4738
            $exerciseResultCoordinates = Session::read('exerciseResultCoordinates');
4739
            $delineationResults = Session::read('hotspot_delineation_result');
4740
            $delineationResults = isset($delineationResults[$objExercise->id]) ? $delineationResults[$objExercise->id] : null;
4741
        }
4742
4743
        $countPendingQuestions = 0;
4744
        $result = [];
4745
        $panelsByParent = [];
4746
        $finalOrder = [];
4747
        if (!empty($question_list)) {
4748
            $parentMap = [];
4749
            $mediaChildren = []; // pid => ['first_idx'=>int, 'children'=>int[]]
4750
            foreach ($question_list as $idx => $qid) {
4751
                $q = Question::read($qid, $objExercise->course);
4752
                $pid = (int) ($q->parent_id ?: 0);
4753
                $parentMap[$qid] = $pid;
4754
                if ($pid > 0) {
4755
                    if (!isset($mediaChildren[$pid])) {
4756
                        $mediaChildren[$pid] = ['first_idx' => $idx, 'children' => []];
4757
                    }
4758
                    $mediaChildren[$pid]['children'][] = $qid;
4759
                }
4760
            }
4761
4762
            // build finalOrder, emitting each media group once.
4763
            $groupEmitted = [];
4764
            foreach ($question_list as $idx => $qid) {
4765
                $pid = $parentMap[$qid] ?? 0;
4766
                if ($pid === 0) {
4767
                    $finalOrder[] = ['type' => 'single', 'qid' => $qid];
4768
                } else {
4769
                    if (empty($groupEmitted[$pid])) {
4770
                        $groupEmitted[$pid] = true;
4771
                        $finalOrder[] = [
4772
                            'type'     => 'group',
4773
                            'parent'   => $pid,
4774
                            'children' => $mediaChildren[$pid]['children'] ?? [$qid],
4775
                        ];
4776
                    }
4777
                    // If already emitted, skip the child here (it will be in the group).
4778
                }
4779
            }
4780
        }
4781
4782
        $orderedOutputHtml = '';
4783
        $renderSingle = function (int $questionId) use (
4784
            &$objExercise,
4785
            $exeId,
4786
            $loadChoiceFromSession,
4787
            &$exerciseResult,
4788
            &$delineationResults,
4789
            &$exerciseResultCoordinates,
4790
            &$save_user_result,
4791
            &$fromDatabase,
4792
            &$show_results,
4793
            &$total_score,
4794
            &$total_weight,
4795
            &$question_list_answers,
4796
            &$showQuestionScore,
4797
            &$counter,
4798
            &$attemptResult,
4799
            &$category_list
4800
        ) {
4801
            // Start buffering rendering for this question
4802
            ob_start();
4803
4804
            // Load choices from session if needed
4805
            $choice = null;
4806
            $delineationChoice = null;
4807
            if ($loadChoiceFromSession) {
4808
                $choice = $exerciseResult[$questionId] ?? null;
4809
                $delineationChoice = $delineationResults[$questionId] ?? null;
4810
            }
4811
4812
            // Compute result for the given question
4813
            $result = $objExercise->manage_answer(
4814
                $exeId,
4815
                $questionId,
4816
                $choice,
4817
                'exercise_result',
4818
                $exerciseResultCoordinates,
4819
                $save_user_result,
4820
                $fromDatabase,
4821
                $show_results,
4822
                $objExercise->selectPropagateNeg(),
4823
                $delineationChoice,
4824
                true // keep user choices in last attempt when applicable
4825
            );
4826
4827
            if (empty($result)) {
4828
                ob_end_clean();
4829
                return [null, null]; // nothing to add
4830
            }
4831
4832
            $total_score  += $result['score'];
4833
            $total_weight += $result['weight'];
4834
4835
            $question_list_answers[] = [
4836
                'question'            => $result['open_question'],
4837
                'answer'              => $result['open_answer'],
4838
                'answer_type'         => $result['answer_type'],
4839
                'generated_oral_file' => $result['generated_oral_file'],
4840
            ];
4841
4842
            $my_total_score  = $result['score'];
4843
            $my_total_weight = $result['weight'];
4844
            $scorePassed     = self::scorePassed($my_total_score, $my_total_weight);
4845
4846
            // Category aggregation
4847
            $objQuestionTmp = Question::read($questionId, $objExercise->course);
4848
            $category_was_added_for_this_test = false;
4849
            if (isset($objQuestionTmp->category) && !empty($objQuestionTmp->category)) {
4850
                $cid = $objQuestionTmp->category;
4851
                $category_list[$cid]['score']           = ($category_list[$cid]['score'] ?? 0) + $my_total_score;
4852
                $category_list[$cid]['total']           = ($category_list[$cid]['total'] ?? 0) + $my_total_weight;
4853
                $category_list[$cid]['total_questions'] = ($category_list[$cid]['total_questions'] ?? 0) + 1;
4854
                if ($scorePassed) {
4855
                    if (!empty($my_total_score)) {
4856
                        $category_list[$cid]['passed'] = ($category_list[$cid]['passed'] ?? 0) + 1;
4857
                    }
4858
                } else {
4859
                    if ($result['user_answered']) {
4860
                        $category_list[$cid]['wrong'] = ($category_list[$cid]['wrong'] ?? 0) + 1;
4861
                    } else {
4862
                        $category_list[$cid]['no_answer'] = ($category_list[$cid]['no_answer'] ?? 0) + 1;
4863
                    }
4864
                }
4865
                $category_was_added_for_this_test = true;
4866
            }
4867
            if (!empty($objQuestionTmp->category_list)) {
4868
                foreach ($objQuestionTmp->category_list as $cid) {
4869
                    $category_list[$cid]['score'] = ($category_list[$cid]['score'] ?? 0) + $my_total_score;
4870
                    $category_list[$cid]['total'] = ($category_list[$cid]['total'] ?? 0) + $my_total_weight;
4871
                    $category_was_added_for_this_test = true;
4872
                }
4873
            }
4874
            if (!$category_was_added_for_this_test) {
4875
                $category_list['none']['score'] = ($category_list['none']['score'] ?? 0) + $my_total_score;
4876
                $category_list['none']['total'] = ($category_list['none']['total'] ?? 0) + $my_total_weight;
4877
            }
4878
4879
            if (0 == $objExercise->selectPropagateNeg() && $my_total_score < 0) {
4880
                $my_total_score = 0;
4881
            }
4882
4883
            $comnt = null;
4884
            if ($show_results) {
4885
                $comnt = Event::get_comments($exeId, $questionId);
4886
                $teacherAudio = self::getOralFeedbackAudio($exeId, $questionId);
4887
4888
                if (!empty($comnt) || $teacherAudio) {
4889
                    echo '<b>'.get_lang('Feedback').'</b>';
4890
                }
4891
                if (!empty($comnt)) {
4892
                    echo self::getFeedbackText($comnt);
4893
                }
4894
                if ($teacherAudio) {
4895
                    echo $teacherAudio;
4896
                }
4897
            }
4898
4899
            $calculatedScore = [
4900
                'result'        => self::show_score($my_total_score, $my_total_weight, false),
4901
                'pass'          => $scorePassed,
4902
                'score'         => $my_total_score,
4903
                'weight'        => $my_total_weight,
4904
                'comments'      => $comnt,
4905
                'user_answered' => $result['user_answered'],
4906
            ];
4907
4908
            $scoreCol = $show_results ? $calculatedScore : [];
4909
4910
            $contents = ob_get_clean();
4911
            $questionContent = '';
4912
            if ($show_results) {
4913
                $questionContent = '<div class="question-answer-result">';
4914
                if (false === $showQuestionScore) {
4915
                    $scoreCol = [];
4916
                }
4917
4918
                // Numbered header (media parents are not rendered here)
4919
                $questionContent .= $objQuestionTmp->return_header(
4920
                    $objExercise,
4921
                    $counter,
4922
                    $scoreCol
4923
                );
4924
            }
4925
            // Count only real questions
4926
            $counter++;
4927
            $questionContent .= $contents;
4928
            if ($show_results) {
4929
                $questionContent .= '</div>';
4930
            }
4931
4932
            $calculatedScore['question_content'] = $questionContent;
4933
            $attemptResult[] = $calculatedScore;
4934
4935
            return [$questionContent, $result];
4936
        };
4937
4938
        // Render entries
4939
        if (!empty($finalOrder)) {
4940
            foreach ($finalOrder as $entry) {
4941
                if ($entry['type'] === 'single') {
4942
                    [$html, $resultLast] = $renderSingle((int)$entry['qid']);
4943
                    if ($html) {
4944
                        $panelsByParent[0][] = Display::panel($html);
4945
                        if ($show_results) {
4946
                            $orderedOutputHtml .= Display::panel($html);
4947
                        }
4948
                        if ($resultLast) {
4949
                            $result = $resultLast; // keep last result for later checks (like chart)
4950
                        }
4951
                    }
4952
                } else {
4953
                    $pid = (int)$entry['parent'];
4954
                    $children = (array)$entry['children'];
4955
4956
                    if ($show_results) {
4957
                        // Open media wrapper
4958
                        $orderedOutputHtml .= '<div class="media-group">';
4959
4960
                        // Render media stem (no numbering)
4961
                        $orderedOutputHtml .= '<div class="media-content">';
4962
                        ob_start();
4963
                        $objExercise->manage_answer(
4964
                            $exeId,
4965
                            $pid,
4966
                            null,
4967
                            'exercise_show',
4968
                            [],
4969
                            false,
4970
                            true,
4971
                            $show_results,
4972
                            $objExercise->selectPropagateNeg()
4973
                        );
4974
                        $orderedOutputHtml .= ob_get_clean();
4975
                        $orderedOutputHtml .= '</div>';
4976
4977
                        $mediaQ = Question::read($pid, $objExercise->course);
4978
                        if (!empty($mediaQ->description)) {
4979
                            $orderedOutputHtml .= '<div class="media-description">'.$mediaQ->description.'</div>';
4980
                        }
4981
4982
                        $orderedOutputHtml .= '<div class="media-children">';
4983
                    }
4984
4985
                    // Render all children contiguously
4986
                    foreach ($children as $cid) {
4987
                        [$html, $resultLast] = $renderSingle((int)$cid);
4988
                        if ($html) {
4989
                            $panelsByParent[$pid][] = Display::panel($html);
4990
                            if ($show_results) {
4991
                                $orderedOutputHtml .= Display::panel($html);
4992
                            }
4993
                            if ($resultLast) {
4994
                                $result = $resultLast;
4995
                            }
4996
                        }
4997
                    }
4998
4999
                    if ($show_results) {
5000
                        // Close media wrapper
5001
                        $orderedOutputHtml .= '</div></div>';
5002
                    }
5003
                }
5004
            }
5005
        }
5006
5007
        // Print output
5008
        if ($show_results) {
5009
            echo $orderedOutputHtml;
5010
        } else {
5011
            // Fallback (no wrappers when results are not shown)
5012
            foreach ($panelsByParent as $pid => $panels) {
5013
                foreach ($panels as $panelHtml) {
5014
                    echo $panelHtml;
5015
                }
5016
            }
5017
        }
5018
5019
        // Display text when test is finished #4074 and for LP #4227
5020
        $endOfMessage = $objExercise->getFinishText($total_score, $total_weight);
5021
        if (!empty($endOfMessage)) {
5022
            echo Display::div(
5023
                $endOfMessage,
5024
                ['id' => 'quiz_end_message']
5025
            );
5026
        }
5027
5028
        $totalScoreText = null;
5029
        $certificateBlock = '';
5030
        if (($show_results || $show_only_score) && $showTotalScore) {
5031
            if (MULTIPLE_ANSWER_TRUE_FALSE_DEGREE_CERTAINTY == ($result['answer_type'] ?? null)) {
5032
                echo '<h1 style="text-align : center; margin : 20px 0;">'.get_lang('Your results').'</h1><br />';
5033
            }
5034
            $totalScoreText .= '<div class="question_row_score">';
5035
            if (!empty($result) && MULTIPLE_ANSWER_TRUE_FALSE_DEGREE_CERTAINTY == ($result['answer_type'] ?? null)) {
5036
                $totalScoreText .= self::getQuestionDiagnosisRibbon(
5037
                    $objExercise,
5038
                    $total_score,
5039
                    $total_weight,
5040
                    true
5041
                );
5042
            } else {
5043
                $pluginEvaluation = QuestionOptionsEvaluationPlugin::create();
5044
                if ('true' === $pluginEvaluation->get(QuestionOptionsEvaluationPlugin::SETTING_ENABLE)) {
5045
                    $formula = $pluginEvaluation->getFormulaForExercise($objExercise->getId());
5046
5047
                    if (!empty($formula)) {
5048
                        $total_score = $pluginEvaluation->getResultWithFormula($exeId, $formula);
5049
                        $total_weight = $pluginEvaluation->getMaxScore();
5050
                    }
5051
                }
5052
5053
                $totalScoreText .= self::getTotalScoreRibbon(
5054
                    $objExercise,
5055
                    $total_score,
5056
                    $total_weight,
5057
                    true,
5058
                    $countPendingQuestions
5059
                );
5060
            }
5061
            $totalScoreText .= '</div>';
5062
5063
            if (!empty($studentInfo)) {
5064
                $certificateBlock = self::generateAndShowCertificateBlock(
5065
                    $total_score,
5066
                    $total_weight,
5067
                    $objExercise,
5068
                    $studentInfo['id'],
5069
                    $courseId,
5070
                    $sessionId
5071
                );
5072
            }
5073
        }
5074
5075
        if (MULTIPLE_ANSWER_TRUE_FALSE_DEGREE_CERTAINTY == ($result['answer_type'] ?? null)) {
5076
            $chartMultiAnswer = MultipleAnswerTrueFalseDegreeCertainty::displayStudentsChartResults(
5077
                $exeId,
5078
                $objExercise
5079
            );
5080
            echo $chartMultiAnswer;
5081
        }
5082
5083
        if (!empty($category_list) &&
5084
            ($show_results || $show_only_score || RESULT_DISABLE_RADAR == $objExercise->results_disabled)
5085
        ) {
5086
            // Adding total
5087
            $category_list['total'] = [
5088
                'score' => $total_score,
5089
                'total' => $total_weight,
5090
            ];
5091
            echo TestCategory::get_stats_table_by_attempt($objExercise, $category_list);
5092
        }
5093
5094
        if ($show_all_but_expected_answer) {
5095
            $exerciseContent .= Display::return_message(get_lang('Note: This test has been setup to hide the expected answers.'));
5096
        }
5097
5098
        // Remove audio auto play from questions on results page - refs BT#7939
5099
        $exerciseContent = preg_replace(
5100
            ['/autoplay[\=\".+\"]+/', '/autostart[\=\".+\"]+/'],
5101
            '',
5102
            $exerciseContent
5103
        );
5104
5105
        echo $certificateBlock;
5106
5107
        // Ofaj change BT#11784
5108
        if (('true' === api_get_setting('exercise.quiz_show_description_on_results_page')) &&
5109
            !empty($objExercise->description)
5110
        ) {
5111
            echo Display::div($objExercise->description, ['class' => 'exercise_description']);
5112
        }
5113
5114
        echo $exerciseContent;
5115
        if (!$show_only_score) {
5116
            echo $totalScoreText;
5117
        }
5118
5119
        if ($save_user_result) {
5120
            // Tracking of results
5121
            if ($exercise_stat_info) {
5122
                $learnpath_id = $exercise_stat_info['orig_lp_id'];
5123
                $learnpath_item_id = $exercise_stat_info['orig_lp_item_id'];
5124
                $learnpath_item_view_id = $exercise_stat_info['orig_lp_item_view_id'];
5125
5126
                if (api_is_allowed_to_session_edit()) {
5127
                    Event::updateEventExercise(
5128
                        $exercise_stat_info['exe_id'],
5129
                        $objExercise->getId(),
5130
                        $total_score,
5131
                        $total_weight,
5132
                        $sessionId,
5133
                        $learnpath_id,
5134
                        $learnpath_item_id,
5135
                        $learnpath_item_view_id,
5136
                        $exercise_stat_info['exe_duration'],
5137
                        $question_list
5138
                    );
5139
5140
                    $allowStats = ('true' === api_get_setting('gradebook.allow_gradebook_stats'));
5141
                    if ($allowStats) {
5142
                        $objExercise->generateStats(
5143
                            $objExercise->getId(),
5144
                            api_get_course_info(),
5145
                            $sessionId
5146
                        );
5147
                    }
5148
                }
5149
            }
5150
5151
            // Send notification at the end
5152
            if (!api_is_allowed_to_edit(null, true) &&
5153
                !api_is_excluded_user_type()
5154
            ) {
5155
                $objExercise->send_mail_notification_for_exam(
5156
                    'end',
5157
                    $question_list_answers,
5158
                    $origin,
5159
                    $exeId,
5160
                    $total_score,
5161
                    $total_weight
5162
                );
5163
            }
5164
        }
5165
5166
        if (in_array(
5167
            $objExercise->selectResultsDisabled(),
5168
            [RESULT_DISABLE_RANKING, RESULT_DISABLE_SHOW_SCORE_AND_EXPECTED_ANSWERS_AND_RANKING]
5169
        )) {
5170
            echo Display::page_header(get_lang('Ranking'), null, 'h4');
5171
            echo self::displayResultsInRanking(
5172
                $objExercise,
5173
                api_get_user_id(),
5174
                $courseId,
5175
                $sessionId
5176
            );
5177
        }
5178
5179
        if (!empty($remainingMessage)) {
5180
            echo Display::return_message($remainingMessage, 'normal', false);
5181
        }
5182
5183
        $failedAnswersCount = 0;
5184
        $wrongQuestionHtml = '';
5185
        $all = '';
5186
        foreach ($attemptResult as $item) {
5187
            if (false === $item['pass']) {
5188
                $failedAnswersCount++;
5189
                $wrongQuestionHtml .= $item['question_content'].'<br />';
5190
            }
5191
            $all .= $item['question_content'].'<br />';
5192
        }
5193
5194
        $passed = self::isPassPercentageAttemptPassed(
5195
            $objExercise,
5196
            $total_score,
5197
            $total_weight
5198
        );
5199
5200
        $percentage = 0;
5201
        if (!empty($total_weight)) {
5202
            $percentage = ($total_score / $total_weight) * 100;
5203
        }
5204
5205
        return [
5206
            'category_list' => $category_list,
5207
            'attempts_result_list' => $attemptResult, // array of results
5208
            'exercise_passed' => $passed, // boolean
5209
            'total_answers_count' => count($attemptResult), // int
5210
            'failed_answers_count' => $failedAnswersCount, // int
5211
            'failed_answers_html' => $wrongQuestionHtml,
5212
            'all_answers_html' => $all,
5213
            'total_score' => $total_score,
5214
            'total_weight' => $total_weight,
5215
            'total_percentage' => $percentage,
5216
            'count_pending_questions' => $countPendingQuestions,
5217
        ];
5218
    }
5219
5220
    /**
5221
     * Display the ranking of results in a exercise.
5222
     *
5223
     * @param Exercise $exercise
5224
     * @param int      $currentUserId
5225
     * @param int      $courseId
5226
     * @param int      $sessionId
5227
     *
5228
     * @return string
5229
     */
5230
    public static function displayResultsInRanking($exercise, $currentUserId, $courseId, $sessionId = 0)
5231
    {
5232
        $exerciseId = $exercise->iId;
5233
        $data = self::exerciseResultsInRanking($exerciseId, $courseId, $sessionId);
5234
5235
        $table = new HTML_Table(['class' => 'table table-hover table-striped table-bordered']);
5236
        $table->setHeaderContents(0, 0, get_lang('Position'), ['class' => 'text-right']);
5237
        $table->setHeaderContents(0, 1, get_lang('Username'));
5238
        $table->setHeaderContents(0, 2, get_lang('Score'), ['class' => 'text-right']);
5239
        $table->setHeaderContents(0, 3, get_lang('Date'), ['class' => 'text-center']);
5240
5241
        foreach ($data as $r => $item) {
5242
            if (!isset($item[1])) {
5243
                continue;
5244
            }
5245
            $selected = $item[1]->getId() == $currentUserId;
5246
5247
            foreach ($item as $c => $value) {
5248
                $table->setCellContents($r + 1, $c, $value);
5249
5250
                $attrClass = '';
5251
5252
                if (in_array($c, [0, 2])) {
5253
                    $attrClass = 'text-right';
5254
                } elseif (3 == $c) {
5255
                    $attrClass = 'text-center';
5256
                }
5257
5258
                if ($selected) {
5259
                    $attrClass .= ' warning';
5260
                }
5261
5262
                $table->setCellAttributes($r + 1, $c, ['class' => $attrClass]);
5263
            }
5264
        }
5265
5266
        return $table->toHtml();
5267
    }
5268
5269
    /**
5270
     * Get the ranking for results in a exercise.
5271
     * Function used internally by ExerciseLib::displayResultsInRanking.
5272
     *
5273
     * @param int $exerciseId
5274
     * @param int $courseId
5275
     * @param int $sessionId
5276
     *
5277
     * @return array
5278
     */
5279
    public static function exerciseResultsInRanking($exerciseId, $courseId, $sessionId = 0)
5280
    {
5281
        $em = Database::getManager();
5282
5283
        $dql = 'SELECT DISTINCT u.id FROM ChamiloCoreBundle:TrackEExercise te JOIN te.user u WHERE te.quiz = :id AND te.course = :cId';
5284
        $dql .= api_get_session_condition($sessionId, true, false, 'te.session');
5285
5286
        $result = $em
5287
            ->createQuery($dql)
5288
            ->setParameters(['id' => $exerciseId, 'cId' => $courseId])
5289
            ->getScalarResult();
5290
5291
        $data = [];
5292
5293
        foreach ($result as $item) {
5294
            $attempt = self::get_best_attempt_by_user($item['id'], $exerciseId, $courseId, $sessionId);
5295
            if (!empty($attempt) && isset($attempt['score']) && isset($attempt['exe_date'])) {
5296
                $data[] = $attempt;
5297
            }
5298
        }
5299
5300
        if (empty($data)) {
5301
            return [];
5302
        }
5303
5304
        usort(
5305
            $data,
5306
            function ($a, $b) {
5307
                if ($a['score'] != $b['score']) {
5308
                    return $a['score'] > $b['score'] ? -1 : 1;
5309
                }
5310
5311
                if ($a['exe_date'] != $b['exe_date']) {
5312
                    return $a['exe_date'] < $b['exe_date'] ? -1 : 1;
5313
                }
5314
5315
                return 0;
5316
            }
5317
        );
5318
5319
        // flags to display the same position in case of tie
5320
        $lastScore = $data[0]['score'];
5321
        $position = 1;
5322
        $data = array_map(
5323
            function ($item) use (&$lastScore, &$position) {
5324
                if ($item['score'] < $lastScore) {
5325
                    $position++;
5326
                }
5327
5328
                $lastScore = $item['score'];
5329
5330
                return [
5331
                    $position,
5332
                    api_get_user_entity($item['exe_user_id']),
5333
                    self::show_score($item['score'], $item['max_score'], true, true, true),
5334
                    api_convert_and_format_date($item['exe_date'], DATE_TIME_FORMAT_SHORT),
5335
                ];
5336
            },
5337
            $data
5338
        );
5339
5340
        return $data;
5341
    }
5342
5343
    /**
5344
     * Get a special ribbon on top of "degree of certainty" questions (
5345
     * variation from getTotalScoreRibbon() for other question types).
5346
     *
5347
     * @param Exercise $objExercise
5348
     * @param float    $score
5349
     * @param float    $weight
5350
     * @param bool     $checkPassPercentage
5351
     *
5352
     * @return string
5353
     */
5354
    public static function getQuestionDiagnosisRibbon($objExercise, $score, $weight, $checkPassPercentage = false)
5355
    {
5356
        $displayChartDegree = true;
5357
        $ribbon = $displayChartDegree ? '<div class="ribbon">' : '';
5358
5359
        if ($checkPassPercentage) {
5360
            $passPercentage = $objExercise->selectPassPercentage();
5361
            $isSuccess = self::isSuccessExerciseResult($score, $weight, $passPercentage);
5362
            // Color the final test score if pass_percentage activated
5363
            $ribbonTotalSuccessOrError = '';
5364
            if (self::isPassPercentageEnabled($passPercentage)) {
5365
                if ($isSuccess) {
5366
                    $ribbonTotalSuccessOrError = ' ribbon-total-success';
5367
                } else {
5368
                    $ribbonTotalSuccessOrError = ' ribbon-total-error';
5369
                }
5370
            }
5371
            $ribbon .= $displayChartDegree ? '<div class="rib rib-total '.$ribbonTotalSuccessOrError.'">' : '';
5372
        } else {
5373
            $ribbon .= $displayChartDegree ? '<div class="rib rib-total">' : '';
5374
        }
5375
5376
        if ($displayChartDegree) {
5377
            $ribbon .= '<h3>'.get_lang('Score for the test').':&nbsp;';
5378
            $ribbon .= self::show_score($score, $weight, false, true);
5379
            $ribbon .= '</h3>';
5380
            $ribbon .= '</div>';
5381
        }
5382
5383
        if ($checkPassPercentage) {
5384
            $ribbon .= self::showSuccessMessage(
5385
                $score,
5386
                $weight,
5387
                $objExercise->selectPassPercentage()
5388
            );
5389
        }
5390
5391
        $ribbon .= $displayChartDegree ? '</div>' : '';
5392
5393
        return $ribbon;
5394
    }
5395
5396
    public static function isPassPercentageAttemptPassed($objExercise, $score, $weight)
5397
    {
5398
        $passPercentage = $objExercise->selectPassPercentage();
5399
5400
        return self::isSuccessExerciseResult($score, $weight, $passPercentage);
5401
    }
5402
5403
    /**
5404
     * @param float $score
5405
     * @param float $weight
5406
     * @param bool  $checkPassPercentage
5407
     * @param int   $countPendingQuestions
5408
     *
5409
     * @return string
5410
     */
5411
    public static function getTotalScoreRibbon(
5412
        Exercise $objExercise,
5413
        $score,
5414
        $weight,
5415
        $checkPassPercentage = false,
5416
        $countPendingQuestions = 0
5417
    ) {
5418
        $hide = (int) $objExercise->getPageConfigurationAttribute('hide_total_score');
5419
        if (1 === $hide) {
5420
            return '';
5421
        }
5422
5423
        $passPercentage = $objExercise->selectPassPercentage();
5424
        $ribbon = '<div class="title-score">';
5425
        if ($checkPassPercentage) {
5426
            $isSuccess = self::isSuccessExerciseResult(
5427
                $score,
5428
                $weight,
5429
                $passPercentage
5430
            );
5431
            // Color the final test score if pass_percentage activated
5432
            $class = '';
5433
            if (self::isPassPercentageEnabled($passPercentage)) {
5434
                if ($isSuccess) {
5435
                    $class = ' ribbon-total-success';
5436
                } else {
5437
                    $class = ' ribbon-total-error';
5438
                }
5439
            }
5440
            $ribbon .= '<div class="total '.$class.'">';
5441
        } else {
5442
            $ribbon .= '<div class="total">';
5443
        }
5444
        $ribbon .= '<h3>'.get_lang('Score for the test').':&nbsp;';
5445
        $ribbon .= self::show_score($score, $weight, false, true);
5446
        $ribbon .= '</h3>';
5447
        $ribbon .= '</div>';
5448
        if ($checkPassPercentage) {
5449
            $ribbon .= self::showSuccessMessage(
5450
                $score,
5451
                $weight,
5452
                $passPercentage
5453
            );
5454
        }
5455
        $ribbon .= '</div>';
5456
5457
        if (!empty($countPendingQuestions)) {
5458
            $ribbon .= '<br />';
5459
            $ribbon .= Display::return_message(
5460
                sprintf(
5461
                    get_lang('Temporary score: %s open question(s) not corrected yet.'),
5462
                    $countPendingQuestions
5463
                ),
5464
                'warning'
5465
            );
5466
        }
5467
5468
        return $ribbon;
5469
    }
5470
5471
    /**
5472
     * @param int $countLetter
5473
     *
5474
     * @return mixed
5475
     */
5476
    public static function detectInputAppropriateClass($countLetter)
5477
    {
5478
        $limits = [
5479
            0 => 'input-mini',
5480
            10 => 'input-mini',
5481
            15 => 'input-medium',
5482
            20 => 'input-xlarge',
5483
            40 => 'input-xlarge',
5484
            60 => 'input-xxlarge',
5485
            100 => 'input-xxlarge',
5486
            200 => 'input-xxlarge',
5487
        ];
5488
5489
        foreach ($limits as $size => $item) {
5490
            if ($countLetter <= $size) {
5491
                return $item;
5492
            }
5493
        }
5494
5495
        return $limits[0];
5496
    }
5497
5498
    /**
5499
     * @param int    $senderId
5500
     * @param array  $course_info
5501
     * @param string $test
5502
     * @param string $url
5503
     *
5504
     * @return string
5505
     */
5506
    public static function getEmailNotification($senderId, $course_info, $test, $url)
5507
    {
5508
        $teacher_info = api_get_user_info($senderId);
5509
        $fromName = api_get_person_name(
5510
            $teacher_info['firstname'],
5511
            $teacher_info['lastname'],
5512
            null,
5513
            PERSON_NAME_EMAIL_ADDRESS
5514
        );
5515
5516
        $params = [
5517
            'course_title' => Security::remove_XSS($course_info['name']),
5518
            'test_title' => Security::remove_XSS($test),
5519
            'url' => $url,
5520
            'teacher_name' => $fromName,
5521
        ];
5522
5523
        return Container::getTwig()->render(
5524
            '@ChamiloCore/Mailer/Exercise/result_alert_body.html.twig',
5525
            $params
5526
        );
5527
    }
5528
5529
    /**
5530
     * @return string
5531
     */
5532
    public static function getNotCorrectedYetText()
5533
    {
5534
        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');
5535
    }
5536
5537
    /**
5538
     * @param string $message
5539
     *
5540
     * @return string
5541
     */
5542
    public static function getFeedbackText($message)
5543
    {
5544
        return Display::return_message($message, 'warning', false);
5545
    }
5546
5547
    /**
5548
     * Get the recorder audio component for save a teacher audio feedback.
5549
     *
5550
     * @param int $attemptId
5551
     * @param int $questionId
5552
     *
5553
     * @return string
5554
     */
5555
    public static function getOralFeedbackForm($attemptId, $questionId)
5556
    {
5557
        $view = new Template('', false, false, false, false, false, false);
5558
5559
        $view->assign('type', OralExpression::RECORDING_TYPE_FEEDBACK);
5560
        $view->assign('question_id', $questionId);
5561
        $view->assign('t_exercise_id', $attemptId);
5562
5563
        $template = $view->get_template('exercise/oral_expression.html.twig');
5564
5565
        return $view->fetch($template);
5566
    }
5567
5568
    /**
5569
     * Get oral file audio for a given exercise attempt and question.
5570
     *
5571
     * If $returnUrls is true, returns an array of URLs.
5572
     * Otherwise returns the HTML string with <audio> players.
5573
     *
5574
     * @param int  $trackExerciseId
5575
     * @param int  $questionId
5576
     * @param bool $returnUrls
5577
     *
5578
     * @return array|string
5579
     */
5580
    public static function getOralFileAudio(
5581
        int $trackExerciseId,
5582
        int $questionId,
5583
        bool $returnUrls = false
5584
    ) {
5585
        /** @var TrackEExercise|null $trackExercise */
5586
        $trackExercise = Container::getTrackEExerciseRepository()->find($trackExerciseId);
5587
5588
        if (null === $trackExercise) {
5589
            return $returnUrls ? [] : '';
5590
        }
5591
5592
        $questionAttempt = $trackExercise->getAttemptByQuestionId($questionId);
5593
5594
        if (null === $questionAttempt) {
5595
            return $returnUrls ? [] : '';
5596
        }
5597
5598
        $attemptId = method_exists($questionAttempt, 'getId')
5599
            ? (int) $questionAttempt->getId()
5600
            : 0;
5601
5602
        // Collect feedback ResourceNode IDs to avoid duplicate players
5603
        $feedbackNodeIds = [];
5604
        if (method_exists($questionAttempt, 'getAttemptFeedbacks')) {
5605
            foreach ($questionAttempt->getAttemptFeedbacks() as $feedback) {
5606
                if (null === $feedback) {
5607
                    continue;
5608
                }
5609
5610
                $feedbackNode = method_exists($feedback, 'getResourceNode')
5611
                    ? $feedback->getResourceNode()
5612
                    : null;
5613
5614
                if (null === $feedbackNode) {
5615
                    continue;
5616
                }
5617
5618
                if (method_exists($feedbackNode, 'getId')) {
5619
                    $feedbackNodeIds[] = (int) $feedbackNode->getId();
5620
                }
5621
            }
5622
5623
            $feedbackNodeIds = array_unique($feedbackNodeIds);
5624
        }
5625
5626
        $filesCollection = $questionAttempt->getAttemptFiles();
5627
        $filesCount = is_countable($filesCollection) ? count($filesCollection) : 0;
5628
5629
        if (0 === $filesCount) {
5630
            return $returnUrls ? [] : '';
5631
        }
5632
5633
        $urls = [];
5634
5635
        foreach ($filesCollection as $attemptFile) {
5636
            if (!$attemptFile) {
5637
                continue;
5638
            }
5639
5640
            $attemptFileId = method_exists($attemptFile, 'getId')
5641
                ? (string) $attemptFile->getId()
5642
                : 'n/a';
5643
5644
            $resourceNode = method_exists($attemptFile, 'getResourceNode')
5645
                ? $attemptFile->getResourceNode()
5646
                : null;
5647
5648
            if (null === $resourceNode) {
5649
                continue;
5650
            }
5651
5652
            $nodeId = method_exists($resourceNode, 'getId')
5653
                ? (int) $resourceNode->getId()
5654
                : 0;
5655
5656
            // Skip files whose ResourceNode is used by feedback (avoid duplicate players)
5657
            if (!empty($feedbackNodeIds) && in_array($nodeId, $feedbackNodeIds, true)) {
5658
                continue;
5659
            }
5660
5661
            $url = self::getPublicUrlForResourceNode($resourceNode);
5662
            if (empty($url)) {
5663
                continue;
5664
            }
5665
5666
            $urls[] = $url;
5667
        }
5668
5669
        if (empty($urls)) {
5670
            return $returnUrls ? [] : '';
5671
        }
5672
5673
        if ($returnUrls) {
5674
            return $urls;
5675
        }
5676
5677
        // Build HTML <audio> tags using the resolved URLs (student attempts only)
5678
        $html = '';
5679
5680
        foreach ($urls as $url) {
5681
            $html .= Display::tag(
5682
                'audio',
5683
                '',
5684
                [
5685
                    'src' => $url,
5686
                    'controls' => '',
5687
                ]
5688
            );
5689
        }
5690
5691
        return $html;
5692
    }
5693
5694
    /**
5695
     * Returns the HTML audio player for the latest oral feedback
5696
     * of a given question attempt.
5697
     *
5698
     * @param int  $attemptId   TrackEExercise id (exercise attempt)
5699
     * @param int  $questionId  Question id inside the attempt
5700
     * @param bool $wrap        Kept for backward compatibility (currently unused)
5701
     *
5702
     * @return string           HTML <audio> tag or empty string if none
5703
     */
5704
    public static function getOralFeedbackAudio(
5705
        int $attemptId,
5706
        int $questionId,
5707
        bool $wrap = true
5708
    ): string {
5709
        /** @var TrackEExercise|null $exercise */
5710
        $exercise = Container::getTrackEExerciseRepository()->find($attemptId);
5711
5712
        if (null === $exercise) {
5713
            return '';
5714
        }
5715
5716
        $attempt = $exercise->getAttemptByQuestionId($questionId);
5717
        if (null === $attempt) {
5718
            return '';
5719
        }
5720
5721
        $html = '';
5722
5723
        // We keep only the latest feedback to avoid duplicated players.
5724
        foreach ($attempt->getAttemptFeedbacks() as $feedback) {
5725
            $node = $feedback->getResourceNode();
5726
5727
            if (null === $node) {
5728
                // Old data might still be asset-based; migration can handle that later.
5729
                continue;
5730
            }
5731
5732
            $url = self::getPublicUrlForResourceNode($node);
5733
5734
            if ('' === $url) {
5735
                // URL could not be generated (missing file or routing issue).
5736
                continue;
5737
            }
5738
5739
            // Override previous HTML so that only the last feedback is rendered.
5740
            $html = Display::tag(
5741
                'audio',
5742
                '',
5743
                [
5744
                    'src' => $url,
5745
                    'controls' => '',
5746
                ]
5747
            );
5748
        }
5749
5750
        return $html;
5751
    }
5752
5753
    /**
5754
     * Get uploaded answer files (resource-based) for a given attempt/question.
5755
     *
5756
     * If $returnUrls is true, returns an array of URLs.
5757
     * Otherwise returns a simple HTML list of links.
5758
     *
5759
     * @param int  $trackExerciseId
5760
     * @param int  $questionId
5761
     * @param bool $returnUrls
5762
     *
5763
     * @return array|string
5764
     */
5765
    public static function getUploadAnswerFiles(int $trackExerciseId, int $questionId, bool $returnUrls = false)
5766
    {
5767
        /** @var TrackEExercise|null $trackExercise */
5768
        $trackExercise = Container::getTrackEExerciseRepository()->find($trackExerciseId);
5769
5770
        if (null === $trackExercise) {
5771
            return $returnUrls ? [] : '';
5772
        }
5773
5774
        $attempt = $trackExercise->getAttemptByQuestionId($questionId);
5775
5776
        if (null === $attempt) {
5777
            return $returnUrls ? [] : '';
5778
        }
5779
5780
        $urls = [];
5781
5782
        // Loop over AttemptFile and use their ResourceNode to get public URLs
5783
        foreach ($attempt->getAttemptFiles() as $attemptFile) {
5784
            $resourceNode = $attemptFile->getResourceNode();
5785
            $url = self::getPublicUrlForResourceNode($resourceNode);
5786
5787
            if (!empty($url)) {
5788
                $urls[] = $url;
5789
            }
5790
        }
5791
5792
        if ($returnUrls) {
5793
            return $urls;
5794
        }
5795
5796
        // Legacy simple HTML (used by some views)
5797
        $html = '';
5798
5799
        foreach ($urls as $url) {
5800
            $path = parse_url($url, PHP_URL_PATH);
5801
            $name = $path ? basename($path) : $url;
5802
5803
            $html .= Display::url($name, $url, ['target' => '_blank']).'<br />';
5804
        }
5805
5806
        return $html;
5807
    }
5808
5809
    public static function getNotificationSettings(): array
5810
    {
5811
        return [
5812
            2 => get_lang('Paranoid: E-mail teacher when a student starts an exercise'),
5813
            1 => get_lang('Aware: E-mail teacher when a student ends an exercise'), // default
5814
            3 => get_lang('Relaxed open: E-mail teacher when a student ends an exercise, only if an open question is answered'),
5815
            4 => get_lang('Relaxed audio: E-mail teacher when a student ends an exercise, only if an oral question is answered'),
5816
        ];
5817
    }
5818
5819
    /**
5820
     * Get the additional actions added in exercise_additional_teacher_modify_actions configuration.
5821
     *
5822
     * @param int $exerciseId
5823
     * @param int $iconSize
5824
     *
5825
     * @return string
5826
     */
5827
    public static function getAdditionalTeacherActions($exerciseId, $iconSize = ICON_SIZE_SMALL)
5828
    {
5829
        $additionalActions = api_get_setting('exercise.exercise_additional_teacher_modify_actions', true) ?: [];
5830
        $actions = [];
5831
5832
        if (is_array($additionalActions)) {
5833
            foreach ($additionalActions as $additionalAction) {
5834
                $actions[] = call_user_func(
5835
                    $additionalAction,
5836
                    $exerciseId,
5837
                    $iconSize
5838
                );
5839
            }
5840
        }
5841
5842
        return implode(PHP_EOL, $actions);
5843
    }
5844
5845
    /**
5846
     * @param int $userId
5847
     * @param int $courseId
5848
     * @param int $sessionId
5849
     *
5850
     * @return int
5851
     */
5852
    public static function countAnsweredQuestionsByUserAfterTime(DateTime $time, $userId, $courseId, $sessionId)
5853
    {
5854
        $em = Database::getManager();
5855
5856
        if (empty($sessionId)) {
5857
            $sessionId = null;
5858
        }
5859
5860
        $time = api_get_utc_datetime($time->format('Y-m-d H:i:s'), false, true);
5861
5862
        $result = $em
5863
            ->createQuery('
5864
                SELECT COUNT(ea) FROM ChamiloCoreBundle:TrackEAttempt ea
5865
                WHERE ea.userId = :user AND ea.cId = :course AND ea.sessionId = :session
5866
                    AND ea.tms > :time
5867
            ')
5868
            ->setParameters(['user' => $userId, 'course' => $courseId, 'session' => $sessionId, 'time' => $time])
5869
            ->getSingleScalarResult();
5870
5871
        return $result;
5872
    }
5873
5874
    /**
5875
     * @param int $userId
5876
     * @param int $numberOfQuestions
5877
     * @param int $courseId
5878
     * @param int $sessionId
5879
     *
5880
     * @throws \Doctrine\ORM\Query\QueryException
5881
     *
5882
     * @return bool
5883
     */
5884
    public static function isQuestionsLimitPerDayReached($userId, $numberOfQuestions, $courseId, $sessionId)
5885
    {
5886
        $questionsLimitPerDay = (int) api_get_course_setting('quiz_question_limit_per_day');
5887
5888
        if ($questionsLimitPerDay <= 0) {
5889
            return false;
5890
        }
5891
5892
        $midnightTime = ChamiloHelper::getServerMidnightTime();
5893
5894
        $answeredQuestionsCount = self::countAnsweredQuestionsByUserAfterTime(
5895
            $midnightTime,
5896
            $userId,
5897
            $courseId,
5898
            $sessionId
5899
        );
5900
5901
        return ($answeredQuestionsCount + $numberOfQuestions) > $questionsLimitPerDay;
5902
    }
5903
5904
    /**
5905
     * Check if an exercise complies with the requirements to be embedded in the mobile app or a video.
5906
     * By making sure it is set on one question per page and it only contains unique-answer or multiple-answer questions
5907
     * or unique-answer image. And that the exam does not have immediate feedback.
5908
     *
5909
     * @return bool
5910
     */
5911
    public static function isQuizEmbeddable(CQuiz $exercise)
5912
    {
5913
        $em = Database::getManager();
5914
5915
        if (ONE_PER_PAGE != $exercise->getType() ||
5916
            in_array($exercise->getFeedbackType(), [EXERCISE_FEEDBACK_TYPE_DIRECT, EXERCISE_FEEDBACK_TYPE_POPUP])
5917
        ) {
5918
            return false;
5919
        }
5920
5921
        $countAll = $em
5922
            ->createQuery('SELECT COUNT(qq)
5923
                FROM ChamiloCourseBundle:CQuizQuestion qq
5924
                INNER JOIN ChamiloCourseBundle:CQuizRelQuestion qrq
5925
                   WITH qq.iid = qrq.question
5926
                WHERE qrq.quiz = :id'
5927
            )
5928
            ->setParameter('id', $exercise->getIid())
5929
            ->getSingleScalarResult();
5930
5931
        $countOfAllowed = $em
5932
            ->createQuery('SELECT COUNT(qq)
5933
                FROM ChamiloCourseBundle:CQuizQuestion qq
5934
                INNER JOIN ChamiloCourseBundle:CQuizRelQuestion qrq
5935
                   WITH qq.iid = qrq.question
5936
                WHERE qrq.quiz = :id AND qq.type IN (:types)'
5937
            )
5938
            ->setParameters(
5939
                [
5940
                    'id' => $exercise->getIid(),
5941
                    'types' => [UNIQUE_ANSWER, MULTIPLE_ANSWER, UNIQUE_ANSWER_IMAGE],
5942
                ]
5943
            )
5944
            ->getSingleScalarResult();
5945
5946
        return $countAll === $countOfAllowed;
5947
    }
5948
5949
    /**
5950
     * Generate a certificate linked to current quiz and.
5951
     * Return the HTML block with links to download and view the certificate.
5952
     *
5953
     * @param float $totalScore
5954
     * @param float $totalWeight
5955
     * @param int   $studentId
5956
     * @param int   $courseId
5957
     * @param int   $sessionId
5958
     *
5959
     * @return string
5960
     */
5961
    public static function generateAndShowCertificateBlock(
5962
        $totalScore,
5963
        $totalWeight,
5964
        Exercise $objExercise,
5965
        $studentId,
5966
        $courseId,
5967
        $sessionId = 0
5968
    ) {
5969
        if (('true' !== api_get_setting('exercise.quiz_generate_certificate_ending')) ||
5970
            !self::isSuccessExerciseResult($totalScore, $totalWeight, $objExercise->selectPassPercentage())
5971
        ) {
5972
            return '';
5973
        }
5974
5975
        $repo = Container::getGradeBookCategoryRepository();
5976
        /** @var GradebookCategory $category */
5977
        $category = $repo->findOneBy(
5978
            ['course' => $courseId, 'session' => $sessionId]
5979
        );
5980
5981
        if (null === $category) {
5982
            return '';
5983
        }
5984
5985
        /*$category = Category::load(null, null, $courseCode, null, null, $sessionId, 'ORDER By id');
5986
        if (empty($category)) {
5987
            return '';
5988
        }*/
5989
        $categoryId = $category->getId();
5990
        /*$link = LinkFactory::load(
5991
            null,
5992
            null,
5993
            $objExercise->getId(),
5994
            null,
5995
            $courseCode,
5996
            $categoryId
5997
        );*/
5998
5999
        if (empty($category->getLinks()->count())) {
6000
            return '';
6001
        }
6002
6003
        $resourceDeletedMessage = Category::show_message_resource_delete($courseId);
6004
        if (!empty($resourceDeletedMessage) || api_is_allowed_to_edit() || api_is_excluded_user_type()) {
6005
            return '';
6006
        }
6007
6008
        $certificate = Category::generateUserCertificate($category, $studentId);
6009
        if (!is_array($certificate)) {
6010
            return '';
6011
        }
6012
6013
        return Category::getDownloadCertificateBlock($certificate);
6014
    }
6015
6016
    /**
6017
     * @param int $exerciseId
6018
     */
6019
    public static function getExerciseTitleById($exerciseId)
6020
    {
6021
        $em = Database::getManager();
6022
6023
        return $em
6024
            ->createQuery('SELECT cq.title
6025
                FROM ChamiloCourseBundle:CQuiz cq
6026
                WHERE cq.iid = :iid'
6027
            )
6028
            ->setParameter('iid', $exerciseId)
6029
            ->getSingleScalarResult();
6030
    }
6031
6032
    /**
6033
     * @param int $exeId      ID from track_e_exercises
6034
     * @param int $userId     User ID
6035
     * @param int $exerciseId Exercise ID
6036
     * @param int $courseId   Optional. Coure ID.
6037
     *
6038
     * @return TrackEExercise|null
6039
     */
6040
    public static function recalculateResult($exeId, $userId, $exerciseId, $courseId = 0)
6041
    {
6042
        if (empty($userId) || empty($exerciseId)) {
6043
            return null;
6044
        }
6045
6046
        $em = Database::getManager();
6047
        /** @var TrackEExercise $trackedExercise */
6048
        $trackedExercise = $em->getRepository(TrackEExercise::class)->find($exeId);
6049
6050
        if (empty($trackedExercise)) {
6051
            return null;
6052
        }
6053
6054
        if ($trackedExercise->getUser()->getId() != $userId ||
6055
            $trackedExercise->getQuiz()?->getIid() != $exerciseId
6056
        ) {
6057
            return null;
6058
        }
6059
6060
        $questionList = $trackedExercise->getDataTracking();
6061
6062
        if (empty($questionList)) {
6063
            return null;
6064
        }
6065
6066
        $questionList = explode(',', $questionList);
6067
6068
        $exercise = new Exercise($courseId);
6069
        $courseInfo = $courseId ? api_get_course_info_by_id($courseId) : [];
6070
6071
        if (false === $exercise->read($exerciseId)) {
6072
            return null;
6073
        }
6074
6075
        $totalScore = 0;
6076
        $totalWeight = 0;
6077
6078
        $pluginEvaluation = QuestionOptionsEvaluationPlugin::create();
6079
6080
        $formula = 'true' === $pluginEvaluation->get(QuestionOptionsEvaluationPlugin::SETTING_ENABLE)
6081
            ? $pluginEvaluation->getFormulaForExercise($exerciseId)
6082
            : 0;
6083
6084
        if (empty($formula)) {
6085
            foreach ($questionList as $questionId) {
6086
                $question = Question::read($questionId, $courseInfo);
6087
6088
                if (false === $question) {
6089
                    continue;
6090
                }
6091
6092
                $totalWeight += $question->selectWeighting();
6093
6094
                // We're inside *one* question. Go through each possible answer for this question
6095
                $result = $exercise->manage_answer(
6096
                    $exeId,
6097
                    $questionId,
6098
                    [],
6099
                    'exercise_result',
6100
                    [],
6101
                    false,
6102
                    true,
6103
                    false,
6104
                    $exercise->selectPropagateNeg(),
6105
                    [],
6106
                    [],
6107
                    true
6108
                );
6109
6110
                //  Adding the new score.
6111
                $totalScore += $result['score'];
6112
            }
6113
        } else {
6114
            $totalScore = $pluginEvaluation->getResultWithFormula($exeId, $formula);
6115
            $totalWeight = $pluginEvaluation->getMaxScore();
6116
        }
6117
6118
        $trackedExercise
6119
            ->setScore($totalScore)
6120
            ->setMaxScore($totalWeight);
6121
6122
        $em->persist($trackedExercise);
6123
        $em->flush();
6124
        $lpItemId = $trackedExercise->getOrigLpItemId();
6125
        $lpId = $trackedExercise->getOrigLpId();
6126
        $lpItemViewId = $trackedExercise->getOrigLpItemViewId();
6127
        if ($lpId && $lpItemId && $lpItemViewId) {
6128
            $lpItem = $em->getRepository(CLpItem::class)->find($lpItemId);
6129
            if ($lpItem && 'quiz' === $lpItem->getItemType()) {
6130
                $lpItemView = $em->getRepository(CLpItemView::class)->find($lpItemViewId);
6131
                if ($lpItemView) {
6132
                    $lpItemView->setScore($totalScore);
6133
                    $em->persist($lpItemView);
6134
                    $em->flush();
6135
                }
6136
            }
6137
        }
6138
6139
        return $trackedExercise;
6140
    }
6141
6142
    public static function getTotalQuestionAnswered($courseId, $exerciseId, $questionId, $onlyStudents = false): int
6143
    {
6144
        $courseId = (int) $courseId;
6145
        $exerciseId = (int) $exerciseId;
6146
        $questionId = (int) $questionId;
6147
6148
        $attemptTable = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT);
6149
        $trackTable = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
6150
        $courseUser = Database::get_main_table(TABLE_MAIN_COURSE_USER);
6151
        $courseUserJoin = "";
6152
        $studentsWhere = "";
6153
        if ($onlyStudents) {
6154
            $courseUserJoin = "
6155
            INNER JOIN $courseUser cu
6156
            ON cu.c_id = te.c_id AND cu.user_id = exe_user_id";
6157
            $studentsWhere = " AND relation_type <> 2 AND cu.status = ".STUDENT;
6158
        }
6159
6160
        $sql = "SELECT count(distinct (te.exe_id)) total
6161
            FROM $attemptTable t
6162
            INNER JOIN $trackTable te
6163
            ON (t.exe_id = te.exe_id)
6164
            $courseUserJoin
6165
            WHERE
6166
                te.c_id = $courseId AND
6167
                exe_exo_id = $exerciseId AND
6168
                t.question_id = $questionId AND
6169
                te.status != 'incomplete'
6170
                $studentsWhere
6171
        ";
6172
        $queryTotal = Database::query($sql);
6173
        $totalRow = Database::fetch_assoc($queryTotal);
6174
        $total = 0;
6175
        if ($totalRow) {
6176
            $total = (int) $totalRow['total'];
6177
        }
6178
6179
        return $total;
6180
    }
6181
6182
    public static function getWrongQuestionResults($courseId, $exerciseId, $sessionId = 0, $limit = 10)
6183
    {
6184
        $courseId = (int) $courseId;
6185
        $exerciseId = (int) $exerciseId;
6186
        $limit = (int) $limit;
6187
6188
        $questionTable = Database::get_course_table(TABLE_QUIZ_QUESTION);
6189
        $attemptTable = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT);
6190
        $trackTable = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
6191
6192
        $sessionCondition = '';
6193
        if (!empty($sessionId)) {
6194
            $sessionCondition = api_get_session_condition($sessionId, true, false, 'te.session_id');
6195
        }
6196
6197
        $sql = "SELECT q.question, question_id, count(q.iid) count
6198
                FROM $attemptTable t
6199
                INNER JOIN $questionTable q
6200
                ON (q.iid = t.question_id)
6201
                INNER JOIN $trackTable te
6202
                ON (t.exe_id = te.exe_id)
6203
                WHERE
6204
                    te.c_id = $courseId AND
6205
                    t.marks != q.ponderation AND
6206
                    exe_exo_id = $exerciseId AND
6207
                    status != 'incomplete'
6208
                    $sessionCondition
6209
                GROUP BY q.iid
6210
                ORDER BY count DESC
6211
                LIMIT $limit
6212
        ";
6213
6214
        $result = Database::query($sql);
6215
6216
        return Database::store_result($result, 'ASSOC');
6217
    }
6218
6219
    public static function getExerciseResultsCount($type, $courseId, $exerciseId, $sessionId = 0)
6220
    {
6221
        $courseId = (int) $courseId;
6222
        $exerciseId = (int) $exerciseId;
6223
6224
        $trackTable = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
6225
6226
        $sessionCondition = '';
6227
        if (!empty($sessionId)) {
6228
            $sessionCondition = api_get_session_condition($sessionId, true, false, 'te.session_id');
6229
        }
6230
6231
        $selectCount = 'count(DISTINCT te.exe_id)';
6232
        $scoreCondition = '';
6233
        switch ($type) {
6234
            case 'correct_student':
6235
                $selectCount = 'count(DISTINCT te.exe_user_id)';
6236
                $scoreCondition = ' AND score = max_score ';
6237
                break;
6238
            case 'wrong_student':
6239
                $selectCount = 'count(DISTINCT te.exe_user_id)';
6240
                $scoreCondition = ' AND score != max_score ';
6241
                break;
6242
            case 'correct':
6243
                $scoreCondition = ' AND score = max_score ';
6244
                break;
6245
            case 'wrong':
6246
                $scoreCondition = ' AND score != max_score ';
6247
                break;
6248
        }
6249
6250
        $sql = "SELECT $selectCount count
6251
                FROM $trackTable te
6252
                WHERE
6253
                    c_id = $courseId AND
6254
                    exe_exo_id = $exerciseId AND
6255
                    status != 'incomplete'
6256
                    $scoreCondition
6257
                    $sessionCondition
6258
        ";
6259
        $result = Database::query($sql);
6260
        $totalRow = Database::fetch_assoc($result);
6261
        $total = 0;
6262
        if ($totalRow) {
6263
            $total = (int) $totalRow['count'];
6264
        }
6265
6266
        return $total;
6267
    }
6268
6269
    public static function parseContent($content, $stats, Exercise $exercise, $trackInfo, $currentUserId = 0)
6270
    {
6271
        $wrongAnswersCount = $stats['failed_answers_count'];
6272
        $attemptDate = substr($trackInfo['exe_date'], 0, 10);
6273
        $exerciseId = $exercise->iId;
6274
        $resultsStudentUrl = api_get_path(WEB_CODE_PATH).
6275
            'exercise/result.php?id='.$exerciseId.'&'.api_get_cidreq();
6276
        $resultsTeacherUrl = api_get_path(WEB_CODE_PATH).
6277
            'exercise/exercise_show.php?action=edit&id='.$exerciseId.'&'.api_get_cidreq();
6278
6279
        $content = str_replace(
6280
            [
6281
                '((exercise_error_count))',
6282
                '((all_answers_html))',
6283
                '((all_answers_teacher_html))',
6284
                '((exercise_title))',
6285
                '((exercise_attempt_date))',
6286
                '((link_to_test_result_page_student))',
6287
                '((link_to_test_result_page_teacher))',
6288
            ],
6289
            [
6290
                $wrongAnswersCount,
6291
                $stats['all_answers_html'],
6292
                $stats['all_answers_teacher_html'],
6293
                $exercise->get_formated_title(),
6294
                $attemptDate,
6295
                $resultsStudentUrl,
6296
                $resultsTeacherUrl,
6297
            ],
6298
            $content
6299
        );
6300
6301
        $currentUserId = empty($currentUserId) ? api_get_user_id() : (int) $currentUserId;
6302
6303
        $content = AnnouncementManager::parseContent(
6304
            $currentUserId,
6305
            $content,
6306
            api_get_course_id(),
6307
            api_get_session_id()
6308
        );
6309
6310
        return $content;
6311
    }
6312
6313
    public static function sendNotification(
6314
        $currentUserId,
6315
        $objExercise,
6316
        $exercise_stat_info,
6317
        $courseInfo,
6318
        $attemptCountToSend,
6319
        $stats,
6320
        $statsTeacher
6321
    ) {
6322
        $notifications = api_get_configuration_value('exercise_finished_notification_settings');
6323
        if (empty($notifications)) {
6324
            return false;
6325
        }
6326
6327
        $studentId = $exercise_stat_info['exe_user_id'];
6328
        $exerciseExtraFieldValue = new ExtraFieldValue('exercise');
6329
        $wrongAnswersCount = $stats['failed_answers_count'];
6330
        $exercisePassed = $stats['exercise_passed'];
6331
        $countPendingQuestions = $stats['count_pending_questions'];
6332
        $stats['all_answers_teacher_html'] = $statsTeacher['all_answers_html'];
6333
6334
        // If there are no pending questions (Open questions).
6335
        if (0 === $countPendingQuestions) {
6336
            /*$extraFieldData = $exerciseExtraFieldValue->get_values_by_handler_and_field_variable(
6337
                $objExercise->iId,
6338
                'signature_mandatory'
6339
            );
6340
6341
            if ($extraFieldData && isset($extraFieldData['value']) && 1 === (int) $extraFieldData['value']) {
6342
                if (ExerciseSignaturePlugin::exerciseHasSignatureActivated($objExercise)) {
6343
                    $signature = ExerciseSignaturePlugin::getSignature($studentId, $exercise_stat_info);
6344
                    if (false !== $signature) {
6345
                        //return false;
6346
                    }
6347
                }
6348
            }*/
6349
6350
            // Notifications.
6351
            $extraFieldData = $exerciseExtraFieldValue->get_values_by_handler_and_field_variable(
6352
                $objExercise->iId,
6353
                'notifications'
6354
            );
6355
            $exerciseNotification = '';
6356
            if ($extraFieldData && isset($extraFieldData['value'])) {
6357
                $exerciseNotification = $extraFieldData['value'];
6358
            }
6359
6360
            $subject = sprintf(get_lang('Failure on attempt %s at %s'), $attemptCountToSend, $courseInfo['title']);
6361
            if ($exercisePassed) {
6362
                $subject = sprintf(get_lang('Validation of exercise at %s'), $courseInfo['title']);
6363
            }
6364
6365
            if ($exercisePassed) {
6366
                $extraFieldData = $exerciseExtraFieldValue->get_values_by_handler_and_field_variable(
6367
                    $objExercise->iId,
6368
                    'MailSuccess'
6369
                );
6370
            } else {
6371
                $extraFieldData = $exerciseExtraFieldValue->get_values_by_handler_and_field_variable(
6372
                    $objExercise->iId,
6373
                    'MailAttempt'.$attemptCountToSend
6374
                );
6375
            }
6376
6377
            // Blocking exercise.
6378
            $blockPercentageExtra = $exerciseExtraFieldValue->get_values_by_handler_and_field_variable(
6379
                $objExercise->iId,
6380
                'blocking_percentage'
6381
            );
6382
            $blockPercentage = false;
6383
            if ($blockPercentageExtra && isset($blockPercentageExtra['value']) && $blockPercentageExtra['value']) {
6384
                $blockPercentage = $blockPercentageExtra['value'];
6385
            }
6386
            if ($blockPercentage) {
6387
                $passBlock = $stats['total_percentage'] > $blockPercentage;
6388
                if (false === $passBlock) {
6389
                    $extraFieldData = $exerciseExtraFieldValue->get_values_by_handler_and_field_variable(
6390
                        $objExercise->iId,
6391
                        'MailIsBlockByPercentage'
6392
                    );
6393
                }
6394
            }
6395
6396
            $extraFieldValueUser = new ExtraFieldValue('user');
6397
6398
            if ($extraFieldData && isset($extraFieldData['value'])) {
6399
                $content = $extraFieldData['value'];
6400
                $content = self::parseContent($content, $stats, $objExercise, $exercise_stat_info, $studentId);
6401
                //if (false === $exercisePassed) {
6402
                if (0 !== $wrongAnswersCount) {
6403
                    $content .= $stats['failed_answers_html'];
6404
                }
6405
6406
                $sendMessage = true;
6407
                if (!empty($exerciseNotification)) {
6408
                    foreach ($notifications as $name => $notificationList) {
6409
                        if ($exerciseNotification !== $name) {
6410
                            continue;
6411
                        }
6412
                        foreach ($notificationList as $notificationName => $attemptData) {
6413
                            if ('student_check' === $notificationName) {
6414
                                $sendMsgIfInList = isset($attemptData['send_notification_if_user_in_extra_field']) ? $attemptData['send_notification_if_user_in_extra_field'] : '';
6415
                                if (!empty($sendMsgIfInList)) {
6416
                                    foreach ($sendMsgIfInList as $skipVariable => $skipValues) {
6417
                                        $userExtraFieldValue = $extraFieldValueUser->get_values_by_handler_and_field_variable(
6418
                                            $studentId,
6419
                                            $skipVariable
6420
                                        );
6421
6422
                                        if (empty($userExtraFieldValue)) {
6423
                                            $sendMessage = false;
6424
                                            break;
6425
                                        } else {
6426
                                            $sendMessage = false;
6427
                                            if (isset($userExtraFieldValue['value']) &&
6428
                                                in_array($userExtraFieldValue['value'], $skipValues)
6429
                                            ) {
6430
                                                $sendMessage = true;
6431
                                                break;
6432
                                            }
6433
                                        }
6434
                                    }
6435
                                }
6436
                                break;
6437
                            }
6438
                        }
6439
                    }
6440
                }
6441
6442
                // Send to student.
6443
                if ($sendMessage) {
6444
                    MessageManager::send_message($currentUserId, $subject, $content);
6445
                }
6446
            }
6447
6448
            if (!empty($exerciseNotification)) {
6449
                foreach ($notifications as $name => $notificationList) {
6450
                    if ($exerciseNotification !== $name) {
6451
                        continue;
6452
                    }
6453
                    foreach ($notificationList as $attemptData) {
6454
                        $skipNotification = false;
6455
                        $skipNotificationList = isset($attemptData['send_notification_if_user_in_extra_field']) ? $attemptData['send_notification_if_user_in_extra_field'] : [];
6456
                        if (!empty($skipNotificationList)) {
6457
                            foreach ($skipNotificationList as $skipVariable => $skipValues) {
6458
                                $userExtraFieldValue = $extraFieldValueUser->get_values_by_handler_and_field_variable(
6459
                                    $studentId,
6460
                                    $skipVariable
6461
                                );
6462
6463
                                if (empty($userExtraFieldValue)) {
6464
                                    $skipNotification = true;
6465
                                    break;
6466
                                } else {
6467
                                    if (isset($userExtraFieldValue['value'])) {
6468
                                        if (!in_array($userExtraFieldValue['value'], $skipValues)) {
6469
                                            $skipNotification = true;
6470
                                            break;
6471
                                        }
6472
                                    } else {
6473
                                        $skipNotification = true;
6474
                                        break;
6475
                                    }
6476
                                }
6477
                            }
6478
                        }
6479
6480
                        if ($skipNotification) {
6481
                            continue;
6482
                        }
6483
6484
                        $email = isset($attemptData['email']) ? $attemptData['email'] : '';
6485
                        $emailList = explode(',', $email);
6486
                        if (empty($emailList)) {
6487
                            continue;
6488
                        }
6489
                        $attempts = isset($attemptData['attempts']) ? $attemptData['attempts'] : [];
6490
                        foreach ($attempts as $attempt) {
6491
                            $sendMessage = false;
6492
                            if (isset($attempt['attempt']) && $attemptCountToSend !== (int) $attempt['attempt']) {
6493
                                continue;
6494
                            }
6495
6496
                            if (!isset($attempt['status'])) {
6497
                                continue;
6498
                            }
6499
6500
                            if ($blockPercentage && isset($attempt['is_block_by_percentage'])) {
6501
                                if ($attempt['is_block_by_percentage']) {
6502
                                    if ($passBlock) {
6503
                                        continue;
6504
                                    }
6505
                                } else {
6506
                                    if (false === $passBlock) {
6507
                                        continue;
6508
                                    }
6509
                                }
6510
                            }
6511
6512
                            switch ($attempt['status']) {
6513
                                case 'passed':
6514
                                    if ($exercisePassed) {
6515
                                        $sendMessage = true;
6516
                                    }
6517
                                    break;
6518
                                case 'failed':
6519
                                    if (false === $exercisePassed) {
6520
                                        $sendMessage = true;
6521
                                    }
6522
                                    break;
6523
                                case 'all':
6524
                                    $sendMessage = true;
6525
                                    break;
6526
                            }
6527
6528
                            if ($sendMessage) {
6529
                                $attachments = [];
6530
                                if (isset($attempt['add_pdf']) && $attempt['add_pdf']) {
6531
                                    // Get pdf content
6532
                                    $pdfExtraData = $exerciseExtraFieldValue->get_values_by_handler_and_field_variable(
6533
                                        $objExercise->iId,
6534
                                        $attempt['add_pdf']
6535
                                    );
6536
6537
                                    if ($pdfExtraData && isset($pdfExtraData['value'])) {
6538
                                        $pdfContent = self::parseContent(
6539
                                            $pdfExtraData['value'],
6540
                                            $stats,
6541
                                            $objExercise,
6542
                                            $exercise_stat_info,
6543
                                            $studentId
6544
                                        );
6545
6546
                                        @$pdf = new PDF();
6547
                                        $filename = get_lang('Test');
6548
                                        $pdfPath = @$pdf->content_to_pdf(
6549
                                            "<html><body>$pdfContent</body></html>",
6550
                                            null,
6551
                                            $filename,
6552
                                            api_get_course_id(),
6553
                                            'F',
6554
                                            false,
6555
                                            null,
6556
                                            false,
6557
                                            true
6558
                                        );
6559
                                        $attachments[] = ['filename' => $filename, 'path' => $pdfPath];
6560
                                    }
6561
                                }
6562
6563
                                $content = isset($attempt['content_default']) ? $attempt['content_default'] : '';
6564
                                if (isset($attempt['content'])) {
6565
                                    $extraFieldData = $exerciseExtraFieldValue->get_values_by_handler_and_field_variable(
6566
                                        $objExercise->iId,
6567
                                        $attempt['content']
6568
                                    );
6569
                                    if ($extraFieldData && isset($extraFieldData['value']) && !empty($extraFieldData['value'])) {
6570
                                        $content = $extraFieldData['value'];
6571
                                    }
6572
                                }
6573
6574
                                if (!empty($content)) {
6575
                                    $content = self::parseContent(
6576
                                        $content,
6577
                                        $stats,
6578
                                        $objExercise,
6579
                                        $exercise_stat_info,
6580
                                        $studentId
6581
                                    );
6582
                                    foreach ($emailList as $email) {
6583
                                        if (empty($email)) {
6584
                                            continue;
6585
                                        }
6586
                                        api_mail_html(
6587
                                            null,
6588
                                            $email,
6589
                                            $subject,
6590
                                            $content,
6591
                                            null,
6592
                                            null,
6593
                                            [],
6594
                                            $attachments
6595
                                        );
6596
                                    }
6597
                                }
6598
6599
                                if (isset($attempt['post_actions'])) {
6600
                                    foreach ($attempt['post_actions'] as $action => $params) {
6601
                                        switch ($action) {
6602
                                            case 'subscribe_student_to_courses':
6603
                                                foreach ($params as $code) {
6604
                                                    $courseInfo = api_get_course_info($code);
6605
                                                    CourseManager::subscribeUser(
6606
                                                        $currentUserId,
6607
                                                        $courseInfo['real_id']
6608
                                                    );
6609
                                                    break;
6610
                                                }
6611
                                                break;
6612
                                        }
6613
                                    }
6614
                                }
6615
                            }
6616
                        }
6617
                    }
6618
                }
6619
            }
6620
        }
6621
    }
6622
6623
    /**
6624
     * Delete an exercise attempt.
6625
     *
6626
     * Log the exe_id deleted with the exe_user_id related.
6627
     *
6628
     * @param int $exeId
6629
     */
6630
    public static function deleteExerciseAttempt($exeId)
6631
    {
6632
        $exeId = (int) $exeId;
6633
6634
        $trackExerciseInfo = self::get_exercise_track_exercise_info($exeId);
6635
6636
        if (empty($trackExerciseInfo)) {
6637
            return;
6638
        }
6639
6640
        $tblTrackExercises = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
6641
        $tblTrackAttempt = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT);
6642
6643
        Database::query("DELETE FROM $tblTrackAttempt WHERE exe_id = $exeId");
6644
        Database::query("DELETE FROM $tblTrackExercises WHERE exe_id = $exeId");
6645
6646
        Event::addEvent(
6647
            LOG_EXERCISE_ATTEMPT_DELETE,
6648
            LOG_EXERCISE_ATTEMPT,
6649
            $exeId,
6650
            api_get_utc_datetime()
6651
        );
6652
        Event::addEvent(
6653
            LOG_EXERCISE_ATTEMPT_DELETE,
6654
            LOG_EXERCISE_AND_USER_ID,
6655
            $exeId.'-'.$trackExerciseInfo['exe_user_id'],
6656
            api_get_utc_datetime()
6657
        );
6658
    }
6659
6660
    public static function scorePassed($score, $total)
6661
    {
6662
        $compareResult = bccomp($score, $total, 3);
6663
        $scorePassed = 1 === $compareResult || 0 === $compareResult;
6664
        if (false === $scorePassed) {
6665
            $epsilon = 0.00001;
6666
            if (abs($score - $total) < $epsilon) {
6667
                $scorePassed = true;
6668
            }
6669
        }
6670
6671
        return $scorePassed;
6672
    }
6673
6674
    /**
6675
     * Returns the HTML for a specific exercise attempt, ready for PDF generation.
6676
     */
6677
    public static function getAttemptPdfHtml(int $exeId, int $courseId, int $sessionId): string
6678
    {
6679
        $_GET = [
6680
            'id'           => $exeId,
6681
            'action'       => 'export',
6682
            'export_type'  => 'all_results',
6683
            'cid'          => $courseId,
6684
            'sid'          => $sessionId,
6685
            'gid'          => 0,
6686
            'gradebook'    => 0,
6687
            'origin'       => '',
6688
        ];
6689
        $_REQUEST = $_GET + $_REQUEST;
6690
6691
        ob_start();
6692
        include __DIR__ . '/../../exercise/exercise_show.php';
6693
        return ob_get_clean();
6694
    }
6695
6696
    /**
6697
     * Generates and saves a PDF for a single exercise attempt
6698
     */
6699
    public static function saveFileExerciseResultPdfDirect(
6700
        int    $exeId,
6701
        int    $courseId,
6702
        int    $sessionId,
6703
        string $exportFolderPath
6704
    ): void {
6705
        // Retrieve the HTML for this attempt and convert it to PDF
6706
        $html = self::getAttemptPdfHtml($exeId, $courseId, $sessionId);
6707
6708
        // Determine filename and path based on user information
6709
        $track   = self::get_exercise_track_exercise_info($exeId);
6710
        $userId  = $track['exe_user_id'] ?? 0;
6711
        $user    = api_get_user_info($userId);
6712
        $pdfName = api_replace_dangerous_char(
6713
            ($user['firstname'] ?? 'user') . '_' .
6714
            ($user['lastname']  ?? 'unknown') .
6715
            '_attemptId' . $exeId . '.pdf'
6716
        );
6717
        $filePath = rtrim($exportFolderPath, '/') . '/' . $pdfName;
6718
6719
        if (file_exists($filePath)) {
6720
            return;
6721
        }
6722
6723
        // Ensure the directory exists
6724
        $dir = dirname($filePath);
6725
        if (!is_dir($dir)) {
6726
            mkdir($dir, 0755, true);
6727
        }
6728
6729
        // Use Chamilo's PDF class to generate and save the file
6730
        $params = [
6731
            'filename'    => $pdfName,
6732
            'course_code' => api_get_course_id(),
6733
        ];
6734
        $pdf = new PDF('A4', 'P', $params);
6735
        $pdf->html_to_pdf_with_template(
6736
            $html,
6737
            true,
6738
            false,
6739
            true,
6740
            [],
6741
            'F',
6742
            $filePath
6743
        );
6744
    }
6745
6746
    /**
6747
     * Exports all results of an exercise to a ZIP archive by generating PDFs on disk and then sending the ZIP to the browser.
6748
     */
6749
    public static function exportExerciseAllResultsZip(
6750
        int   $sessionId,
6751
        int   $courseId,
6752
        int   $exerciseId,
6753
        array $filterDates = [],
6754
        string $mainPath    = ''
6755
    ) {
6756
        $em = Container::getEntityManager();
6757
6758
        /** @var CourseEntity|null $course */
6759
        $course = $em->getRepository(CourseEntity::class)->find($courseId);
6760
        /** @var CQuiz|null $quiz */
6761
        $quiz   = $em->getRepository(CQuiz::class)->findOneBy(['iid' => $exerciseId]);
6762
        $session = null;
6763
6764
        if (!$course) {
6765
            Display::addFlash(Display::return_message(get_lang('Course not found'), 'warning', false));
6766
            return false;
6767
        }
6768
        if (!$quiz) {
6769
            Display::addFlash(Display::return_message(get_lang('Test not found or not visible'), 'warning', false));
6770
            return false;
6771
        }
6772
        if ($sessionId > 0) {
6773
            $session = $em->getRepository(SessionEntity::class)->find($sessionId);
6774
            if (!$session) {
6775
                Display::addFlash(Display::return_message(get_lang('Session not found'), 'warning', false));
6776
                return false;
6777
            }
6778
        }
6779
6780
        // Fetch exe_ids with Doctrine, accepting NULL/0 session when $sessionId == 0
6781
        $exeIds = self::findAttemptExeIdsForExport($course, $quiz, $session, $filterDates);
6782
6783
        // Optional: hard fallback with native SQL to catch legacy session_id=0 rows if needed
6784
        if (empty($exeIds) && $sessionId === 0) {
6785
            $exeIds = self::findAttemptExeIdsFallbackSql($courseId, $exerciseId, $filterDates);
6786
        }
6787
6788
        if (empty($exeIds)) {
6789
            Display::addFlash(
6790
                Display::return_message(
6791
                    get_lang('No result found for export in this test.'),
6792
                    'warning',
6793
                    false
6794
                )
6795
            );
6796
            return false;
6797
        }
6798
6799
        // Prepare a temporary folder for the PDFs
6800
        $exportName       = 'S' . (int)($sessionId) . '-C' . (int)($courseId) . '-T' . (int)($exerciseId);
6801
        $baseDir          = api_get_path(SYS_ARCHIVE_PATH);
6802
        $exportFolderPath = $baseDir . 'pdfexport-' . $exportName;
6803
        if (is_dir($exportFolderPath)) {
6804
            rmdirr($exportFolderPath);
6805
        }
6806
        mkdir($exportFolderPath, 0755, true);
6807
6808
        // Generate a PDF for each attempt
6809
        foreach ($exeIds as $exeId) {
6810
            self::saveFileExerciseResultPdfDirect(
6811
                (int)$exeId,
6812
                (int)$courseId,
6813
                (int)$sessionId,
6814
                $exportFolderPath
6815
            );
6816
        }
6817
6818
        // Create the ZIP archive containing all generated PDFs
6819
        $zipFilePath = $baseDir . 'pdfexport-' . $exportName . '.zip';
6820
        $zip = new \ZipArchive();
6821
        if ($zip->open($zipFilePath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
6822
            throw new \Exception('Failed to create ZIP file');
6823
        }
6824
        $files = new RecursiveIteratorIterator(
6825
            new RecursiveDirectoryIterator($exportFolderPath),
6826
            RecursiveIteratorIterator::LEAVES_ONLY
6827
        );
6828
        foreach ($files as $file) {
6829
            if (!$file->isDir()) {
6830
                $filePath     = $file->getRealPath();
6831
                $relativePath = substr($filePath, strlen($exportFolderPath) + 1);
6832
                $zip->addFile($filePath, $relativePath);
6833
            }
6834
        }
6835
        $zip->close();
6836
        rmdirr($exportFolderPath);
6837
6838
        // Send the ZIP file to the browser or move it to mainPath
6839
        if (!empty($mainPath)) {
6840
            @rename($zipFilePath, $mainPath . '/pdfexport-' . $exportName . '.zip');
6841
            return true;
6842
        }
6843
6844
        session_write_close();
6845
        while (ob_get_level()) {
6846
            @ob_end_clean();
6847
        }
6848
6849
        header('Content-Description: File Transfer');
6850
        header('Content-Type: application/zip');
6851
        header('Content-Disposition: attachment; filename="pdfexport-' . $exportName . '.zip"');
6852
        header('Content-Transfer-Encoding: binary');
6853
        header('Expires: 0');
6854
        header('Cache-Control: must-revalidate');
6855
        header('Pragma: public');
6856
        header('Content-Length: ' . filesize($zipFilePath));
6857
6858
        readfile($zipFilePath);
6859
        @unlink($zipFilePath);
6860
        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...
6861
    }
6862
6863
    /**
6864
     * Return exe_ids for export using Doctrine (handles NULL/0 sessions safely).
6865
     */
6866
    private static function findAttemptExeIdsForExport(
6867
        CourseEntity $course,
6868
        CQuiz $quiz,
6869
        ?SessionEntity $session,
6870
        array $filterDates
6871
    ): array {
6872
        $em = Container::getEntityManager();
6873
6874
        $qb = $em->createQueryBuilder()
6875
            ->select('te.exeId AS exeId')
6876
            ->from(TrackEExercise::class, 'te')
6877
            ->where('te.course = :course')
6878
            ->andWhere('te.quiz = :quiz')
6879
            ->setParameter('course', $course)
6880
            ->setParameter('quiz', $quiz);
6881
6882
        // Session filter:
6883
        if ($session) {
6884
            $qb->andWhere('te.session = :session')->setParameter('session', $session);
6885
        } else {
6886
            // Accept both NULL and legacy "0" values
6887
            // IDENTITY() extracts the FK raw value to match 0 if present
6888
            $qb->andWhere('(te.session IS NULL OR IDENTITY(te.session) = 0)');
6889
        }
6890
6891
        // Date filters on exeDate
6892
        if (!empty($filterDates['start_date'])) {
6893
            $qb->andWhere('te.exeDate >= :start')
6894
                ->setParameter('start', new DateTime($filterDates['start_date']));
6895
        }
6896
        if (!empty($filterDates['end_date'])) {
6897
            $qb->andWhere('te.exeDate <= :end')
6898
                ->setParameter('end', new DateTime($filterDates['end_date']));
6899
        }
6900
6901
        $qb->orderBy('te.exeDate', 'DESC')->setMaxResults(5000);
6902
6903
        $rows = $qb->getQuery()->getScalarResult();
6904
        $exeIds = array_map(static fn($r) => (int)$r['exeId'], $rows);
6905
6906
6907
        return array_values(array_unique($exeIds));
6908
    }
6909
6910
    /**
6911
     * Fallback with native SQL for very legacy rows (session_id=0 and column names).
6912
     */
6913
    private static function findAttemptExeIdsFallbackSql(
6914
        int $courseId,
6915
        int $quizIid,
6916
        array $filterDates
6917
    ): array {
6918
        $conn = Container::getEntityManager()->getConnection();
6919
6920
        $sql = 'SELECT te.exe_id
6921
            FROM track_e_exercises te
6922
            WHERE te.c_id = :cid
6923
              AND te.exe_exo_id = :iid
6924
              AND (te.session_id IS NULL OR te.session_id = 0)';
6925
6926
        $params = ['cid' => $courseId, 'iid' => $quizIid];
6927
        $types  = [];
6928
6929
        if (!empty($filterDates['start_date'])) {
6930
            $sql .= ' AND te.exe_date >= :start';
6931
            $params['start'] = $filterDates['start_date'];
6932
        }
6933
        if (!empty($filterDates['end_date'])) {
6934
            $sql .= ' AND te.exe_date <= :end';
6935
            $params['end'] = $filterDates['end_date'];
6936
        }
6937
6938
        $sql .= ' ORDER BY te.exe_date DESC LIMIT 5000';
6939
6940
        $rows = $conn->fetchAllAssociative($sql, $params, $types);
6941
        $exeIds = array_map(static fn($r) => (int)$r['exe_id'], $rows);
6942
6943
        return $exeIds;
6944
    }
6945
6946
    /**
6947
     * Calculates the overall score for Combination-type questions.
6948
     */
6949
    public static function getUserQuestionScoreGlobal(
6950
        int   $answerType,
6951
        array $listCorrectAnswers,
6952
        int   $exeId,
6953
        int   $questionId,
6954
        float $questionWeighting,
6955
        array $choice = [],
6956
        int $nbrAnswers = 0
6957
    ): float
6958
    {
6959
        $nbrCorrect = 0;
6960
        $nbrOptions = 0;
6961
        $choice = is_array($choice) ? $choice : [];
6962
        switch ($answerType) {
6963
            case FILL_IN_BLANKS_COMBINATION:
6964
                if (!empty($listCorrectAnswers)) {
6965
                    if (!empty($listCorrectAnswers['student_score']) && is_array($listCorrectAnswers['student_score'])) {
6966
                        foreach ($listCorrectAnswers['student_score'] as $val) {
6967
                            if ((int) $val === 1) {
6968
                                $nbrCorrect++;
6969
                            }
6970
                        }
6971
                    }
6972
                    if (!empty($listCorrectAnswers['words_count'])) {
6973
                        $nbrOptions = (int) $listCorrectAnswers['words_count'];
6974
                    } elseif (!empty($listCorrectAnswers['words']) && is_array($listCorrectAnswers['words'])) {
6975
                        $nbrOptions = count($listCorrectAnswers['words']);
6976
                    }
6977
                }
6978
                break;
6979
6980
            case HOT_SPOT_COMBINATION:
6981
                if (!empty($listCorrectAnswers) && is_array($listCorrectAnswers) && is_array($choice)) {
6982
                    foreach ($listCorrectAnswers as $idx => $val) {
6983
                        if (isset($choice[$idx]) && (int) $choice[$idx] === 1) {
6984
                            $nbrCorrect++;
6985
                        }
6986
                    }
6987
                } else {
6988
                    $TBL_TRACK_HOTSPOT = Database::get_main_table(TABLE_STATISTIC_TRACK_E_HOTSPOT);
6989
                    $exeIdEsc = Database::escape_string($exeId);
6990
                    $qIdEsc   = Database::escape_string($questionId);
6991
                    $sql = "SELECT COUNT(hotspot_id) AS ct
6992
                        FROM $TBL_TRACK_HOTSPOT
6993
                        WHERE hotspot_exe_id = '$exeIdEsc'
6994
                          AND hotspot_question_id = '$qIdEsc'
6995
                          AND hotspot_correct = 1";
6996
                    $result = Database::query($sql);
6997
                    $nbrCorrect = (int) Database::result($result, 0, 0);
6998
                }
6999
                $nbrOptions = (int) $nbrAnswers;
7000
                break;
7001
7002
            case MATCHING_COMBINATION:
7003
            case MATCHING_DRAGGABLE_COMBINATION:
7004
                if (isset($listCorrectAnswers['form_values'])) {
7005
                    if (isset($listCorrectAnswers['form_values']['correct'])) {
7006
                        $nbrCorrect = count($listCorrectAnswers['form_values']['correct']);
7007
                        $nbrOptions = (int) $listCorrectAnswers['form_values']['count_options'];
7008
                    }
7009
                } else {
7010
                    if (isset($listCorrectAnswers['from_database'])) {
7011
                        if (isset($listCorrectAnswers['from_database']['correct'])) {
7012
                            $nbrCorrect = count($listCorrectAnswers['from_database']['correct']);
7013
                            $nbrOptions = (int) $listCorrectAnswers['from_database']['count_options'];
7014
                        }
7015
                    }
7016
                }
7017
                break;
7018
        }
7019
7020
        $questionScore = 0.0;
7021
        if ($nbrOptions > 0 && $nbrCorrect === $nbrOptions) {
7022
            $questionScore = (float) $questionWeighting;
7023
        }
7024
7025
        return $questionScore;
7026
    }
7027
7028
    /**
7029
     * Build a public URL for a ResourceNode file used in exercises.
7030
     * Returns an empty string when the node is null or when the underlying
7031
     * file/route cannot be resolved (we do not want to break the exercise view).
7032
     *
7033
     * @param ResourceNode|null $resourceNode
7034
     * @param array                                        $extraParams
7035
     *
7036
     * @return string
7037
     */
7038
    public static function getPublicUrlForResourceNode(?ResourceNode $resourceNode): string
7039
    {
7040
        if (null === $resourceNode) {
7041
            return '';
7042
        }
7043
7044
        try {
7045
            /** @var ResourceNodeRepository $resourceNodeRepo */
7046
            $resourceNodeRepo = Container::getResourceNodeRepository();
7047
            $resourceType = $resourceNode->getResourceType();
7048
            $tool         = $resourceType?->getTool();
7049
            $url = $resourceNodeRepo->getResourceFileUrl($resourceNode);
7050
7051
            return $url;
7052
        } catch (Throwable $e) {
7053
            error_log(sprintf(
7054
                '[ORAL_FILE_AUDIO][node=%s] Exception in getPublicUrlForResourceNode(): %s (%s) at %s:%d',
7055
                $resourceNode?->getId() ?? 'null',
7056
                $e->getMessage(),
7057
                get_class($e),
7058
                $e->getFile(),
7059
                $e->getLine()
7060
            ));
7061
7062
            return '';
7063
        }
7064
    }
7065
7066
    /**
7067
     * Normalize the attempt question list:
7068
     * - Media questions are containers and must NOT be counted as real questions.
7069
     * - When random questions are enabled ($objExercise->random > 0),
7070
     *   ensure we have exactly N answerable questions by topping up from the exercise pool.
7071
     *
7072
     * @param Exercise $objExercise
7073
     * @param int[]    $ids
7074
     *
7075
     * @return int[]
7076
     */
7077
    public static function normalizeAttemptQuestionList(Exercise $objExercise, array $ids): array
7078
    {
7079
        $ids = array_values(array_unique(array_map('intval', $ids)));
7080
7081
        $randomCount = isset($objExercise->random) ? (int) $objExercise->random : 0;
7082
7083
        // Remove media questions from the list (and page breaks only in random mode).
7084
        $normalized = [];
7085
        foreach ($ids as $qid) {
7086
            $q = Question::read((int) $qid);
7087
            if (!$q) {
7088
                continue;
7089
            }
7090
7091
            // Media questions are not answerable.
7092
            if ((int) $q->type === MEDIA_QUESTION) {
7093
                continue;
7094
            }
7095
7096
            // Random selection applies to answerable questions, not structural breaks.
7097
            if ($randomCount > 0 && (int) $q->type === PAGE_BREAK) {
7098
                continue;
7099
            }
7100
7101
            $normalized[] = (int) $qid;
7102
        }
7103
7104
        if ($randomCount <= 0) {
7105
            return $normalized;
7106
        }
7107
7108
        // Trim if we somehow have too many.
7109
        if (count($normalized) > $randomCount) {
7110
            return array_slice($normalized, 0, $randomCount);
7111
        }
7112
7113
        // Top up if we have too few after removing media questions.
7114
        if (count($normalized) < $randomCount) {
7115
            $pool = [];
7116
            foreach ((array) $objExercise->getQuestionOrderedList() as $qid) {
7117
                $qid = (int) $qid;
7118
                $q = Question::read($qid);
7119
                if (!$q) {
7120
                    continue;
7121
                }
7122
7123
                if ((int) $q->type === MEDIA_QUESTION || (int) $q->type === PAGE_BREAK) {
7124
                    continue;
7125
                }
7126
7127
                $pool[] = $qid;
7128
            }
7129
7130
            // Remove already selected.
7131
            $pool = array_values(array_diff($pool, $normalized));
7132
7133
            if (!empty($pool)) {
7134
                shuffle($pool);
7135
            }
7136
7137
            $needed = $randomCount - count($normalized);
7138
            if ($needed > 0) {
7139
                $normalized = array_merge($normalized, array_slice($pool, 0, $needed));
7140
            }
7141
        }
7142
7143
        return $normalized;
7144
    }
7145
7146
    /**
7147
     * Persist corrected data_tracking back to DB so the attempt stays stable.
7148
     *
7149
     * @param int   $exeId
7150
     * @param int[] $questionList
7151
     * @param array $exerciseStatInfo
7152
     */
7153
    public static function updateAttemptDataTrackingIfNeeded(int $exeId, array $questionList, array &$exerciseStatInfo): void
7154
    {
7155
        if ($exeId <= 0) {
7156
            return;
7157
        }
7158
7159
        $newTracking = implode(',', array_map('intval', $questionList));
7160
        $oldTracking = isset($exerciseStatInfo['data_tracking']) ? (string) $exerciseStatInfo['data_tracking'] : '';
7161
7162
        if ($newTracking === $oldTracking) {
7163
            return;
7164
        }
7165
7166
        $table = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
7167
        $sql = "UPDATE $table
7168
            SET data_tracking = '".Database::escape_string($newTracking)."'
7169
            WHERE exe_id = ".(int) $exeId;
7170
        Database::query($sql);
7171
7172
        $exerciseStatInfo['data_tracking'] = $newTracking;
7173
    }
7174
}
7175