MDL-32214 quiz regrading: with many attempts, PHP runs out of memory
[moodle.git] / mod / quiz / report / overview / report.php
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/>.
17 /**
18  * This file defines the quiz overview report class.
19  *
20  * @package    quiz
21  * @subpackage overview
22  * @copyright  1999 onwards Martin Dougiamas and others {@link http://moodle.com}
23  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24  */
27 defined('MOODLE_INTERNAL') || die();
29 require_once($CFG->dirroot.'/mod/quiz/report/attemptsreport.php');
30 require_once($CFG->dirroot.'/mod/quiz/report/overview/overviewsettings_form.php');
31 require_once($CFG->dirroot.'/mod/quiz/report/overview/overview_table.php');
34 /**
35  * Quiz report subclass for the overview (grades) report.
36  *
37  * @copyright  1999 onwards Martin Dougiamas and others {@link http://moodle.com}
38  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
39  */
40 class quiz_overview_report extends quiz_attempt_report {
42     public function display($quiz, $cm, $course) {
43         global $CFG, $COURSE, $DB, $OUTPUT;
45         $this->context = get_context_instance(CONTEXT_MODULE, $cm->id);
47         $download = optional_param('download', '', PARAM_ALPHA);
49         list($currentgroup, $students, $groupstudents, $allowed) =
50                 $this->load_relevant_students($cm, $course);
52         $pageoptions = array();
53         $pageoptions['id'] = $cm->id;
54         $pageoptions['mode'] = 'overview';
56         $reporturl = new moodle_url('/mod/quiz/report.php', $pageoptions);
57         $qmsubselect = quiz_report_qm_filter_select($quiz);
59         $mform = new mod_quiz_report_overview_settings($reporturl,
60                 array('qmsubselect' => $qmsubselect, 'quiz' => $quiz,
61                 'currentgroup' => $currentgroup, 'context' => $this->context));
63         if ($fromform = $mform->get_data()) {
64             $regradeall = false;
65             $regradealldry = false;
66             $regradealldrydo = false;
67             $attemptsmode = $fromform->attemptsmode;
68             if ($qmsubselect) {
69                 $qmfilter = $fromform->qmfilter;
70             } else {
71                 $qmfilter = 0;
72             }
73             $regradefilter = !empty($fromform->regradefilter);
74             set_user_preference('quiz_report_overview_detailedmarks', $fromform->detailedmarks);
75             set_user_preference('quiz_report_pagesize', $fromform->pagesize);
76             $detailedmarks = $fromform->detailedmarks;
77             $pagesize = $fromform->pagesize;
79         } else {
80             $regradeall  = optional_param('regradeall', 0, PARAM_BOOL);
81             $regradealldry  = optional_param('regradealldry', 0, PARAM_BOOL);
82             $regradealldrydo  = optional_param('regradealldrydo', 0, PARAM_BOOL);
83             $attemptsmode = optional_param('attemptsmode', null, PARAM_INT);
84             if ($qmsubselect) {
85                 $qmfilter = optional_param('qmfilter', 0, PARAM_INT);
86             } else {
87                 $qmfilter = 0;
88             }
89             $regradefilter = optional_param('regradefilter', 0, PARAM_INT);
90             $detailedmarks = get_user_preferences('quiz_report_overview_detailedmarks', 1);
91             $pagesize = get_user_preferences('quiz_report_pagesize', 0);
92         }
94         $this->validate_common_options($attemptsmode, $pagesize, $course, $currentgroup);
95         $displayoptions = array();
96         $displayoptions['attemptsmode'] = $attemptsmode;
97         $displayoptions['qmfilter'] = $qmfilter;
98         $displayoptions['regradefilter'] = $regradefilter;
100         $mform->set_data($displayoptions +
101                 array('detailedmarks' => $detailedmarks, 'pagesize' => $pagesize));
103         if (!$this->should_show_grades($quiz)) {
104             $detailedmarks = 0;
105         }
107         // We only want to show the checkbox to delete attempts
108         // if the user has permissions and if the report mode is showing attempts.
109         $includecheckboxes = has_any_capability(
110                 array('mod/quiz:regrade', 'mod/quiz:deleteattempts'), $this->context)
111                 && ($attemptsmode != QUIZ_REPORT_ATTEMPTS_STUDENTS_WITH_NO);
113         if ($attemptsmode == QUIZ_REPORT_ATTEMPTS_ALL) {
114             // This option is only available to users who can access all groups in
115             // groups mode, so setting allowed to empty (which means all quiz attempts
116             // are accessible, is not a security porblem.
117             $allowed = array();
118         }
120         $coursecontext = get_context_instance(CONTEXT_COURSE, $course->id);
121         $courseshortname = format_string($course->shortname, true,
122                 array('context' => $coursecontext));
124         $displaycoursecontext = get_context_instance(CONTEXT_COURSE, $COURSE->id);
125         $displaycourseshortname = format_string($COURSE->shortname, true,
126                 array('context' => $displaycoursecontext));
128         // Load the required questions.
129         $questions = quiz_report_get_significant_questions($quiz);
131         $table = new quiz_report_overview_table($quiz, $this->context, $qmsubselect,
132                 $qmfilter, $attemptsmode, $groupstudents, $students, $detailedmarks,
133                 $questions, $includecheckboxes, $reporturl, $displayoptions);
134         $filename = quiz_report_download_filename(get_string('overviewfilename', 'quiz_overview'),
135                 $courseshortname, $quiz->name);
136         $table->is_downloading($download, $filename,
137                 $displaycourseshortname . ' ' . format_string($quiz->name, true));
138         if ($table->is_downloading()) {
139             raise_memory_limit(MEMORY_EXTRA);
140         }
142         // Process actions.
143         if (empty($currentgroup) || $groupstudents) {
144             if (optional_param('delete', 0, PARAM_BOOL) && confirm_sesskey()) {
145                 if ($attemptids = optional_param_array('attemptid', array(), PARAM_INT)) {
146                     require_capability('mod/quiz:deleteattempts', $this->context);
147                     $this->delete_selected_attempts($quiz, $cm, $attemptids, $allowed);
148                     redirect($reporturl->out(false, $displayoptions));
149                 }
151             } else if (optional_param('regrade', 0, PARAM_BOOL) && confirm_sesskey()) {
152                 if ($attemptids = optional_param_array('attemptid', array(), PARAM_INT)) {
153                     require_capability('mod/quiz:regrade', $this->context);
154                     $this->regrade_attempts($quiz, false, $groupstudents, $attemptids);
155                     redirect($reporturl->out(false, $displayoptions));
156                 }
157             }
158         }
160         if ($regradeall && confirm_sesskey()) {
161             require_capability('mod/quiz:regrade', $this->context);
162             $this->regrade_attempts($quiz, false, $groupstudents);
163             redirect($reporturl->out(false, $displayoptions), '', 5);
165         } else if ($regradealldry && confirm_sesskey()) {
166             require_capability('mod/quiz:regrade', $this->context);
167             $this->regrade_attempts($quiz, true, $groupstudents);
168             redirect($reporturl->out(false, $displayoptions), '', 5);
170         } else if ($regradealldrydo && confirm_sesskey()) {
171             require_capability('mod/quiz:regrade', $this->context);
172             $this->regrade_attempts_needing_it($quiz, $groupstudents);
173             redirect($reporturl->out(false, $displayoptions), '', 5);
174         }
176         // Start output.
177         if (!$table->is_downloading()) {
178             // Only print headers if not asked to download data
179             $this->print_header_and_tabs($cm, $course, $quiz, 'overview');
180         }
182         if ($groupmode = groups_get_activity_groupmode($cm)) {   // Groups are being used
183             if (!$table->is_downloading()) {
184                 groups_print_activity_menu($cm, $reporturl->out(true, $displayoptions));
185             }
186         }
188         // Print information on the number of existing attempts
189         if (!$table->is_downloading()) { //do not print notices when downloading
190             if ($strattemptnum = quiz_num_attempt_summary($quiz, $cm, true, $currentgroup)) {
191                 echo '<div class="quizattemptcounts">' . $strattemptnum . '</div>';
192             }
193         }
195         $hasquestions = quiz_questions_in_quiz($quiz->questions);
196         if (!$table->is_downloading()) {
197             if (!$hasquestions) {
198                 echo quiz_no_questions_message($quiz, $cm, $this->context);
199             } else if (!$students) {
200                 echo $OUTPUT->notification(get_string('nostudentsyet'));
201             } else if ($currentgroup && !$groupstudents) {
202                 echo $OUTPUT->notification(get_string('nostudentsingroup'));
203             }
205             // Print display options
206             $mform->display();
207         }
209         $hasstudents = $students && (!$currentgroup || $groupstudents);
210         if ($hasquestions && ($hasstudents || ($attemptsmode == QUIZ_REPORT_ATTEMPTS_ALL))) {
211             // Construct the SQL
212             $fields = $DB->sql_concat('u.id', "'#'", 'COALESCE(quiza.attempt, 0)') .
213                     ' AS uniqueid, ';
214             if ($qmsubselect) {
215                 $fields .=
216                     "(CASE " .
217                     "   WHEN $qmsubselect THEN 1" .
218                     "   ELSE 0 " .
219                     "END) AS gradedattempt, ";
220             }
222             list($fields, $from, $where, $params) = $table->base_sql($allowed);
224             $table->set_count_sql("SELECT COUNT(1) FROM $from WHERE $where", $params);
226             // Test to see if there are any regraded attempts to be listed.
227             $fields .= ", COALESCE((
228                                 SELECT MAX(qqr.regraded)
229                                   FROM {quiz_overview_regrades} qqr
230                                  WHERE qqr.questionusageid = quiza.uniqueid
231                           ), -1) AS regraded";
232             if ($regradefilter) {
233                 $where .= " AND COALESCE((
234                                     SELECT MAX(qqr.regraded)
235                                       FROM {quiz_overview_regrades} qqr
236                                      WHERE qqr.questionusageid = quiza.uniqueid
237                                 ), -1) <> -1";
238             }
239             $table->set_sql($fields, $from, $where, $params);
241             if (!$table->is_downloading()) {
242                 // Regrade buttons
243                 if (has_capability('mod/quiz:regrade', $this->context)) {
244                     $regradesneeded = $this->count_question_attempts_needing_regrade(
245                             $quiz, $groupstudents);
246                     if ($currentgroup) {
247                         $a= new stdClass();
248                         $a->groupname = groups_get_group_name($currentgroup);
249                         $a->coursestudents = get_string('participants');
250                         $a->countregradeneeded = $regradesneeded;
251                         $regradealldrydolabel =
252                                 get_string('regradealldrydogroup', 'quiz_overview', $a);
253                         $regradealldrylabel =
254                                 get_string('regradealldrygroup', 'quiz_overview', $a);
255                         $regradealllabel =
256                                 get_string('regradeallgroup', 'quiz_overview', $a);
257                     } else {
258                         $regradealldrydolabel =
259                                 get_string('regradealldrydo', 'quiz_overview', $regradesneeded);
260                         $regradealldrylabel =
261                                 get_string('regradealldry', 'quiz_overview');
262                         $regradealllabel =
263                                 get_string('regradeall', 'quiz_overview');
264                     }
265                     $displayurl = new moodle_url($reporturl,
266                             $displayoptions + array('sesskey' => sesskey()));
267                     echo '<div class="mdl-align">';
268                     echo '<form action="'.$displayurl->out_omit_querystring().'">';
269                     echo '<div>';
270                     echo html_writer::input_hidden_params($displayurl);
271                     echo '<input type="submit" name="regradeall" value="'.$regradealllabel.'"/>';
272                     echo '<input type="submit" name="regradealldry" value="' .
273                             $regradealldrylabel . '"/>';
274                     if ($regradesneeded) {
275                         echo '<input type="submit" name="regradealldrydo" value="' .
276                                 $regradealldrydolabel . '"/>';
277                     }
278                     echo '</div>';
279                     echo '</form>';
280                     echo '</div>';
281                 }
282                 // Print information on the grading method
283                 if ($strattempthighlight = quiz_report_highlighting_grading_method(
284                         $quiz, $qmsubselect, $qmfilter)) {
285                     echo '<div class="quizattemptcounts">' . $strattempthighlight . '</div>';
286                 }
287             }
289             // Define table columns
290             $columns = array();
291             $headers = array();
293             if (!$table->is_downloading() && $includecheckboxes) {
294                 $columns[] = 'checkbox';
295                 $headers[] = null;
296             }
298             $this->add_user_columns($table, $columns, $headers);
300             $this->add_time_columns($columns, $headers);
302             if ($detailedmarks) {
303                 foreach ($questions as $slot => $question) {
304                     // Ignore questions of zero length
305                     $columns[] = 'qsgrade' . $slot;
306                     $header = get_string('qbrief', 'quiz', $question->number);
307                     if (!$table->is_downloading()) {
308                         $header .= '<br />';
309                     } else {
310                         $header .= ' ';
311                     }
312                     $header .= '/' . quiz_rescale_grade($question->maxmark, $quiz, 'question');
313                     $headers[] = $header;
314                 }
315             }
317             if (!$table->is_downloading() && has_capability('mod/quiz:regrade', $this->context) &&
318                     $this->has_regraded_questions($from, $where, $params)) {
319                 $columns[] = 'regraded';
320                 $headers[] = get_string('regrade', 'quiz_overview');
321             }
323             $this->add_grade_columns($quiz, $columns, $headers, false);
325             $this->set_up_table_columns(
326                     $table, $columns, $headers, $reporturl, $displayoptions, false);
327             $table->set_attribute('class', 'generaltable generalbox grades');
329             $table->out($pagesize, true);
330         }
332         if (!$table->is_downloading() && $this->should_show_grades($quiz)) {
333             if ($currentgroup && $groupstudents) {
334                 list($usql, $params) = $DB->get_in_or_equal($groupstudents);
335                 $params[] = $quiz->id;
336                 if ($DB->record_exists_select('quiz_grades', "userid $usql AND quiz = ?",
337                         $params)) {
338                      $imageurl = new moodle_url('/mod/quiz/report/overview/overviewgraph.php',
339                             array('id' => $quiz->id, 'groupid' => $currentgroup));
340                      $graphname = get_string('overviewreportgraphgroup', 'quiz_overview',
341                             groups_get_group_name($currentgroup));
342                      echo $OUTPUT->heading($graphname);
343                      echo html_writer::tag('div', html_writer::empty_tag('img',
344                             array('src' => $imageurl, 'alt' => $graphname)),
345                             array('class' => 'graph'));
346                 }
347             }
349             if ($DB->record_exists('quiz_grades', array('quiz'=> $quiz->id))) {
350                  $graphname = get_string('overviewreportgraph', 'quiz_overview');
351                  $imageurl = new moodle_url('/mod/quiz/report/overview/overviewgraph.php',
352                         array('id' => $quiz->id));
353                  echo $OUTPUT->heading($graphname);
354                  echo html_writer::tag('div', html_writer::empty_tag('img',
355                         array('src' => $imageurl, 'alt' => $graphname)),
356                         array('class' => 'graph'));
357             }
358         }
359         return true;
360     }
362     /**
363      * Regrade a particular quiz attempt. Either for real ($dryrun = false), or
364      * as a pretend regrade to see which fractions would change. The outcome is
365      * stored in the quiz_overview_regrades table.
366      *
367      * Note, $attempt is not upgraded in the database. The caller needs to do that.
368      * However, $attempt->sumgrades is updated, if this is not a dry run.
369      *
370      * @param object $attempt the quiz attempt to regrade.
371      * @param bool $dryrun if true, do a pretend regrade, otherwise do it for real.
372      * @param array $slots if null, regrade all questions, otherwise, just regrade
373      *      the quetsions with those slots.
374      */
375     protected function regrade_attempt($attempt, $dryrun = false, $slots = null) {
376         global $DB;
377         set_time_limit(30);
379         $transaction = $DB->start_delegated_transaction();
381         $quba = question_engine::load_questions_usage_by_activity($attempt->uniqueid);
383         if (is_null($slots)) {
384             $slots = $quba->get_slots();
385         }
387         $finished = $attempt->timefinish > 0;
388         foreach ($slots as $slot) {
389             $qqr = new stdClass();
390             $qqr->oldfraction = $quba->get_question_fraction($slot);
392             $quba->regrade_question($slot, $finished);
394             $qqr->newfraction = $quba->get_question_fraction($slot);
396             if (abs($qqr->oldfraction - $qqr->newfraction) > 1e-7) {
397                 $qqr->questionusageid = $quba->get_id();
398                 $qqr->slot = $slot;
399                 $qqr->regraded = empty($dryrun);
400                 $qqr->timemodified = time();
401                 $DB->insert_record('quiz_overview_regrades', $qqr, false);
402             }
403         }
405         if (!$dryrun) {
406             question_engine::save_questions_usage_by_activity($quba);
407         }
409         $transaction->allow_commit();
411         // Really, PHP should not need this hint, but without this, we just run out of memory.
412         $quba = null;
413         $transaction = null;
414         gc_collect_cycles();
415     }
417     /**
418      * Regrade attempts for this quiz, exactly which attempts are regraded is
419      * controlled by the parameters.
420      * @param object $quiz the quiz settings.
421      * @param bool $dryrun if true, do a pretend regrade, otherwise do it for real.
422      * @param array $groupstudents blank for all attempts, otherwise regrade attempts
423      * for these users.
424      * @param array $attemptids blank for all attempts, otherwise only regrade
425      * attempts whose id is in this list.
426      */
427     protected function regrade_attempts($quiz, $dryrun = false,
428             $groupstudents = array(), $attemptids = array()) {
429         global $DB;
431         $where = "quiz = ? AND preview = 0";
432         $params = array($quiz->id);
434         if ($groupstudents) {
435             list($usql, $uparams) = $DB->get_in_or_equal($groupstudents);
436             $where .= " AND userid $usql";
437             $params = array_merge($params, $uparams);
438         }
440         if ($attemptids) {
441             list($asql, $aparams) = $DB->get_in_or_equal($attemptids);
442             $where .= " AND id $asql";
443             $params = array_merge($params, $aparams);
444         }
446         $attempts = $DB->get_records_select('quiz_attempts', $where, $params);
447         if (!$attempts) {
448             return;
449         }
451         $this->clear_regrade_table($quiz, $groupstudents);
453         foreach ($attempts as $attempt) {
454             $this->regrade_attempt($attempt, $dryrun);
455         }
457         if (!$dryrun) {
458             $this->update_overall_grades($quiz);
459         }
460     }
462     /**
463      * Regrade those questions in those attempts that are marked as needing regrading
464      * in the quiz_overview_regrades table.
465      * @param object $quiz the quiz settings.
466      * @param array $groupstudents blank for all attempts, otherwise regrade attempts
467      * for these users.
468      */
469     protected function regrade_attempts_needing_it($quiz, $groupstudents) {
470         global $DB;
472         $where = "quiza.quiz = ? AND quiza.preview = 0 AND qqr.regraded = 0";
473         $params = array($quiz->id);
475         // Fetch all attempts that need regrading
476         if ($groupstudents) {
477             list($usql, $uparams) = $DB->get_in_or_equal($groupstudents);
478             $where .= " AND quiza.userid $usql";
479             $params += $uparams;
480         }
482         $toregrade = $DB->get_records_sql("
483                 SELECT quiza.uniqueid, qqr.slot
484                 FROM {quiz_attempts} quiza
485                 JOIN {quiz_overview_regrades} qqr ON qqr.questionusageid = quiza.uniqueid
486                 WHERE $where", $params);
488         if (!$toregrade) {
489             return;
490         }
492         $attemptquestions = array();
493         foreach ($toregrade as $row) {
494             $attemptquestions[$row->uniqueid][] = $row->slot;
495         }
496         $attempts = $DB->get_records_list('quiz_attempts', 'uniqueid',
497                 array_keys($attemptquestions));
499         $this->clear_regrade_table($quiz, $groupstudents);
501         foreach ($attempts as $attempt) {
502             $this->regrade_attempt($attempt, false, $attemptquestions[$attempt->uniqueid]);
503         }
505         $this->update_overall_grades($quiz);
506     }
508     /**
509      * Count the number of attempts in need of a regrade.
510      * @param object $quiz the quiz settings.
511      * @param array $groupstudents user ids. If this is given, only data relating
512      * to these users is cleared.
513      */
514     protected function count_question_attempts_needing_regrade($quiz, $groupstudents) {
515         global $DB;
517         $usertest = '';
518         $params = array();
519         if ($groupstudents) {
520             list($usql, $params) = $DB->get_in_or_equal($groupstudents);
521             $usertest = "quiza.userid $usql AND ";
522         }
524         $params[] = $quiz->id;
525         $sql = "SELECT COUNT(DISTINCT quiza.id)
526                 FROM {quiz_attempts} quiza
527                 JOIN {quiz_overview_regrades} qqr ON quiza.uniqueid = qqr.questionusageid
528                 WHERE
529                     $usertest
530                     quiza.quiz = ? AND
531                     quiza.preview = 0 AND
532                     qqr.regraded = 0";
533         return $DB->count_records_sql($sql, $params);
534     }
536     /**
537      * Are there any pending regrades in the table we are going to show?
538      * @param string $from tables used by the main query.
539      * @param string $where where clause used by the main query.
540      * @param array $params required by the SQL.
541      * @return bool whether there are pending regrades.
542      */
543     protected function has_regraded_questions($from, $where, $params) {
544         global $DB;
545         $qubaids = new qubaid_join($from, 'uniqueid', $where, $params);
546         return $DB->record_exists_select('quiz_overview_regrades',
547                 'questionusageid ' . $qubaids->usage_id_in(),
548                 $qubaids->usage_id_in_params());
549     }
551     /**
552      * Remove all information about pending/complete regrades from the database.
553      * @param object $quiz the quiz settings.
554      * @param array $groupstudents user ids. If this is given, only data relating
555      * to these users is cleared.
556      */
557     protected function clear_regrade_table($quiz, $groupstudents) {
558         global $DB;
560         // Fetch all attempts that need regrading
561         $where = '';
562         $params = array();
563         if ($groupstudents) {
564             list($usql, $params) = $DB->get_in_or_equal($groupstudents);
565             $where = "userid $usql AND ";
566         }
568         $params[] = $quiz->id;
569         $DB->delete_records_select('quiz_overview_regrades',
570                 "questionusageid IN (
571                     SELECT uniqueid
572                     FROM {quiz_attempts}
573                     WHERE $where quiz = ?
574                 )", $params);
575     }
577     /**
578      * Update the final grades for all attempts. This method is used following
579      * a regrade.
580      * @param object $quiz the quiz settings.
581      * @param array $userids only update scores for these userids.
582      * @param array $attemptids attemptids only update scores for these attempt ids.
583      */
584     protected function update_overall_grades($quiz) {
585         quiz_update_all_attempt_sumgrades($quiz);
586         quiz_update_all_final_grades($quiz);
587         quiz_update_grades($quiz);
588     }