Passed
Pull Request — master (#7150)
by
unknown
08:54
created

ExerciseLib::get_average_score()   A

Complexity

Conditions 5

Size

Total Lines 19
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 12
nop 3
dl 0
loc 19
rs 9.5555
c 0
b 0
f 0
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
     * Get the exercise result ranking for a given user score.
3432
     *
3433
     * @param float|null $my_score    Normalized score (0..1) of the current user
3434
     * @param int        $my_exe_id   Execution ID of the current user attempt
3435
     * @param int        $exercise_id Exercise identifier
3436
     * @param string|int $course_code Course code or course ID (for BC we keep the param name)
3437
     * @param int        $session_id  Session identifier
3438
     * @param array      $user_list   List of users in the ranking
3439
     * @param bool       $return_string If true, returns "position/count" string, otherwise array
3440
     *
3441
     * @return string|array
3442
     */
3443
    public static function get_exercise_result_ranking(
3444
        $my_score,
3445
        $my_exe_id,
3446
        $exercise_id,
3447
        $courseId,
3448
        $session_id = 0,
3449
        $user_list = [],
3450
        $return_string = true
3451
    ) {
3452
        // No score given, return placeholder
3453
        if (is_null($my_score)) {
3454
            return '-';
3455
        }
3456
3457
        // No user list, nothing to compare with
3458
        if (empty($user_list)) {
3459
            return '-';
3460
        }
3461
3462
        $courseId = (int) $courseId;
3463
        $exercise_id = (int) $exercise_id;
3464
        $session_id = (int) $session_id;
3465
3466
        $best_attempts = [];
3467
3468
        // Get best attempt for each user
3469
        foreach ($user_list as $user_data) {
3470
            if (is_array($user_data) && isset($user_data['user_id'])) {
3471
                $user_id = (int) $user_data['user_id'];
3472
            } else {
3473
                // Backwards-compatibility: if item is a plain user_id
3474
                $user_id = (int) $user_data;
3475
            }
3476
3477
            $best_attempts[$user_id] = self::get_best_attempt_by_user(
3478
                $user_id,
3479
                $exercise_id,
3480
                $courseId,
3481
                $session_id
3482
            );
3483
        }
3484
3485
        if (empty($best_attempts)) {
3486
            return 1;
3487
        }
3488
3489
        $my_ranking = [];
3490
        foreach ($best_attempts as $user_id => $result) {
3491
            if (!empty($result['max_score']) && 0 != (float) $result['max_score']) {
3492
                $my_ranking[$user_id] = $result['score'] / $result['max_score'];
3493
            } else {
3494
                $my_ranking[$user_id] = 0;
3495
            }
3496
        }
3497
3498
        // Sort ascending by score
3499
        asort($my_ranking);
3500
3501
        $position = count($my_ranking);
3502
        if (!empty($my_ranking)) {
3503
            foreach ($my_ranking as $user_id => $ranking) {
3504
                if ($my_score >= $ranking) {
3505
                    if ($my_score == $ranking && isset($best_attempts[$user_id]['exe_id'])) {
3506
                        $exe_id = $best_attempts[$user_id]['exe_id'];
3507
                        if ($my_exe_id < $exe_id) {
3508
                            $position--;
3509
                        }
3510
                    } else {
3511
                        $position--;
3512
                    }
3513
                }
3514
            }
3515
        }
3516
3517
        $return_value = [
3518
            'position' => $position,
3519
            'count' => count($my_ranking),
3520
        ];
3521
3522
        if ($return_string) {
3523
            if (!empty($position) && !empty($my_ranking)) {
3524
                $return_value = $position.'/'.count($my_ranking);
3525
            } else {
3526
                $return_value = '-';
3527
            }
3528
        }
3529
3530
        return $return_value;
3531
    }
3532
3533
    /**
3534
     * Gets the position of the score based in a given score (result/weight) and the exe_id based in all attempts
3535
     * (NO Exercises in LPs ) old functionality by attempt.
3536
     *
3537
     * @param   float   user score to be compared attention => score/weight
3538
     * @param   int     exe id of the exercise
3539
     * (this is necessary because if 2 students have the same score the one
3540
     * with the minor exe_id will have a best position, just to be fair and FIFO)
3541
     * @param   int     exercise id
3542
     * @param   string  course code
3543
     * @param   int     session id
3544
     * @param bool $return_string
3545
     *
3546
     * @return int the position of the user between his friends in a course (or course within a session)
3547
     */
3548
    public static function get_exercise_result_ranking_by_attempt(
3549
        $my_score,
3550
        $my_exe_id,
3551
        $exercise_id,
3552
        $courseId,
3553
        $session_id = 0,
3554
        $return_string = true
3555
    ) {
3556
        if (empty($session_id)) {
3557
            $session_id = 0;
3558
        }
3559
        if (is_null($my_score)) {
3560
            return '-';
3561
        }
3562
        $user_results = Event::get_all_exercise_results(
3563
            $exercise_id,
3564
            $courseId,
3565
            $session_id,
3566
            false
3567
        );
3568
        $position_data = [];
3569
        if (empty($user_results)) {
3570
            return 1;
3571
        } else {
3572
            $position = 1;
3573
            $my_ranking = [];
3574
            foreach ($user_results as $result) {
3575
                if (!empty($result['max_score']) && 0 != intval($result['max_score'])) {
3576
                    $my_ranking[$result['exe_id']] = $result['score'] / $result['max_score'];
3577
                } else {
3578
                    $my_ranking[$result['exe_id']] = 0;
3579
                }
3580
            }
3581
            asort($my_ranking);
3582
            $position = count($my_ranking);
3583
            if (!empty($my_ranking)) {
3584
                foreach ($my_ranking as $exe_id => $ranking) {
3585
                    if ($my_score >= $ranking) {
3586
                        if ($my_score == $ranking) {
3587
                            if ($my_exe_id < $exe_id) {
3588
                                $position--;
3589
                            }
3590
                        } else {
3591
                            $position--;
3592
                        }
3593
                    }
3594
                }
3595
            }
3596
            $return_value = [
3597
                'position' => $position,
3598
                'count' => count($my_ranking),
3599
            ];
3600
3601
            if ($return_string) {
3602
                if (!empty($position) && !empty($my_ranking)) {
3603
                    return $position.'/'.count($my_ranking);
3604
                }
3605
            }
3606
3607
            return $return_value;
3608
        }
3609
    }
3610
3611
    /**
3612
     * Get the best attempt in a exercise (NO Exercises in LPs ).
3613
     *
3614
     * @param int $exercise_id
3615
     * @param int $courseId
3616
     * @param int $session_id
3617
     *
3618
     * @return array
3619
     */
3620
    public static function get_best_attempt_in_course($exercise_id, $courseId, $session_id)
3621
    {
3622
        $user_results = Event::get_all_exercise_results(
3623
            $exercise_id,
3624
            $courseId,
3625
            $session_id,
3626
            false
3627
        );
3628
3629
        $best_score_data = [];
3630
        $best_score = 0;
3631
        if (!empty($user_results)) {
3632
            foreach ($user_results as $result) {
3633
                if (!empty($result['max_score']) &&
3634
                    0 != intval($result['max_score'])
3635
                ) {
3636
                    $score = $result['score'] / $result['max_score'];
3637
                    if ($score >= $best_score) {
3638
                        $best_score = $score;
3639
                        $best_score_data = $result;
3640
                    }
3641
                }
3642
            }
3643
        }
3644
3645
        return $best_score_data;
3646
    }
3647
3648
    /**
3649
     * Returns the best attempt data for a given user and exercise.
3650
     *
3651
     * This method accepts either a course ID (int) or a course code (string)
3652
     * and always converts it to the internal course ID (c_id) used in tracking tables.
3653
     *
3654
     * @param int        $user_id
3655
     * @param int        $exercise_id
3656
     * @param int|string $courseId   Course ID (c_id) or course code
3657
     * @param int        $session_id
3658
     *
3659
     * @return array Best attempt row from track_e_exercises (or empty array if none)
3660
     */
3661
    public static function get_best_attempt_by_user(
3662
        $user_id,
3663
        $exercise_id,
3664
        $courseId,
3665
        $session_id
3666
    ) {
3667
        // Make sure courseId is an integer course real_id
3668
        $courseId = (int) $courseId;
3669
        $exercise_id = (int) $exercise_id;
3670
        $session_id = (int) $session_id;
3671
        $user_id = (int) $user_id;
3672
3673
        // Load all attempts for this user / exercise / course / session
3674
        $user_results = Event::get_all_exercise_results(
3675
            $exercise_id,
3676
            $courseId,
3677
            $session_id,
3678
            false,
3679
            $user_id
3680
        );
3681
3682
        $best_score_data = [];
3683
        $best_score = 0;
3684
3685
        if (!empty($user_results)) {
3686
            foreach ($user_results as $result) {
3687
                if (!empty($result['max_score']) && 0 != (float) $result['max_score']) {
3688
                    $score = $result['score'] / $result['max_score'];
3689
                    if ($score >= $best_score) {
3690
                        // Keep the best attempt (highest ratio score/max_score)
3691
                        $best_score = $score;
3692
                        $best_score_data = $result;
3693
                    }
3694
                }
3695
            }
3696
        }
3697
3698
        return $best_score_data;
3699
    }
3700
3701
    /**
3702
     * Get average score (NO Exercises in LPs ).
3703
     *
3704
     * @param    int    exercise id
3705
     * @param int $courseId
3706
     * @param    int    session id
3707
     *
3708
     * @return float Average score
3709
     */
3710
    public static function get_average_score($exercise_id, $courseId, $session_id)
3711
    {
3712
        $user_results = Event::get_all_exercise_results(
3713
            $exercise_id,
3714
            $courseId,
3715
            $session_id
3716
        );
3717
        $avg_score = 0;
3718
        if (!empty($user_results)) {
3719
            foreach ($user_results as $result) {
3720
                if (!empty($result['max_score']) && 0 != intval($result['max_score'])) {
3721
                    $score = $result['score'] / $result['max_score'];
3722
                    $avg_score += $score;
3723
                }
3724
            }
3725
            $avg_score = float_format($avg_score / count($user_results), 1);
3726
        }
3727
3728
        return $avg_score;
3729
    }
3730
3731
    /**
3732
     * Get average score by score (NO Exercises in LPs ).
3733
     *
3734
     * @param int $courseId
3735
     * @param    int    session id
3736
     *
3737
     * @return float Average score
3738
     */
3739
    public static function get_average_score_by_course($courseId, $session_id)
3740
    {
3741
        $user_results = Event::get_all_exercise_results_by_course(
3742
            $courseId,
3743
            $session_id,
3744
            false
3745
        );
3746
        $avg_score = 0;
3747
        if (!empty($user_results)) {
3748
            foreach ($user_results as $result) {
3749
                if (!empty($result['max_score']) && 0 != intval(
3750
                        $result['max_score']
3751
                    )
3752
                ) {
3753
                    $score = $result['score'] / $result['max_score'];
3754
                    $avg_score += $score;
3755
                }
3756
            }
3757
            // We assume that all max_score
3758
            $avg_score = $avg_score / count($user_results);
3759
        }
3760
3761
        return $avg_score;
3762
    }
3763
3764
    /**
3765
     * @param int $user_id
3766
     * @param int $courseId
3767
     * @param int $session_id
3768
     *
3769
     * @return float|int
3770
     */
3771
    public static function get_average_score_by_course_by_user(
3772
        $user_id,
3773
        $courseId,
3774
        $session_id
3775
    ) {
3776
        $user_results = Event::get_all_exercise_results_by_user(
3777
            $user_id,
3778
            $courseId,
3779
            $session_id
3780
        );
3781
        $avg_score = 0;
3782
        if (!empty($user_results)) {
3783
            foreach ($user_results as $result) {
3784
                if (!empty($result['max_score']) && 0 != intval($result['max_score'])) {
3785
                    $score = $result['score'] / $result['max_score'];
3786
                    $avg_score += $score;
3787
                }
3788
            }
3789
            // We assume that all max_score
3790
            $avg_score = ($avg_score / count($user_results));
3791
        }
3792
3793
        return $avg_score;
3794
    }
3795
3796
    /**
3797
     * Get average score by score (NO Exercises in LPs ).
3798
     *
3799
     * @param int $exercise_id
3800
     * @param int $courseId
3801
     * @param int $session_id
3802
     * @param int $user_count
3803
     *
3804
     * @return float Best average score
3805
     */
3806
    public static function get_best_average_score_by_exercise(
3807
        $exercise_id,
3808
        $courseId,
3809
        $session_id,
3810
        $user_count
3811
    ) {
3812
        $user_results = Event::get_best_exercise_results_by_user(
3813
            $exercise_id,
3814
            $courseId,
3815
            $session_id
3816
        );
3817
        $avg_score = 0;
3818
        if (!empty($user_results)) {
3819
            foreach ($user_results as $result) {
3820
                if (!empty($result['max_score']) && 0 != intval($result['max_score'])) {
3821
                    $score = $result['score'] / $result['max_score'];
3822
                    $avg_score += $score;
3823
                }
3824
            }
3825
            // We asumme that all max_score
3826
            if (!empty($user_count)) {
3827
                $avg_score = float_format($avg_score / $user_count, 1) * 100;
3828
            } else {
3829
                $avg_score = 0;
3830
            }
3831
        }
3832
3833
        return $avg_score;
3834
    }
3835
3836
    /**
3837
     * Get average score by score (NO Exercises in LPs ).
3838
     *
3839
     * @param int $exercise_id
3840
     * @param int $courseId
3841
     * @param int $session_id
3842
     *
3843
     * @return float Best average score
3844
     */
3845
    public static function getBestScoreByExercise(
3846
        $exercise_id,
3847
        $courseId,
3848
        $session_id
3849
    ) {
3850
        $user_results = Event::get_best_exercise_results_by_user(
3851
            $exercise_id,
3852
            $courseId,
3853
            $session_id
3854
        );
3855
        $avg_score = 0;
3856
        if (!empty($user_results)) {
3857
            foreach ($user_results as $result) {
3858
                if (!empty($result['max_score']) && 0 != intval($result['max_score'])) {
3859
                    $score = $result['score'] / $result['max_score'];
3860
                    $avg_score += $score;
3861
                }
3862
            }
3863
        }
3864
3865
        return $avg_score;
3866
    }
3867
3868
    /**
3869
     * Get student results (only in completed exercises) stats by question.
3870
     *
3871
     * @throws \Doctrine\DBAL\Exception
3872
     */
3873
    public static function getStudentStatsByQuestion(
3874
        int $questionId,
3875
        int $exerciseId,
3876
        string $courseCode,
3877
        int $sessionId,
3878
        bool $onlyStudent = false
3879
    ): array
3880
    {
3881
        $trackExercises = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
3882
        $trackAttempt = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT);
3883
        $courseUser = Database::get_main_table(TABLE_MAIN_COURSE_USER);
3884
3885
        $questionId = (int) $questionId;
3886
        $exerciseId = (int) $exerciseId;
3887
        $courseCode = Database::escape_string($courseCode);
3888
        $sessionId = (int) $sessionId;
3889
        $courseId = api_get_course_int_id($courseCode);
3890
3891
        $sql = "SELECT MAX(marks) as max, MIN(marks) as min, AVG(marks) as average
3892
                FROM $trackExercises e ";
3893
        $sessionCondition = api_get_session_condition($sessionId, true, false, 'e.session_id');
3894
        if ($onlyStudent) {
3895
            $courseCondition = '';
3896
            if (empty($sessionId)) {
3897
                $courseCondition = "
3898
                INNER JOIN $courseUser c
3899
                ON (
3900
                    e.exe_user_id = c.user_id AND
3901
                    e.c_id = c.c_id AND
3902
                    c.status = ".STUDENT." AND
3903
                    relation_type <> 2
3904
                )";
3905
            } else {
3906
                $sessionRelCourse = Database::get_main_table(TABLE_MAIN_SESSION_COURSE_USER);
3907
                $courseCondition = "
3908
            INNER JOIN $sessionRelCourse sc
3909
            ON (
3910
                        e.exe_user_id = sc.user_id AND
3911
                        e.c_id = sc.c_id AND
3912
                        e.session_id = sc.session_id AND
3913
                        sc.status = ".SessionEntity::STUDENT."
3914
                )";
3915
            }
3916
            $sql .= $courseCondition;
3917
        }
3918
        $sql .= "
3919
    		INNER JOIN $trackAttempt a
3920
    		ON (
3921
    		    a.exe_id = e.exe_id
3922
            )
3923
    		WHERE
3924
    		    exe_exo_id 	= $exerciseId AND
3925
                e.c_id = $courseId AND
3926
                question_id = $questionId AND
3927
                e.status = ''
3928
                $sessionCondition
3929
            LIMIT 1";
3930
        $result = Database::query($sql);
3931
        $return = [];
3932
        if ($result) {
3933
            $return = Database::fetch_assoc($result);
3934
        }
3935
3936
        return $return;
3937
    }
