Passed
Push — master ( 78a9c7...5b4a32 )
by
unknown
17:36 queued 08:33
created

qp_session_clause()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 2
eloc 3
c 1
b 0
f 1
nc 2
nop 1
dl 0
loc 8
rs 10
1
<?php
2
3
/* For licensing terms, see /license.txt */
4
5
use Chamilo\CoreBundle\Framework\Container;
6
7
require_once __DIR__.'/../inc/global.inc.php';
8
$this_section = SECTION_COURSES;
9
10
api_protect_course_script(true);
11
12
$isAllowedToEdit = api_is_allowed_to_edit(null, true);
13
14
if (!$isAllowedToEdit) {
15
    api_not_allowed(true);
16
}
17
18
$exerciseId = isset($_REQUEST['id']) ? (int) $_REQUEST['id'] : 0;
19
$groups = $_REQUEST['groups'] ?? [];
20
$users = $_REQUEST['users'] ?? [];
21
22
if (empty($exerciseId)) {
23
    api_not_allowed(true);
24
}
25
26
$sessionId = api_get_session_id();
27
$exercise = new Exercise();
28
$result = $exercise->read($exerciseId);
29
$course  = api_get_course_entity();
30
$courseId = api_get_course_int_id();
31
32
if (empty($result)) {
33
    api_not_allowed(true);
34
}
35
36
$nameTools = get_lang('Question statistics');
37
$interbreadcrumb[] = [
38
    'url' => 'exercise.php?'.api_get_cidreq(),
39
    'name' => get_lang('Tests'),
40
];
41
$interbreadcrumb[] = [
42
    'url' => 'admin.php?exerciseId='.$exercise->iId.'&'.api_get_cidreq(),
43
    'name' => $exercise->selectTitle(true),
44
];
45
46
$interbreadcrumb[] = [
47
    'url' => 'exercise_report.php?'.api_get_cidreq().'&exerciseId='.$exercise->iId,
48
    'name' => get_lang('Learner score'),
49
];
50
51
$form = new FormValidator('search_form', 'GET', api_get_self().'?id='.$exerciseId.'&'.api_get_cidreq());
52
$form->addCourseHiddenParams();
53
$form->addHidden('id', $exerciseId);
54
55
$courseGroups = GroupManager::get_group_list(null, $course);
56
57
if (!empty($courseGroups)) {
58
    $courseGroups = array_column($courseGroups, 'name', 'iid');
59
    $form->addSelect(
60
        'groups',
61
        get_lang('Groups'),
62
        $courseGroups,
63
        [
64
            'multiple' => true,
65
        ]
66
    );
67
}
68
69
$courseUsers = CourseManager::get_user_list_from_course_code($course->getCode());
70
if (!empty($courseUsers)) {
71
    array_walk(
72
        $courseUsers,
73
        function (&$data, $key) {
74
            $data = api_get_person_name($data['firstname'], $data['lastname']);
75
        }
76
    );
77
}
78
79
$form->addSelect('users', get_lang('Users'), $courseUsers, [
80
    'multiple' => true,
81
    'id' => 'users-select',
82
    'class' => 'form-control select2',
83
    'style' => 'width:100%',
84
]);
85
86
$form->addButtonSearch(get_lang('Search'));
87
88
$formToString = $form->toHtml();
89
90
$scoreDisplay = new ScoreDisplay();
91
92
/* -----------------------------------------------------------------------------
93
 * Helpers (new)
94
 * -----------------------------------------------------------------------------
95
 * We move the "wrong stats" from item-level to attempt-level:
96
 *   - total attempts per question = COUNT(DISTINCT exe_id) answering that question
97
 *   - incorrect attempts          = COUNT(DISTINCT exe_id) where marks < max_score
98
 *
99
 * IMPORTANT: adjust column names if needed
100
 * - track_e_attempt:  marks, max_score     (per question, per attempt)
101
 * - track_e_exercises: exe_id, exe_exo_id, exe_user_id, c_id, status, session_id
102
 * ---------------------------------------------------------------------------*/
