Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401] [Versions 400 and 401] [Versions 401 and 402] [Versions 401 and 403]

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