3938
3939
    /**
3940
     * Get the correct answer count for a fill blanks question.
3941
     *
3942
     * @param int $question_id
3943
     * @param int $exercise_id
3944
     *
3945
     * @return array
3946
     */
3947
    public static function getNumberStudentsFillBlanksAnswerCount(
3948
        $question_id,
3949
        $exercise_id
3950
    ) {
3951
        $listStudentsId = [];
3952
        $listAllStudentInfo = CourseManager::get_student_list_from_course_code(
3953
            api_get_course_id(),
3954
            true
3955
        );
3956
        foreach ($listAllStudentInfo as $i => $listStudentInfo) {
3957
            $listStudentsId[] = $listStudentInfo['user_id'];
3958
        }
3959
3960
        $listFillTheBlankResult = FillBlanks::getFillTheBlankResult(
3961
            $exercise_id,
3962
            $question_id,
3963
            $listStudentsId,
3964
            '1970-01-01',
3965
            '3000-01-01'
3966
        );
3967
3968
        $arrayCount = [];
3969
3970
        foreach ($listFillTheBlankResult as $resultCount) {
3971
            foreach ($resultCount as $index => $count) {
3972
                //this is only for declare the array index per answer
3973
                $arrayCount[$index] = 0;
3974
            }
3975
        }
3976
3977
        foreach ($listFillTheBlankResult as $resultCount) {
3978
            foreach ($resultCount as $index => $count) {
3979
                $count = (0 === $count) ? 1 : 0;
3980
                $arrayCount[$index] += $count;
3981
            }
3982
        }
3983
3984
        return $arrayCount;
3985
    }
3986
3987
    /**
3988
     * Get the number of questions with answers.
3989
     *
3990
     * @param int    $question_id
3991
     * @param int    $exercise_id
3992
     * @param string $course_code
3993
     * @param int    $session_id
3994
     * @param string $questionType
3995
     *
3996
     * @return int
3997
     */
3998
    public static function get_number_students_question_with_answer_count(
3999
        $question_id,
4000
        $exercise_id,
4001
        $course_code,
4002
        $session_id,
4003
        $questionType = ''
4004
    ) {
4005
        $track_exercises   = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
4006
        $track_attempt     = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT);
4007
        $courseUser        = Database::get_main_table(TABLE_MAIN_COURSE_USER);
4008
        $courseTable       = Database::get_main_table(TABLE_MAIN_COURSE);
4009
        $courseUserSession = Database::get_main_table(TABLE_MAIN_SESSION_COURSE_USER);
4010
4011
        $question_id = (int) $question_id;
4012
        $exercise_id = (int) $exercise_id;
4013
        $courseId    = (int) api_get_course_int_id($course_code);
4014
        $session_id  = (int) $session_id;
4015
4016
        if (in_array($questionType, [FILL_IN_BLANKS, FILL_IN_BLANKS_COMBINATION], true)) {
4017
            $listStudentsId     = [];
4018
            $listAllStudentInfo = CourseManager::get_student_list_from_course_code(api_get_course_id(), true);
4019
            foreach ($listAllStudentInfo as $listStudentInfo) {
4020
                $listStudentsId[] = (int) $listStudentInfo['user_id'];
4021
            }
4022
4023
            $listFillTheBlankResult = FillBlanks::getFillTheBlankResult(
4024
                $exercise_id,
4025
                $question_id,
4026
                $listStudentsId,
4027
                '1970-01-01',
4028
                '3000-01-01'
4029
            );
4030
4031
            return FillBlanks::getNbResultFillBlankAll($listFillTheBlankResult);
4032
        }
4033
4034
        if (empty($session_id)) {
4035
            $courseCondition = "
4036
            INNER JOIN $courseUser cu
4037
                ON cu.c_id = c.id AND cu.user_id = exe_user_id";
4038
            $courseConditionWhere = " AND relation_type <> 2 AND cu.status = ".STUDENT;
4039
        } else {
4040
            $courseCondition = "
4041
            INNER JOIN $courseUserSession cu
4042
                ON (cu.c_id = c.id AND cu.user_id = e.exe_user_id AND e.session_id = cu.session_id)";
4043
            $courseConditionWhere = ' AND cu.status = '.SessionEntity::STUDENT;
4044
        }
4045
4046
        $sessionCondition = api_get_session_condition($session_id, true, false, 'e.session_id');
4047
        $sql = "SELECT DISTINCT exe_user_id
4048
            FROM $track_exercises e
4049
            INNER JOIN $track_attempt a
4050
                ON (a.exe_id = e.exe_id AND a.c_id = e.c_id)
4051
            INNER JOIN $courseTable c
4052
                ON c.id = e.c_id
4053
            $courseCondition
4054
            WHERE
4055
                exe_exo_id  = $exercise_id AND
4056
                e.c_id      = $courseId AND
4057
                question_id = $question_id AND
4058
                answer <> '0' AND
4059
                e.status = ''
4060
                $courseConditionWhere
4061
                $sessionCondition
4062
    ";
4063
4064
        $result = Database::query($sql);
4065
4066
        return $result ? (int) Database::num_rows($result) : 0;
4067
    }
4068
4069
    /**
4070
     * Get number of answers to hotspot questions.
4071
     */
4072
    public static function getNumberStudentsAnswerHotspotCount(
4073
        int    $answerId,
4074
        int    $questionId,
4075
        int    $exerciseId,
4076
        string $courseCode,
4077
        int $sessionId
4078
    ): int
4079
    {
4080
        $trackExercises = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
4081
        $trackHotspot = Database::get_main_table(TABLE_STATISTIC_TRACK_E_HOTSPOT);
4082
        $courseUser = Database::get_main_table(TABLE_MAIN_COURSE_USER);
4083
        $courseTable = Database::get_main_table(TABLE_MAIN_COURSE);
4084
        $courseUserSession = Database::get_main_table(TABLE_MAIN_SESSION_COURSE_USER);
4085
4086
        $questionId = (int) $questionId;
4087
        $answerId = (int) $answerId;
4088
        $exerciseId = (int) $exerciseId;
4089
        $courseId = api_get_course_int_id($courseCode);
4090
        $sessionId = (int) $sessionId;
4091
4092
        if (empty($sessionId)) {
4093
            $courseCondition = "
4094
            INNER JOIN $courseUser cu
4095
            ON cu.c_id = c.id AND cu.user_id  = exe_user_id";
4096
            $courseConditionWhere = " AND relation_type <> 2 AND cu.status = ".STUDENT;
4097
        } else {
4098
            $courseCondition = "
4099
            INNER JOIN $courseUserSession cu
4100
            ON (cu.c_id = c.id AND cu.user_id = e.exe_user_id AND e.session_id = cu.session_id)";
4101
            $courseConditionWhere = ' AND cu.status = '.SessionEntity::STUDENT;
4102
        }
4103
4104
        $sessionCondition = api_get_session_condition($sessionId, true, false, 'e.session_id');
4105
        $sql = "SELECT DISTINCT exe_user_id
4106
                FROM $trackExercises e
4107
                INNER JOIN $trackHotspot a
4108
                ON (a.hotspot_exe_id = e.exe_id)
4109
                INNER JOIN $courseTable c
4110
                ON (a.c_id = c.id)
4111
                $courseCondition
4112
                WHERE
4113
                    exe_exo_id              = $exerciseId AND
4114
                    a.c_id 	= $courseId AND
4115
                    hotspot_answer_id       = $answerId AND
4116
                    hotspot_question_id     = $questionId AND
4117
                    hotspot_correct         =  1 AND
4118
                    e.status                = ''
4119
                    $courseConditionWhere
4120
                    $sessionCondition
4121
            ";
4122
        $result = Database::query($sql);
4123
        $return = 0;
4124
        if ($result) {
4125
            $return = Database::num_rows($result);
4126
        }
4127
4128
        return $return;
4129
    }
4130
4131
    /**
4132
     * @param int    $answer_id
4133
     * @param int    $question_id
4134
     * @param int    $exercise_id
4135
     * @param string $course_code
4136
     * @param int    $session_id
4137
     * @param string $question_type
4138
     * @param string $correct_answer
4139
     * @param string $current_answer
4140
     *
4141
     * @return int
4142
     */
4143
    public static function get_number_students_answer_count(
4144
        $answer_id,
4145
        $question_id,
4146
        $exercise_id,
4147
        $course_code,
4148
        $session_id,
4149
        $question_type = null,
4150
        $correct_answer = null,
4151
        $current_answer = null
4152
    ) {
4153
        $track_exercises = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
4154
        $track_attempt   = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT);
4155
        $courseTable     = Database::get_main_table(TABLE_MAIN_COURSE);
4156
        $courseUser      = Database::get_main_table(TABLE_MAIN_COURSE_USER);
4157
        $courseUserSession = Database::get_main_table(TABLE_MAIN_SESSION_COURSE_USER);
4158
4159
        $question_id = (int) $question_id;
4160
        $answer_id   = (int) $answer_id;
4161
        $exercise_id = (int) $exercise_id;
4162
        $courseId    = (int) api_get_course_int_id($course_code);
4163
        $session_id  = (int) $session_id;
4164
4165
        switch ($question_type) {
4166
            case FILL_IN_BLANKS:
4167
            case FILL_IN_BLANKS_COMBINATION:
4168
                $answer_condition = '';
4169
                $select_condition = ' e.exe_id, answer ';
4170
                break;
4171
            case MATCHING:
4172
            case MATCHING_COMBINATION:
4173
            case MATCHING_DRAGGABLE:
4174
            case MATCHING_DRAGGABLE_COMBINATION:
4175
            default:
4176
                $answer_condition = " answer = $answer_id AND ";
4177
                $select_condition = ' DISTINCT exe_user_id ';
4178
        }
4179
4180
        if (empty($session_id)) {
4181
            $courseCondition = "
4182
            INNER JOIN $courseUser cu
4183
                ON cu.c_id = e.c_id AND cu.user_id = e.exe_user_id";
4184
            $courseConditionWhere = " AND relation_type <> 2 AND cu.status = ".STUDENT;
4185
        } else {
4186
            $courseCondition = "
4187
            INNER JOIN $courseUserSession cu
4188
                ON (cu.c_id = e.c_id AND cu.user_id = e.exe_user_id AND e.session_id = cu.session_id)";
4189
            $courseConditionWhere = ' AND cu.status = '.SessionEntity::STUDENT;
4190
        }
4191
4192
        $sessionCondition = api_get_session_condition($session_id, true, false, 'e.session_id');
4193
        $sql = "SELECT $select_condition
4194
            FROM $track_exercises e
4195
            INNER JOIN $track_attempt a
4196
                ON (a.exe_id = e.exe_id AND a.c_id = e.c_id)
4197
            INNER JOIN $courseTable c
4198
                ON c.id = e.c_id
4199
            $courseCondition
4200
            WHERE
4201
                exe_exo_id = $exercise_id AND
4202
                e.c_id = $courseId AND
4203
                $answer_condition
4204
                question_id = $question_id AND
4205
                e.status = ''
4206
                $courseConditionWhere
4207
                $sessionCondition
4208
    ";
4209
4210
        $result = Database::query($sql);
4211
        $return = 0;
4212
        if ($result) {
4213
            switch ($question_type) {
4214
                case FILL_IN_BLANKS:
4215
                case FILL_IN_BLANKS_COMBINATION:
4216
                    $good_answers = 0;
4217
                    while ($row = Database::fetch_assoc($result)) {
4218
                        $fill_blank = self::check_fill_in_blanks(
4219
                            $correct_answer,
4220
                            $row['answer'],
4221
                            $current_answer
4222
                        );
4223
                        if (isset($fill_blank[$current_answer]) && 1 == (int) $fill_blank[$current_answer]) {
4224
                            $good_answers++;
4225
                        }
4226
                    }
4227
4228
                    return $good_answers;
4229
4230
                case MATCHING:
4231
                case MATCHING_COMBINATION:
4232
                case MATCHING_DRAGGABLE:
4233
                case MATCHING_DRAGGABLE_COMBINATION:
4234
                default:
4235
                    $return = Database::num_rows($result);
4236
            }
4237
        }
4238
4239
        return $return;
4240
    }
4241
4242
    /**
4243
     * Get the number of times an answer was selected.
4244
     */
4245
    public static function getCountOfAnswers(
4246
        int $answerId,
4247
        int $questionId,
4248
        int $exerciseId,
4249
        string $courseCode,
4250
        int $sessionId,
4251
        $questionType = null,
4252
    ): int
4253
    {
4254
        $trackExercises = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
4255
        $trackAttempt = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT);
4256
        $courseTable = Database::get_main_table(TABLE_MAIN_COURSE);
4257
        $courseUser = Database::get_main_table(TABLE_MAIN_COURSE_USER);
4258
        $courseUserSession = Database::get_main_table(TABLE_MAIN_SESSION_COURSE_USER);
4259
4260
        $answerId = (int) $answerId;
4261
        $questionId = (int) $questionId;
4262
        $exerciseId = (int) $exerciseId;
4263
        $courseId = api_get_course_int_id($courseCode);
4264
        $sessionId = (int) $sessionId;
4265
        $return = 0;
4266
4267
        $answerCondition = match ($questionType) {
4268
            FILL_IN_BLANKS => '',
4269
            default => " answer = $answerId AND ",
4270
        };
4271
4272
        if (empty($sessionId)) {
4273
            $courseCondition = "
4274
            INNER JOIN $courseUser cu
4275
            ON cu.c_id = c.id AND cu.user_id = exe_user_id";
4276
            $courseConditionWhere = " AND relation_type <> 2 AND cu.status = ".STUDENT;
4277
        } else {
4278
            $courseCondition = "
4279
            INNER JOIN $courseUserSession cu
4280
            ON (cu.c_id = a.c_id AND cu.user_id = e.exe_user_id AND e.session_id = cu.session_id)";
4281
            $courseConditionWhere = ' AND cu.status = '.SessionEntity::STUDENT;
4282
        }
4283
4284
        $sessionCondition = api_get_session_condition($sessionId, true, false, 'e.session_id');
4285
        $sql = "SELECT count(a.answer) as total
4286
                FROM $trackExercises e
4287
                INNER JOIN $trackAttempt a
4288
                ON (
4289
                    a.exe_id = e.exe_id
4290
                )
4291
                INNER JOIN $courseTable c
4292
                ON c.id = e.c_id
4293
                $courseCondition
4294
                WHERE
4295
                    exe_exo_id = $exerciseId AND
4296
                    e.c_id = $courseId AND
4297
                    $answerCondition
4298
                    question_id = $questionId AND
4299
                    e.status = ''
4300
                    $courseConditionWhere
4301
                    $sessionCondition
4302
            ";
4303
        $result = Database::query($sql);
4304
        if ($result) {
4305
            $count = Database::fetch_array($result);
4306
            $return = (int) $count['total'];
4307
        }
4308
        return $return;
4309
    }
4310
4311
    /**
4312
     * @param array  $answer
4313
     * @param string $user_answer
4314
     *
4315
     * @return array
4316
     */
4317
    public static function check_fill_in_blanks($answer, $user_answer, $current_answer)
