ExerciseLib::show_score()   D
last analyzed

Complexity

Conditions 18

Size

Total Lines 76
Code Lines 42

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 18
eloc 42
nop 10
dl 0
loc 76
rs 4.8666
c 0
b 0
f 0

How to fix   Long Method    Complexity    Many Parameters   

Long Method

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

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

Commonly applied refactorings include:

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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

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

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

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