Merge branch 'MDL-42069' of https://github.com/appalachianstate/moodle
[moodle.git] / mod / quiz / report / grading / 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 manual grading report class.
19  *
20  * @package   quiz_grading
21  * @copyright 2006 Gustav Delius
22  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
26 defined('MOODLE_INTERNAL') || die();
28 require_once($CFG->dirroot . '/mod/quiz/report/grading/gradingsettings_form.php');
31 /**
32  * Quiz report to help teachers manually grade questions that need it.
33  *
34  * This report basically provides two screens:
35  * - List question that might need manual grading (or optionally all questions).
36  * - Provide an efficient UI to grade all attempts at a particular question.
37  *
38  * @copyright 2006 Gustav Delius
39  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
40  */
41 class quiz_grading_report extends quiz_default_report {
42     const DEFAULT_PAGE_SIZE = 5;
43     const DEFAULT_ORDER = 'random';
45     protected $viewoptions = array();
46     protected $questions;
47     protected $cm;
48     protected $quiz;
49     protected $context;
51     public function display($quiz, $cm, $course) {
52         global $CFG, $DB, $PAGE;
54         $this->quiz = $quiz;
55         $this->cm = $cm;
56         $this->course = $course;
58         // Get the URL options.
59         $slot = optional_param('slot', null, PARAM_INT);
60         $questionid = optional_param('qid', null, PARAM_INT);
61         $grade = optional_param('grade', null, PARAM_ALPHA);
63         $includeauto = optional_param('includeauto', false, PARAM_BOOL);
64         if (!in_array($grade, array('all', 'needsgrading', 'autograded', 'manuallygraded'))) {
65             $grade = null;
66         }
67         $pagesize = optional_param('pagesize', self::DEFAULT_PAGE_SIZE, PARAM_INT);
68         $page = optional_param('page', 0, PARAM_INT);
69         $order = optional_param('order', self::DEFAULT_ORDER, PARAM_ALPHA);
71         // Assemble the options requried to reload this page.
72         $optparams = array('includeauto', 'page');
73         foreach ($optparams as $param) {
74             if ($$param) {
75                 $this->viewoptions[$param] = $$param;
76             }
77         }
78         if ($pagesize != self::DEFAULT_PAGE_SIZE) {
79             $this->viewoptions['pagesize'] = $pagesize;
80         }
81         if ($order != self::DEFAULT_ORDER) {
82             $this->viewoptions['order'] = $order;
83         }
85         // Check permissions.
86         $this->context = context_module::instance($cm->id);
87         require_capability('mod/quiz:grade', $this->context);
88         $shownames = has_capability('quiz/grading:viewstudentnames', $this->context);
89         $showidnumbers = has_capability('quiz/grading:viewidnumber', $this->context);
91         // Validate order.
92         if (!in_array($order, array('random', 'date', 'studentfirstname', 'studentlastname', 'idnumber'))) {
93             $order = self::DEFAULT_ORDER;
94         } else if (!$shownames && ($order == 'studentfirstname' || $order == 'studentlastname')) {
95             $order = self::DEFAULT_ORDER;
96         } else if (!$showidnumbers && $order == 'idnumber') {
97             $order = self::DEFAULT_ORDER;
98         }
99         if ($order == 'random') {
100             $page = 0;
101         }
103         // Get the list of questions in this quiz.
104         $this->questions = quiz_report_get_significant_questions($quiz);
105         if ($slot && !array_key_exists($slot, $this->questions)) {
106             throw new moodle_exception('unknownquestion', 'quiz_grading');
107         }
109         // Process any submitted data.
110         if ($data = data_submitted() && confirm_sesskey() && $this->validate_submitted_marks()) {
111             $this->process_submitted_data();
113             redirect($this->grade_question_url($slot, $questionid, $grade, $page + 1));
114         }
116         // Get the group, and the list of significant users.
117         $this->currentgroup = $this->get_current_group($cm, $course, $this->context);
118         if ($this->currentgroup == self::NO_GROUPS_ALLOWED) {
119             $this->users = array();
120         } else {
121             $this->users = get_users_by_capability($this->context,
122                     array('mod/quiz:reviewmyattempts', 'mod/quiz:attempt'), '', '', '', '',
123                     $this->currentgroup, '', false);
124         }
126         $questionsinquiz = quiz_questions_in_quiz($quiz->questions);
127         $counts = null;
128         if ($slot && $questionsinquiz) {
129             // Make sure there is something to do.
130             $statecounts = $this->get_question_state_summary(array($slot));
131             foreach ($statecounts as $record) {
132                 if ($record->questionid == $questionid) {
133                     $counts = $record;
134                     break;
135                 }
136             }
137             // If not, redirect back to the list.
138             if (!$counts || $counts->$grade == 0) {
139                 redirect($this->list_questions_url(), get_string('alldoneredirecting', 'quiz_grading'));
140             }
141         }
143         // Start output.
144         $this->print_header_and_tabs($cm, $course, $quiz, 'grading');
146         // What sort of page to display?
147         if (!$questionsinquiz) {
148             echo quiz_no_questions_message($quiz, $cm, $this->context);
150         } else if (!$slot) {
151             $this->display_index($includeauto);
153         } else {
154             $this->display_grading_interface($slot, $questionid, $grade,
155                     $pagesize, $page, $shownames, $showidnumbers, $order, $counts);
156         }
157         return true;
158     }
160     protected function get_qubaids_condition() {
161         global $DB;
163         $where = "quiza.quiz = :mangrquizid AND
164                 quiza.preview = 0 AND
165                 quiza.state = :statefinished";
166         $params = array('mangrquizid' => $this->cm->instance, 'statefinished' => quiz_attempt::FINISHED);
168         $currentgroup = groups_get_activity_group($this->cm, true);
169         if ($currentgroup) {
170             $users = get_users_by_capability($this->context,
171                     array('mod/quiz:reviewmyattempts', 'mod/quiz:attempt'), 'u.id, u.id', '', '', '',
172                     $currentgroup, '', false);
173             if (empty($users)) {
174                 $where .= ' AND quiza.userid = 0';
175             } else {
176                 list($usql, $uparam) = $DB->get_in_or_equal(array_keys($users),
177                         SQL_PARAMS_NAMED, 'mangru');
178                 $where .= ' AND quiza.userid ' . $usql;
179                 $params += $uparam;
180             }
181         }
183         return new qubaid_join('{quiz_attempts} quiza', 'quiza.uniqueid', $where, $params);
184     }
186     protected function load_attempts_by_usage_ids($qubaids) {
187         global $DB;
189         list($asql, $params) = $DB->get_in_or_equal($qubaids);
190         $params[] = quiz_attempt::FINISHED;
191         $params[] = $this->quiz->id;
193         $attemptsbyid = $DB->get_records_sql("
194                 SELECT quiza.*, u.firstname, u.lastname, u.idnumber
195                 FROM {quiz_attempts} quiza
196                 JOIN {user} u ON u.id = quiza.userid
197                 WHERE quiza.uniqueid $asql AND quiza.state = ? AND quiza.quiz = ?",
198                 $params);
200         $attempts = array();
201         foreach ($attemptsbyid as $attempt) {
202             $attempts[$attempt->uniqueid] = $attempt;
203         }
204         return $attempts;
205     }
207     /**
208      * Get the URL of the front page of the report that lists all the questions.
209      * @param $includeauto if not given, use the current setting, otherwise,
210      *      force a paricular value of includeauto in the URL.
211      * @return string the URL.
212      */
213     protected function base_url() {
214         return new moodle_url('/mod/quiz/report.php',
215                 array('id' => $this->cm->id, 'mode' => 'grading'));
216     }
218     /**
219      * Get the URL of the front page of the report that lists all the questions.
220      * @param $includeauto if not given, use the current setting, otherwise,
221      *      force a paricular value of includeauto in the URL.
222      * @return string the URL.
223      */
224     protected function list_questions_url($includeauto = null) {
225         $url = $this->base_url();
227         $url->params($this->viewoptions);
229         if (!is_null($includeauto)) {
230             $url->param('includeauto', $includeauto);
231         }
233         return $url;
234     }
236     /**
237      * @param int $slot
238      * @param int $questionid
239      * @param string $grade
240      * @param mixed $page = true, link to current page. false = omit page.
241      *      number = link to specific page.
242      */
243     protected function grade_question_url($slot, $questionid, $grade, $page = true) {
244         $url = $this->base_url();
245         $url->params(array('slot' => $slot, 'qid' => $questionid, 'grade' => $grade));
246         $url->params($this->viewoptions);
248         $options = $this->viewoptions;
249         if (!$page) {
250             $url->remove_params('page');
251         } else if (is_integer($page)) {
252             $url->param('page', $page);
253         }
255         return $url;
256     }
258     protected function format_count_for_table($counts, $type, $gradestring) {
259         $result = $counts->$type;
260         if ($counts->$type > 0) {
261             $result .= ' ' . html_writer::link($this->grade_question_url(
262                     $counts->slot, $counts->questionid, $type),
263                     get_string($gradestring, 'quiz_grading'),
264                     array('class' => 'gradetheselink'));
265         }
266         return $result;
267     }
269     protected function display_index($includeauto) {
270         global $OUTPUT;
272         if ($groupmode = groups_get_activity_groupmode($this->cm)) {
273             // Groups is being used.
274             groups_print_activity_menu($this->cm, $this->list_questions_url());
275         }
277         echo $OUTPUT->heading(get_string('questionsthatneedgrading', 'quiz_grading'));
278         if ($includeauto) {
279             $linktext = get_string('hideautomaticallygraded', 'quiz_grading');
280         } else {
281             $linktext = get_string('alsoshowautomaticallygraded', 'quiz_grading');
282         }
283         echo html_writer::tag('p', html_writer::link($this->list_questions_url(!$includeauto),
284                 $linktext), array('class' => 'toggleincludeauto'));
286         $statecounts = $this->get_question_state_summary(array_keys($this->questions));
288         $data = array();
289         foreach ($statecounts as $counts) {
290             if ($counts->all == 0) {
291                 continue;
292             }
293             if (!$includeauto && $counts->needsgrading == 0 && $counts->manuallygraded == 0) {
294                 continue;
295             }
297             $row = array();
299             $row[] = $this->questions[$counts->slot]->number;
301             $row[] = format_string($counts->name);
303             $row[] = $this->format_count_for_table($counts, 'needsgrading', 'grade');
305             $row[] = $this->format_count_for_table($counts, 'manuallygraded', 'updategrade');
307             if ($includeauto) {
308                 $row[] = $this->format_count_for_table($counts, 'autograded', 'updategrade');
309             }
311             $row[] = $this->format_count_for_table($counts, 'all', 'gradeall');
313             $data[] = $row;
314         }
316         if (empty($data)) {
317             echo $OUTPUT->heading(get_string('nothingfound', 'quiz_grading'));
318             return;
319         }
321         $table = new html_table();
322         $table->class = 'generaltable';
323         $table->id = 'questionstograde';
325         $table->head[] = get_string('qno', 'quiz_grading');
326         $table->head[] = get_string('questionname', 'quiz_grading');
327         $table->head[] = get_string('tograde', 'quiz_grading');
328         $table->head[] = get_string('alreadygraded', 'quiz_grading');
329         if ($includeauto) {
330             $table->head[] = get_string('automaticallygraded', 'quiz_grading');
331         }
332         $table->head[] = get_string('total', 'quiz_grading');
334         $table->data = $data;
335         echo html_writer::table($table);
336     }
338     protected function display_grading_interface($slot, $questionid, $grade,
339             $pagesize, $page, $shownames, $showidnumbers, $order, $counts) {
340         global $OUTPUT;
342         if ($pagesize * $page >= $counts->$grade) {
343             $page = 0;
344         }
346         list($qubaids, $count) = $this->get_usage_ids_where_question_in_state(
347                 $grade, $slot, $questionid, $order, $page, $pagesize);
348         $attempts = $this->load_attempts_by_usage_ids($qubaids);
350         // Prepare the form.
351         $hidden = array(
352             'id' => $this->cm->id,
353             'mode' => 'grading',
354             'slot' => $slot,
355             'qid' => $questionid,
356             'page' => $page,
357         );
358         if (array_key_exists('includeauto', $this->viewoptions)) {
359             $hidden['includeauto'] = $this->viewoptions['includeauto'];
360         }
361         $mform = new quiz_grading_settings_form($hidden, $counts, $shownames, $showidnumbers);
363         // Tell the form the current settings.
364         $settings = new stdClass();
365         $settings->grade = $grade;
366         $settings->pagesize = $pagesize;
367         $settings->order = $order;
368         $mform->set_data($settings);
370         // Print the heading and form.
371         echo question_engine::initialise_js();
373         $a = new stdClass();
374         $a->number = $this->questions[$slot]->number;
375         $a->questionname = format_string($counts->name);
376         echo $OUTPUT->heading(get_string('gradingquestionx', 'quiz_grading', $a));
377         echo html_writer::tag('p', html_writer::link($this->list_questions_url(),
378                 get_string('backtothelistofquestions', 'quiz_grading')),
379                 array('class' => 'mdl-align'));
381         $mform->display();
383         // Paging info.
384         $a = new stdClass();
385         $a->from = $page * $pagesize + 1;
386         $a->to = min(($page + 1) * $pagesize, $count);
387         $a->of = $count;
388         echo $OUTPUT->heading(get_string('gradingattemptsxtoyofz', 'quiz_grading', $a), 3);
390         if ($count > $pagesize && $order != 'random') {
391             echo $OUTPUT->paging_bar($count, $page, $pagesize,
392                     $this->grade_question_url($slot, $questionid, $grade, false));
393         }
395         // Display the form with one section for each attempt.
396         $sesskey = sesskey();
397         $qubaidlist = implode(',', $qubaids);
398         echo html_writer::start_tag('form', array('method' => 'post',
399                 'action' => $this->grade_question_url($slot, $questionid, $grade, $page),
400                 'class' => 'mform', 'id' => 'manualgradingform')) .
401                 html_writer::start_tag('div') .
402                 html_writer::input_hidden_params(new moodle_url('', array(
403                 'qubaids' => $qubaidlist, 'slots' => $slot, 'sesskey' => $sesskey)));
405         foreach ($qubaids as $qubaid) {
406             $attempt = $attempts[$qubaid];
407             $quba = question_engine::load_questions_usage_by_activity($qubaid);
408             $displayoptions = quiz_get_review_options($this->quiz, $attempt, $this->context);
409             $displayoptions->hide_all_feedback();
410             $displayoptions->history = question_display_options::HIDDEN;
411             $displayoptions->manualcomment = question_display_options::EDITABLE;
413             $heading = $this->get_question_heading($attempt, $shownames, $showidnumbers);
414             if ($heading) {
415                 echo $OUTPUT->heading($heading, 4);
416             }
417             echo $quba->render_question($slot, $displayoptions, $this->questions[$slot]->number);
418         }
420         echo html_writer::tag('div', html_writer::empty_tag('input', array(
421                 'type' => 'submit', 'value' => get_string('saveandnext', 'quiz_grading'))),
422                 array('class' => 'mdl-align')) .
423                 html_writer::end_tag('div') . html_writer::end_tag('form');
424     }
426     protected function get_question_heading($attempt, $shownames, $showidnumbers) {
427         $a = new stdClass();
428         $a->attempt = $attempt->attempt;
429         $a->fullname = fullname($attempt);
430         $a->idnumber = $attempt->idnumber;
432         $showidnumbers &= !empty($attempt->idnumber);
434         if ($shownames && $showidnumbers) {
435             return get_string('gradingattemptwithidnumber', 'quiz_grading', $a);
436         } else if ($shownames) {
437             return get_string('gradingattempt', 'quiz_grading', $a);
438         } else if ($showidnumbers) {
439             $a->fullname = $attempt->idnumber;
440             return get_string('gradingattempt', 'quiz_grading', $a);
441         } else {
442             return '';
443         }
444     }
446     protected function validate_submitted_marks() {
448         $qubaids = optional_param('qubaids', null, PARAM_SEQUENCE);
449         if (!$qubaids) {
450             return false;
451         }
452         $qubaids = clean_param_array(explode(',', $qubaids), PARAM_INT);
454         $slots = optional_param('slots', '', PARAM_SEQUENCE);
455         if (!$slots) {
456             $slots = array();
457         } else {
458             $slots = explode(',', $slots);
459         }
461         foreach ($qubaids as $qubaid) {
462             foreach ($slots as $slot) {
463                 if (!question_engine::is_manual_grade_in_range($qubaid, $slot)) {
464                     return false;
465                 }
466             }
467         }
469         return true;
470     }
472     protected function process_submitted_data() {
473         global $DB;
475         $qubaids = optional_param('qubaids', null, PARAM_SEQUENCE);
476         if (!$qubaids) {
477             return;
478         }
480         $qubaids = clean_param_array(explode(',', $qubaids), PARAM_INT);
481         $attempts = $this->load_attempts_by_usage_ids($qubaids);
483         $transaction = $DB->start_delegated_transaction();
484         foreach ($qubaids as $qubaid) {
485             $attempt = $attempts[$qubaid];
486             $attemptobj = new quiz_attempt($attempt, $this->quiz, $this->cm, $this->course);
487             $attemptobj->process_submitted_actions(time());
488         }
489         $transaction->allow_commit();
490     }
492     /**
493      * Load information about the number of attempts at various questions in each
494      * summarystate.
495      *
496      * The results are returned as an two dimensional array $qubaid => $slot => $dataobject
497      *
498      * @param array $slots A list of slots for the questions you want to konw about.
499      * @return array The array keys are slot,qestionid. The values are objects with
500      * fields $slot, $questionid, $inprogress, $name, $needsgrading, $autograded,
501      * $manuallygraded and $all.
502      */
503     protected function get_question_state_summary($slots) {
504         $dm = new question_engine_data_mapper();
505         return $dm->load_questions_usages_question_state_summary(
506                 $this->get_qubaids_condition(), $slots);
507     }
509     /**
510      * Get a list of usage ids where the question with slot $slot, and optionally
511      * also with question id $questionid, is in summary state $summarystate. Also
512      * return the total count of such states.
513      *
514      * Only a subset of the ids can be returned by using $orderby, $limitfrom and
515      * $limitnum. A special value 'random' can be passed as $orderby, in which case
516      * $limitfrom is ignored.
517      *
518      * @param int $slot The slot for the questions you want to konw about.
519      * @param int $questionid (optional) Only return attempts that were of this specific question.
520      * @param string $summarystate 'all', 'needsgrading', 'autograded' or 'manuallygraded'.
521      * @param string $orderby 'random', 'date', 'student' or 'idnumber'.
522      * @param int $page implements paging of the results.
523      *      Ignored if $orderby = random or $pagesize is null.
524      * @param int $pagesize implements paging of the results. null = all.
525      */
526     protected function get_usage_ids_where_question_in_state($summarystate, $slot,
527             $questionid = null, $orderby = 'random', $page = 0, $pagesize = null) {
528         global $CFG, $DB;
529         $dm = new question_engine_data_mapper();
531         if ($pagesize && $orderby != 'random') {
532             $limitfrom = $page * $pagesize;
533         } else {
534             $limitfrom = 0;
535         }
537         $qubaids = $this->get_qubaids_condition();
539         $params = array();
540         if ($orderby == 'date') {
541             list($statetest, $params) = $dm->in_summary_state_test(
542                     'manuallygraded', false, 'mangrstate');
543             $orderby = "(
544                     SELECT MAX(sortqas.timecreated)
545                     FROM {question_attempt_steps} sortqas
546                     WHERE sortqas.questionattemptid = qa.id
547                         AND sortqas.state $statetest
548                     )";
549         } else if ($orderby == 'studentfirstname' || $orderby == 'studentlastname' || $orderby == 'idnumber') {
550             $qubaids->from .= " JOIN {user} u ON quiza.userid = u.id ";
551             // For name sorting, map orderby form value to
552             // actual column names; 'idnumber' maps naturally
553             switch ($orderby) {
554                 case "studentlastname":
555                     $orderby = "u.lastname, u.firstname";
556                     break;
557                 case "studentfirstname":
558                     $orderby = "u.firstname, u.lastname";
559                     break;
560             }
561         }
563         return $dm->load_questions_usages_where_question_in_state($qubaids, $summarystate,
564                 $slot, $questionid, $orderby, $params, $limitfrom, $pagesize);
565     }