4318
    {
4319
        // the question is encoded like this
4320
        // [A] B [C] D [E] F::10,10,10@1
4321
        // number 1 before the "@" means that is a switchable fill in blank question
4322
        // [A] B [C] D [E] F::10,10,10@ or  [A] B [C] D [E] F::10,10,10
4323
        // means that is a normal fill blank question
4324
        // first we explode the "::"
4325
        $pre_array = explode('::', $answer);
4326
        // is switchable fill blank or not
4327
        $last = count($pre_array) - 1;
4328
        $is_set_switchable = explode('@', $pre_array[$last]);
4329
        $switchable_answer_set = false;
4330
        if (isset($is_set_switchable[1]) && 1 == $is_set_switchable[1]) {
4331
            $switchable_answer_set = true;
4332
        }
4333
        $answer = '';
4334
        for ($k = 0; $k < $last; $k++) {
4335
            $answer .= $pre_array[$k];
4336
        }
4337
        // splits weightings that are joined with a comma
4338
        $answerWeighting = explode(',', $is_set_switchable[0]);
4339
4340
        // we save the answer because it will be modified
4341
        //$temp = $answer;
4342
        $temp = $answer;
4343
4344
        $answer = '';
4345
        $j = 0;
4346
        //initialise answer tags
4347
        $user_tags = $correct_tags = $real_text = [];
4348
        // the loop will stop at the end of the text
4349
        while (1) {
4350
            // quits the loop if there are no more blanks (detect '[')
4351
            if (false === ($pos = api_strpos($temp, '['))) {
4352
                // adds the end of the text
4353
                $answer = $temp;
4354
                $real_text[] = $answer;
4355
                break; //no more "blanks", quit the loop
4356
            }
4357
            // adds the piece of text that is before the blank
4358
            //and ends with '[' into a general storage array
4359
            $real_text[] = api_substr($temp, 0, $pos + 1);
4360
            $answer .= api_substr($temp, 0, $pos + 1);
4361
            //take the string remaining (after the last "[" we found)
4362
            $temp = api_substr($temp, $pos + 1);
4363
            // quit the loop if there are no more blanks, and update $pos to the position of next ']'
4364
            if (false === ($pos = api_strpos($temp, ']'))) {
4365
                // adds the end of the text
4366
                $answer .= $temp;
4367
                break;
4368
            }
4369
4370
            $str = $user_answer;
4371
4372
            preg_match_all('#\[([^[]*)\]#', $str, $arr);
4373
            $str = str_replace('\r\n', '', $str);
4374
            $choices = $arr[1];
4375
            $choice = [];
4376
            $check = false;
4377
            $i = 0;
4378
            foreach ($choices as $item) {
4379
                if ($current_answer === $item) {
4380
                    $check = true;
4381
                }
4382
                if ($check) {
4383
                    $choice[] = $item;
4384
                    $i++;
4385
                }
4386
                if (3 == $i) {
4387
                    break;
4388
                }
4389
            }
4390
            $tmp = api_strrpos($choice[$j], ' / ');
4391
4392
            if (false !== $tmp) {
4393
                $choice[$j] = api_substr($choice[$j], 0, $tmp);
4394
            }
4395
4396
            $choice[$j] = trim($choice[$j]);
4397
4398
            //Needed to let characters ' and " to work as part of an answer
4399
            $choice[$j] = stripslashes($choice[$j]);
4400
4401
            $user_tags[] = api_strtolower($choice[$j]);
4402
            //put the contents of the [] answer tag into correct_tags[]
4403
            $correct_tags[] = api_strtolower(api_substr($temp, 0, $pos));
4404
            $j++;
4405
            $temp = api_substr($temp, $pos + 1);
4406
        }
4407
4408
        $answer = '';
4409
        $real_correct_tags = $correct_tags;
4410
        $chosen_list = [];
4411
        $good_answer = [];
4412
4413
        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...
4414
            if (!$switchable_answer_set) {
4415
                //needed to parse ' and " characters
4416
                $user_tags[$i] = stripslashes($user_tags[$i]);
4417
                if ($correct_tags[$i] == $user_tags[$i]) {
4418
                    $good_answer[$correct_tags[$i]] = 1;
4419
                } elseif (!empty($user_tags[$i])) {
4420
                    $good_answer[$correct_tags[$i]] = 0;
4421
                } else {
4422
                    $good_answer[$correct_tags[$i]] = 0;
4423
                }
4424
            } else {
4425
                // switchable fill in the blanks
4426
                if (in_array($user_tags[$i], $correct_tags)) {
4427
                    $correct_tags = array_diff($correct_tags, $chosen_list);
4428
                    $good_answer[$correct_tags[$i]] = 1;
4429
                } elseif (!empty($user_tags[$i])) {
4430
                    $good_answer[$correct_tags[$i]] = 0;
4431
                } else {
4432
                    $good_answer[$correct_tags[$i]] = 0;
4433
                }
4434
            }
4435
            // adds the correct word, followed by ] to close the blank
4436
            $answer .= ' / <font color="green"><b>'.$real_correct_tags[$i].'</b></font>]';
4437
            if (isset($real_text[$i + 1])) {
4438
                $answer .= $real_text[$i + 1];
4439
            }
4440
        }
4441
4442
        return $good_answer;
4443
    }
4444
4445
    /**
4446
     * Return an HTML select menu with the student groups.
4447
     *
4448
     * @param string $name     is the name and the id of the <select>
4449
     * @param string $default  default value for option
4450
     * @param string $onchange
4451
     *
4452
     * @return string the html code of the <select>
4453
     */
4454
    public static function displayGroupMenu($name, $default, $onchange = "")
4455
    {
4456
        // check the default value of option
4457
        $tabSelected = [$default => " selected='selected' "];
4458
        $res = "<select name='$name' id='$name' onchange='".$onchange."' >";
4459
        $res .= "<option value='-1'".$tabSelected["-1"].">-- ".get_lang('All groups')." --</option>";
4460
        $res .= "<option value='0'".$tabSelected["0"].">- ".get_lang('Not in a group')." -</option>";
4461
        $groups = GroupManager::get_group_list();
4462
        $currentCatId = 0;
4463
        $countGroups = count($groups);
4464
        for ($i = 0; $i < $countGroups; $i++) {
4465
            $category = GroupManager::get_category_from_group($groups[$i]['iid']);
4466
            if ($category['id'] != $currentCatId) {
4467
                $res .= "<option value='-1' disabled='disabled'>".$category['title']."</option>";
4468
                $currentCatId = $category['id'];
4469
            }
4470
            $res .= "<option ".$tabSelected[$groups[$i]['id']]."style='margin-left:40px' value='".
4471
                $groups[$i]["iid"]."'>".
4472
                $groups[$i]["name"].
4473
                "</option>";
4474
        }
4475
        $res .= "</select>";
4476
4477
        return $res;
4478
    }
4479
4480
    /**
4481
     * @param int $exe_id
4482
     */
4483
    public static function create_chat_exercise_session($exe_id)
4484
    {
4485
        if (!isset($_SESSION['current_exercises'])) {
4486
            $_SESSION['current_exercises'] = [];
4487
        }
4488
        $_SESSION['current_exercises'][$exe_id] = true;
4489
    }
4490
4491
    /**
4492
     * @param int $exe_id
4493
     */
4494
    public static function delete_chat_exercise_session($exe_id)
4495
    {
4496
        if (isset($_SESSION['current_exercises'])) {
4497
            $_SESSION['current_exercises'][$exe_id] = false;
4498
        }
4499
    }
4500
4501
    /**
4502
     * Display the exercise results.
4503
     *
4504
     * @param Exercise $objExercise
4505
     * @param int      $exeId
4506
     * @param bool     $save_user_result save users results (true) or just show the results (false)
4507
     * @param string   $remainingMessage
4508
     * @param bool     $allowSignature
4509
     * @param bool     $allowExportPdf
4510
     * @param bool     $isExport
4511
     */