103
104
/** Build user filter list from users and (optionally) groups. */
105
function qp_build_user_filter(array $users, array $groups, $course): array
106
{
107
    $ids = array_map('intval', $users ?? []);
108
109
    // Best-effort group → users expansion. If GroupManager API differs, adapt here.
110
    if (!empty($groups)) {
111
        foreach ($groups as $gid) {
112
            $gid = (int) $gid;
113
            if ($gid <= 0) {
114
                continue;
115
            }
116
            // Try common helper names used in Chamilo code base.
117
            if (method_exists('GroupManager', 'get_users')) {
118
                $gUsers = GroupManager::get_users($gid, $course) ?: [];
119
                foreach ($gUsers as $uid => $_) {
120
                    $ids[] = (int) $uid;
121
                }
122
            } elseif (method_exists('GroupManager', 'get_subscribed_users')) {
123
                $gUsers = GroupManager::get_subscribed_users($gid) ?: [];
124
                foreach ($gUsers as $u) {
125
                    $ids[] = (int) ($u['user_id'] ?? 0);
126
                }
127
            }
128
        }
129
    }
130
131
    $ids = array_values(array_unique(array_filter($ids)));
132
    return $ids;
133
}
134
135
/** Return "AND te.session_id ..." clause matching current session filter. */
136
function qp_session_clause(int $sessionId): string
137
{
138
    if ($sessionId > 0) {
139
        return ' AND te.session_id = '.$sessionId.' ';
140
    }
141
142
    // No session → consider base course attempts (NULL or 0)
143
    return ' AND (te.session_id IS NULL OR te.session_id = 0) ';
144
}
145
146
/** Count DISTINCT exe_id that answered the question (total attempts). */
147
function qp_total_attempts_for_question(
148
    int $courseId,
149
    int $exerciseId,
150
    int $questionId,
151
    int $sessionId,
152
    array $userIds = []
153
): int {
154
    $TBL_ATT = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT);
155
    $TBL_EXE = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
156
157
    $userSql = '';
158
    if (!empty($userIds)) {
159
        $userSql = ' AND te.exe_user_id IN ('.implode(',', array_map('intval', $userIds)).') ';
160
    }
161
162
    $sql = "
163
        SELECT COUNT(DISTINCT te.exe_id) AS total
164
        FROM $TBL_ATT ta
165
        INNER JOIN $TBL_EXE te ON te.exe_id = ta.exe_id
166
        WHERE te.c_id = $courseId
167
          AND te.exe_exo_id = $exerciseId
168
          AND ta.question_id = $questionId
169
          AND te.status <> 'incomplete'
170
          ".qp_session_clause($sessionId)."
171
          $userSql
172
    ";
173
174
    $row = Database::fetch_assoc(Database::query($sql));
175
    return (int) ($row['total'] ?? 0);
176
}
177
178
/** Count DISTINCT exe_id where the question was not fully correct (attempt-level). */
179
function qp_incorrect_attempts_for_question(
180
    int $courseId,
181
    int $exerciseId,
182
    int $questionId,
183
    int $sessionId,
184
    array $userIds = []
185
): int {
186
    $TBL_ATT = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ATTEMPT);
187
    $TBL_EXE = Database::get_main_table(TABLE_STATISTIC_TRACK_E_EXERCISES);
188
    $TBL_QQQ = Database::get_course_table(TABLE_QUIZ_QUESTION); // c_quiz_question
189
190
    $userSql = '';
191
    if (!empty($userIds)) {
192
        $userSql = ' AND te.exe_user_id IN ('.implode(',', array_map('intval', $userIds)).') ';
193
    }
194
195
    // Incorrect if question didn't reach its full score
196
    $sql = "
197
        SELECT COUNT(DISTINCT te.exe_id) AS wrong
198
        FROM $TBL_ATT ta
199
        INNER JOIN $TBL_EXE te ON te.exe_id = ta.exe_id
200
        INNER JOIN $TBL_QQQ qq ON qq.iid = ta.question_id
201
        WHERE te.c_id = $courseId
202
          AND te.exe_exo_id = $exerciseId
203
          AND ta.question_id = $questionId
204
          AND te.status <> 'incomplete'
205
          AND ta.marks < qq.ponderation
206
          ".qp_session_clause($sessionId)."
207
          $userSql
208
    ";
209
210
    $row = Database::fetch_assoc(Database::query($sql));
