Completed
Push — master ( f1ab08...76cb70 )
by
unknown
01:46 queued 39s
created

ExerciseLib::check_fill_in_blanks()   D

Complexity

Conditions 19

Size

Total Lines 126
Code Lines 74

Duplication

Lines 0
Ratio 0 %

Importance

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