4512
    public static function displayQuestionListByAttempt(
4513
        $objExercise,
4514
        $exeId,
4515
        $save_user_result = false,
4516
        $remainingMessage = '',
4517
        $allowSignature = false,
4518
        $allowExportPdf = false,
4519
        $isExport = false
4520
    ) {
4521
        $origin = api_get_origin();
4522
        $courseId = api_get_course_int_id();
4523
        $courseCode = api_get_course_id();
4524
        $sessionId = api_get_session_id();
4525
4526
        // Getting attempt info
4527
        $exercise_stat_info = $objExercise->get_stat_track_exercise_info_by_exe_id($exeId);
4528
4529
        // Getting question list
4530
        $question_list = [];
4531
        $studentInfo = [];
4532
        if (!empty($exercise_stat_info['data_tracking'])) {
4533
            $studentInfo = api_get_user_info($exercise_stat_info['exe_user_id']);
4534
            $question_list = explode(',', $exercise_stat_info['data_tracking']);
4535
        } else {
4536
            // Try getting the question list only if save result is off
4537
            if (false == $save_user_result) {
4538
                $question_list = $objExercise->get_validated_question_list();
4539
            }
4540
            if (in_array(
4541
                $objExercise->getFeedbackType(),
4542
                [EXERCISE_FEEDBACK_TYPE_DIRECT, EXERCISE_FEEDBACK_TYPE_POPUP]
4543
            )) {
4544
                $question_list = $objExercise->get_validated_question_list();
4545
            }
4546
        }
4547
4548
        if ($objExercise->getResultAccess()) {
4549
            if (false === $objExercise->hasResultsAccess($exercise_stat_info)) {
4550
                echo Display::return_message(
4551
                    sprintf(get_lang('You have passed the %s minutes limit to see the results.'), $objExercise->getResultsAccess())
4552
                );
4553
4554
                return false;
4555
            }
4556
4557
            if (!empty($objExercise->getResultAccess())) {
4558
                $url = api_get_path(WEB_CODE_PATH).'exercise/overview.php?'.api_get_cidreq().'&exerciseId='.$objExercise->id;
4559
                echo $objExercise->returnTimeLeftDiv();
4560
                echo $objExercise->showSimpleTimeControl(
4561
                    $objExercise->getResultAccessTimeDiff($exercise_stat_info),
4562
                    $url
4563
                );
4564
            }
4565
        }
4566
4567
        $counter = 1;
4568
        $total_score = $total_weight = 0;
4569
        $exerciseContent = null;
4570
4571
        // Hide results
4572
        $show_results = false;
4573
        $show_only_score = false;
4574
        if (in_array($objExercise->results_disabled,
4575
            [
4576
                RESULT_DISABLE_SHOW_ONLY_IN_CORRECT_ANSWER,
4577
                RESULT_DISABLE_SHOW_SCORE_AND_EXPECTED_ANSWERS,
4578
                RESULT_DISABLE_SHOW_SCORE_AND_EXPECTED_ANSWERS_AND_RANKING,
4579
            ]
4580
        )) {
4581
            $show_results = true;
4582
        }
4583
4584
        if (in_array(
4585
            $objExercise->results_disabled,
4586
            [
4587
                RESULT_DISABLE_SHOW_SCORE_ONLY,
4588
                RESULT_DISABLE_SHOW_FINAL_SCORE_ONLY_WITH_CATEGORIES,
4589
                RESULT_DISABLE_RANKING,
4590
            ]
4591
        )
4592
        ) {
4593
            $show_only_score = true;
4594
        }
4595
4596
        // Not display expected answer, but score, and feedback
4597
        $show_all_but_expected_answer = false;
4598
        if (RESULT_DISABLE_SHOW_SCORE_ONLY == $objExercise->results_disabled &&
4599
            EXERCISE_FEEDBACK_TYPE_END == $objExercise->getFeedbackType()
4600
        ) {
4601
            $show_all_but_expected_answer = true;
4602
            $show_results = true;
4603
            $show_only_score = false;
4604
        }
4605
4606
        $showTotalScoreAndUserChoicesInLastAttempt = true;
4607
        $showTotalScore = true;
4608
        $showQuestionScore = true;
4609
        $attemptResult = [];
4610
4611
        if (in_array(
4612
            $objExercise->results_disabled,
4613
            [
4614
                RESULT_DISABLE_SHOW_SCORE_ATTEMPT_SHOW_ANSWERS_LAST_ATTEMPT,
4615
                RESULT_DISABLE_SHOW_SCORE_ATTEMPT_SHOW_ANSWERS_LAST_ATTEMPT_NO_FEEDBACK,
4616
                RESULT_DISABLE_DONT_SHOW_SCORE_ONLY_IF_USER_FINISHES_ATTEMPTS_SHOW_ALWAYS_FEEDBACK,
4617
            ])
4618
        ) {
4619
            $show_only_score = true;
4620
            $show_results = true;
4621
            $numberAttempts = 0;
4622
            if ($objExercise->attempts > 0) {
4623
                $attempts = Event::getExerciseResultsByUser(
4624
                    api_get_user_id(),
4625
                    $objExercise->id,
4626
                    $courseId,
4627
                    $sessionId,
4628
                    $exercise_stat_info['orig_lp_id'],
4629
                    $exercise_stat_info['orig_lp_item_id'],
4630
                    'desc'
4631
                );
4632
                if ($attempts) {
4633
                    $numberAttempts = count($attempts);
4634
                }
4635
4636
                if ($save_user_result) {
4637
                    $numberAttempts++;
4638
                }
4639
4640
                $showTotalScore = false;
4641
                if (RESULT_DISABLE_SHOW_SCORE_ATTEMPT_SHOW_ANSWERS_LAST_ATTEMPT == $objExercise->results_disabled) {
4642
                    $showTotalScore = true;
4643
                }
4644
                $showTotalScoreAndUserChoicesInLastAttempt = false;
4645
                if ($numberAttempts >= $objExercise->attempts) {
4646
                    $showTotalScore = true;
4647
                    $show_results = true;
4648
                    $show_only_score = false;
4649
                    $showTotalScoreAndUserChoicesInLastAttempt = true;
4650
                }
4651
4652
                if (RESULT_DISABLE_SHOW_SCORE_ATTEMPT_SHOW_ANSWERS_LAST_ATTEMPT_NO_FEEDBACK == $objExercise->results_disabled) {
4653
                    $showTotalScore = true;
4654
                    $show_results = true;
4655
                    $show_only_score = false;
4656
                    $showTotalScoreAndUserChoicesInLastAttempt = false;
4657
                    if ($numberAttempts >= $objExercise->attempts) {
4658
                        $showTotalScoreAndUserChoicesInLastAttempt = true;
4659
                    }
4660
4661
                    // Check if the current attempt is the last.
4662
                    if (false === $save_user_result && !empty($attempts)) {
4663
                        $showTotalScoreAndUserChoicesInLastAttempt = false;
4664
                        $position = 1;
4665
                        foreach ($attempts as $attempt) {
4666
                            if ($exeId == $attempt['exe_id']) {
4667
                                break;
4668
                            }
4669
                            $position++;
4670
                        }
4671
4672
                        if ($position == $objExercise->attempts) {
4673
                            $showTotalScoreAndUserChoicesInLastAttempt = true;
4674
                        }
4675
                    }
4676
                }
4677
            }
4678
4679
            if (RESULT_DISABLE_DONT_SHOW_SCORE_ONLY_IF_USER_FINISHES_ATTEMPTS_SHOW_ALWAYS_FEEDBACK ==
4680
                $objExercise->results_disabled
4681
            ) {
4682
                $show_only_score = false;
4683
                $show_results = true;
4684
                $show_all_but_expected_answer = false;
4685
                $showTotalScore = false;
4686
                $showQuestionScore = false;
4687
                if ($numberAttempts >= $objExercise->attempts) {
4688
                    $showTotalScore = true;
4689
                    $showQuestionScore = true;
4690
                }
4691
            }
4692
        }
4693
4694
        // When exporting to PDF hide feedback/comment/score show warning in hotspot.
4695
        if ($allowExportPdf && $isExport) {
4696
            $showTotalScore = false;
4697
            $showQuestionScore = false;
4698
            $objExercise->feedback_type = 2;
4699
            $objExercise->hideComment = true;
4700
            $objExercise->hideNoAnswer = true;
4701
            $objExercise->results_disabled = 0;
4702
            $objExercise->hideExpectedAnswer = true;
4703
            $show_results = true;
4704
        }
4705
4706
        if ('embeddable' !== $origin &&
4707
            !empty($exercise_stat_info['exe_user_id']) &&
4708
            !empty($studentInfo)
4709
        ) {
4710
            // Shows exercise header.
4711
            echo $objExercise->showExerciseResultHeader(
4712
                $studentInfo,
4713
                $exercise_stat_info,
4714
                $save_user_result,
4715
                $allowSignature,
4716
                $allowExportPdf
4717
            );
4718
        }
4719
4720
        $question_list_answers = [];
4721
        $category_list = [];
4722
        $loadChoiceFromSession = false;
4723
        $fromDatabase = true;
4724
        $exerciseResult = null;
4725
        $exerciseResultCoordinates = null;
4726
        $delineationResults = null;
4727
        if (true === $save_user_result && in_array(
4728
                $objExercise->getFeedbackType(),
4729
                [EXERCISE_FEEDBACK_TYPE_DIRECT, EXERCISE_FEEDBACK_TYPE_POPUP]
4730
            )) {
4731
            $loadChoiceFromSession = true;
4732
            $fromDatabase = false;
4733
            $exerciseResult = Session::read('exerciseResult');
4734
            $exerciseResultCoordinates = Session::read('exerciseResultCoordinates');
4735
            $delineationResults = Session::read('hotspot_delineation_result');
4736
            $delineationResults = isset($delineationResults[$objExercise->id]) ? $delineationResults[$objExercise->id] : null;
4737
        }
4738
4739
        $countPendingQuestions = 0;
4740
        $result = [];
4741
        $panelsByParent = [];
4742
        $finalOrder = [];
4743
        if (!empty($question_list)) {
4744
            $parentMap = [];
4745
            $mediaChildren = []; // pid => ['first_idx'=>int, 'children'=>int[]]
4746
            foreach ($question_list as $idx => $qid) {
4747
                $q = Question::read($qid, $objExercise->course);
4748
                $pid = (int) ($q->parent_id ?: 0);
4749
                $parentMap[$qid] = $pid;
4750
                if ($pid > 0) {
4751
                    if (!isset($mediaChildren[$pid])) {
4752
                        $mediaChildren[$pid] = ['first_idx' => $idx, 'children' => []];
4753
                    }
4754
                    $mediaChildren[$pid]['children'][] = $qid;
4755
                }
4756
            }
4757
4758
            // build finalOrder, emitting each media group once.
4759
            $groupEmitted = [];
4760
            foreach ($question_list as $idx => $qid) {
4761
                $pid = $parentMap[$qid] ?? 0;
4762
                if ($pid === 0) {
4763
                    $finalOrder[] = ['type' => 'single', 'qid' => $qid];
4764
                } else {
4765
                    if (empty($groupEmitted[$pid])) {
4766
                        $groupEmitted[$pid] = true;
4767
                        $finalOrder[] = [
4768
                            'type'     => 'group',
4769
                            'parent'   => $pid,
4770
                            'children' => $mediaChildren[$pid]['children'] ?? [$qid],
4771
                        ];
4772
                    }
4773
                    // If already emitted, skip the child here (it will be in the group).
4774
                }
4775
            }
4776
        }
4777
4778
        $orderedOutputHtml = '';
4779
        $renderSingle = function (int $questionId) use (
4780
            &$objExercise,
4781
            $exeId,
4782
            $loadChoiceFromSession,
4783
            &$exerciseResult,
4784
            &$delineationResults,
4785
            &$exerciseResultCoordinates,
4786
            &$save_user_result,
4787
            &$fromDatabase,
4788
            &$show_results,
4789
            &$total_score,
4790
            &$total_weight,
4791
            &$question_list_answers,
4792
            &$showQuestionScore,
4793
            &$counter,
4794
            &$attemptResult,
4795
            &$category_list
4796
        ) {
4797
            // Start buffering rendering for this question
4798
            ob_start();
4799
4800
            // Load choices from session if needed
4801
            $choice = null;
4802
            $delineationChoice = null;
4803
            if ($loadChoiceFromSession) {
4804
                $choice = $exerciseResult[$questionId] ?? null;
4805
                $delineationChoice = $delineationResults[$questionId] ?? null;
4806
            }
4807
4808
            // Compute result for the given question
4809
            $result = $objExercise->manage_answer(
4810
                $exeId,
4811
                $questionId,
4812
                $choice,
4813
                'exercise_result',
4814
                $exerciseResultCoordinates,
4815
                $save_user_result,
4816
                $fromDatabase,
4817
                $show_results,
4818
                $objExercise->selectPropagateNeg(),
4819
                $delineationChoice,
4820
                true // keep user choices in last attempt when applicable
4821
            );
4822
4823
            if (empty($result)) {
4824
                ob_end_clean();
4825
                return [null, null]; // nothing to add
4826
            }
4827
4828
            $total_score  += $result['score'];
4829
            $total_weight += $result['weight'];
4830
4831
            $question_list_answers[] = [
4832
                'question'            => $result['open_question'],
4833
                'answer'              => $result['open_answer'],
4834
                'answer_type'         => $result['answer_type'],
4835
                'generated_oral_file' => $result['generated_oral_file'],
4836
            ];
4837
4838
            $my_total_score  = $result['score'];
4839
            $my_total_weight = $result['weight'];
4840
            $scorePassed     = self::scorePassed($my_total_score, $my_total_weight);
4841
4842
            // Category aggregation
4843
            $objQuestionTmp = Question::read($questionId, $objExercise->course);
4844
            $category_was_added_for_this_test = false;
4845
            if (isset($objQuestionTmp->category) && !empty($objQuestionTmp->category)) {
4846
                $cid = $objQuestionTmp->category;
4847
                $category_list[$cid]['score']           = ($category_list[$cid]['score'] ?? 0) + $my_total_score;
4848
                $category_list[$cid]['total']           = ($category_list[$cid]['total'] ?? 0) + $my_total_weight;
4849
                $category_list[$cid]['total_questions'] = ($category_list[$cid]['total_questions'] ?? 0) + 1;
4850
                if ($scorePassed) {
4851
                    if (!empty($my_total_score)) {
4852
                        $category_list[$cid]['passed'] = ($category_list[$cid]['passed'] ?? 0) + 1;
4853
                    }
4854
                } else {
4855
                    if ($result['user_answered']) {
4856
                        $category_list[$cid]['wrong'] = ($category_list[$cid]['wrong'] ?? 0) + 1;
4857
                    } else {
4858
                        $category_list[$cid]['no_answer'] = ($category_list[$cid]['no_answer'] ?? 0) + 1;
4859
                    }
4860
                }
4861
                $category_was_added_for_this_test = true;
4862
            }
4863
            if (!empty($objQuestionTmp->category_list)) {
4864
                foreach ($objQuestionTmp->category_list as $cid) {
4865
                    $category_list[$cid]['score'] = ($category_list[$cid]['score'] ?? 0) + $my_total_score;
4866
                    $category_list[$cid]['total'] = ($category_list[$cid]['total'] ?? 0) + $my_total_weight;
4867
                    $category_was_added_for_this_test = true;
4868
                }
4869
            }
4870
            if (!$category_was_added_for_this_test) {
4871
                $category_list['none']['score'] = ($category_list['none']['score'] ?? 0) + $my_total_score;
4872
                $category_list['none']['total'] = ($category_list['none']['total'] ?? 0) + $my_total_weight;
4873
            }
4874
4875
            if (0 == $objExercise->selectPropagateNeg() && $my_total_score < 0) {
4876
                $my_total_score = 0;
4877
            }
4878
4879
            $comnt = null;
4880
            if ($show_results) {
4881
                $comnt = Event::get_comments($exeId, $questionId);
4882
                $teacherAudio = self::getOralFeedbackAudio($exeId, $questionId);
4883
4884
                if (!empty($comnt) || $teacherAudio) {
4885
                    echo '<b>'.get_lang('Feedback').'</b>';
4886
                }
4887
                if (!empty($comnt)) {
4888
                    echo self::getFeedbackText($comnt);
4889
                }
4890
                if ($teacherAudio) {
4891
                    echo $teacherAudio;
4892
                }
4893
            }
4894
4895
            $calculatedScore = [
4896
                'result'        => self::show_score($my_total_score, $my_total_weight, false),
4897
                'pass'          => $scorePassed,
4898
                'score'         => $my_total_score,
4899
                'weight'        => $my_total_weight,
4900
                'comments'      => $comnt,
4901
                'user_answered' => $result['user_answered'],
4902
            ];
4903
4904
            $scoreCol = $show_results ? $calculatedScore : [];
4905
4906
            $contents = ob_get_clean();
4907
            $questionContent = '';
4908
            if ($show_results) {
4909
                $questionContent = '<div class="question-answer-result">';
4910
                if (false === $showQuestionScore) {
4911
                    $scoreCol = [];
4912
                }
4913
4914
                // Numbered header (media parents are not rendered here)
4915
                $questionContent .= $objQuestionTmp->return_header(
4916
                    $objExercise,
4917
                    $counter,
4918
                    $scoreCol
4919
                );
4920
            }
4921
            // Count only real questions
4922
            $counter++;
4923
            $questionContent .= $contents;
4924
            if ($show_results) {
4925
                $questionContent .= '</div>';
4926
            }
4927
4928
            $calculatedScore['question_content'] = $questionContent;
4929
            $attemptResult[] = $calculatedScore;
4930
4931
            return [$questionContent, $result];
4932
        };
4933
4934
        // Render entries
4935
        if (!empty($finalOrder)) {
4936
            foreach ($finalOrder as $entry) {
4937
                if ($entry['type'] === 'single') {
4938
                    [$html, $resultLast] = $renderSingle((int)$entry['qid']);
4939
                    if ($html) {
4940
                        $panelsByParent[0][] = Display::panel($html);
4941
                        if ($show_results) {
4942
                            $orderedOutputHtml .= Display::panel($html);
4943
                        }
4944
                        if ($resultLast) {
4945
                            $result = $resultLast; // keep last result for later checks (like chart)
4946
                        }
4947
                    }
4948
                } else {
4949
                    $pid = (int)$entry['parent'];
4950
                    $children = (array)$entry['children'];
4951
4952
                    if ($show_results) {
4953
                        // Open media wrapper
4954
                        $orderedOutputHtml .= '<div class="media-group">';
4955
4956
                        // Render media stem (no numbering)
4957
                        $orderedOutputHtml .= '<div class="media-content">';
4958
                        ob_start();
4959
                        $objExercise->manage_answer(
4960
                            $exeId,
4961
                            $pid,
4962
                            null,
4963
                            'exercise_show',
4964
                            [],
4965
                            false,
4966
                            true,
4967
                            $show_results,
4968
                            $objExercise->selectPropagateNeg()
4969
                        );
4970
                        $orderedOutputHtml .= ob_get_clean();
4971
                        $orderedOutputHtml .= '</div>';
4972
4973
                        $mediaQ = Question::read($pid, $objExercise->course);
4974
                        if (!empty($mediaQ->description)) {
4975
                            $orderedOutputHtml .= '<div class="media-description">'.$mediaQ->description.'</div>';
4976
                        }
4977
4978
                        $orderedOutputHtml .= '<div class="media-children">';
4979
                    }
4980
4981
                    // Render all children contiguously
4982
                    foreach ($children as $cid) {
4983
                        [$html, $resultLast] = $renderSingle((int)$cid);
4984
                        if ($html) {
4985
                            $panelsByParent[$pid][] = Display::panel($html);
4986
                            if ($show_results) {
4987
                                $orderedOutputHtml .= Display::panel($html);
4988
                            }
4989
                            if ($resultLast) {
4990
                                $result = $resultLast;
4991
                            }
4992
                        }
4993
                    }
4994
4995
                    if ($show_results) {
4996
                        // Close media wrapper
4997
                        $orderedOutputHtml .= '</div></div>';
4998
                    }
4999
                }
5000
            }
5001
        }
5002
5003
        // Print output
5004
        if ($show_results) {
5005
            echo $orderedOutputHtml;
5006
        } else {
5007
            // Fallback (no wrappers when results are not shown)
5008
            foreach ($panelsByParent as $pid => $panels) {
5009
                foreach ($panels as $panelHtml) {
5010
                    echo $panelHtml;
5011
                }
5012
            }
5013
        }
5014
5015
        // Display text when test is finished #4074 and for LP #4227
5016
        $endOfMessage = $objExercise->getFinishText($total_score, $total_weight);
5017
        if (!empty($endOfMessage)) {
5018
            echo Display::div(
5019
                $endOfMessage,
5020
                ['id' => 'quiz_end_message']
5021
            );
5022
        }
5023
5024
        $totalScoreText = null;
5025
        $certificateBlock = '';
5026
        if (($show_results || $show_only_score) && $showTotalScore) {
5027
            if (MULTIPLE_ANSWER_TRUE_FALSE_DEGREE_CERTAINTY == ($result['answer_type'] ?? null)) {
5028
                echo '<h1 style="text-align : center; margin : 20px 0;">'.get_lang('Your results').'</h1><br />';
5029
            }
5030
            $totalScoreText .= '<div class="question_row_score">';
5031
            if (!empty($result) && MULTIPLE_ANSWER_TRUE_FALSE_DEGREE_CERTAINTY == ($result['answer_type'] ?? null)) {
5032
                $totalScoreText .= self::getQuestionDiagnosisRibbon(
5033
                    $objExercise,
5034
                    $total_score,
5035
                    $total_weight,
5036
                    true
5037
                );
5038
            } else {
5039
                $pluginEvaluation = QuestionOptionsEvaluationPlugin::create();
5040
                if ('true' === $pluginEvaluation->get(QuestionOptionsEvaluationPlugin::SETTING_ENABLE)) {
5041
                    $formula = $pluginEvaluation->getFormulaForExercise($objExercise->getId());
5042
5043
                    if (!empty($formula)) {
5044
                        $total_score = $pluginEvaluation->getResultWithFormula($exeId, $formula);
5045
                        $total_weight = $pluginEvaluation->getMaxScore();
5046
                    }
5047
                }
5048
5049
                $totalScoreText .= self::getTotalScoreRibbon(
5050
                    $objExercise,
5051
                    $total_score,
5052
                    $total_weight,
5053
                    true,
5054
                    $countPendingQuestions
5055
                );
5056
            }
5057
            $totalScoreText .= '</div>';
5058
5059
            if (!empty($studentInfo)) {
5060
                $certificateBlock = self::generateAndShowCertificateBlock(
5061
                    $total_score,
5062
                    $total_weight,
5063
                    $objExercise,
5064
                    $studentInfo['id'],
5065
                    $courseId,
5066
                    $sessionId
5067
                );
5068
            }
5069
        }
5070
5071
        if (MULTIPLE_ANSWER_TRUE_FALSE_DEGREE_CERTAINTY == ($result['answer_type'] ?? null)) {
5072
            $chartMultiAnswer = MultipleAnswerTrueFalseDegreeCertainty::displayStudentsChartResults(
5073
                $exeId,
5074
                $objExercise
5075
            );
5076
            echo $chartMultiAnswer;
5077
        }
5078
5079
        if (!empty($category_list) &&
5080
            ($show_results || $show_only_score || RESULT_DISABLE_RADAR == $objExercise->results_disabled)
5081
        ) {
5082
            // Adding total
5083
            $category_list['total'] = [
5084
                'score' => $total_score,
5085
                'total' => $total_weight,
5086
            ];
5087
            echo TestCategory::get_stats_table_by_attempt($objExercise, $category_list);
5088
        }
5089
5090
        if ($show_all_but_expected_answer) {
5091
            $exerciseContent .= Display::return_message(get_lang('Note: This test has been setup to hide the expected answers.'));
5092
        }
5093
5094
        // Remove audio auto play from questions on results page - refs BT#7939
5095
        $exerciseContent = preg_replace(
5096
            ['/autoplay[\=\".+\"]+/', '/autostart[\=\".+\"]+/'],
5097
            '',
5098
            $exerciseContent
5099
        );
5100
5101
        echo $certificateBlock;
5102
5103
        // Ofaj change BT#11784
5104
        if (('true' === api_get_setting('exercise.quiz_show_description_on_results_page')) &&
5105
            !empty($objExercise->description)
5106
        ) {
5107
            echo Display::div($objExercise->description, ['class' => 'exercise_description']);
5108
        }
5109
5110
        echo $exerciseContent;
5111
        if (!$show_only_score) {
5112
            echo $totalScoreText;
5113
        }
5114
5115
        if ($save_user_result) {
5116
            // Tracking of results
5117
            if ($exercise_stat_info) {
5118
                $learnpath_id = $exercise_stat_info['orig_lp_id'];
5119
                $learnpath_item_id = $exercise_stat_info['orig_lp_item_id'];
5120
                $learnpath_item_view_id = $exercise_stat_info['orig_lp_item_view_id'];
5121
5122
                if (api_is_allowed_to_session_edit()) {
5123
                    Event::updateEventExercise(
5124
                        $exercise_stat_info['exe_id'],
5125
                        $objExercise->getId(),
5126
                        $total_score,
5127
                        $total_weight,
5128
                        $sessionId,
5129
                        $learnpath_id,
5130
                        $learnpath_item_id,
5131
                        $learnpath_item_view_id,
5132
                        $exercise_stat_info['exe_duration'],
5133
                        $question_list
5134
                    );
5135
5136
                    $allowStats = ('true' === api_get_setting('gradebook.allow_gradebook_stats'));
5137
                    if ($allowStats) {
5138
                        $objExercise->generateStats(
5139
                            $objExercise->getId(),
5140
                            api_get_course_info(),
5141
                            $sessionId
5142
                        );
5143
                    }
5144
                }
5145
            }
5146
5147
            // Send notification at the end
5148
            if (!api_is_allowed_to_edit(null, true) &&
5149
                !api_is_excluded_user_type()
5150
            ) {
5151
                $objExercise->send_mail_notification_for_exam(
5152
                    'end',
5153
                    $question_list_answers,
5154
                    $origin,
5155
                    $exeId,
5156
                    $total_score,
5157
                    $total_weight
5158
                );
5159
            }
5160
        }
5161
5162
        if (in_array(
5163
            $objExercise->selectResultsDisabled(),
5164
            [RESULT_DISABLE_RANKING, RESULT_DISABLE_SHOW_SCORE_AND_EXPECTED_ANSWERS_AND_RANKING]
5165
        )) {
5166
            echo Display::page_header(get_lang('Ranking'), null, 'h4');
5167
            echo self::displayResultsInRanking(
5168
                $objExercise,
5169
                api_get_user_id(),
5170
                $courseId,
5171
                $sessionId
5172
            );
5173
        }
5174
5175
        if (!empty($remainingMessage)) {
5176
            echo Display::return_message($remainingMessage, 'normal', false);
5177
        }
5178
5179
        $failedAnswersCount = 0;
5180
        $wrongQuestionHtml = '';
5181
        $all = '';
5182
        foreach ($attemptResult as $item) {
5183
            if (false === $item['pass']) {
5184
                $failedAnswersCount++;
5185
                $wrongQuestionHtml .= $item['question_content'].'<br />';
5186
            }
5187
            $all .= $item['question_content'].'<br />';
5188
        }
5189
5190
        $passed = self::isPassPercentageAttemptPassed(
5191
            $objExercise,
5192
            $total_score,
5193
            $total_weight
5194
        );
5195
5196
        $percentage = 0;
5197
        if (!empty($total_weight)) {
5198
            $percentage = ($total_score / $total_weight) * 100;
5199
        }
5200
5201
        return [
5202
            'category_list' => $category_list,
5203
            'attempts_result_list' => $attemptResult, // array of results
5204
            'exercise_passed' => $passed, // boolean
5205
            'total_answers_count' => count($attemptResult), // int
5206
            'failed_answers_count' => $failedAnswersCount, // int
5207
            'failed_answers_html' => $wrongQuestionHtml,
5208
            'all_answers_html' => $all,
5209
            'total_score' => $total_score,
5210
            'total_weight' => $total_weight,
5211
            'total_percentage' => $percentage,
5212
            'count_pending_questions' => $countPendingQuestions,
5213
        ];
5214
    }
