performActionsAfterConfigure()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
/* For licensing terms, see /license.txt */
3
4
use Chamilo\CoreBundle\Entity\TrackEAttempt;
5
use Chamilo\CoreBundle\Entity\TrackEExercise;
6
7
/**
8
 * Class QuestionOptionsEvaluationPlugin.
9
 */
10
class QuestionOptionsEvaluationPlugin extends Plugin
11
{
12
    public const SETTING_ENABLE     = 'enable';
13
    public const SETTING_MAX_SCORE  = 'exercise_max_score';
14
    public const EXTRAFIELD_FORMULA = 'quiz_evaluation_formula';
15
16
    /** Use C2 handler only. Do NOT use "quiz" to avoid warnings. */
17
    private const EF_HANDLER = 'exercise';
18
19
    /**
20
     * QuestionValuationPlugin constructor.
21
     */
22
    protected function __construct()
23
    {
24
        $version = '1.0';
25
        $author  = 'Angel Fernando Quiroz Campos';
26
27
        parent::__construct(
28
            $version,
29
            $author,
30
            [
31
                self::SETTING_ENABLE    => 'boolean',
32
                self::SETTING_MAX_SCORE => 'text',
33
            ]
34
        );
35
    }
36
37
    /**
38
     * @return QuestionOptionsEvaluationPlugin|null
39
     */
40
    public static function create()
41
    {
42
        static $result = null;
43
44
        return $result ? $result : $result = new self();
45
    }
46
47
    /**
48
     * @param int $exerciseId
49
     * @param int $iconSize
50
     *
51
     * @return string
52
     */
53
    public static function filterModify($exerciseId, $iconSize = ICON_SIZE_SMALL)
54
    {
55
        $directory = basename(__DIR__);
56
        $title     = get_plugin_lang('plugin_title', self::class);
57
        $enabled   = api_get_plugin_setting('questionoptionsevaluation', 'enable');
58
59
        if ('true' !== $enabled) {
60
            return '';
61
        }
62
63
        return Display::url(
64
            Display::return_icon('options_evaluation.png', $title, [], $iconSize),
65
            api_get_path(WEB_PATH)."plugin/$directory/evaluation.php?exercise=$exerciseId",
66
            [
67
                'class'      => 'ajax',
68
                'data-size'  => 'md',
69
                'data-title' => get_plugin_lang('plugin_title', self::class),
70
            ]
71
        );
72
    }
73
74
    public function install()
75
    {
76
        $this->createExtraField();
77
    }
78
79
    public function uninstall()
80
    {
81
        $this->removeExtraField();
82
    }
83
84
    /**
85
     * @return Plugin
86
     */
87
    public function performActionsAfterConfigure()
88
    {
89
        return $this;
90
    }
91
92
    /**
93
     * Persist the selected formula for a given Exercise.
94
     *
95
     * @param int      $formula
96
     * @param Exercise $exercise
97
     */
98
    public function saveFormulaForExercise($formula, Exercise $exercise)
99
    {
100
        $formula = (int) $formula;
101
102
        $this->recalculateQuestionScore($formula, $exercise);
103
104
        // Write using the C2 handler to avoid "Undefined array key 'quiz'" warnings
105
        $extraFieldValue = new ExtraFieldValue(self::EF_HANDLER);
106
        $extraFieldValue->save(
107
            [
108
                'item_id'  => (int) $exercise->iid,
0 ignored issues
show
Bug introduced by
The property iid does not seem to exist on Exercise.
Loading history...
109
                'variable' => self::EXTRAFIELD_FORMULA,
110
                'value'    => $formula,
111
            ]
112
        );
113
    }
114
115
    /**
116
     * Read formula for an Exercise
117
     *
118
     * @param int $exerciseId
119
     *
120
     * @return int
121
     */
122
    public function getFormulaForExercise($exerciseId)
123
    {
124
        $efv   = new ExtraFieldValue(self::EF_HANDLER);
125
        $value = $efv->get_values_by_handler_and_field_variable((int) $exerciseId, self::EXTRAFIELD_FORMULA);
126
127
        if (empty($value)) {
128
            return 0;
129
        }
130
131
        return (int) $value['value'];
132
    }
133
134
    /**
135
     * @return int
136
     */
137
    public function getMaxScore()
138
    {
139
        $max = $this->get(self::SETTING_MAX_SCORE);
140
141
        if (!empty($max)) {
142
            return (int) $max;
143
        }
144
145
        return 10;
146
    }
147
148
    /**
149
     * Compute result using negative marking formulas.
150
     *
151
     * @param int $trackId
152
     * @param int $formula
153
     *
154
     * @throws \Doctrine\ORM\ORMException
155
     * @throws \Doctrine\ORM\OptimisticLockException
156
     * @throws \Doctrine\ORM\TransactionRequiredException
157
     *
158
     * @return float|int
159
     */
160
    public function getResultWithFormula($trackId, $formula)
161
    {
162
        $em = Database::getManager();
163
164
        /** @var TrackEExercise|null $eTrack */
165
        $eTrack = $em->find(TrackEExercise::class, (int) $trackId);
166
        if (!$eTrack) {
167
            return 0;
168
        }
169
170
        $qTracks = $em
171
            ->createQuery(
172
                'SELECT a FROM ChamiloCoreBundle:TrackEAttempt a
173
                 WHERE a.exeId = :id AND a.userId = :user AND a.cId = :course AND a.sessionId = :session'
174
            )
175
            ->setParameters(
176
                [
177
                    'id'      => $eTrack->getExeId(),
178
                    'course'  => $eTrack->getCourse(),
179
                    'session' => $eTrack->getSession(),
180
                    'user'    => $eTrack->getUser(),
181
                ]
182
            )
183
            ->getResult();
184
185
        // Guard: avoid division by zero if there are no attempts
186
        if (!$qTracks) {
187
            return 0;
188
        }
189
190
        $counts = ['correct' => 0, 'incorrect' => 0];
191
192
        /** @var TrackEAttempt $qTrack */
193
        foreach ($qTracks as $qTrack) {
194
            if ($qTrack->getMarks() > 0) {
195
                $counts['correct']++;
196
            } elseif ($qTrack->getMarks() < 0) {
197
                $counts['incorrect']++;
198
            }
199
        }
200
201
        // Safe default then apply formula
202
        $formula = (int) $formula;
203
        $result  = $counts['correct'];
204
205
        switch ($formula) {
206
            case 1:
207
                $result = $counts['correct'] - $counts['incorrect'];
208
                break;
209
            case 2:
210
                $result = $counts['correct'] - $counts['incorrect'] / 2;
211
                break;
212
            case 3:
213
                $result = $counts['correct'] - $counts['incorrect'] / 3;
214
                break;
215
            default:
216
                // Keep default = only positives counted
217
                break;
218
        }
219
220
        $score = ($result / count($qTracks)) * $this->getMaxScore();
221
222
        return $score >= 0 ? $score : 0;
223
    }
224
225
    /**
226
     * Recalculate question scores according to the selected formula.
227
     *
228
     * @param int      $formula
229
     * @param Exercise $exercise
230
     */
231
    private function recalculateQuestionScore($formula, Exercise $exercise)
232
    {
233
        $tblQuestion = Database::get_course_table(TABLE_QUIZ_QUESTION);
234
        $tblAnswer   = Database::get_course_table(TABLE_QUIZ_ANSWER);
235
236
        foreach ($exercise->questionList as $questionId) {
237
            $question = Question::read($questionId, $exercise->course, false);
238
            if (!in_array($question->selectType(), [UNIQUE_ANSWER, MULTIPLE_ANSWER], true)) {
239
                continue;
240
            }
241
242
            $questionAnswers = new Answer($questionId, $exercise->course_id, $exercise);
243
            $counts          = array_count_values($questionAnswers->correct);
244
245
            // Ensure keys exist to avoid "Undefined index" and division by zero
246
            $totalCorrect   = (int) ($counts[1] ?? 0);
247
            $totalIncorrect = (int) ($counts[0] ?? 0);
248
249
            $questionPonderation = 0.0;
250
251
            foreach ($questionAnswers->correct as $i => $isCorrect) {
252
                if (!isset($questionAnswers->iid[$i])) {
253
                    continue;
254
                }
255
256
                $iid = (int) $questionAnswers->iid[$i];
257
258
                if ($question->selectType() === MULTIPLE_ANSWER || 0 === (int) $formula) {
259
                    // Multiple-answer or formula 0 distributes weights across options.
260
                    // Use max(1, totalX) to avoid division by zero.
261
                    $ponderation = (1 == $isCorrect)
262
                        ? 1 / max(1, $totalCorrect)
263
                        : -1 / max(1, $totalIncorrect);
264
                } else {
265
                    // Single-answer: correct=1; wrong=-1/formula
266
                    $ponderation = (1 == $isCorrect) ? 1.0 : -1.0 / (int) $formula;
267
                }
268
269
                if ($ponderation > 0) {
270
                    $questionPonderation += $ponderation;
271
                }
272
273
                Database::query("UPDATE $tblAnswer SET ponderation = ".(float)$ponderation." WHERE iid = $iid");
274
            }
275
276
            Database::query(
277
                "UPDATE $tblQuestion SET ponderation = ".(float)$questionPonderation." WHERE iid = ".(int)$question->iid
278
            );
279
        }
280
    }
281
282
    /**
283
     * Creates the ExtraField for storing the evaluation formula.
284
     * We force integer 0/1 for boolean flags to satisfy strict MySQL.
285
     */
286
    private function createExtraField()
287
    {
288
        // Use ONLY the C2 handler; "quiz" is not a valid handler in C2 and triggers warnings.
289
        $extraField = new ExtraField(self::EF_HANDLER);
290
291
        // If the field already exists, do nothing (idempotent install)
292
        if (false !== $extraField->get_handler_field_info_by_field_variable(self::EXTRAFIELD_FORMULA)) {
293
            return;
294
        }
295
296
        // Determine a safe value_type to satisfy NOT NULL columns in strict MySQL
297
        $valueType = 0;
298
        if (defined('ExtraField::VALUE_TYPE_TEXT')) {
299
            $valueType = (int) constant('ExtraField::VALUE_TYPE_TEXT');
300
        } elseif (defined('ExtraField::VALUE_TEXT')) {
301
            $valueType = (int) constant('ExtraField::VALUE_TEXT');
302
        } elseif (defined('ExtraField::TYPE_TEXT')) {
303
            $valueType = (int) constant('ExtraField::TYPE_TEXT');
304
        }
305
306
        // Build payload including explicit ints for boolean-like fields
307
        $payload = [
308
            'variable'        => self::EXTRAFIELD_FORMULA,
309
            'field_type'      => ExtraField::FIELD_TYPE_TEXT,
310
            'display_text'    => $this->get_lang('EvaluationFormula'),
311
            'visible'         => 1,
312
            'visible_to_self' => 0,
313
            'changeable'      => 0,
314
            'value_type'      => $valueType,
315
            'default_value'   => '0',
316
        ];
317
318
        $extraField->save($payload);
319
    }
320
321
    /**
322
     * Removes the ExtraField (C2 handler only).
323
     */
324
    private function removeExtraField()
325
    {
326
        $extraField = new ExtraField(self::EF_HANDLER);
327
        $value      = $extraField->get_handler_field_info_by_field_variable(self::EXTRAFIELD_FORMULA);
328
329
        if (false !== $value) {
330
            $extraField->delete($value['id']);
331
        }
332
    }
333
}
334