<?php // This file is part of Stack - http://stack.maths.ed.ac.uk/ // // Stack is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Stack is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with STACK. If not, see <http://www.gnu.org/licenses/>. /** * This script lets the user see what attempts have been made at this question. * * The script loops over summarise_response data from the database, and does not * re-generate reports. The script is designed to let a question author improve feedback * and assessment by looking at what students type, easily and without going through a quiz report. * * @copyright 2020 the University of Edinburgh * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ require_once(__DIR__.'/../../../config.php'); require_once($CFG->libdir . '/questionlib.php'); require_once(__DIR__ . '/vle_specific.php'); // Get the parameters from the URL. $questionid = required_param('questionid', PARAM_INT); // Load the necessary data. $questiondata = question_bank::load_question_data($questionid); if (!$questiondata) { throw new stack_exception('questiondoesnotexist'); } $question = question_bank::load_question($questionid); // Process any other URL parameters, and do require_login. list($context, $seed, $urlparams) = qtype_stack_setup_question_test_page($question); // Check permissions. question_require_capability_on($questiondata, 'view'); $canedit = question_has_capability_on($questiondata, 'edit'); // Initialise $PAGE. $PAGE->set_url('/question/type/stack/questiontestreport.php', $urlparams); $title = stack_string('basicquestionreport'); $PAGE->set_title($title); $PAGE->set_heading($title); $PAGE->set_pagelayout('popup'); $testquestionlink = new moodle_url('/question/type/stack/questiontestrun.php', $urlparams); require_login(); // Start output. echo $OUTPUT->header(); $renderer = $PAGE->get_renderer('qtype_stack'); echo $OUTPUT->heading($question->name, 2); // Link back to question tests. $out = html_writer::link($testquestionlink, stack_string('runquestiontests'), array('target' => '_blank')); // If question has no random variants. if (empty($question->deployedseeds)) { if ($question->has_random_variants()) { $out .= ' ' . stack_string('questionnotdeployedyet'); } } if (stack_determine_moodle_version() < 400) { $qurl = question_preview_url($questionid, null, null, null, null, $context); } else { $qurl = qbank_previewquestion\helper::question_preview_url($questionid, null, null, null, null, $context); } echo html_writer::tag('p', $out . ' ' . $OUTPUT->action_icon($qurl, new pix_icon('t/preview', get_string('preview')))); // Display a representation of the question, variables and PRTs for easy reference. echo $OUTPUT->heading(stack_string('questiontext'), 3); echo html_writer::tag('pre', $question->questiontext, array('class' => 'questiontext')); $vars = $question->questionvariables; if ($vars != '') { $vars = trim($vars) . "\n\n"; } $inputdisplay = array($vars); foreach ($question->inputs as $inputname => $input) { $vars .= $inputname . ':' . $input->get_teacher_answer() . ";\n"; } $maxima = html_writer::start_tag('div', array('class' => 'questionvariables')); $maxima .= html_writer::tag('pre', s(trim($vars))); $maxima .= html_writer::end_tag('div'); echo $maxima; flush(); // Later we only display inputs relevant to a particular PTR, so we sort out prt input requirements here. $inputsbyprt = $question->get_cached('required'); $params = [$questionid]; $query = "SELECT qa.*, qas_last.* FROM {question_attempts} qa LEFT JOIN {question_attempt_steps} qas_last ON qas_last.questionattemptid = qa.id /* attach another copy of qas to those rows with the most recent timecreated, using method from https://stackoverflow.com/a/28090544 */ LEFT JOIN {question_attempt_steps} qas_prev ON qas_last.questionattemptid = qas_prev.questionattemptid AND (qas_last.sequencenumber < qas_prev.sequencenumber OR (qas_last.sequencenumber = qas_prev.sequencenumber AND qas_last.id < qas_prev.id)) LEFT JOIN {user} u ON qas_last.userid = u.id WHERE qas_prev.timecreated IS NULL"; if (stack_determine_moodle_version() < 400) { $query .= " AND qa.questionid = ?"; } else { // In moodle 4 we look at all attempts at all versions. // Otherwise an edit, regrade and re-analysis becomes impossible. $query .= " AND qa.questionid IN ( SELECT qv.questionid FROM {question_versions} qv_original JOIN {question_versions} qv ON qv.questionbankentryid = qv_original.questionbankentryid WHERE qv_original.questionid = ?)"; } $query .= " ORDER BY u.username, qas_last.timecreated"; global $DB; $result = $DB->get_records_sql($query, $params); $summary = array(); foreach ($result as $qattempt) { if (!array_key_exists($qattempt->variant, $summary)) { $summary[$qattempt->variant] = array(); } $rsummary = trim($qattempt->responsesummary); if ($rsummary !== '') { if (array_key_exists($rsummary, $summary[$qattempt->variant])) { $summary[$qattempt->variant][$rsummary] += 1; } else { $summary[$qattempt->variant][$rsummary] = 1; } } } foreach ($summary as $vkey => $variant) { arsort($variant); $summary[$vkey] = $variant; } // Match up variants to answer notes. $questionnotes = array(); $questionseeds = array(); foreach (array_keys($summary) as $variant) { $questionnotes[$variant] = $variant; $question = question_bank::load_question($questionid); $question->start_attempt(new question_attempt_step(), $variant); $questionseeds[$variant] = $question->seed; $notesummary = $question->get_question_summary(); // TODO check for duplicate notes. $questionnotes[$variant] = stack_ouput_castext($notesummary); } // Create blank arrays in which to store data. $qinputs = array_flip(array_keys($question->inputs)); foreach ($qinputs as $key => $val) { $qinputs[$key] = array('score' => array(), 'valid' => array(), 'invalid' => array(), 'other' => array()); } $inputreport = array(); // The inputreportsummary is used to store inputs, regardless of variant. // Multi-part questions may have inputs which are not subject to randomisation. $inputreportsummary = $qinputs; $inputtotals = array(); $qprts = array_flip(array_keys($question->prts)); foreach ($qprts as $key => $notused) { $qprts[$key] = array(); } $prtreport = array(); $prtreportinputs = array(); // Create a summary of the data without different variants. $prtreportsummary = array(); foreach ($summary as $variant => $vdata) { $inputreport[$variant] = $qinputs; $prtreport[$variant] = $qprts; $prtreportinputs[$variant] = $qprts; foreach ($vdata as $attemptsummary => $num) { $inputvals = array(); $rawdat = explode(';', $attemptsummary); foreach ($rawdat as $data) { $data = trim($data); foreach ($qinputs as $input => $notused) { if (substr($data, 0, strlen($input . ':')) === $input . ':') { // Tidy up inputs by (i) trimming status and whitespace, and (2) removing input name. $datas = trim(substr($data, strlen($input . ':'))); $status = 'other'; if (strpos($datas, '[score]') !== false) { $status = 'score'; $datas = trim(substr($datas, 0, -7)); } else if (strpos($datas, '[valid]') !== false) { $status = 'valid'; $datas = trim(substr($datas, 0, -7)); } else if (strpos($datas, '[invalid]') !== false) { $status = 'invalid'; $datas = trim(substr($datas, 0, -9)); } // Reconstruct input string but whitespace is trimmed. $inputvals[$input] = $input . ':' . $datas; // Add data. if (array_key_exists($datas, $inputreport[$variant][$input][$status])) { $inputreport[$variant][$input][$status][$datas] += (int) $num; } else { $inputreport[$variant][$input][$status][$datas] = $num; } if (array_key_exists($datas, $inputreportsummary[$input][$status])) { $inputreportsummary[$input][$status][$datas] += (int) $num; } else { $inputreportsummary[$input][$status][$datas] = $num; } // Count the total numbers in this array. if (array_key_exists($input, $inputtotals)) { $inputtotals[$input] += (int) $num; } else { $inputtotals[$input] = $num; } } } foreach ($qprts as $prt => $notused) { // Only create an input summary of the inputs required for this PRT. $inputsummary = ''; foreach ($inputsbyprt[$prt] as $input => $alsonotused) { if (array_key_exists($input, $inputvals)) { $inputsummary .= $inputvals[$input] . '; '; } } if (substr($data, 0, strlen($prt . ':')) === $prt . ':') { $datas = trim(substr($data, strlen($prt . ':'))); if (array_key_exists($datas, $prtreport[$variant][$prt])) { $prtreport[$variant][$prt][$datas] += (int) $num; if (array_key_exists($inputsummary, $prtreportinputs[$variant][$prt][$datas])) { $prtreportinputs[$variant][$prt][$datas][$inputsummary] += (int) $num; } else { $prtreportinputs[$variant][$prt][$datas][$inputsummary] = (int) $num; } } else { $prtreport[$variant][$prt][$datas] = $num; $prtreportinputs[$variant][$prt][$datas] = array($inputsummary => (int) $num); } if (!array_key_exists($prt, $prtreportsummary)) { $prtreportsummary[$prt] = array(); } if (array_key_exists($datas, $prtreportsummary[$prt])) { $prtreportsummary[$prt][$datas] += (int) $num; } else { $prtreportsummary[$prt][$datas] = $num; } } } } } } // Sort the values. foreach ($inputreport as $variant => $vdata) { foreach ($vdata as $input => $idata) { foreach ($idata as $key => $value) { arsort($value); $inputreport[$variant][$input][$key] = $value; } } } foreach ($inputreportsummary as $input => $idata) { foreach ($idata as $key => $value) { arsort($value); $inputreportsummary[$input][$key] = $value; } } foreach ($prtreport as $variant => $vdata) { foreach ($vdata as $prt => $tdata) { arsort($tdata); $prtreport[$variant][$prt] = $tdata; } } $notesummary = array(); foreach ($prtreportsummary as $prt => $tdata) { ksort($tdata); $prtreportsummary[$prt] = $tdata; if (!array_key_exists($prt, $notesummary)) { $notesummary[$prt] = array(); } foreach ($tdata as $rawnote => $num) { $notes = explode('|', $rawnote); foreach ($notes as $note) { $note = trim($note); if (array_key_exists($note, $notesummary[$prt])) { $notesummary[$prt][$note] += (int) $num; } else { $notesummary[$prt][$note] = $num; } } } foreach ($prtreportinputs[$variant][$prt] as $note => $ipts) { arsort($ipts); $prtreportinputs[$variant][$prt][$note] = $ipts; } } foreach ($notesummary as $prt => $tdata) { ksort($tdata); $notesummary[$prt] = $tdata; } // Frequency of answer notes, for each PRT, split by |, regardless of which variant was used. echo html_writer::tag('h3', stack_string('basicreportnotes')); $sumout = array(); foreach ($prtreportsummary as $prt => $data) { $sumouti = ''; $tot = 0; $pad = max($data); foreach ($data as $key => $val) { $tot += $val; } if ($data !== array()) { foreach ($data as $dat => $num) { $sumouti .= str_pad($num, strlen((string) $pad) + 1) . '(' . str_pad(number_format((float) 100 * $num / $tot, 2, '.', ''), 6, ' ', STR_PAD_LEFT) . '%); ' . $dat . "\n"; } } if (trim($sumouti) !== '') { $sumout[$prt] = '## ' . $prt . ' ('. $tot . ")\n" . $sumouti . "\n";; } } // Produce a text-based summary of a PRT. foreach ($question->prts as $prtname => $prt) { // Here we render each PRT as a separate single-row table. $tablerow = array($prtname); $graph = $prt->get_prt_graph(); $tablerow[] = stack_abstract_graph_svg_renderer::render($graph, $prtname . 'graphsvg'); $tablerow[] = stack_prt_graph_text_renderer::render($graph); $maxima = html_writer::tag('summary', $prtname) . html_writer::tag('pre', s($prt->get_maxima_representation())); $maxima = html_writer::tag('details', $maxima); $tablerow[] = html_writer::tag('div', $maxima, array('class' => 'questionvariables')); // Render the html as a single table. $html = ''; foreach ($tablerow as $td) { $html .= html_writer::tag('td', $td); } $html = html_writer::tag('tr', $html); $html = html_writer::tag('table', $html); echo $html; if (array_key_exists($prtname, $sumout)) { echo html_writer::tag('pre', trim($sumout[$prtname])); } } $sumout = array(); $prtlabels = array(); foreach ($notesummary as $prt => $data) { $sumouti = ''; if ($data !== array()) { foreach ($data as $dat => $num) { // Use the old $tot, to give meaningful percentages of which individual notes occur overall. $prtlabels[$prt][$dat] = $num; $sumouti .= str_pad($num, strlen((string) $pad) + 1) . '(' . str_pad(number_format((float) 100 * $num / $tot, 2, '.', ''), 6, ' ', STR_PAD_LEFT) . '%); '. $dat . "\n"; } } if (trim($sumouti) !== '') { $sumout[$prt] = '## ' . $prt . ' ('. $tot . ")\n" . $sumouti . "\n";; } } if (trim(implode($sumout)) !== '') { echo html_writer::tag('h3', stack_string('basicreportnotessplit')); } $tablerows = array(); foreach ($question->prts as $prtname => $prt) { if (array_key_exists($prtname, $prtlabels)) { $tablerow = array($prtname); $graph = $prt->get_prt_graph(); $tablerow[] = stack_abstract_graph_svg_renderer::render($graph, $prtname . 'graphsvg'); $tablerow[] = stack_prt_graph_text_renderer::render($graph); $maxima = html_writer::tag('pre', s($sumout[$prtname])); $tablerow[] = html_writer::tag('div', $maxima, array('class' => 'questionvariables')); $tablerows[] = $tablerow; } } // Now create the HTML table. $html = ''; foreach ($tablerows as $tablerow) { $rowhtml = ''; foreach ($tablerow as $td) { $rowhtml .= html_writer::tag('td', $td); } $html .= html_writer::tag('tr', $rowhtml); } echo html_writer::tag('table', $html); // Raw inputs and PRT answer notes by variant. if (array_keys($summary) !== array()) { echo html_writer::tag('h3', stack_string('basicreportvariants')); } foreach (array_keys($summary) as $variant) { $sumout = ''; foreach ($prtreport[$variant] as $prt => $idata) { $pad = 0; $tot = 0; foreach ($idata as $dat => $num) { $tot += $num; } if ($idata !== array()) { $sumout .= '## ' . $prt . ' ('. $tot . ")\n"; $pad = max($idata); } foreach ($idata as $dat => $num) { $sumout .= str_pad($num, strlen((string) $pad) + 1) . '(' . str_pad(number_format((float) 100 * $num / $tot, 2, '.', ''), 6, ' ', STR_PAD_LEFT) . '%); ' . $dat . "\n"; foreach ($prtreportinputs[$variant][$prt][$dat] as $inputsummary => $inum) { $sumout .= str_pad($inum, strlen((string) $pad) + 1) . '(' . str_pad(number_format((float) 100 * $inum / $tot, 2, '.', ''), 6, ' ', STR_PAD_LEFT) . '%); ' . $inputsummary . "\n"; } $sumout .= "\n"; } $sumout .= "\n"; } if (trim($sumout) !== '') { echo html_writer::tag('h3', $questionseeds[$variant] . ': ' . $questionnotes[$variant]); echo html_writer::tag('pre', $sumout); } } foreach (array_keys($summary) as $variant) { $sumout = ''; foreach ($inputreport[$variant] as $input => $idata) { $sumouti = ''; $tot = 0; foreach ($idata as $key => $data) { foreach ($data as $dat => $num) { $tot += $num; } } foreach ($idata as $key => $data) { if ($data !== array()) { $sumouti .= '### ' . $key . "\n"; $pad = max($data); foreach ($data as $dat => $num) { $sumouti .= str_pad($num, strlen((string) $pad) + 1) . '(' . str_pad(number_format((float) 100 * $num / $tot, 2, '.', ''), 6, ' ', STR_PAD_LEFT) . '%); ' . $dat . "\n"; } $sumouti .= "\n"; } } if (trim($sumouti) !== '') { $sumout .= '## ' . $input . ' ('. $tot . ")\n" . $sumouti; } } if (trim($sumout) !== '') { echo html_writer::tag('h3', $questionseeds[$variant] . ': ' . $questionnotes[$variant]); echo html_writer::tag('pre', $sumout); } } // Summary of just the inputs. $sumout = ''; foreach ($inputreportsummary as $input => $idata) { $sumouti = ''; $tot = 0; foreach ($idata as $key => $data) { foreach ($data as $dat => $num) { $tot += $num; } } foreach ($idata as $key => $data) { if ($data !== array()) { $sumouti .= '### ' . $key . "\n"; $pad = max($data); foreach ($data as $dat => $num) { $sumouti .= str_pad($num, strlen((string) $pad) + 1) . '(' . str_pad(number_format((float) 100 * $num / $tot, 2, '.', ''), 6, ' ', STR_PAD_LEFT) . '%); ' . $dat . "\n"; } $sumouti .= "\n"; } } if (trim($sumouti) !== '') { $sumout .= '## ' . $input . ' ('. $tot . ")\n" . $sumouti; } } if (trim($sumout) !== '') { echo html_writer::tag('h3', stack_string('basicreportinputsummary')); echo html_writer::tag('pre', $sumout); } // Output the raw data. echo html_writer::tag('h3', stack_string('basicreportraw')); $sumout = ''; foreach ($summary as $variant => $vdata) { if ($vdata !== array()) { $tot = 0; foreach ($vdata as $dat => $num) { $tot += $num; } $pad = max($vdata); $sumout .= "\n# " . $variant . ' ('. $tot . ")\n"; foreach ($vdata as $dat => $num) { $sumout .= str_pad($num, strlen((string) $pad) + 1) . '(' . str_pad(number_format((float) 100 * $num / $tot, 2, '.', ''), 6, ' ', STR_PAD_LEFT) . '%); ' . $dat . "\n"; } } } echo html_writer::tag('pre', $sumout); // Finish output. echo $OUTPUT->footer();