5215
5216
    /**
5217
     * Display the ranking of results in a exercise.
5218
     *
5219
     * @param Exercise $exercise
5220
     * @param int      $currentUserId
5221
     * @param int      $courseId
5222
     * @param int      $sessionId
5223
     *
5224
     * @return string
5225
     */
5226
    public static function displayResultsInRanking($exercise, $currentUserId, $courseId, $sessionId = 0)
5227
    {
5228
        $exerciseId = $exercise->iId;
5229
        $data = self::exerciseResultsInRanking($exerciseId, $courseId, $sessionId);
5230
5231
        $table = new HTML_Table(['class' => 'table table-hover table-striped table-bordered']);
5232
        $table->setHeaderContents(0, 0, get_lang('Position'), ['class' => 'text-right']);
5233
        $table->setHeaderContents(0, 1, get_lang('Username'));
5234
        $table->setHeaderContents(0, 2, get_lang('Score'), ['class' => 'text-right']);
5235
        $table->setHeaderContents(0, 3, get_lang('Date'), ['class' => 'text-center']);
5236
5237
        foreach ($data as $r => $item) {
5238
            if (!isset($item[1])) {
5239
                continue;
5240
            }
5241
            $selected = $item[1]->getId() == $currentUserId;
5242
5243
            foreach ($item as $c => $value) {
5244
                $table->setCellContents($r + 1, $c, $value);
5245
5246
                $attrClass = '';
5247
5248
                if (in_array($c, [0, 2])) {
5249
                    $attrClass = 'text-right';
5250
                } elseif (3 == $c) {
5251
                    $attrClass = 'text-center';
5252
                }
5253
5254
                if ($selected) {
5255
                    $attrClass .= ' warning';
5256
                }
5257
5258
                $table->setCellAttributes($r + 1, $c, ['class' => $attrClass]);
5259
            }
5260
        }
5261
5262
        return $table->toHtml();
5263
    }
5264
5265
    /**
5266
     * Get the ranking for results in a exercise.
5267
     * Function used internally by ExerciseLib::displayResultsInRanking.
5268
     *
5269
     * @param int $exerciseId
5270
     * @param int $courseId
5271
     * @param int $sessionId
5272
     *
5273
     * @return array
5274
     */
5275
    public static function exerciseResultsInRanking($exerciseId, $courseId, $sessionId = 0)
5276
    {
5277
        $em = Database::getManager();
5278
5279
        $dql = 'SELECT DISTINCT u.id FROM ChamiloCoreBundle:TrackEExercise te JOIN te.user u WHERE te.quiz = :id AND te.course = :cId';
5280
        $dql .= api_get_session_condition($sessionId, true, false, 'te.session');
5281
5282
        $result = $em
5283
            ->createQuery($dql)
5284
            ->setParameters(['id' => $exerciseId, 'cId' => $courseId])
5285
            ->getScalarResult();
5286
5287
        $data = [];
5288
5289
        foreach ($result as $item) {
5290
            $attempt = self::get_best_attempt_by_user($item['id'], $exerciseId, $courseId, $sessionId);
5291
            if (!empty($attempt) && isset($attempt['score']) && isset($attempt['exe_date'])) {
5292
                $data[] = $attempt;
5293
            }
5294
        }
5295
5296
        if (empty($data)) {
5297
            return [];
5298
        }
5299
5300
        usort(
5301
            $data,
5302
            function ($a, $b) {
5303
                if ($a['score'] != $b['score']) {
5304
                    return $a['score'] > $b['score'] ? -1 : 1;
5305
                }
5306
5307
                if ($a['exe_date'] != $b['exe_date']) {
5308
                    return $a['exe_date'] < $b['exe_date'] ? -1 : 1;
5309
                }
5310
5311
                return 0;
5312
            }
5313
        );
5314
5315
        // flags to display the same position in case of tie
5316
        $lastScore = $data[0]['score'];
5317
        $position = 1;
5318
        $data = array_map(
5319
            function ($item) use (&$lastScore, &$position) {
5320
                if ($item['score'] < $lastScore) {
5321
                    $position++;
5322
                }
5323
5324
                $lastScore = $item['score'];
5325
5326
                return [
5327
                    $position,
5328
                    api_get_user_entity($item['exe_user_id']),
5329
                    self::show_score($item['score'], $item['max_score'], true, true, true),
5330
                    api_convert_and_format_date($item['exe_date'], DATE_TIME_FORMAT_SHORT),
5331
                ];
5332
            },
5333
            $data
5334
        );
5335
5336
        return $data;
5337
    }
5338
5339
    /**
5340
     * Get a special ribbon on top of "degree of certainty" questions (
5341
     * variation from getTotalScoreRibbon() for other question types).
5342
     *
5343
     * @param Exercise $objExercise
5344
     * @param float    $score
5345
     * @param float    $weight
5346
     * @param bool     $checkPassPercentage
5347
     *
5348
     * @return string
5349
     */
5350
    public static function getQuestionDiagnosisRibbon($objExercise, $score, $weight, $checkPassPercentage = false)
5351
    {
5352
        $displayChartDegree = true;
5353
        $ribbon = $displayChartDegree ? '<div class="ribbon">' : '';
5354
5355
        if ($checkPassPercentage) {
5356
            $passPercentage = $objExercise->selectPassPercentage();
5357
            $isSuccess = self::isSuccessExerciseResult($score, $weight, $passPercentage);
5358
            // Color the final test score if pass_percentage activated
5359
            $ribbonTotalSuccessOrError = '';
5360
            if (self::isPassPercentageEnabled($passPercentage)) {
5361
                if ($isSuccess) {
5362
                    $ribbonTotalSuccessOrError = ' ribbon-total-success';
5363
                } else {
5364
                    $ribbonTotalSuccessOrError = ' ribbon-total-error';
5365
                }
5366
            }
5367
            $ribbon .= $displayChartDegree ? '<div class="rib rib-total '.$ribbonTotalSuccessOrError.'">' : '';
5368
        } else {
5369
            $ribbon .= $displayChartDegree ? '<div class="rib rib-total">' : '';
5370
        }
5371
5372
        if ($displayChartDegree) {
5373
            $ribbon .= '<h3>'.get_lang('Score for the test').':&nbsp;';
5374
            $ribbon .= self::show_score($score, $weight, false, true);
5375
            $ribbon .= '</h3>';
5376
            $ribbon .= '</div>';
5377
        }
5378
5379
        if ($checkPassPercentage) {
5380
            $ribbon .= self::showSuccessMessage(
5381
                $score,
5382
                $weight,
5383
                $objExercise->selectPassPercentage()
5384
            );
5385
        }
5386
5387
        $ribbon .= $displayChartDegree ? '</div>' : '';
5388
5389
        return $ribbon;
5390
    }
5391
5392
    public static function isPassPercentageAttemptPassed($objExercise, $score, $weight)
5393
    {
5394
        $passPercentage = $objExercise->selectPassPercentage();
5395
5396
        return self::isSuccessExerciseResult($score, $weight, $passPercentage);
5397
    }
5398
5399
    /**
5400
     * @param float $score
5401
     * @param float $weight
5402
     * @param bool  $checkPassPercentage
5403
     * @param int   $countPendingQuestions
5404
     *
5405
     * @return string
5406
     */
5407
    public static function getTotalScoreRibbon(
5408
        Exercise $objExercise,
5409
                 $score,
5410
                 $weight,
5411
                 $checkPassPercentage = false,
5412
                 $countPendingQuestions = 0
5413
    ) {
5414
        $hide = (int) $objExercise->getPageConfigurationAttribute('hide_total_score');
5415
        if (1 === $hide) {
5416
            return '';
5417
        }
5418
5419
        $passPercentage = $objExercise->selectPassPercentage();
5420
        $ribbon = '<div class="title-score">';
5421
        if ($checkPassPercentage) {
5422
            $isSuccess = self::isSuccessExerciseResult(
5423
                $score,
5424
                $weight,
5425
                $passPercentage
5426
            );
5427
            // Color the final test score if pass_percentage activated
5428
            $class = '';
5429
            if (self::isPassPercentageEnabled($passPercentage)) {
5430
                if ($isSuccess) {
5431
                    $class = ' ribbon-total-success';
5432
                } else {
5433
                    $class = ' ribbon-total-error';
5434
                }
5435
            }
5436
            $ribbon .= '<div class="total '.$class.'">';
5437
        } else {
5438
            $ribbon .= '<div class="total">';
5439
        }
5440
        $ribbon .= '<h3>'.get_lang('Score for the test').':&nbsp;';
5441
        $ribbon .= self::show_score($score, $weight, false, true);
5442
        $ribbon .= '</h3>';
5443
        $ribbon .= '</div>';
5444
        if ($checkPassPercentage) {
5445
            $ribbon .= self::showSuccessMessage(
5446
                $score,
5447
                $weight,
5448
                $passPercentage
5449
            );
5450
        }
5451
        $ribbon .= '</div>';
5452
5453
        if (!empty($countPendingQuestions)) {
5454
            $ribbon .= '<br />';
5455
            $ribbon .= Display::return_message(
5456
                sprintf(
5457
                    get_lang('Temporary score: %s open question(s) not corrected yet.'),
5458
                    $countPendingQuestions
5459
                ),
5460
                'warning'
5461
            );
5462
        }
5463
5464
        return $ribbon;
5465
    }
5466
5467
    /**
5468
     * @param int $countLetter
5469
     *
5470
     * @return mixed
5471
     */
5472
    public static function detectInputAppropriateClass($countLetter)
5473
    {
5474
        $limits = [
5475
            0 => 'input-mini',
5476
            10 => 'input-mini',
5477
            15 => 'input-medium',
5478
            20 => 'input-xlarge',
5479
            40 => 'input-xlarge',
5480
            60 => 'input-xxlarge',
5481
            100 => 'input-xxlarge',
5482
            200 => 'input-xxlarge',
5483
        ];
5484
5485
        foreach ($limits as $size => $item) {
5486
            if ($countLetter <= $size) {
5487
                return $item;
5488
            }
5489
        }
5490
5491
        return $limits[0];
5492
    }
5493
5494
    /**
5495
     * @param int    $senderId
5496
     * @param array  $course_info
5497
     * @param string $test
5498
     * @param string $url
5499
     *
5500
     * @return string
5501
     */
5502
    public static function getEmailNotification($senderId, $course_info, $test, $url)
5503
    {
5504
        $teacher_info = api_get_user_info($senderId);
5505
        $fromName = api_get_person_name(
5506
            $teacher_info['firstname'],
5507
            $teacher_info['lastname'],
5508
            null,
5509
            PERSON_NAME_EMAIL_ADDRESS
5510
        );
5511
5512
        $params = [
5513
            'course_title' => Security::remove_XSS($course_info['name']),
5514
            'test_title' => Security::remove_XSS($test),
5515
            'url' => $url,
5516
            'teacher_name' => $fromName,
5517
        ];
5518
5519
        return Container::getTwig()->render(
5520
            '@ChamiloCore/Mailer/Exercise/result_alert_body.html.twig',
5521
            $params
5522
        );
5523
    }
5524
5525
    /**
5526
     * @return string
5527
     */
5528
    public static function getNotCorrectedYetText()
5529
    {
5530
        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');
5531
    }
5532
5533
    /**
5534
     * @param string $message
5535
     *
5536
     * @return string
5537
     */
5538
    public static function getFeedbackText($message)
5539
    {
5540
        return Display::return_message($message, 'warning', false);
5541
    }
5542
5543
    /**
5544
     * Get the recorder audio component for save a teacher audio feedback.
5545
     *
5546
     * @param int $attemptId
5547
     * @param int $questionId
5548
     *
5549
     * @return string
5550
     */
5551
    public static function getOralFeedbackForm($attemptId, $questionId)
5552
    {
5553
        $view = new Template('', false, false, false, false, false, false);
5554
        $view->assign('type', Asset::EXERCISE_FEEDBACK);
5555
        $view->assign('question_id', $questionId);
5556
        $view->assign('t_exercise_id', $attemptId);
5557
        $template = $view->get_template('exercise/oral_expression.html.twig');
5558
5559
        return $view->fetch($template);
5560
    }
5561
5562
    /**
5563
     * Retrieves the generated audio files for an oral question in an exercise attempt.
5564
     *
5565
     * @param int  $trackExerciseId The ID of the tracked exercise.
5566
     * @param int  $questionId      The ID of the question.
5567
     * @param bool $returnUrls      (Optional) If set to true, only the URLs of the audio files are returned. Default is false.
5568
     *
5569
     * @return array|string If $returnUrls is true, returns an array of URLs of the audio files. Otherwise, returns an HTML string with audio tags.
5570
     */
5571
    public static function getOralFileAudio(int $trackExerciseId, int $questionId, bool $returnUrls = false): array|string
5572
    {
5573
        /** @var TrackEExercise $trackExercise */
5574
        $trackExercise = Container::getTrackEExerciseRepository()->find($trackExerciseId);
5575
5576
        if (null === $trackExercise) {
5577
            return $returnUrls ? [] : '';
5578
        }
5579
5580
        $questionAttempt = $trackExercise->getAttemptByQuestionId($questionId);
5581
5582
        if (null === $questionAttempt) {
5583
            return $returnUrls ? [] : '';
5584
        }
5585
5586
        $basePath = rtrim(api_get_path(WEB_PATH), '/');
5587
        $assetRepo = Container::getAssetRepository();
5588
5589
        if ($returnUrls) {
5590
            $urls = [];
5591
            foreach ($questionAttempt->getAttemptFiles() as $attemptFile) {
5592
                $urls[] = $basePath.$assetRepo->getAssetUrl($attemptFile->getAsset());
5593
            }
5594
5595
            return $urls;
5596
        } else {
5597
            $html = '';
5598
            foreach ($questionAttempt->getAttemptFiles() as $attemptFile) {
5599
                $html .= Display::tag(
5600
                    'audio',
5601
                    '',
5602
                    [
5603
                        'src' => $basePath.$assetRepo->getAssetUrl($attemptFile->getAsset()),
5604
                        'controls' => '',
5605
                    ]
5606
                );
5607
            }
5608
5609
            return $html;
5610
        }
5611
    }
5612
5613
    /**
5614
     * Get the audio component for a teacher audio feedback.
5615
     */
5616
    public static function getOralFeedbackAudio(int $attemptId, int $questionId): string
