1<?php
2// This file is part of Moodle - http://moodle.org/
3//
4// Moodle is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// Moodle is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16
17/**
18 * Helper functions for the quiz reports.
19 *
20 * @package   mod_quiz
21 * @copyright 2008 Jamie Pratt
22 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23 */
24
25
26defined('MOODLE_INTERNAL') || die();
27
28require_once($CFG->dirroot . '/mod/quiz/lib.php');
29require_once($CFG->libdir . '/filelib.php');
30
31/**
32 * Takes an array of objects and constructs a multidimensional array keyed by
33 * the keys it finds on the object.
34 * @param array $datum an array of objects with properties on the object
35 * including the keys passed as the next param.
36 * @param array $keys Array of strings with the names of the properties on the
37 * objects in datum that you want to index the multidimensional array by.
38 * @param bool $keysunique If there is not only one object for each
39 * combination of keys you are using you should set $keysunique to true.
40 * Otherwise all the object will be added to a zero based array. So the array
41 * returned will have count($keys) + 1 indexs.
42 * @return array multidimensional array properly indexed.
43 */
44function quiz_report_index_by_keys($datum, $keys, $keysunique = true) {
45    if (!$datum) {
46        return array();
47    }
48    $key = array_shift($keys);
49    $datumkeyed = array();
50    foreach ($datum as $data) {
51        if ($keys || !$keysunique) {
52            $datumkeyed[$data->{$key}][]= $data;
53        } else {
54            $datumkeyed[$data->{$key}]= $data;
55        }
56    }
57    if ($keys) {
58        foreach ($datumkeyed as $datakey => $datakeyed) {
59            $datumkeyed[$datakey] = quiz_report_index_by_keys($datakeyed, $keys, $keysunique);
60        }
61    }
62    return $datumkeyed;
63}
64
65function quiz_report_unindex($datum) {
66    if (!$datum) {
67        return $datum;
68    }
69    $datumunkeyed = array();
70    foreach ($datum as $value) {
71        if (is_array($value)) {
72            $datumunkeyed = array_merge($datumunkeyed, quiz_report_unindex($value));
73        } else {
74            $datumunkeyed[] = $value;
75        }
76    }
77    return $datumunkeyed;
78}
79
80/**
81 * Are there any questions in this quiz?
82 * @param int $quizid the quiz id.
83 */
84function quiz_has_questions($quizid) {
85    global $DB;
86    return $DB->record_exists('quiz_slots', array('quizid' => $quizid));
87}
88
89/**
90 * Get the slots of real questions (not descriptions) in this quiz, in order.
91 * @param object $quiz the quiz.
92 * @return array of slot => $question object with fields
93 *      ->slot, ->id, ->maxmark, ->number, ->length.
94 */
95function quiz_report_get_significant_questions($quiz) {
96    global $DB;
97
98    $qsbyslot = $DB->get_records_sql("
99            SELECT slot.slot,
100                   q.id,
101                   q.qtype,
102                   q.length,
103                   slot.maxmark
104
105              FROM {question} q
106              JOIN {quiz_slots} slot ON slot.questionid = q.id
107
108             WHERE slot.quizid = ?
109               AND q.length > 0
110
111          ORDER BY slot.slot", array($quiz->id));
112
113    $number = 1;
114    foreach ($qsbyslot as $question) {
115        $question->number = $number;
116        $number += $question->length;
117        $question->type = $question->qtype;
118    }
119
120    return $qsbyslot;
121}
122
123/**
124 * @param object $quiz the quiz settings.
125 * @return bool whether, for this quiz, it is possible to filter attempts to show
126 *      only those that gave the final grade.
127 */
128function quiz_report_can_filter_only_graded($quiz) {
129    return $quiz->attempts != 1 && $quiz->grademethod != QUIZ_GRADEAVERAGE;
130}
131
132/**
133 * This is a wrapper for {@link quiz_report_grade_method_sql} that takes the whole quiz object instead of just the grading method
134 * as a param. See definition for {@link quiz_report_grade_method_sql} below.
135 *
136 * @param object $quiz
137 * @param string $quizattemptsalias sql alias for 'quiz_attempts' table
138 * @return string sql to test if this is an attempt that will contribute towards the grade of the user
139 */
140function quiz_report_qm_filter_select($quiz, $quizattemptsalias = 'quiza') {
141    if ($quiz->attempts == 1) {
142        // This quiz only allows one attempt.
143        return '';
144    }
145    return quiz_report_grade_method_sql($quiz->grademethod, $quizattemptsalias);
146}
147
148/**
149 * Given a quiz grading method return sql to test if this is an
150 * attempt that will be contribute towards the grade of the user. Or return an
151 * empty string if the grading method is QUIZ_GRADEAVERAGE and thus all attempts
152 * contribute to final grade.
153 *
154 * @param string $grademethod quiz grading method.
155 * @param string $quizattemptsalias sql alias for 'quiz_attempts' table
156 * @return string sql to test if this is an attempt that will contribute towards the graded of the user
157 */
158function quiz_report_grade_method_sql($grademethod, $quizattemptsalias = 'quiza') {
159    switch ($grademethod) {
160        case QUIZ_GRADEHIGHEST :
161            return "($quizattemptsalias.state = 'finished' AND NOT EXISTS (
162                           SELECT 1 FROM {quiz_attempts} qa2
163                            WHERE qa2.quiz = $quizattemptsalias.quiz AND
164                                qa2.userid = $quizattemptsalias.userid AND
165                                 qa2.state = 'finished' AND (
166                COALESCE(qa2.sumgrades, 0) > COALESCE($quizattemptsalias.sumgrades, 0) OR
167               (COALESCE(qa2.sumgrades, 0) = COALESCE($quizattemptsalias.sumgrades, 0) AND qa2.attempt < $quizattemptsalias.attempt)
168                                )))";
169
170        case QUIZ_GRADEAVERAGE :
171            return '';
172
173        case QUIZ_ATTEMPTFIRST :
174            return "($quizattemptsalias.state = 'finished' AND NOT EXISTS (
175                           SELECT 1 FROM {quiz_attempts} qa2
176                            WHERE qa2.quiz = $quizattemptsalias.quiz AND
177                                qa2.userid = $quizattemptsalias.userid AND
178                                 qa2.state = 'finished' AND
179                               qa2.attempt < $quizattemptsalias.attempt))";
180
181        case QUIZ_ATTEMPTLAST :
182            return "($quizattemptsalias.state = 'finished' AND NOT EXISTS (
183                           SELECT 1 FROM {quiz_attempts} qa2
184                            WHERE qa2.quiz = $quizattemptsalias.quiz AND
185                                qa2.userid = $quizattemptsalias.userid AND
186                                 qa2.state = 'finished' AND
187                               qa2.attempt > $quizattemptsalias.attempt))";
188    }
189}
190
191/**
192 * Get the number of students whose score was in a particular band for this quiz.
193 * @param number $bandwidth the width of each band.
194 * @param int $bands the number of bands
195 * @param int $quizid the quiz id.
196 * @param \core\dml\sql_join $usersjoins (joins, wheres, params) to get enrolled users
197 * @return array band number => number of users with scores in that band.
198 */
199function quiz_report_grade_bands($bandwidth, $bands, $quizid, \core\dml\sql_join $usersjoins = null) {
200    global $DB;
201    if (!is_int($bands)) {
202        debugging('$bands passed to quiz_report_grade_bands must be an integer. (' .
203                gettype($bands) . ' passed.)', DEBUG_DEVELOPER);
204        $bands = (int) $bands;
205    }
206
207    if ($usersjoins && !empty($usersjoins->joins)) {
208        $userjoin = "JOIN {user} u ON u.id = qg.userid
209                {$usersjoins->joins}";
210        $usertest = $usersjoins->wheres;
211        $params = $usersjoins->params;
212    } else {
213        $userjoin = '';
214        $usertest = '1=1';
215        $params = array();
216    }
217    $sql = "
218SELECT band, COUNT(1)
219
220FROM (
221    SELECT FLOOR(qg.grade / :bandwidth) AS band
222      FROM {quiz_grades} qg
223    $userjoin
224    WHERE $usertest AND qg.quiz = :quizid
225) subquery
226
227GROUP BY
228    band
229
230ORDER BY
231    band";
232
233    $params['quizid'] = $quizid;
234    $params['bandwidth'] = $bandwidth;
235
236    $data = $DB->get_records_sql_menu($sql, $params);
237
238    // We need to create array elements with values 0 at indexes where there is no element.
239    $data = $data + array_fill(0, $bands + 1, 0);
240    ksort($data);
241
242    // Place the maximum (perfect grade) into the last band i.e. make last
243    // band for example 9 <= g <=10 (where 10 is the perfect grade) rather than
244    // just 9 <= g <10.
245    $data[$bands - 1] += $data[$bands];
246    unset($data[$bands]);
247
248    return $data;
249}
250
251function quiz_report_highlighting_grading_method($quiz, $qmsubselect, $qmfilter) {
252    if ($quiz->attempts == 1) {
253        return '<p>' . get_string('onlyoneattemptallowed', 'quiz_overview') . '</p>';
254
255    } else if (!$qmsubselect) {
256        return '<p>' . get_string('allattemptscontributetograde', 'quiz_overview') . '</p>';
257
258    } else if ($qmfilter) {
259        return '<p>' . get_string('showinggraded', 'quiz_overview') . '</p>';
260
261    } else {
262        return '<p>' . get_string('showinggradedandungraded', 'quiz_overview',
263                '<span class="gradedattempt">' . quiz_get_grading_option_name($quiz->grademethod) .
264                '</span>') . '</p>';
265    }
266}
267
268/**
269 * Get the feedback text for a grade on this quiz. The feedback is
270 * processed ready for display.
271 *
272 * @param float $grade a grade on this quiz.
273 * @param int $quizid the id of the quiz object.
274 * @return string the comment that corresponds to this grade (empty string if there is not one.
275 */
276function quiz_report_feedback_for_grade($grade, $quizid, $context) {
277    global $DB;
278
279    static $feedbackcache = array();
280
281    if (!isset($feedbackcache[$quizid])) {
282        $feedbackcache[$quizid] = $DB->get_records('quiz_feedback', array('quizid' => $quizid));
283    }
284
285    // With CBM etc, it is possible to get -ve grades, which would then not match
286    // any feedback. Therefore, we replace -ve grades with 0.
287    $grade = max($grade, 0);
288
289    $feedbacks = $feedbackcache[$quizid];
290    $feedbackid = 0;
291    $feedbacktext = '';
292    $feedbacktextformat = FORMAT_MOODLE;
293    foreach ($feedbacks as $feedback) {
294        if ($feedback->mingrade <= $grade && $grade < $feedback->maxgrade) {
295            $feedbackid = $feedback->id;
296            $feedbacktext = $feedback->feedbacktext;
297            $feedbacktextformat = $feedback->feedbacktextformat;
298            break;
299        }
300    }
301
302    // Clean the text, ready for display.
303    $formatoptions = new stdClass();
304    $formatoptions->noclean = true;
305    $feedbacktext = file_rewrite_pluginfile_urls($feedbacktext, 'pluginfile.php',
306            $context->id, 'mod_quiz', 'feedback', $feedbackid);
307    $feedbacktext = format_text($feedbacktext, $feedbacktextformat, $formatoptions);
308
309    return $feedbacktext;
310}
311
312/**
313 * Format a number as a percentage out of $quiz->sumgrades
314 * @param number $rawgrade the mark to format.
315 * @param object $quiz the quiz settings
316 * @param bool $round whether to round the results ot $quiz->decimalpoints.
317 */
318function quiz_report_scale_summarks_as_percentage($rawmark, $quiz, $round = true) {
319    if ($quiz->sumgrades == 0) {
320        return '';
321    }
322    if (!is_numeric($rawmark)) {
323        return $rawmark;
324    }
325
326    $mark = $rawmark * 100 / $quiz->sumgrades;
327    if ($round) {
328        $mark = quiz_format_grade($quiz, $mark);
329    }
330
331    return get_string('percents', 'moodle', $mark);
332}
333
334/**
335 * Returns an array of reports to which the current user has access to.
336 * @return array reports are ordered as they should be for display in tabs.
337 */
338function quiz_report_list($context) {
339    global $DB;
340    static $reportlist = null;
341    if (!is_null($reportlist)) {
342        return $reportlist;
343    }
344
345    $reports = $DB->get_records('quiz_reports', null, 'displayorder DESC', 'name, capability');
346    $reportdirs = core_component::get_plugin_list('quiz');
347
348    // Order the reports tab in descending order of displayorder.
349    $reportcaps = array();
350    foreach ($reports as $key => $report) {
351        if (array_key_exists($report->name, $reportdirs)) {
352            $reportcaps[$report->name] = $report->capability;
353        }
354    }
355
356    // Add any other reports, which are on disc but not in the DB, on the end.
357    foreach ($reportdirs as $reportname => $notused) {
358        if (!isset($reportcaps[$reportname])) {
359            $reportcaps[$reportname] = null;
360        }
361    }
362    $reportlist = array();
363    foreach ($reportcaps as $name => $capability) {
364        if (empty($capability)) {
365            $capability = 'mod/quiz:viewreports';
366        }
367        if (has_capability($capability, $context)) {
368            $reportlist[] = $name;
369        }
370    }
371    return $reportlist;
372}
373
374/**
375 * Create a filename for use when downloading data from a quiz report. It is
376 * expected that this will be passed to flexible_table::is_downloading, which
377 * cleans the filename of bad characters and adds the file extension.
378 * @param string $report the type of report.
379 * @param string $courseshortname the course shortname.
380 * @param string $quizname the quiz name.
381 * @return string the filename.
382 */
383function quiz_report_download_filename($report, $courseshortname, $quizname) {
384    return $courseshortname . '-' . format_string($quizname, true) . '-' . $report;
385}
386
387/**
388 * Get the default report for the current user.
389 * @param object $context the quiz context.
390 */
391function quiz_report_default_report($context) {
392    $reports = quiz_report_list($context);
393    return reset($reports);
394}
395
396/**
397 * Generate a message saying that this quiz has no questions, with a button to
398 * go to the edit page, if the user has the right capability.
399 * @param object $quiz the quiz settings.
400 * @param object $cm the course_module object.
401 * @param object $context the quiz context.
402 * @return string HTML to output.
403 */
404function quiz_no_questions_message($quiz, $cm, $context) {
405    global $OUTPUT;
406
407    $output = '';
408    $output .= $OUTPUT->notification(get_string('noquestions', 'quiz'));
409    if (has_capability('mod/quiz:manage', $context)) {
410        $output .= $OUTPUT->single_button(new moodle_url('/mod/quiz/edit.php',
411        array('cmid' => $cm->id)), get_string('editquiz', 'quiz'), 'get');
412    }
413
414    return $output;
415}
416
417/**
418 * Should the grades be displayed in this report. That depends on the quiz
419 * display options, and whether the quiz is graded.
420 * @param object $quiz the quiz settings.
421 * @param context $context the quiz context.
422 * @return bool
423 */
424function quiz_report_should_show_grades($quiz, context $context) {
425    if ($quiz->timeclose && time() > $quiz->timeclose) {
426        $when = mod_quiz_display_options::AFTER_CLOSE;
427    } else {
428        $when = mod_quiz_display_options::LATER_WHILE_OPEN;
429    }
430    $reviewoptions = mod_quiz_display_options::make_from_quiz($quiz, $when);
431
432    return quiz_has_grades($quiz) &&
433            ($reviewoptions->marks >= question_display_options::MARK_AND_MAX ||
434            has_capability('moodle/grade:viewhidden', $context));
435}
436