|
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
|
|
|
|