5617
    {
5618
        /** @var TrackEExercise|null $tExercise */
5619
        $tExercise = Container::getTrackEExerciseRepository()->find($attemptId);
5620
5621
        if (null === $tExercise) {
5622
            return '';
5623
        }
5624
5625
        $qAttempt = $tExercise->getAttemptByQuestionId($questionId);
5626
5627
        if (null === $qAttempt) {
5628
            return '';
5629
        }
5630
5631
        $feedbacks = $qAttempt->getAttemptFeedbacks();
5632
5633
        if ($feedbacks->isEmpty()) {
5634
            return '';
5635
        }
5636
5637
        $latestFeedback = null;
5638
5639
        foreach ($feedbacks as $feedback) {
5640
            // Skip feedbacks without asset, just in case
5641
            if (null === $feedback->getAsset()) {
5642
                continue;
5643
            }
5644
5645
            if (null === $latestFeedback) {
5646
                $latestFeedback = $feedback;
5647
                continue;
5648
            }
5649
5650
            // Choose the feedback with the latest createdAt
5651
            if ($feedback->getCreatedAt() > $latestFeedback->getCreatedAt()) {
5652
                $latestFeedback = $feedback;
5653
            }
5654
        }
5655
5656
        if (null === $latestFeedback) {
5657
            return '';
5658
        }
5659
5660
        $assetRepo = Container::getAssetRepository();
5661
5662
        return Display::tag(
5663
            'audio',
5664
            '',
5665
            [
5666
                'src' => $assetRepo->getAssetUrl($latestFeedback->getAsset()),
5667
                'controls' => '',
5668
            ]
5669
        );
5670
    }
5671
5672
    public static function getUploadAnswerFiles(int $trackExerciseId, int $questionId, bool $returnUrls = false)
5673
    {
5674
        $trackExercise = Container::getTrackEExerciseRepository()->find($trackExerciseId);
5675
        if (!$trackExercise) { return $returnUrls ? [] : ''; }
5676
        $attempt = $trackExercise->getAttemptByQuestionId($questionId);
5677
        if (!$attempt) { return $returnUrls ? [] : ''; }
5678
5679
        $assetRepo = Container::getAssetRepository();
5680
        $basePath = rtrim(api_get_path(WEB_PATH), '/');
5681
5682
        if ($returnUrls) {
5683
            $urls = [];
5684
            foreach ($attempt->getAttemptFiles() as $af) {
5685
                $urls[] = $basePath.$assetRepo->getAssetUrl($af->getAsset());
5686
            }
5687
            return $urls;
5688
        }
5689
5690
        $html = '';
5691
        foreach ($attempt->getAttemptFiles() as $af) {
5692
            $url = $basePath.$assetRepo->getAssetUrl($af->getAsset());
5693
            $html .= Display::url(basename($url), $url, ['target' => '_blank']).'<br />';
5694
        }
5695
        return $html;
5696
    }
5697
5698
    public static function getNotificationSettings(): array
5699
    {
5700
        return [
5701
            2 => get_lang('Paranoid: E-mail teacher when a student starts an exercise'),
5702
            1 => get_lang('Aware: E-mail teacher when a student ends an exercise'), // default
5703
            3 => get_lang('Relaxed open: E-mail teacher when a student ends an exercise, only if an open question is answered'),
5704
            4 => get_lang('Relaxed audio: E-mail teacher when a student ends an exercise, only if an oral question is answered'),
5705
        ];
5706
    }
5707
5708
    /**
5709
     * Get the additional actions added in exercise_additional_teacher_modify_actions configuration.
5710
     *
5711
     * @param int $exerciseId
5712
     * @param int $iconSize
5713
     *
5714
     * @return string
5715
     */
5716
    public static function getAdditionalTeacherActions($exerciseId, $iconSize = ICON_SIZE_SMALL)
5717
    {
5718
        $additionalActions = api_get_setting('exercise.exercise_additional_teacher_modify_actions', true) ?: [];
5719
        $actions = [];
5720
5721
        if (is_array($additionalActions)) {
5722
            foreach ($additionalActions as $additionalAction) {
5723
                $actions[] = call_user_func(
5724
                    $additionalAction,
5725
                    $exerciseId,
5726
                    $iconSize
5727
                );
5728
            }
5729
        }
5730
5731
        return implode(PHP_EOL, $actions);
5732
    }
5733
5734
    /**
5735
     * @param int $userId
5736
     * @param int $courseId
5737
     * @param int $sessionId
5738
     *
5739
     * @return int
5740
     */
5741
    public static function countAnsweredQuestionsByUserAfterTime(DateTime $time, $userId, $courseId, $sessionId)
5742
    {
5743
        $em = Database::getManager();
5744
5745
        if (empty($sessionId)) {
5746
            $sessionId = null;
5747
        }
5748
5749
        $time = api_get_utc_datetime($time->format('Y-m-d H:i:s'), false, true);
5750
5751
        $result = $em
5752
            ->createQuery('
5753
                SELECT COUNT(ea) FROM ChamiloCoreBundle:TrackEAttempt ea
5754
                WHERE ea.userId = :user AND ea.cId = :course AND ea.sessionId = :session
5755
                    AND ea.tms > :time
5756
            ')
5757
            ->setParameters(['user' => $userId, 'course' => $courseId, 'session' => $sessionId, 'time' => $time])
5758
            ->getSingleScalarResult();
5759
5760
        return $result;
5761
    }
5762
5763
    /**
5764
     * @param int $userId
5765
     * @param int $numberOfQuestions
5766
     * @param int $courseId
5767
     * @param int $sessionId
5768
     *
5769
     * @throws \Doctrine\ORM\Query\QueryException
5770
     *
5771
     * @return bool
5772
     */
5773
    public static function isQuestionsLimitPerDayReached($userId, $numberOfQuestions, $courseId, $sessionId)
5774
    {
5775
        $questionsLimitPerDay = (int) api_get_course_setting('quiz_question_limit_per_day');
5776
5777
        if ($questionsLimitPerDay <= 0) {
5778
            return false;
5779
        }
5780
5781
        $midnightTime = ChamiloHelper::getServerMidnightTime();
5782
5783
        $answeredQuestionsCount = self::countAnsweredQuestionsByUserAfterTime(
5784
            $midnightTime,
5785
            $userId,
5786
            $courseId,
5787
            $sessionId
5788
        );
5789
5790
        return ($answeredQuestionsCount + $numberOfQuestions) > $questionsLimitPerDay;
5791
    }
5792
5793
    /**
5794
     * Check if an exercise complies with the requirements to be embedded in the mobile app or a video.
5795
     * By making sure it is set on one question per page and it only contains unique-answer or multiple-answer questions
5796
     * or unique-answer image. And that the exam does not have immediate feedback.
5797
     *
5798
     * @return bool
5799
     */
5800
    public static function isQuizEmbeddable(CQuiz $exercise)
5801
    {
5802
        $em = Database::getManager();
5803
5804
        if (ONE_PER_PAGE != $exercise->getType() ||
5805
            in_array($exercise->getFeedbackType(), [EXERCISE_FEEDBACK_TYPE_DIRECT, EXERCISE_FEEDBACK_TYPE_POPUP])
5806
        ) {
5807
            return false;
5808
        }
5809
5810
        $countAll = $em
5811
            ->createQuery('SELECT COUNT(qq)
5812
                FROM ChamiloCourseBundle:CQuizQuestion qq
5813
                INNER JOIN ChamiloCourseBundle:CQuizRelQuestion qrq
5814
                   WITH qq.iid = qrq.question
5815
                WHERE qrq.quiz = :id'
5816
            )
5817
            ->setParameter('id', $exercise->getIid())
5818
            ->getSingleScalarResult();
5819
5820
        $countOfAllowed = $em
5821
            ->createQuery('SELECT COUNT(qq)
5822
                FROM ChamiloCourseBundle:CQuizQuestion qq
5823
                INNER JOIN ChamiloCourseBundle:CQuizRelQuestion qrq
5824
                   WITH qq.iid = qrq.question
5825
                WHERE qrq.quiz = :id AND qq.type IN (:types)'
5826
            )
5827
            ->setParameters(
5828
                [
5829
                    'id' => $exercise->getIid(),
5830
                    'types' => [UNIQUE_ANSWER, MULTIPLE_ANSWER, UNIQUE_ANSWER_IMAGE],
5831
                ]
5832
            )
5833
            ->getSingleScalarResult();
5834
5835
        return $countAll === $countOfAllowed;
5836
    }
5837
5838
    /**
5839
     * Generate a certificate linked to current quiz and.
5840
     * Return the HTML block with links to download and view the certificate.
5841
     *
5842
     * @param float $totalScore
5843
     * @param float $totalWeight
5844
     * @param int   $studentId
5845
     * @param int   $courseId
5846
     * @param int   $sessionId
5847
     *
5848
     * @return string
5849
     */
5850
    public static function generateAndShowCertificateBlock(
5851
        $totalScore,
5852
        $totalWeight,
5853
        Exercise $objExercise,
5854
        $studentId,
5855
        $courseId,
5856
        $sessionId = 0
5857
    ) {
5858
        if (('true' !== api_get_setting('exercise.quiz_generate_certificate_ending')) ||
5859
            !self::isSuccessExerciseResult($totalScore, $totalWeight, $objExercise->selectPassPercentage())
5860
        ) {
5861
            return '';
5862
        }
5863
5864
        $repo = Container::getGradeBookCategoryRepository();
5865
        /** @var GradebookCategory $category */
5866
        $category = $repo->findOneBy(
5867
            ['course' => $courseId, 'session' => $sessionId]
5868
        );
5869
5870
        if (null === $category) {
5871
            return '';
5872
        }
5873
5874
        /*$category = Category::load(null, null, $courseCode, null, null, $sessionId, 'ORDER By id');
5875
        if (empty($category)) {
5876
            return '';
5877
        }*/
5878
        $categoryId = $category->getId();
5879
        /*$link = LinkFactory::load(
5880
            null,
5881
            null,
5882
            $objExercise->getId(),
5883
            null,
5884
            $courseCode,
5885
            $categoryId
5886
        );*/
5887
5888
        if (empty($category->getLinks()->count())) {
5889
            return '';
5890
        }
5891
5892
        $resourceDeletedMessage = Category::show_message_resource_delete($courseId);
5893
        if (!empty($resourceDeletedMessage) || api_is_allowed_to_edit() || api_is_excluded_user_type()) {
5894
            return '';
5895
        }
5896
5897
        $certificate = Category::generateUserCertificate($category, $studentId);
5898
        if (!is_array($certificate)) {
5899
            return '';
5900
        }
5901
5902
        return Category::getDownloadCertificateBlock($certificate);
5903
    }
5904
5905
    /**
5906
     * @param int $exerciseId
5907
     */
5908
    public static function getExerciseTitleById($exerciseId)
5909
    {
5910
        $em = Database::getManager();
5911
5912
        return $em
5913
            ->createQuery('SELECT cq.title
5914
                FROM ChamiloCourseBundle:CQuiz cq
5915
                WHERE cq.iid = :iid'
5916
            )
5917
            ->setParameter('iid', $exerciseId)
5918
            ->getSingleScalarResult();
5919
    }
5920
5921
    /**
5922
     * @param int $exeId      ID from track_e_exercises
5923
     * @param int $userId     User ID
5924
     * @param int $exerciseId Exercise ID
5925
     * @param int $courseId   Optional. Coure ID.
5926
     *
5927
     * @return TrackEExercise|null
5928
     */
5929
    public static function recalculateResult($exeId, $userId, $exerciseId, $courseId = 0)
5930
    {
5931
        if (empty($userId) || empty($exerciseId)) {
5932
            return null;
5933
        }
5934
5935
        $em = Database::getManager();
5936
        /** @var TrackEExercise $trackedExercise */
5937
        $trackedExercise = $em->getRepository(TrackEExercise::class)->find($exeId);
5938
5939
        if (empty($trackedExercise)) {
5940
            return null;
5941
        }
5942
5943
        if ($trackedExercise->getUser()->getId() != $userId ||
5944
            $trackedExercise->getQuiz()?->getIid() != $exerciseId
5945
        ) {
5946
            return null;
5947
        }
5948
5949
        $questionList = $trackedExercise->getDataTracking();
5950
5951
        if (empty($questionList)) {
5952
            return null;
5953
        }
5954
5955
        $questionList = explode(',', $questionList);
5956
5957
        $exercise = new Exercise($courseId);
5958
        $courseInfo = $courseId ? api_get_course_info_by_id($courseId) : [];
5959
5960
        if (false === $exercise->read($exerciseId)) {
5961
            return null;
5962
        }
5963
5964
        $totalScore = 0;
5965
        $totalWeight = 0;
5966
5967
        $pluginEvaluation = QuestionOptionsEvaluationPlugin::create();
5968
5969
        $formula = 'true' === $pluginEvaluation->get(QuestionOptionsEvaluationPlugin::SETTING_ENABLE)
5970
            ? $pluginEvaluation->getFormulaForExercise($exerciseId)
5971
            : 0;
5972
5973
        if (empty($formula)) {
5974
            foreach ($questionList as $questionId) {
5975
                $question = Question::read($questionId, $courseInfo);
5976
5977
                if (false === $question) {
5978
                    continue;
5979
                }
5980
5981
                $totalWeight += $question->selectWeighting();
5982
5983
                // We're inside *one* question. Go through each possible answer for this question
5984
                $result = $exercise->manage_answer(
5985
                    $exeId,
5986
                    $questionId,
5987
                    [],
5988
                    'exercise_result',
5989
                    [],
5990
                    false,
5991
                    true,
5992
                    false,
5993
                    $exercise->selectPropagateNeg(),
5994
                    [],
5995
                    [],
5996
                    true
5997
                );
5998
5999
                //  Adding the new score.
6000
                $totalScore += $result['score'];
6001
            }
6002
        } else {
6003
            $totalScore = $pluginEvaluation->getResultWithFormula($exeId, $formula);
6004
            $totalWeight = $pluginEvaluation->getMaxScore();
6005
        }
6006
6007
        $trackedExercise
6008
            ->setScore($totalScore)
6009
            ->setMaxScore($totalWeight);
6010
6011
        $em->persist($trackedExercise);
6012
        $em->flush();
6013
        $lpItemId = $trackedExercise->getOrigLpItemId();
6014
        $lpId = $trackedExercise->getOrigLpId();
6015
        $lpItemViewId = $trackedExercise->getOrigLpItemViewId();
6016
        if ($lpId && $lpItemId && $lpItemViewId) {
6017
            $lpItem = $em->getRepository(CLpItem::class)->find($lpItemId);
6018
            if ($lpItem && 'quiz' === $lpItem->getItemType()) {
6019
                $lpItemView = $em->getRepository(CLpItemView::class)->find($lpItemViewId);
6020
                if ($lpItemView) {
6021
                    $lpItemView->setScore($totalScore);
6022
                    $em->persist($lpItemView);
6023
                    $em->flush();
6024
                }
6025
            }
6026
        }
6027
6028
        return $trackedExercise;
6029
    }
6030
6031
    public static function getTotalQuestionAnswered($courseId, $exerciseId, $questionId, $onlyStudents = false): int
6032
    {
6033
        $courseId = (int) $courseId;
6034
        $exerciseId = (int) $exerciseId;
6035
        $questionId = (int) $questionId;
6036
6037
        $attemptTable = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT);
6038
        $trackTable = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
6039
        $courseUser = Database::get_main_table(TABLE_MAIN_COURSE_USER);
6040
        $courseUserJoin = "";
6041
        $studentsWhere = "";
6042
        if ($onlyStudents) {
6043
            $courseUserJoin = "
6044
            INNER JOIN $courseUser cu
6045
            ON cu.c_id = te.c_id AND cu.user_id = exe_user_id";
6046
            $studentsWhere = " AND relation_type <> 2 AND cu.status = ".STUDENT;
6047
        }
6048
6049
        $sql = "SELECT count(distinct (te.exe_id)) total
6050
            FROM $attemptTable t
6051
            INNER JOIN $trackTable te
6052
            ON (t.exe_id = te.exe_id)
6053
            $courseUserJoin
6054
            WHERE
6055
                te.c_id = $courseId AND
6056
                exe_exo_id = $exerciseId AND
6057
                t.question_id = $questionId AND
6058
                te.status != 'incomplete'
6059
                $studentsWhere
6060
        ";
6061
        $queryTotal = Database::query($sql);
6062
        $totalRow = Database::fetch_assoc($queryTotal);
6063
        $total = 0;
6064
        if ($totalRow) {
6065
            $total = (int) $totalRow['total'];
6066
        }
6067
6068
        return $total;
6069
    }
6070
6071
    public static function getWrongQuestionResults($courseId, $exerciseId, $sessionId = 0, $limit = 10)