211
    return (int) ($row['wrong'] ?? 0);
212
}
213
214
215
/** Fetch candidate questions to list (keeps previous behavior for compatibility). */
216
function qp_fetch_questions(int $courseId, int $exerciseId, int $sessionId, array $groups = [], array $users = []): array
217
{
218
    // Reuse existing helper to get question labels; we will ignore its "count" and recompute
219
    // with the new attempt-based logic to avoid changing other parts of the code.
220
    if (!empty($groups) || !empty($users)) {
221
        return ExerciseLib::getWrongQuestionResults($courseId, $exerciseId, $sessionId, $groups, $users);
222
    }
223
224
    return ExerciseLib::getWrongQuestionResults($courseId, $exerciseId, $sessionId);
225
}
226
227
/* -----------------------------------------------------------------------------
228
 * Build table rows
229
 * ---------------------------------------------------------------------------*/
230
$orderedData = [];
231
$userFilterIds = qp_build_user_filter($users, $groups, $course);
232
233
if ($form->validate()) {
234
    $questions = qp_fetch_questions($courseId, $exerciseId, $sessionId, $groups, $users);
235
} else {
236
    $questions = qp_fetch_questions($courseId, $exerciseId, $sessionId);
237
}
238
239
// If for some reason there are no "wrong" records (all perfect), we still want to
240
// show the questions of the exercise; we can fall back to the exercise structure.
241
if (empty($questions)) {
242
    $exerciseEntity = Container::getQuizRepository()->find($exerciseId);
243
    if ($exerciseEntity) {
244
        $seen = [];
245
        foreach ($exerciseEntity->getQuestions() as $rel) { // CQuizRelQuestion
246
            $qq = $rel->getQuestion();                      // CQuizQuestion
247
            if (!$qq) { continue; }
248
            $qid = (int) $qq->getIid();
249
250
            if (isset($seen[$qid])) { continue; }
251
            $seen[$qid] = true;
252
253
            $questions[] = [
254
                'question_id' => $qid,
255
                'question'    => $qq->getQuestion(),
256
                'count'       => 0,
257
            ];
258
        }
259
    }
260
}
261
262
foreach ($questions as $data) {
263
    $questionId = (int) $data['question_id'];
264
265
    $total     = qp_total_attempts_for_question($courseId, $exerciseId, $questionId, $sessionId, $userFilterIds);
266
    $incorrect = qp_incorrect_attempts_for_question($courseId, $exerciseId, $questionId, $sessionId, $userFilterIds);
267
268
    // Keep UI contract: "Wrong / Total" and "%".
269
    $orderedData[] = [
270
        $data['question'],
271
        $incorrect.' / '.$total,
272
        $total > 0 ? $scoreDisplay->display_score([$incorrect, $total], SCORE_AVERAGE) : '0%',
273
    ];
274
}
275
276
/* -----------------------------------------------------------------------------
277
 * Render
278
 * ---------------------------------------------------------------------------*/
279
$table = new SortableTableFromArray(
280
    $orderedData,
281
    0,
282
    100,
283
    'question_tracking'
284
);
285
286
$table->hideNavigation = true;
287
288
$headers = [
289
    get_lang('Question'),
290
    get_lang('Wrong answer').' / '.get_lang('Total'),
291
    '%',
292
];
293
294
$table->column = 2;
295
$col = 0;
296
foreach ($headers as $header) {
297
    $table->set_header($col, $header, false);
298
    $col++;
299
}
300
301
Display::display_header($nameTools, get_lang('Test'));
302
echo Display::page_header(
303
    $exercise->selectTitle(true).' — '.get_lang('Question statistics')
304
);
305
echo '<div class="panel panel-default mt-8"><div class="panel-body">';
306
echo $formToString;
307
echo '</div></div>';
308
?>
309
    <script>
310
        $(function(){
311
            $("#users-select").select2({
312
                width: "100%",
313
                placeholder: "<?php echo addslashes(get_lang('Filter users')) ?>",
314
                closeOnSelect: false
315
            });
316
        });
317
    </script>
318
319
<?php
320
echo '<div class="table-responsive">';
321
echo $table->return_table();
322
echo '</div>';
323
Display::display_footer();
324