PageRenderTime 59ms CodeModel.GetById 19ms RepoModel.GetById 0ms app.codeStats 1ms

/mod/quiz/report/statistics/report.php

https://bitbucket.org/moodle/moodle
PHP | 909 lines | 572 code | 120 blank | 217 comment | 96 complexity | ffe2fd26c551970ef8d5b39245779098 MD5 | raw file
Possible License(s): Apache-2.0, LGPL-2.1, BSD-3-Clause, MIT, GPL-3.0
  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. * Quiz statistics report class.
  18. *
  19. * @package quiz_statistics
  20. * @copyright 2014 Open University
  21. * @author James Pratt <me@jamiep.org>
  22. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23. */
  24. defined('MOODLE_INTERNAL') || die();
  25. require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_form.php');
  26. require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_table.php');
  27. require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_question_table.php');
  28. require_once($CFG->dirroot . '/mod/quiz/report/statistics/statisticslib.php');
  29. /**
  30. * The quiz statistics report provides summary information about each question in
  31. * a quiz, compared to the whole quiz. It also provides a drill-down to more
  32. * detailed information about each question.
  33. *
  34. * @copyright 2008 Jamie Pratt
  35. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  36. */
  37. class quiz_statistics_report extends quiz_default_report {
  38. /** @var context_module context of this quiz.*/
  39. protected $context;
  40. /** @var quiz_statistics_table instance of table class used for main questions stats table. */
  41. protected $table;
  42. /** @var \core\progress\base|null $progress Handles progress reporting or not. */
  43. protected $progress = null;
  44. /**
  45. * Display the report.
  46. */
  47. public function display($quiz, $cm, $course) {
  48. global $OUTPUT, $DB;
  49. raise_memory_limit(MEMORY_HUGE);
  50. $this->context = context_module::instance($cm->id);
  51. if (!quiz_has_questions($quiz->id)) {
  52. $this->print_header_and_tabs($cm, $course, $quiz, 'statistics');
  53. echo quiz_no_questions_message($quiz, $cm, $this->context);
  54. return true;
  55. }
  56. // Work out the display options.
  57. $download = optional_param('download', '', PARAM_ALPHA);
  58. $everything = optional_param('everything', 0, PARAM_BOOL);
  59. $recalculate = optional_param('recalculate', 0, PARAM_BOOL);
  60. // A qid paramter indicates we should display the detailed analysis of a sub question.
  61. $qid = optional_param('qid', 0, PARAM_INT);
  62. $slot = optional_param('slot', 0, PARAM_INT);
  63. $variantno = optional_param('variant', null, PARAM_INT);
  64. $whichattempts = optional_param('whichattempts', $quiz->grademethod, PARAM_INT);
  65. $whichtries = optional_param('whichtries', question_attempt::LAST_TRY, PARAM_ALPHA);
  66. $pageoptions = array();
  67. $pageoptions['id'] = $cm->id;
  68. $pageoptions['mode'] = 'statistics';
  69. $reporturl = new moodle_url('/mod/quiz/report.php', $pageoptions);
  70. $mform = new quiz_statistics_settings_form($reporturl, compact('quiz'));
  71. $mform->set_data(array('whichattempts' => $whichattempts, 'whichtries' => $whichtries));
  72. if ($whichattempts != $quiz->grademethod) {
  73. $reporturl->param('whichattempts', $whichattempts);
  74. }
  75. if ($whichtries != question_attempt::LAST_TRY) {
  76. $reporturl->param('whichtries', $whichtries);
  77. }
  78. // Find out current groups mode.
  79. $currentgroup = $this->get_current_group($cm, $course, $this->context);
  80. $nostudentsingroup = false; // True if a group is selected and there is no one in it.
  81. if (empty($currentgroup)) {
  82. $currentgroup = 0;
  83. $groupstudentsjoins = new \core\dml\sql_join();
  84. } else if ($currentgroup == self::NO_GROUPS_ALLOWED) {
  85. $groupstudentsjoins = new \core\dml\sql_join();
  86. $nostudentsingroup = true;
  87. } else {
  88. // All users who can attempt quizzes and who are in the currently selected group.
  89. $groupstudentsjoins = get_enrolled_with_capabilities_join($this->context, '',
  90. array('mod/quiz:reviewmyattempts', 'mod/quiz:attempt'), $currentgroup);
  91. if (!empty($groupstudentsjoins->joins)) {
  92. $sql = "SELECT DISTINCT u.id
  93. FROM {user} u
  94. {$groupstudentsjoins->joins}
  95. WHERE {$groupstudentsjoins->wheres}";
  96. if (!$DB->record_exists_sql($sql, $groupstudentsjoins->params)) {
  97. $nostudentsingroup = true;
  98. }
  99. }
  100. }
  101. $qubaids = quiz_statistics_qubaids_condition($quiz->id, $groupstudentsjoins, $whichattempts);
  102. // If recalculate was requested, handle that.
  103. if ($recalculate && confirm_sesskey()) {
  104. $this->clear_cached_data($qubaids);
  105. redirect($reporturl);
  106. }
  107. // Set up the main table.
  108. $this->table = new quiz_statistics_table();
  109. if ($everything) {
  110. $report = get_string('completestatsfilename', 'quiz_statistics');
  111. } else {
  112. $report = get_string('questionstatsfilename', 'quiz_statistics');
  113. }
  114. $courseshortname = format_string($course->shortname, true,
  115. array('context' => context_course::instance($course->id)));
  116. $filename = quiz_report_download_filename($report, $courseshortname, $quiz->name);
  117. $this->table->is_downloading($download, $filename,
  118. get_string('quizstructureanalysis', 'quiz_statistics'));
  119. $questions = $this->load_and_initialise_questions_for_calculations($quiz);
  120. // Print the page header stuff (if not downloading.
  121. if (!$this->table->is_downloading()) {
  122. $this->print_header_and_tabs($cm, $course, $quiz, 'statistics');
  123. }
  124. if (!$nostudentsingroup) {
  125. // Get the data to be displayed.
  126. $progress = $this->get_progress_trace_instance();
  127. list($quizstats, $questionstats) =
  128. $this->get_all_stats_and_analysis($quiz, $whichattempts, $whichtries, $groupstudentsjoins, $questions, $progress);
  129. } else {
  130. // Or create empty stats containers.
  131. $quizstats = new \quiz_statistics\calculated($whichattempts);
  132. $questionstats = new \core_question\statistics\questions\all_calculated_for_qubaid_condition();
  133. }
  134. // Set up the table.
  135. $this->table->statistics_setup($quiz, $cm->id, $reporturl, $quizstats->s());
  136. // Print the rest of the page header stuff (if not downloading.
  137. if (!$this->table->is_downloading()) {
  138. if (groups_get_activity_groupmode($cm)) {
  139. groups_print_activity_menu($cm, $reporturl->out());
  140. if ($currentgroup && $nostudentsingroup) {
  141. $OUTPUT->notification(get_string('nostudentsingroup', 'quiz_statistics'));
  142. }
  143. }
  144. if (!$this->table->is_downloading() && $quizstats->s() == 0) {
  145. echo $OUTPUT->notification(get_string('nogradedattempts', 'quiz_statistics'));
  146. }
  147. foreach ($questionstats->any_error_messages() as $errormessage) {
  148. echo $OUTPUT->notification($errormessage);
  149. }
  150. // Print display options form.
  151. $mform->display();
  152. }
  153. if ($everything) { // Implies is downloading.
  154. // Overall report, then the analysis of each question.
  155. $quizinfo = $quizstats->get_formatted_quiz_info_data($course, $cm, $quiz);
  156. $this->download_quiz_info_table($quizinfo);
  157. if ($quizstats->s()) {
  158. $this->output_quiz_structure_analysis_table($questionstats);
  159. if ($this->table->is_downloading() == 'html' && $quizstats->s() != 0) {
  160. $this->output_statistics_graph($quiz->id, $qubaids);
  161. }
  162. $this->output_all_question_response_analysis($qubaids, $questions, $questionstats, $reporturl, $whichtries);
  163. }
  164. $this->table->export_class_instance()->finish_document();
  165. } else if ($qid) {
  166. // Report on an individual sub-question indexed questionid.
  167. if (!$questionstats->has_subq($qid, $variantno)) {
  168. print_error('questiondoesnotexist', 'question');
  169. }
  170. $this->output_individual_question_data($quiz, $questionstats->for_subq($qid, $variantno));
  171. $this->output_individual_question_response_analysis($questionstats->for_subq($qid, $variantno)->question,
  172. $variantno,
  173. $questionstats->for_subq($qid, $variantno)->s,
  174. $reporturl,
  175. $qubaids,
  176. $whichtries);
  177. // Back to overview link.
  178. echo $OUTPUT->box('<a href="' . $reporturl->out() . '">' .
  179. get_string('backtoquizreport', 'quiz_statistics') . '</a>',
  180. 'boxaligncenter generalbox boxwidthnormal mdl-align');
  181. } else if ($slot) {
  182. // Report on an individual question indexed by position.
  183. if (!isset($questions[$slot])) {
  184. print_error('questiondoesnotexist', 'question');
  185. }
  186. if ($variantno === null &&
  187. ($questionstats->for_slot($slot)->get_sub_question_ids()
  188. || $questionstats->for_slot($slot)->get_variants())) {
  189. if (!$this->table->is_downloading()) {
  190. $number = $questionstats->for_slot($slot)->question->number;
  191. echo $OUTPUT->heading(get_string('slotstructureanalysis', 'quiz_statistics', $number), 3);
  192. }
  193. $this->table->define_baseurl(new moodle_url($reporturl, array('slot' => $slot)));
  194. $this->table->format_and_add_array_of_rows($questionstats->structure_analysis_for_one_slot($slot));
  195. } else {
  196. $this->output_individual_question_data($quiz, $questionstats->for_slot($slot, $variantno));
  197. $this->output_individual_question_response_analysis($questions[$slot],
  198. $variantno,
  199. $questionstats->for_slot($slot, $variantno)->s,
  200. $reporturl,
  201. $qubaids,
  202. $whichtries);
  203. }
  204. if (!$this->table->is_downloading()) {
  205. // Back to overview link.
  206. echo $OUTPUT->box('<a href="' . $reporturl->out() . '">' .
  207. get_string('backtoquizreport', 'quiz_statistics') . '</a>',
  208. 'backtomainstats boxaligncenter generalbox boxwidthnormal mdl-align');
  209. } else {
  210. $this->table->finish_output();
  211. }
  212. } else if ($this->table->is_downloading()) {
  213. // Downloading overview report.
  214. $quizinfo = $quizstats->get_formatted_quiz_info_data($course, $cm, $quiz);
  215. $this->download_quiz_info_table($quizinfo);
  216. if ($quizstats->s()) {
  217. $this->output_quiz_structure_analysis_table($questionstats);
  218. }
  219. $this->table->export_class_instance()->finish_document();
  220. } else {
  221. // On-screen display of overview report.
  222. echo $OUTPUT->heading(get_string('quizinformation', 'quiz_statistics'), 3);
  223. echo $this->output_caching_info($quizstats->timemodified, $quiz->id, $groupstudentsjoins, $whichattempts, $reporturl);
  224. echo $this->everything_download_options($reporturl);
  225. $quizinfo = $quizstats->get_formatted_quiz_info_data($course, $cm, $quiz);
  226. echo $this->output_quiz_info_table($quizinfo);
  227. if ($quizstats->s()) {
  228. echo $OUTPUT->heading(get_string('quizstructureanalysis', 'quiz_statistics'), 3);
  229. $this->output_quiz_structure_analysis_table($questionstats);
  230. $this->output_statistics_graph($quiz, $qubaids);
  231. }
  232. }
  233. return true;
  234. }
  235. /**
  236. * Display the statistical and introductory information about a question.
  237. * Only called when not downloading.
  238. *
  239. * @param object $quiz the quiz settings.
  240. * @param \core_question\statistics\questions\calculated $questionstat the question to report on.
  241. */
  242. protected function output_individual_question_data($quiz, $questionstat) {
  243. global $OUTPUT;
  244. // On-screen display. Show a summary of the question's place in the quiz,
  245. // and the question statistics.
  246. $datumfromtable = $this->table->format_row($questionstat);
  247. // Set up the question info table.
  248. $questioninfotable = new html_table();
  249. $questioninfotable->align = array('center', 'center');
  250. $questioninfotable->width = '60%';
  251. $questioninfotable->attributes['class'] = 'generaltable titlesleft';
  252. $questioninfotable->data = array();
  253. $questioninfotable->data[] = array(get_string('modulename', 'quiz'), $quiz->name);
  254. $questioninfotable->data[] = array(get_string('questionname', 'quiz_statistics'),
  255. $questionstat->question->name.'&nbsp;'.$datumfromtable['actions']);
  256. if ($questionstat->variant !== null) {
  257. $questioninfotable->data[] = array(get_string('variant', 'quiz_statistics'), $questionstat->variant);
  258. }
  259. $questioninfotable->data[] = array(get_string('questiontype', 'quiz_statistics'),
  260. $datumfromtable['icon'] . '&nbsp;' .
  261. question_bank::get_qtype($questionstat->question->qtype, false)->menu_name() . '&nbsp;' .
  262. $datumfromtable['icon']);
  263. $questioninfotable->data[] = array(get_string('positions', 'quiz_statistics'),
  264. $questionstat->positions);
  265. // Set up the question statistics table.
  266. $questionstatstable = new html_table();
  267. $questionstatstable->align = array('center', 'center');
  268. $questionstatstable->width = '60%';
  269. $questionstatstable->attributes['class'] = 'generaltable titlesleft';
  270. unset($datumfromtable['number']);
  271. unset($datumfromtable['icon']);
  272. $actions = $datumfromtable['actions'];
  273. unset($datumfromtable['actions']);
  274. unset($datumfromtable['name']);
  275. $labels = array(
  276. 's' => get_string('attempts', 'quiz_statistics'),
  277. 'facility' => get_string('facility', 'quiz_statistics'),
  278. 'sd' => get_string('standarddeviationq', 'quiz_statistics'),
  279. 'random_guess_score' => get_string('random_guess_score', 'quiz_statistics'),
  280. 'intended_weight' => get_string('intended_weight', 'quiz_statistics'),
  281. 'effective_weight' => get_string('effective_weight', 'quiz_statistics'),
  282. 'discrimination_index' => get_string('discrimination_index', 'quiz_statistics'),
  283. 'discriminative_efficiency' =>
  284. get_string('discriminative_efficiency', 'quiz_statistics')
  285. );
  286. foreach ($datumfromtable as $item => $value) {
  287. $questionstatstable->data[] = array($labels[$item], $value);
  288. }
  289. // Display the various bits.
  290. echo $OUTPUT->heading(get_string('questioninformation', 'quiz_statistics'), 3);
  291. echo html_writer::table($questioninfotable);
  292. echo $this->render_question_text($questionstat->question);
  293. echo $OUTPUT->heading(get_string('questionstatistics', 'quiz_statistics'), 3);
  294. echo html_writer::table($questionstatstable);
  295. }
  296. /**
  297. * Output question text in a box with urls appropriate for a preview of the question.
  298. *
  299. * @param object $question question data.
  300. * @return string HTML of question text, ready for display.
  301. */
  302. protected function render_question_text($question) {
  303. global $OUTPUT;
  304. $text = question_rewrite_question_preview_urls($question->questiontext, $question->id,
  305. $question->contextid, 'question', 'questiontext', $question->id,
  306. $this->context->id, 'quiz_statistics');
  307. return $OUTPUT->box(format_text($text, $question->questiontextformat,
  308. array('noclean' => true, 'para' => false, 'overflowdiv' => true)),
  309. 'questiontext boxaligncenter generalbox boxwidthnormal mdl-align');
  310. }
  311. /**
  312. * Display the response analysis for a question.
  313. *
  314. * @param object $question the question to report on.
  315. * @param int|null $variantno the variant
  316. * @param int $s
  317. * @param moodle_url $reporturl the URL to redisplay this report.
  318. * @param qubaid_condition $qubaids
  319. * @param string $whichtries
  320. */
  321. protected function output_individual_question_response_analysis($question, $variantno, $s, $reporturl, $qubaids,
  322. $whichtries = question_attempt::LAST_TRY) {
  323. global $OUTPUT;
  324. if (!question_bank::get_qtype($question->qtype, false)->can_analyse_responses()) {
  325. return;
  326. }
  327. $qtable = new quiz_statistics_question_table($question->id);
  328. $exportclass = $this->table->export_class_instance();
  329. $qtable->export_class_instance($exportclass);
  330. if (!$this->table->is_downloading()) {
  331. // Output an appropriate title.
  332. echo $OUTPUT->heading(get_string('analysisofresponses', 'quiz_statistics'), 3);
  333. } else {
  334. // Work out an appropriate title.
  335. $a = clone($question);
  336. $a->variant = $variantno;
  337. if (!empty($question->number) && !is_null($variantno)) {
  338. $questiontabletitle = get_string('analysisnovariant', 'quiz_statistics', $a);
  339. } else if (!empty($question->number)) {
  340. $questiontabletitle = get_string('analysisno', 'quiz_statistics', $a);
  341. } else if (!is_null($variantno)) {
  342. $questiontabletitle = get_string('analysisvariant', 'quiz_statistics', $a);
  343. } else {
  344. $questiontabletitle = get_string('analysisnameonly', 'quiz_statistics', $a);
  345. }
  346. if ($this->table->is_downloading() == 'html') {
  347. $questiontabletitle = get_string('analysisofresponsesfor', 'quiz_statistics', $questiontabletitle);
  348. }
  349. // Set up the table.
  350. $exportclass->start_table($questiontabletitle);
  351. if ($this->table->is_downloading() == 'html') {
  352. echo $this->render_question_text($question);
  353. }
  354. }
  355. $responesanalyser = new \core_question\statistics\responses\analyser($question, $whichtries);
  356. $responseanalysis = $responesanalyser->load_cached($qubaids, $whichtries);
  357. $qtable->question_setup($reporturl, $question, $s, $responseanalysis);
  358. if ($this->table->is_downloading()) {
  359. $exportclass->output_headers($qtable->headers);
  360. }
  361. // Where no variant no is specified the variant no is actually one.
  362. if ($variantno === null) {
  363. $variantno = 1;
  364. }
  365. foreach ($responseanalysis->get_subpart_ids($variantno) as $partid) {
  366. $subpart = $responseanalysis->get_analysis_for_subpart($variantno, $partid);
  367. foreach ($subpart->get_response_class_ids() as $responseclassid) {
  368. $responseclass = $subpart->get_response_class($responseclassid);
  369. $tabledata = $responseclass->data_for_question_response_table($subpart->has_multiple_response_classes(), $partid);
  370. foreach ($tabledata as $row) {
  371. $qtable->add_data_keyed($qtable->format_row($row));
  372. }
  373. }
  374. }
  375. $qtable->finish_output(!$this->table->is_downloading());
  376. }
  377. /**
  378. * Output the table that lists all the questions in the quiz with their statistics.
  379. *
  380. * @param \core_question\statistics\questions\all_calculated_for_qubaid_condition $questionstats the stats for all questions in
  381. * the quiz including subqs and
  382. * variants.
  383. */
  384. protected function output_quiz_structure_analysis_table($questionstats) {
  385. $limitvariants = !$this->table->is_downloading();
  386. foreach ($questionstats->get_all_slots() as $slot) {
  387. // Output the data for these question statistics.
  388. $structureanalysis = $questionstats->structure_analysis_for_one_slot($slot, $limitvariants);
  389. if (is_null($structureanalysis)) {
  390. $this->table->add_separator();
  391. } else {
  392. foreach ($structureanalysis as $row) {
  393. $bgcssclass = '';
  394. // The only way to identify in this point of the report if a row is a summary row
  395. // is checking if it's a instance of calculated_question_summary class.
  396. if ($row instanceof \core_question\statistics\questions\calculated_question_summary) {
  397. // Apply a custom css class to summary row to remove border and reduce paddings.
  398. $bgcssclass = 'quiz_statistics-summaryrow';
  399. // For question that contain a summary row, we add a "hidden" row in between so the report
  400. // display both rows with same background color.
  401. $this->table->add_data_keyed([], 'd-none hidden');
  402. }
  403. $this->table->add_data_keyed($this->table->format_row($row), $bgcssclass);
  404. }
  405. }
  406. }
  407. $this->table->finish_output(!$this->table->is_downloading());
  408. }
  409. /**
  410. * Return HTML for table of overall quiz statistics.
  411. *
  412. * @param array $quizinfo as returned by {@link get_formatted_quiz_info_data()}.
  413. * @return string the HTML.
  414. */
  415. protected function output_quiz_info_table($quizinfo) {
  416. $quizinfotable = new html_table();
  417. $quizinfotable->align = array('center', 'center');
  418. $quizinfotable->width = '60%';
  419. $quizinfotable->attributes['class'] = 'generaltable titlesleft';
  420. $quizinfotable->data = array();
  421. foreach ($quizinfo as $heading => $value) {
  422. $quizinfotable->data[] = array($heading, $value);
  423. }
  424. return html_writer::table($quizinfotable);
  425. }
  426. /**
  427. * Download the table of overall quiz statistics.
  428. *
  429. * @param array $quizinfo as returned by {@link get_formatted_quiz_info_data()}.
  430. */
  431. protected function download_quiz_info_table($quizinfo) {
  432. global $OUTPUT;
  433. // HTML download is a special case.
  434. if ($this->table->is_downloading() == 'html') {
  435. echo $OUTPUT->heading(get_string('quizinformation', 'quiz_statistics'), 3);
  436. echo $this->output_quiz_info_table($quizinfo);
  437. return;
  438. }
  439. // Reformat the data ready for output.
  440. $headers = array();
  441. $row = array();
  442. foreach ($quizinfo as $heading => $value) {
  443. $headers[] = $heading;
  444. $row[] = $value;
  445. }
  446. // Do the output.
  447. $exportclass = $this->table->export_class_instance();
  448. $exportclass->start_table(get_string('quizinformation', 'quiz_statistics'));
  449. $exportclass->output_headers($headers);
  450. $exportclass->add_data($row);
  451. $exportclass->finish_table();
  452. }
  453. /**
  454. * Output the HTML needed to show the statistics graph.
  455. *
  456. * @param int|object $quizorid The quiz, or its ID.
  457. * @param qubaid_condition $qubaids the question usages whose responses to analyse.
  458. * @param string $whichattempts Which attempts constant.
  459. */
  460. protected function output_statistics_graph($quizorid, $qubaids) {
  461. global $DB, $PAGE;
  462. $quiz = $quizorid;
  463. if (!is_object($quiz)) {
  464. $quiz = $DB->get_record('quiz', array('id' => $quizorid), '*', MUST_EXIST);
  465. }
  466. // Load the rest of the required data.
  467. $questions = quiz_report_get_significant_questions($quiz);
  468. // Only load main question not sub questions.
  469. $questionstatistics = $DB->get_records_select('question_statistics',
  470. 'hashcode = ? AND slot IS NOT NULL AND variant IS NULL',
  471. [$qubaids->get_hash_code()]);
  472. // Configure what to display.
  473. $fieldstoplot = [
  474. 'facility' => get_string('facility', 'quiz_statistics'),
  475. 'discriminativeefficiency' => get_string('discriminative_efficiency', 'quiz_statistics')
  476. ];
  477. $fieldstoplotfactor = ['facility' => 100, 'discriminativeefficiency' => 1];
  478. // Prepare the arrays to hold the data.
  479. $xdata = [];
  480. foreach (array_keys($fieldstoplot) as $fieldtoplot) {
  481. $ydata[$fieldtoplot] = [];
  482. }
  483. // Fill in the data for each question.
  484. foreach ($questionstatistics as $questionstatistic) {
  485. $number = $questions[$questionstatistic->slot]->number;
  486. $xdata[$number] = $number;
  487. foreach ($fieldstoplot as $fieldtoplot => $notused) {
  488. $value = $questionstatistic->$fieldtoplot;
  489. if (is_null($value)) {
  490. $value = 0;
  491. }
  492. $value *= $fieldstoplotfactor[$fieldtoplot];
  493. $ydata[$fieldtoplot][$number] = number_format($value, 2);
  494. }
  495. }
  496. // Create the chart.
  497. sort($xdata);
  498. $chart = new \core\chart_bar();
  499. $chart->get_xaxis(0, true)->set_label(get_string('position', 'quiz_statistics'));
  500. $chart->set_labels(array_values($xdata));
  501. foreach ($fieldstoplot as $fieldtoplot => $notused) {
  502. ksort($ydata[$fieldtoplot]);
  503. $series = new \core\chart_series($fieldstoplot[$fieldtoplot], array_values($ydata[$fieldtoplot]));
  504. $chart->add_series($series);
  505. }
  506. // Find max.
  507. $max = 0;
  508. foreach ($fieldstoplot as $fieldtoplot => $notused) {
  509. $max = max($max, max($ydata[$fieldtoplot]));
  510. }
  511. // Set Y properties.
  512. $yaxis = $chart->get_yaxis(0, true);
  513. $yaxis->set_stepsize(10);
  514. $yaxis->set_label('%');
  515. $output = $PAGE->get_renderer('mod_quiz');
  516. $graphname = get_string('statisticsreportgraph', 'quiz_statistics');
  517. echo $output->chart($chart, $graphname);
  518. }
  519. /**
  520. * Get the quiz and question statistics, either by loading the cached results,
  521. * or by recomputing them.
  522. *
  523. * @param object $quiz the quiz settings.
  524. * @param string $whichattempts which attempts to use, represented internally as one of the constants as used in
  525. * $quiz->grademethod ie.
  526. * QUIZ_GRADEAVERAGE, QUIZ_GRADEHIGHEST, QUIZ_ATTEMPTLAST or QUIZ_ATTEMPTFIRST
  527. * we calculate stats based on which attempts would affect the grade for each student.
  528. * @param string $whichtries which tries to analyse for response analysis. Will be one of
  529. * question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES.
  530. * @param \core\dml\sql_join $groupstudentsjoins Contains joins, wheres, params for students in this group.
  531. * @param array $questions full question data.
  532. * @param \core\progress\base|null $progress
  533. * @return array with 2 elements: - $quizstats The statistics for overall attempt scores.
  534. * - $questionstats \core_question\statistics\questions\all_calculated_for_qubaid_condition
  535. */
  536. public function get_all_stats_and_analysis(
  537. $quiz, $whichattempts, $whichtries, \core\dml\sql_join $groupstudentsjoins, $questions, $progress = null) {
  538. if ($progress === null) {
  539. $progress = new \core\progress\none();
  540. }
  541. $qubaids = quiz_statistics_qubaids_condition($quiz->id, $groupstudentsjoins, $whichattempts);
  542. $qcalc = new \core_question\statistics\questions\calculator($questions, $progress);
  543. $quizcalc = new \quiz_statistics\calculator($progress);
  544. $progress->start_progress('', 3);
  545. if ($quizcalc->get_last_calculated_time($qubaids) === false) {
  546. // Recalculate now.
  547. $questionstats = $qcalc->calculate($qubaids);
  548. $progress->progress(1);
  549. $quizstats = $quizcalc->calculate($quiz->id, $whichattempts, $groupstudentsjoins, count($questions),
  550. $qcalc->get_sum_of_mark_variance());
  551. $progress->progress(2);
  552. } else {
  553. $quizstats = $quizcalc->get_cached($qubaids);
  554. $progress->progress(1);
  555. $questionstats = $qcalc->get_cached($qubaids);
  556. $progress->progress(2);
  557. }
  558. if ($quizstats->s()) {
  559. $subquestions = $questionstats->get_sub_questions();
  560. $this->analyse_responses_for_all_questions_and_subquestions($questions,
  561. $subquestions,
  562. $qubaids,
  563. $whichtries,
  564. $progress);
  565. }
  566. $progress->progress(3);
  567. $progress->end_progress();
  568. return array($quizstats, $questionstats);
  569. }
  570. /**
  571. * Appropriate instance depending if we want html output for the user or not.
  572. *
  573. * @return \core\progress\base child of \core\progress\base to handle the display (or not) of task progress.
  574. */
  575. protected function get_progress_trace_instance() {
  576. if ($this->progress === null) {
  577. if (!$this->table->is_downloading()) {
  578. $this->progress = new \core\progress\display_if_slow(get_string('calculatingallstats', 'quiz_statistics'));
  579. $this->progress->set_display_names();
  580. } else {
  581. $this->progress = new \core\progress\none();
  582. }
  583. }
  584. return $this->progress;
  585. }
  586. /**
  587. * Analyse responses for all questions and sub questions in this quiz.
  588. *
  589. * @param object[] $questions as returned by self::load_and_initialise_questions_for_calculations
  590. * @param object[] $subquestions full question objects.
  591. * @param qubaid_condition $qubaids the question usages whose responses to analyse.
  592. * @param string $whichtries which tries to analyse \question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES.
  593. * @param null|\core\progress\base $progress Used to indicate progress of task.
  594. */
  595. protected function analyse_responses_for_all_questions_and_subquestions($questions, $subquestions, $qubaids,
  596. $whichtries, $progress = null) {
  597. if ($progress === null) {
  598. $progress = new \core\progress\none();
  599. }
  600. // Starting response analysis tasks.
  601. $progress->start_progress('', count($questions) + count($subquestions));
  602. $done = $this->analyse_responses_for_questions($questions, $qubaids, $whichtries, $progress);
  603. $this->analyse_responses_for_questions($subquestions, $qubaids, $whichtries, $progress, $done);
  604. // Finished all response analysis tasks.
  605. $progress->end_progress();
  606. }
  607. /**
  608. * Analyse responses for an array of questions or sub questions.
  609. *
  610. * @param object[] $questions as returned by self::load_and_initialise_questions_for_calculations.
  611. * @param qubaid_condition $qubaids the question usages whose responses to analyse.
  612. * @param string $whichtries which tries to analyse \question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES.
  613. * @param null|\core\progress\base $progress Used to indicate progress of task.
  614. * @param int[] $done array keys are ids of questions that have been analysed before calling method.
  615. * @return array array keys are ids of questions that were analysed after this method call.
  616. */
  617. protected function analyse_responses_for_questions($questions, $qubaids, $whichtries, $progress = null, $done = array()) {
  618. $countquestions = count($questions);
  619. if (!$countquestions) {
  620. return array();
  621. }
  622. if ($progress === null) {
  623. $progress = new \core\progress\none();
  624. }
  625. $progress->start_progress('', $countquestions, $countquestions);
  626. foreach ($questions as $question) {
  627. $progress->increment_progress();
  628. if (question_bank::get_qtype($question->qtype, false)->can_analyse_responses() && !isset($done[$question->id])) {
  629. $responesstats = new \core_question\statistics\responses\analyser($question, $whichtries);
  630. if ($responesstats->get_last_analysed_time($qubaids, $whichtries) === false) {
  631. $responesstats->calculate($qubaids, $whichtries);
  632. }
  633. }
  634. $done[$question->id] = 1;
  635. }
  636. $progress->end_progress();
  637. return $done;
  638. }
  639. /**
  640. * Return a little form for the user to request to download the full report, including quiz stats and response analysis for
  641. * all questions and sub-questions.
  642. *
  643. * @param moodle_url $reporturl the base URL of the report.
  644. * @return string HTML.
  645. */
  646. protected function everything_download_options(moodle_url $reporturl) {
  647. global $OUTPUT;
  648. return $OUTPUT->download_dataformat_selector(get_string('downloadeverything', 'quiz_statistics'),
  649. $reporturl->out_omit_querystring(), 'download', $reporturl->params() + array('everything' => 1));
  650. }
  651. /**
  652. * Return HTML for a message that says when the stats were last calculated and a 'recalculate now' button.
  653. *
  654. * @param int $lastcachetime the time the stats were last cached.
  655. * @param int $quizid the quiz id.
  656. * @param array $groupstudentsjoins (joins, wheres, params) for students in the group or empty array if groups not used.
  657. * @param string $whichattempts which attempts to use, represented internally as one of the constants as used in
  658. * $quiz->grademethod ie.
  659. * QUIZ_GRADEAVERAGE, QUIZ_GRADEHIGHEST, QUIZ_ATTEMPTLAST or QUIZ_ATTEMPTFIRST
  660. * we calculate stats based on which attempts would affect the grade for each student.
  661. * @param moodle_url $reporturl url for this report
  662. * @return string HTML.
  663. */
  664. protected function output_caching_info($lastcachetime, $quizid, $groupstudentsjoins, $whichattempts, $reporturl) {
  665. global $DB, $OUTPUT;
  666. if (empty($lastcachetime)) {
  667. return '';
  668. }
  669. // Find the number of attempts since the cached statistics were computed.
  670. list($fromqa, $whereqa, $qaparams) = quiz_statistics_attempts_sql($quizid, $groupstudentsjoins, $whichattempts, true);
  671. $count = $DB->count_records_sql("
  672. SELECT COUNT(1)
  673. FROM $fromqa
  674. WHERE $whereqa
  675. AND quiza.timefinish > {$lastcachetime}", $qaparams);
  676. if (!$count) {
  677. $count = 0;
  678. }
  679. // Generate the output.
  680. $a = new stdClass();
  681. $a->lastcalculated = format_time(time() - $lastcachetime);
  682. $a->count = $count;
  683. $recalcualteurl = new moodle_url($reporturl,
  684. array('recalculate' => 1, 'sesskey' => sesskey()));
  685. $output = '';
  686. $output .= $OUTPUT->box_start(
  687. 'boxaligncenter generalbox boxwidthnormal mdl-align', 'cachingnotice');
  688. $output .= get_string('lastcalculated', 'quiz_statistics', $a);
  689. $output .= $OUTPUT->single_button($recalcualteurl,
  690. get_string('recalculatenow', 'quiz_statistics'));
  691. $output .= $OUTPUT->box_end(true);
  692. return $output;
  693. }
  694. /**
  695. * Clear the cached data for a particular report configuration. This will trigger a re-computation the next time the report
  696. * is displayed.
  697. *
  698. * @param $qubaids qubaid_condition
  699. */
  700. protected function clear_cached_data($qubaids) {
  701. global $DB;
  702. $DB->delete_records('quiz_statistics', array('hashcode' => $qubaids->get_hash_code()));
  703. $DB->delete_records('question_statistics', array('hashcode' => $qubaids->get_hash_code()));
  704. $DB->delete_records('question_response_analysis', array('hashcode' => $qubaids->get_hash_code()));
  705. }
  706. /**
  707. * Load the questions in this quiz and add some properties to the objects needed in the reports.
  708. *
  709. * @param object $quiz the quiz.
  710. * @return array of questions for this quiz.
  711. */
  712. public function load_and_initialise_questions_for_calculations($quiz) {
  713. // Load the questions.
  714. $questions = quiz_report_get_significant_questions($quiz);
  715. $questionids = array();
  716. foreach ($questions as $question) {
  717. $questionids[] = $question->id;
  718. }
  719. $fullquestions = question_load_questions($questionids);
  720. foreach ($questions as $qno => $question) {
  721. $q = $fullquestions[$question->id];
  722. $q->maxmark = $question->maxmark;
  723. $q->slot = $qno;
  724. $q->number = $question->number;
  725. $questions[$qno] = $q;
  726. }
  727. return $questions;
  728. }
  729. /**
  730. * Output all response analysis for all questions, sub-questions and variants. For download in a number of formats.
  731. *
  732. * @param $qubaids
  733. * @param $questions
  734. * @param $questionstats
  735. * @param $reporturl
  736. * @param $whichtries string
  737. */
  738. protected function output_all_question_response_analysis($qubaids,
  739. $questions,
  740. $questionstats,
  741. $reporturl,
  742. $whichtries = question_attempt::LAST_TRY) {
  743. foreach ($questions as $slot => $question) {
  744. if (question_bank::get_qtype(
  745. $question->qtype, false)->can_analyse_responses()
  746. ) {
  747. if ($questionstats->for_slot($slot)->get_variants()) {
  748. foreach ($questionstats->for_slot($slot)->get_variants() as $variantno) {
  749. $this->output_individual_question_response_analysis($question,
  750. $variantno,
  751. $questionstats->for_slot($slot, $variantno)->s,
  752. $reporturl,
  753. $qubaids,
  754. $whichtries);
  755. }
  756. } else {
  757. $this->output_individual_question_response_analysis($question,
  758. null,
  759. $questionstats->for_slot($slot)->s,
  760. $reporturl,
  761. $qubaids,
  762. $whichtries);
  763. }
  764. } else if ($subqids = $questionstats->for_slot($slot)->get_sub_question_ids()) {
  765. foreach ($subqids as $subqid) {
  766. if ($variants = $questionstats->for_subq($subqid)->get_variants()) {
  767. foreach ($variants as $variantno) {
  768. $this->output_individual_question_response_analysis(
  769. $questionstats->for_subq($subqid, $variantno)->question,
  770. $variantno,
  771. $questionstats->for_subq($subqid, $variantno)->s,
  772. $reporturl,
  773. $qubaids,
  774. $whichtries);
  775. }
  776. } else {
  777. $this->output_individual_question_response_analysis(
  778. $questionstats->for_subq($subqid)->question,
  779. null,
  780. $questionstats->for_subq($subqid)->s,
  781. $reporturl,
  782. $qubaids,
  783. $whichtries);
  784. }
  785. }
  786. }
  787. }
  788. }
  789. }