6072
    {
6073
        $courseId = (int) $courseId;
6074
        $exerciseId = (int) $exerciseId;
6075
        $limit = (int) $limit;
6076
6077
        $questionTable = Database::get_course_table(TABLE_QUIZ_QUESTION);
6078
        $attemptTable = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT);
6079
        $trackTable = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
6080
6081
        $sessionCondition = '';
6082
        if (!empty($sessionId)) {
6083
            $sessionCondition = api_get_session_condition($sessionId, true, false, 'te.session_id');
6084
        }
6085
6086
        $sql = "SELECT q.question, question_id, count(q.iid) count
6087
                FROM $attemptTable t
6088
                INNER JOIN $questionTable q
6089
                ON (q.iid = t.question_id)
6090
                INNER JOIN $trackTable te
6091
                ON (t.exe_id = te.exe_id)
6092
                WHERE
6093
                    te.c_id = $courseId AND
6094
                    t.marks != q.ponderation AND
6095
                    exe_exo_id = $exerciseId AND
6096
                    status != 'incomplete'
6097
                    $sessionCondition
6098
                GROUP BY q.iid
6099
                ORDER BY count DESC
6100
                LIMIT $limit
6101
        ";
6102
6103
        $result = Database::query($sql);
6104
6105
        return Database::store_result($result, 'ASSOC');
6106
    }
6107
6108
    public static function getExerciseResultsCount($type, $courseId, $exerciseId, $sessionId = 0)
6109
    {
6110
        $courseId = (int) $courseId;
6111
        $exerciseId = (int) $exerciseId;
6112
6113
        $trackTable = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
6114
6115
        $sessionCondition = '';
6116
        if (!empty($sessionId)) {
6117
            $sessionCondition = api_get_session_condition($sessionId, true, false, 'te.session_id');
6118
        }
6119
6120
        $selectCount = 'count(DISTINCT te.exe_id)';
6121
        $scoreCondition = '';
6122
        switch ($type) {
6123
            case 'correct_student':
6124
                $selectCount = 'count(DISTINCT te.exe_user_id)';
6125
                $scoreCondition = ' AND score = max_score ';
6126
                break;
6127
            case 'wrong_student':
6128
                $selectCount = 'count(DISTINCT te.exe_user_id)';
6129
                $scoreCondition = ' AND score != max_score ';
6130
                break;
6131
            case 'correct':
6132
                $scoreCondition = ' AND score = max_score ';
6133
                break;
6134
            case 'wrong':
6135
                $scoreCondition = ' AND score != max_score ';
6136
                break;
6137
        }
6138
6139
        $sql = "SELECT $selectCount count
6140
                FROM $trackTable te
6141
                WHERE
6142
                    c_id = $courseId AND
6143
                    exe_exo_id = $exerciseId AND
6144
                    status != 'incomplete'
6145
                    $scoreCondition
6146
                    $sessionCondition
6147
        ";
6148
        $result = Database::query($sql);
6149
        $totalRow = Database::fetch_assoc($result);
6150
        $total = 0;
6151
        if ($totalRow) {
6152
            $total = (int) $totalRow['count'];
6153
        }
6154
6155
        return $total;
6156
    }
6157
6158
    public static function parseContent($content, $stats, Exercise $exercise, $trackInfo, $currentUserId = 0)
6159
    {
6160
        $wrongAnswersCount = $stats['failed_answers_count'];
6161
        $attemptDate = substr($trackInfo['exe_date'], 0, 10);
6162
        $exerciseId = $exercise->iId;
6163
        $resultsStudentUrl = api_get_path(WEB_CODE_PATH).
6164
            'exercise/result.php?id='.$exerciseId.'&'.api_get_cidreq();
6165
        $resultsTeacherUrl = api_get_path(WEB_CODE_PATH).
6166
            'exercise/exercise_show.php?action=edit&id='.$exerciseId.'&'.api_get_cidreq();
6167
6168
        $content = str_replace(
6169
            [
6170
                '((exercise_error_count))',
6171
                '((all_answers_html))',
6172
                '((all_answers_teacher_html))',
6173
                '((exercise_title))',
6174
                '((exercise_attempt_date))',
6175
                '((link_to_test_result_page_student))',
6176
                '((link_to_test_result_page_teacher))',
6177
            ],
6178
            [
6179
                $wrongAnswersCount,
6180
                $stats['all_answers_html'],
6181
                $stats['all_answers_teacher_html'],
6182
                $exercise->get_formated_title(),
6183
                $attemptDate,
6184
                $resultsStudentUrl,
6185
                $resultsTeacherUrl,
6186
            ],
6187
            $content
6188
        );
6189
6190
        $currentUserId = empty($currentUserId) ? api_get_user_id() : (int) $currentUserId;
6191
6192
        $content = AnnouncementManager::parseContent(
6193
            $currentUserId,
6194
            $content,
6195
            api_get_course_id(),
6196
            api_get_session_id()
6197
        );
6198
6199
        return $content;
6200
    }
6201
6202
    public static function sendNotification(
6203
        $currentUserId,
6204
        $objExercise,
6205
        $exercise_stat_info,
6206
        $courseInfo,
6207
        $attemptCountToSend,
6208
        $stats,
6209
        $statsTeacher
6210
    ) {
6211
        $notifications = api_get_configuration_value('exercise_finished_notification_settings');
6212
        if (empty($notifications)) {
6213
            return false;
6214
        }
6215
6216
        $studentId = $exercise_stat_info['exe_user_id'];
6217
        $exerciseExtraFieldValue = new ExtraFieldValue('exercise');
6218
        $wrongAnswersCount = $stats['failed_answers_count'];
6219
        $exercisePassed = $stats['exercise_passed'];
6220
        $countPendingQuestions = $stats['count_pending_questions'];
6221
        $stats['all_answers_teacher_html'] = $statsTeacher['all_answers_html'];
6222
6223
        // If there are no pending questions (Open questions).
6224
        if (0 === $countPendingQuestions) {
6225
            /*$extraFieldData = $exerciseExtraFieldValue->get_values_by_handler_and_field_variable(
6226
                $objExercise->iId,
6227
                'signature_mandatory'
6228
            );
6229
6230
            if ($extraFieldData && isset($extraFieldData['value']) && 1 === (int) $extraFieldData['value']) {
6231
                if (ExerciseSignaturePlugin::exerciseHasSignatureActivated($objExercise)) {
6232
                    $signature = ExerciseSignaturePlugin::getSignature($studentId, $exercise_stat_info);
6233
                    if (false !== $signature) {
6234
                        //return false;
6235
                    }
6236
                }
6237
            }*/
6238
6239
            // Notifications.
6240
            $extraFieldData = $exerciseExtraFieldValue->get_values_by_handler_and_field_variable(
6241
                $objExercise->iId,
6242
                'notifications'
6243
            );
6244
            $exerciseNotification = '';
6245
            if ($extraFieldData && isset($extraFieldData['value'])) {
6246
                $exerciseNotification = $extraFieldData['value'];
6247
            }
6248
6249
            $subject = sprintf(get_lang('Failure on attempt %s at %s'), $attemptCountToSend, $courseInfo['title']);
6250
            if ($exercisePassed) {
6251
                $subject = sprintf(get_lang('Validation of exercise at %s'), $courseInfo['title']);
6252
            }
6253
6254
            if ($exercisePassed) {
6255
                $extraFieldData = $exerciseExtraFieldValue->get_values_by_handler_and_field_variable(
6256
                    $objExercise->iId,
6257
                    'MailSuccess'
6258
                );
6259
            } else {
6260
                $extraFieldData = $exerciseExtraFieldValue->get_values_by_handler_and_field_variable(
6261
                    $objExercise->iId,
6262
                    'MailAttempt'.$attemptCountToSend
6263
                );
6264
            }
6265
6266
            // Blocking exercise.
6267
            $blockPercentageExtra = $exerciseExtraFieldValue->get_values_by_handler_and_field_variable(
6268
                $objExercise->iId,
6269
                'blocking_percentage'
6270
            );
6271
            $blockPercentage = false;
6272
            if ($blockPercentageExtra && isset($blockPercentageExtra['value']) && $blockPercentageExtra['value']) {
6273
                $blockPercentage = $blockPercentageExtra['value'];
6274
            }
6275
            if ($blockPercentage) {
6276
                $passBlock = $stats['total_percentage'] > $blockPercentage;
6277
                if (false === $passBlock) {
6278
                    $extraFieldData = $exerciseExtraFieldValue->get_values_by_handler_and_field_variable(
6279
                        $objExercise->iId,
6280
                        'MailIsBlockByPercentage'
6281
                    );
6282
                }
6283
            }
6284
6285
            $extraFieldValueUser = new ExtraFieldValue('user');
6286
6287
            if ($extraFieldData && isset($extraFieldData['value'])) {
6288
                $content = $extraFieldData['value'];
6289
                $content = self::parseContent($content, $stats, $objExercise, $exercise_stat_info, $studentId);
6290
                //if (false === $exercisePassed) {
6291
                if (0 !== $wrongAnswersCount) {
6292
                    $content .= $stats['failed_answers_html'];
6293
                }
6294
6295
                $sendMessage = true;
6296
                if (!empty($exerciseNotification)) {
6297
                    foreach ($notifications as $name => $notificationList) {
6298
                        if ($exerciseNotification !== $name) {
6299
                            continue;
6300
                        }
6301
                        foreach ($notificationList as $notificationName => $attemptData) {
6302
                            if ('student_check' === $notificationName) {
6303
                                $sendMsgIfInList = isset($attemptData['send_notification_if_user_in_extra_field']) ? $attemptData['send_notification_if_user_in_extra_field'] : '';
6304
                                if (!empty($sendMsgIfInList)) {
6305
                                    foreach ($sendMsgIfInList as $skipVariable => $skipValues) {
6306
                                        $userExtraFieldValue = $extraFieldValueUser->get_values_by_handler_and_field_variable(
6307
                                            $studentId,
6308
                                            $skipVariable
6309
                                        );
6310
6311
                                        if (empty($userExtraFieldValue)) {
6312
                                            $sendMessage = false;
6313
                                            break;
6314
                                        } else {
6315
                                            $sendMessage = false;
6316
                                            if (isset($userExtraFieldValue['value']) &&
6317
                                                in_array($userExtraFieldValue['value'], $skipValues)
6318
                                            ) {
6319
                                                $sendMessage = true;
6320
                                                break;
6321
                                            }
6322
                                        }
6323
                                    }
6324
                                }
6325
                                break;
6326
                            }
6327
                        }
6328
                    }
6329
                }
6330
6331
                // Send to student.
6332
                if ($sendMessage) {
6333
                    MessageManager::send_message($currentUserId, $subject, $content);
6334
                }
6335
            }
6336
6337
            if (!empty($exerciseNotification)) {
6338
                foreach ($notifications as $name => $notificationList) {
6339
                    if ($exerciseNotification !== $name) {
6340
                        continue;
6341
                    }
6342
                    foreach ($notificationList as $attemptData) {
6343
                        $skipNotification = false;
6344
                        $skipNotificationList = isset($attemptData['send_notification_if_user_in_extra_field']) ? $attemptData['send_notification_if_user_in_extra_field'] : [];
6345
                        if (!empty($skipNotificationList)) {
6346
                            foreach ($skipNotificationList as $skipVariable => $skipValues) {
6347
                                $userExtraFieldValue = $extraFieldValueUser->get_values_by_handler_and_field_variable(
6348
                                    $studentId,
6349
                                    $skipVariable
6350
                                );
6351
6352
                                if (empty($userExtraFieldValue)) {
6353
                                    $skipNotification = true;
6354
                                    break;
6355
                                } else {
6356
                                    if (isset($userExtraFieldValue['value'])) {
6357
                                        if (!in_array($userExtraFieldValue['value'], $skipValues)) {
6358
                                            $skipNotification = true;
6359
                                            break;
6360
                                        }
6361
                                    } else {
6362
                                        $skipNotification = true;
6363
                                        break;
6364
                                    }
6365
                                }
6366
                            }
6367
                        }
6368
6369
                        if ($skipNotification) {
6370
                            continue;
6371
                        }
6372
6373
                        $email = isset($attemptData['email']) ? $attemptData['email'] : '';
6374
                        $emailList = explode(',', $email);
6375
                        if (empty($emailList)) {
6376
                            continue;
6377
                        }
6378
                        $attempts = isset($attemptData['attempts']) ? $attemptData['attempts'] : [];
6379
                        foreach ($attempts as $attempt) {
6380
                            $sendMessage = false;
6381
                            if (isset($attempt['attempt']) && $attemptCountToSend !== (int) $attempt['attempt']) {
6382
                                continue;
6383
                            }
6384
6385
                            if (!isset($attempt['status'])) {
6386
                                continue;
6387
                            }
6388
6389
                            if ($blockPercentage && isset($attempt['is_block_by_percentage'])) {
6390
                                if ($attempt['is_block_by_percentage']) {
6391
                                    if ($passBlock) {
6392
                                        continue;
6393
                                    }
6394
                                } else {
6395
                                    if (false === $passBlock) {
6396
                                        continue;
6397
                                    }
6398
                                }
6399
                            }
6400
6401
                            switch ($attempt['status']) {
6402
                                case 'passed':
6403
                                    if ($exercisePassed) {
6404
                                        $sendMessage = true;
6405
                                    }
6406
                                    break;
6407
                                case 'failed':
6408
                                    if (false === $exercisePassed) {
6409
                                        $sendMessage = true;
6410
                                    }
6411
                                    break;
6412
                                case 'all':
6413
                                    $sendMessage = true;
6414
                                    break;
6415
                            }
6416
6417
                            if ($sendMessage) {
6418
                                $attachments = [];
6419
                                if (isset($attempt['add_pdf']) && $attempt['add_pdf']) {
6420
                                    // Get pdf content
6421
                                    $pdfExtraData = $exerciseExtraFieldValue->get_values_by_handler_and_field_variable(
6422
                                        $objExercise->iId,
6423
                                        $attempt['add_pdf']
6424
                                    );
6425
6426
                                    if ($pdfExtraData && isset($pdfExtraData['value'])) {
6427
                                        $pdfContent = self::parseContent(
6428
                                            $pdfExtraData['value'],
6429
                                            $stats,
6430
                                            $objExercise,
6431
                                            $exercise_stat_info,
6432
                                            $studentId
6433
                                        );
6434
6435
                                        @$pdf = new PDF();
6436
                                        $filename = get_lang('Test');
6437
                                        $pdfPath = @$pdf->content_to_pdf(
6438
                                            "<html><body>$pdfContent</body></html>",
6439
                                            null,
6440
                                            $filename,
6441
                                            api_get_course_id(),
6442
                                            'F',
6443
                                            false,
6444
                                            null,
6445
                                            false,
6446
                                            true
6447
                                        );
6448
                                        $attachments[] = ['filename' => $filename, 'path' => $pdfPath];
6449
                                    }
6450
                                }
6451
6452
                                $content = isset($attempt['content_default']) ? $attempt['content_default'] : '';
6453
                                if (isset($attempt['content'])) {
6454
                                    $extraFieldData = $exerciseExtraFieldValue->get_values_by_handler_and_field_variable(
6455
                                        $objExercise->iId,
6456
                                        $attempt['content']
6457
                                    );
6458
                                    if ($extraFieldData && isset($extraFieldData['value']) && !empty($extraFieldData['value'])) {
6459
                                        $content = $extraFieldData['value'];
6460
                                    }
6461
                                }
6462
6463
                                if (!empty($content)) {
6464
                                    $content = self::parseContent(
6465
                                        $content,
6466
                                        $stats,
6467
                                        $objExercise,
6468
                                        $exercise_stat_info,
6469
                                        $studentId
6470
                                    );
6471
                                    foreach ($emailList as $email) {
6472
                                        if (empty($email)) {
6473
                                            continue;
6474
                                        }
6475
                                        api_mail_html(
6476
                                            null,
6477
                                            $email,
6478
                                            $subject,
6479
                                            $content,
6480
                                            null,
6481
                                            null,
6482
                                            [],
6483
                                            $attachments
6484
                                        );
6485
                                    }
6486
                                }
6487
6488
                                if (isset($attempt['post_actions'])) {
6489
                                    foreach ($attempt['post_actions'] as $action => $params) {
6490
                                        switch ($action) {
6491
                                            case 'subscribe_student_to_courses':
6492
                                                foreach ($params as $code) {
6493
                                                    $courseInfo = api_get_course_info($code);
6494
                                                    CourseManager::subscribeUser(
6495
                                                        $currentUserId,
6496
                                                        $courseInfo['real_id']
6497
                                                    );
6498
                                                    break;
6499
                                                }
6500
                                                break;
6501
                                        }
6502
                                    }
6503
                                }
6504
                            }
6505
                        }
6506
                    }
6507
                }
6508
            }
6509
        }
6510
    }
6511
6512
    /**
6513
     * Delete an exercise attempt.
6514
     *
6515
     * Log the exe_id deleted with the exe_user_id related.
6516
     *
6517
     * @param int $exeId
6518
     */
6519
    public static function deleteExerciseAttempt($exeId)
6520
    {
6521
        $exeId = (int) $exeId;
6522
6523
        $trackExerciseInfo = self::get_exercise_track_exercise_info($exeId);
6524
6525
        if (empty($trackExerciseInfo)) {
6526
            return;
6527
        }
6528
6529
        $tblTrackExercises = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
6530
        $tblTrackAttempt = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT);
6531
6532
        Database::query("DELETE FROM $tblTrackAttempt WHERE exe_id = $exeId");
6533
        Database::query("DELETE FROM $tblTrackExercises WHERE exe_id = $exeId");
6534
6535
        Event::addEvent(
6536
            LOG_EXERCISE_ATTEMPT_DELETE,
6537
            LOG_EXERCISE_ATTEMPT,
6538
            $exeId,
6539
            api_get_utc_datetime()
6540
        );
6541
        Event::addEvent(
6542
            LOG_EXERCISE_ATTEMPT_DELETE,
6543
            LOG_EXERCISE_AND_USER_ID,
6544
            $exeId.'-'.$trackExerciseInfo['exe_user_id'],
6545
            api_get_utc_datetime()
6546
        );
6547
    }
6548
6549
    public static function scorePassed($score, $total)
6550
    {
6551
        $compareResult = bccomp($score, $total, 3);
6552
        $scorePassed = 1 === $compareResult || 0 === $compareResult;
6553
        if (false === $scorePassed) {
6554
            $epsilon = 0.00001;
6555
            if (abs($score - $total) < $epsilon) {
6556
                $scorePassed = true;
6557
            }
6558
        }
6559
6560
        return $scorePassed;
6561
    }
6562
6563
    /**
6564
     * Returns the HTML for a specific exercise attempt, ready for PDF generation.
6565
     */
6566
    public static function getAttemptPdfHtml(int $exeId, int $courseId, int $sessionId): string
6567
    {
6568
        $_GET = [
6569
            'id'           => $exeId,
6570
            'action'       => 'export',
6571
            'export_type'  => 'all_results',
6572
            'cid'          => $courseId,
6573
            'sid'          => $sessionId,
6574
            'gid'          => 0,
6575
            'gradebook'    => 0,
6576
            'origin'       => '',
6577
        ];
6578
        $_REQUEST = $_GET + $_REQUEST;
6579
6580
        ob_start();
6581
        include __DIR__ . '/../../exercise/exercise_show.php';
6582
        return ob_get_clean();
6583
    }
6584
6585
    /**
6586
     * Generates and saves a PDF for a single exercise attempt
6587
     */
6588
    public static function saveFileExerciseResultPdfDirect(
6589
        int    $exeId,
6590
        int    $courseId,
6591
        int    $sessionId,
6592
        string $exportFolderPath
6593
    ): void {
6594
        // Retrieve the HTML for this attempt and convert it to PDF
6595
        $html = self::getAttemptPdfHtml($exeId, $courseId, $sessionId);
6596
6597
        // Determine filename and path based on user information
6598
        $track   = self::get_exercise_track_exercise_info($exeId);
6599
        $userId  = $track['exe_user_id'] ?? 0;
6600
        $user    = api_get_user_info($userId);
6601
        $pdfName = api_replace_dangerous_char(
6602
            ($user['firstname'] ?? 'user') . '_' .
6603
            ($user['lastname']  ?? 'unknown') .
6604
            '_attemptId' . $exeId . '.pdf'
6605
        );
6606
        $filePath = rtrim($exportFolderPath, '/') . '/' . $pdfName;
6607
6608
        if (file_exists($filePath)) {
6609
            return;
6610
        }
6611
6612
        // Ensure the directory exists
6613
        $dir = dirname($filePath);
6614
        if (!is_dir($dir)) {
6615
            mkdir($dir, 0755, true);
6616
        }
6617
6618
        // Use Chamilo's PDF class to generate and save the file
6619
        $params = [
6620
            'filename'    => $pdfName,
6621
            'course_code' => api_get_course_id(),
6622
        ];
6623
        $pdf = new PDF('A4', 'P', $params);
6624
        $pdf->html_to_pdf_with_template(
6625
            $html,
6626
            true,
6627
            false,
6628
            true,
6629
            [],
6630
            'F',
6631
            $filePath
6632
        );
6633
    }
6634
6635
    /**
6636
     * Exports all results of an exercise to a ZIP archive by generating PDFs on disk and then sending the ZIP to the browser.
6637
     */
6638
    public static function exportExerciseAllResultsZip(
6639
        int   $sessionId,
6640
        int   $courseId,
6641
        int   $exerciseId,
6642
        array $filterDates = [],
6643
        string $mainPath    = ''
6644
    ) {
6645
        $em = Container::getEntityManager();
6646
6647
        /** @var CourseEntity|null $course */
6648
        $course = $em->getRepository(CourseEntity::class)->find($courseId);
6649
        /** @var CQuiz|null $quiz */
6650
        $quiz   = $em->getRepository(CQuiz::class)->findOneBy(['iid' => $exerciseId]);
6651
        $session = null;
6652
6653
        if (!$course) {
6654
            Display::addFlash(Display::return_message(get_lang('Course not found'), 'warning', false));
6655
            return false;
6656
        }
6657
        if (!$quiz) {
6658
            Display::addFlash(Display::return_message(get_lang('Test not found'), 'warning', false));
6659
            return false;
6660
        }
6661
        if ($sessionId > 0) {
6662
            $session = $em->getRepository(SessionEntity::class)->find($sessionId);
6663
            if (!$session) {
6664
                Display::addFlash(Display::return_message(get_lang('Session not found'), 'warning', false));
6665
                return false;
6666
            }
6667
        }
6668
6669
        // Fetch exe_ids with Doctrine, accepting NULL/0 session when $sessionId == 0
6670
        $exeIds = self::findAttemptExeIdsForExport($course, $quiz, $session, $filterDates);
6671
6672
        // Optional: hard fallback with native SQL to catch legacy session_id=0 rows if needed
6673
        if (empty($exeIds) && $sessionId === 0) {
6674
            $exeIds = self::findAttemptExeIdsFallbackSql($courseId, $exerciseId, $filterDates);
6675
        }
6676
6677
        if (empty($exeIds)) {
6678
            Display::addFlash(
6679
                Display::return_message(
6680
                    get_lang('No result found for export in this test.'),
6681
                    'warning',
6682
                    false
6683
                )
6684
            );
6685
            return false;
6686
        }
6687
6688
        // Prepare a temporary folder for the PDFs
6689
        $exportName       = 'S' . (int)($sessionId) . '-C' . (int)($courseId) . '-T' . (int)($exerciseId);
6690
        $baseDir          = api_get_path(SYS_ARCHIVE_PATH);
6691
        $exportFolderPath = $baseDir . 'pdfexport-' . $exportName;
6692
        if (is_dir($exportFolderPath)) {
6693
            rmdirr($exportFolderPath);
6694
        }
6695
        mkdir($exportFolderPath, 0755, true);
6696
6697
        // Generate a PDF for each attempt
6698
        foreach ($exeIds as $exeId) {
6699
            self::saveFileExerciseResultPdfDirect(
6700
                (int)$exeId,
6701
                (int)$courseId,
6702
                (int)$sessionId,
6703
                $exportFolderPath
6704
            );
6705
        }
6706
6707
        // Create the ZIP archive containing all generated PDFs
6708
        $zipFilePath = $baseDir . 'pdfexport-' . $exportName . '.zip';
6709
        $zip = new \ZipArchive();
6710
        if ($zip->open($zipFilePath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
6711
            throw new \Exception('Failed to create ZIP file');
6712
        }
6713
        $files = new RecursiveIteratorIterator(
6714
            new RecursiveDirectoryIterator($exportFolderPath),
6715
            RecursiveIteratorIterator::LEAVES_ONLY
6716
        );
6717
        foreach ($files as $file) {
6718
            if (!$file->isDir()) {
6719
                $filePath     = $file->getRealPath();
6720
                $relativePath = substr($filePath, strlen($exportFolderPath) + 1);
6721
                $zip->addFile($filePath, $relativePath);
6722
            }
6723
        }
6724
        $zip->close();
6725
        rmdirr($exportFolderPath);
6726
6727
        // Send the ZIP file to the browser or move it to mainPath
6728
        if (!empty($mainPath)) {
6729
            @rename($zipFilePath, $mainPath . '/pdfexport-' . $exportName . '.zip');
6730
            return true;
6731
        }
6732
6733
        session_write_close();
6734
        while (ob_get_level()) {
6735
            @ob_end_clean();
6736
        }
6737
6738
        header('Content-Description: File Transfer');
6739
        header('Content-Type: application/zip');
6740
        header('Content-Disposition: attachment; filename="pdfexport-' . $exportName . '.zip"');
6741
        header('Content-Transfer-Encoding: binary');
6742
        header('Expires: 0');
6743
        header('Cache-Control: must-revalidate');
6744
        header('Pragma: public');
6745
        header('Content-Length: ' . filesize($zipFilePath));
6746
6747
        readfile($zipFilePath);
6748
        @unlink($zipFilePath);
6749
        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...
6750
    }
6751
6752
    /**
6753
     * Return exe_ids for export using Doctrine (handles NULL/0 sessions safely).
6754
     */
6755
    private static function findAttemptExeIdsForExport(
6756
        CourseEntity $course,
6757
        CQuiz $quiz,
6758
        ?SessionEntity $session,
6759
        array $filterDates
6760
    ): array {
6761
        $em = Container::getEntityManager();
6762
6763
        $qb = $em->createQueryBuilder()
6764
            ->select('te.exeId AS exeId')
6765
            ->from(TrackEExercise::class, 'te')
6766
            ->where('te.course = :course')
6767
            ->andWhere('te.quiz = :quiz')
6768
            ->setParameter('course', $course)
6769
            ->setParameter('quiz', $quiz);
6770
6771
        // Session filter:
6772
        if ($session) {
6773
            $qb->andWhere('te.session = :session')->setParameter('session', $session);
6774
        } else {
6775
            // Accept both NULL and legacy "0" values
6776
            // IDENTITY() extracts the FK raw value to match 0 if present
6777
            $qb->andWhere('(te.session IS NULL OR IDENTITY(te.session) = 0)');
6778
        }
6779
6780
        // Date filters on exeDate
6781
        if (!empty($filterDates['start_date'])) {
6782
            $qb->andWhere('te.exeDate >= :start')
6783
                ->setParameter('start', new DateTime($filterDates['start_date']));
6784
        }
6785
        if (!empty($filterDates['end_date'])) {
6786
            $qb->andWhere('te.exeDate <= :end')
6787
                ->setParameter('end', new DateTime($filterDates['end_date']));
6788
        }
6789
6790
        $qb->orderBy('te.exeDate', 'DESC')->setMaxResults(5000);
6791
6792
        $rows = $qb->getQuery()->getScalarResult();
6793
        $exeIds = array_map(static fn($r) => (int)$r['exeId'], $rows);
6794
6795
6796
        return array_values(array_unique($exeIds));
6797
    }
6798
6799
    /**
6800
     * Fallback with native SQL for very legacy rows (session_id=0 and column names).
6801
     */
6802
    private static function findAttemptExeIdsFallbackSql(
6803
        int $courseId,
6804
        int $quizIid,
6805
        array $filterDates
6806
    ): array {
6807
        $conn = Container::getEntityManager()->getConnection();
6808
6809
        $sql = 'SELECT te.exe_id
6810
            FROM track_e_exercises te
6811
            WHERE te.c_id = :cid
6812
              AND te.exe_exo_id = :iid
6813
              AND (te.session_id IS NULL OR te.session_id = 0)';
6814
6815
        $params = ['cid' => $courseId, 'iid' => $quizIid];
6816
        $types  = [];
6817
6818
        if (!empty($filterDates['start_date'])) {
6819
            $sql .= ' AND te.exe_date >= :start';
6820
            $params['start'] = $filterDates['start_date'];
6821
        }
6822
        if (!empty($filterDates['end_date'])) {
6823
            $sql .= ' AND te.exe_date <= :end';
6824
            $params['end'] = $filterDates['end_date'];
6825
        }
6826
6827
        $sql .= ' ORDER BY te.exe_date DESC LIMIT 5000';
6828
6829
        $rows = $conn->fetchAllAssociative($sql, $params, $types);
6830
        $exeIds = array_map(static fn($r) => (int)$r['exe_id'], $rows);
6831
6832
        return $exeIds;
6833
    }
6834
6835
    /**
6836
     * Calculates the overall score for Combination-type questions.
6837
     */
6838
    public static function getUserQuestionScoreGlobal(
6839
        int   $answerType,
6840
        array $listCorrectAnswers,
6841
        int   $exeId,
6842
        int   $questionId,
6843
        float $questionWeighting,
6844
        array $choice = [],
6845
        int $nbrAnswers = 0
6846
    ): float
6847
    {
6848
        $nbrCorrect = 0;
6849
        $nbrOptions = 0;
6850
        $choice = is_array($choice) ? $choice : [];
6851
        switch ($answerType) {
6852
            case FILL_IN_BLANKS_COMBINATION:
6853
                if (!empty($listCorrectAnswers)) {
6854
                    if (!empty($listCorrectAnswers['student_score']) && is_array($listCorrectAnswers['student_score'])) {
6855
                        foreach ($listCorrectAnswers['student_score'] as $val) {
6856
                            if ((int) $val === 1) {
6857
                                $nbrCorrect++;
6858
                            }
6859
                        }
6860
                    }
6861
                    if (!empty($listCorrectAnswers['words_count'])) {
6862
                        $nbrOptions = (int) $listCorrectAnswers['words_count'];
6863
                    } elseif (!empty($listCorrectAnswers['words']) && is_array($listCorrectAnswers['words'])) {
6864
                        $nbrOptions = count($listCorrectAnswers['words']);
6865
                    }
6866
                }
6867
                break;
6868
6869
            case HOT_SPOT_COMBINATION:
6870
                if (!empty($listCorrectAnswers) && is_array($listCorrectAnswers) && is_array($choice)) {
6871
                    foreach ($listCorrectAnswers as $idx => $val) {
6872
                        if (isset($choice[$idx]) && (int) $choice[$idx] === 1) {
6873
                            $nbrCorrect++;
6874
                        }
6875
                    }
6876
                } else {
6877
                    $TBL_TRACK_HOTSPOT = Database::get_main_table(TABLE_STATISTIC_TRACK_E_HOTSPOT);
6878
                    $exeIdEsc = Database::escape_string($exeId);
6879
                    $qIdEsc   = Database::escape_string($questionId);
6880
                    $sql = "SELECT COUNT(hotspot_id) AS ct
6881
                        FROM $TBL_TRACK_HOTSPOT
6882
                        WHERE hotspot_exe_id = '$exeIdEsc'
6883
                          AND hotspot_question_id = '$qIdEsc'
6884
                          AND hotspot_correct = 1";
6885
                    $result = Database::query($sql);
6886
                    $nbrCorrect = (int) Database::result($result, 0, 0);
6887
                }
6888
                $nbrOptions = (int) $nbrAnswers;
6889
                break;
6890
6891
            case MATCHING_COMBINATION:
6892
            case MATCHING_DRAGGABLE_COMBINATION:
6893
                if (isset($listCorrectAnswers['form_values'])) {
6894
                    if (isset($listCorrectAnswers['form_values']['correct'])) {
6895
                        $nbrCorrect = count($listCorrectAnswers['form_values']['correct']);
6896
                        $nbrOptions = (int) $listCorrectAnswers['form_values']['count_options'];
6897
                    }
6898
                } else {
6899
                    if (isset($listCorrectAnswers['from_database'])) {
6900
                        if (isset($listCorrectAnswers['from_database']['correct'])) {
6901
                            $nbrCorrect = count($listCorrectAnswers['from_database']['correct']);
6902
                            $nbrOptions = (int) $listCorrectAnswers['from_database']['count_options'];
6903
                        }
6904
                    }
6905
                }
6906
                break;
6907
        }
6908
6909
        $questionScore = 0.0;
6910
        if ($nbrOptions > 0 && $nbrCorrect === $nbrOptions) {
6911
            $questionScore = (float) $questionWeighting;
6912
        }
6913
6914
        return $questionScore;
6915
    }
6916
}
6917