Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

Differences Between: [Versions 310 and 403] [Versions 311 and 403] [Versions 39 and 403] [Versions 400 and 403] [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  use mod_quiz\local\reports\report_base;
  18  use mod_quiz\quiz_attempt;
  19  
  20  defined('MOODLE_INTERNAL') || die();
  21  
  22  require_once($CFG->dirroot . '/mod/quiz/report/grading/gradingsettings_form.php');
  23  
  24  /**
  25   * Quiz report to help teachers manually grade questions that need it.
  26   *
  27   * This report basically provides two screens:
  28   * - List question that might need manual grading (or optionally all questions).
  29   * - Provide an efficient UI to grade all attempts at a particular question.
  30   *
  31   * @copyright 2006 Gustav Delius
  32   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  33   */
  34  class quiz_grading_report extends report_base {
  35      const DEFAULT_PAGE_SIZE = 5;
  36      const DEFAULT_ORDER = 'random';
  37  
  38      /** @var string Positive integer regular expression. */
  39      const REGEX_POSITIVE_INT = '/^[1-9]\d*$/';
  40  
  41      /** @var array URL parameters for what is being displayed when grading. */
  42      protected $viewoptions = [];
  43  
  44      /** @var int the current group, 0 if none, or NO_GROUPS_ALLOWED. */
  45      protected $currentgroup;
  46  
  47      /** @var array from quiz_report_get_significant_questions. */
  48      protected $questions;
  49  
  50      /** @var stdClass the course settings. */
  51      protected $course;
  52  
  53      /** @var stdClass the course_module settings. */
  54      protected $cm;
  55  
  56      /** @var stdClass the quiz settings. */
  57      protected $quiz;
  58  
  59      /** @var context the quiz context. */
  60      protected $context;
  61  
  62      /** @var quiz_grading_renderer Renderer of Quiz Grading. */
  63      protected $renderer;
  64  
  65      /** @var string fragment of SQL code to restrict to the relevant users. */
  66      protected $userssql;
  67  
  68      /** @var array extra user fields. */
  69      protected $extrauserfields = [];
  70  
  71      public function display($quiz, $cm, $course) {
  72  
  73          $this->quiz = $quiz;
  74          $this->cm = $cm;
  75          $this->course = $course;
  76  
  77          // Get the URL options.
  78          $slot = optional_param('slot', null, PARAM_INT);
  79          $questionid = optional_param('qid', null, PARAM_INT);
  80          $grade = optional_param('grade', null, PARAM_ALPHA);
  81  
  82          $includeauto = optional_param('includeauto', false, PARAM_BOOL);
  83          if (!in_array($grade, ['all', 'needsgrading', 'autograded', 'manuallygraded'])) {
  84              $grade = null;
  85          }
  86          $pagesize = optional_param('pagesize',
  87                  get_user_preferences('quiz_grading_pagesize', self::DEFAULT_PAGE_SIZE),
  88                  PARAM_INT);
  89          $page = optional_param('page', 0, PARAM_INT);
  90          $order = optional_param('order',
  91                  get_user_preferences('quiz_grading_order', self::DEFAULT_ORDER),
  92                  PARAM_ALPHAEXT);
  93  
  94          // Assemble the options required to reload this page.
  95          $optparams = ['includeauto', 'page'];
  96          foreach ($optparams as $param) {
  97              if ($$param) {
  98                  $this->viewoptions[$param] = $$param;
  99              }
 100          }
 101          if (!data_submitted() && !preg_match(self::REGEX_POSITIVE_INT, $pagesize)) {
 102              // We only validate if the user accesses the page via a cleaned-up GET URL here.
 103              throw new moodle_exception('invalidpagesize');
 104          }
 105          if ($pagesize != self::DEFAULT_PAGE_SIZE) {
 106              $this->viewoptions['pagesize'] = $pagesize;
 107          }
 108          if ($order != self::DEFAULT_ORDER) {
 109              $this->viewoptions['order'] = $order;
 110          }
 111  
 112          // Check permissions.
 113          $this->context = context_module::instance($this->cm->id);
 114          require_capability('mod/quiz:grade', $this->context);
 115          $shownames = has_capability('quiz/grading:viewstudentnames', $this->context);
 116          // Whether the current user can see custom user fields.
 117          $showcustomfields = has_capability('quiz/grading:viewidnumber', $this->context);
 118          $userfieldsapi = \core_user\fields::for_identity($this->context)->with_name();
 119          $customfields = [];
 120          foreach ($userfieldsapi->get_required_fields([\core_user\fields::PURPOSE_IDENTITY]) as $field) {
 121              $customfields[] = $field;
 122          }
 123          // Validate order.
 124          $orderoptions = array_merge(['random', 'date', 'studentfirstname', 'studentlastname'], $customfields);
 125          if (!in_array($order, $orderoptions)) {
 126              $order = self::DEFAULT_ORDER;
 127          } else if (!$shownames && ($order == 'studentfirstname' || $order == 'studentlastname')) {
 128              $order = self::DEFAULT_ORDER;
 129          } else if (!$showcustomfields && in_array($order, $customfields)) {
 130              $order = self::DEFAULT_ORDER;
 131          }
 132          if ($order == 'random') {
 133              $page = 0;
 134          }
 135  
 136          // Get the list of questions in this quiz.
 137          $this->questions = quiz_report_get_significant_questions($quiz);
 138          if ($slot && !array_key_exists($slot, $this->questions)) {
 139              throw new moodle_exception('unknownquestion', 'quiz_grading');
 140          }
 141  
 142          // Process any submitted data.
 143          if ($data = data_submitted() && confirm_sesskey() && $this->validate_submitted_marks()) {
 144              // Changes done to handle attempts being missed from grading due to redirecting to new page.
 145              $attemptsgraded = $this->process_submitted_data();
 146  
 147              $nextpagenumber = $page + 1;
 148              // If attempts need grading and one or more have now been graded, then page number should remain the same.
 149              if ($grade == 'needsgrading' && $attemptsgraded) {
 150                  $nextpagenumber = $page;
 151              }
 152  
 153              redirect($this->grade_question_url($slot, $questionid, $grade, $nextpagenumber));
 154          }
 155  
 156          // Get the group, and the list of significant users.
 157          $this->currentgroup = $this->get_current_group($cm, $course, $this->context);
 158          if ($this->currentgroup == self::NO_GROUPS_ALLOWED) {
 159              $this->userssql = [];
 160          } else {
 161              $this->userssql = get_enrolled_sql($this->context,
 162                      ['mod/quiz:reviewmyattempts', 'mod/quiz:attempt'], $this->currentgroup);
 163          }
 164  
 165          $hasquestions = quiz_has_questions($this->quiz->id);
 166          if (!$hasquestions) {
 167              $this->print_header_and_tabs($cm, $course, $quiz, 'grading');
 168              echo $this->renderer->render_quiz_no_question_notification($quiz, $cm, $this->context);
 169              return true;
 170          }
 171  
 172          if (!$slot) {
 173              $this->display_index($includeauto);
 174              return true;
 175          }
 176  
 177          // Display the grading UI for one question.
 178  
 179          // Make sure there is something to do.
 180          $counts = null;
 181          $statecounts = $this->get_question_state_summary([$slot]);
 182          foreach ($statecounts as $record) {
 183              if ($record->questionid == $questionid) {
 184                  $counts = $record;
 185                  break;
 186              }
 187          }
 188  
 189          // If not, redirect back to the list.
 190          if (!$counts || $counts->$grade == 0) {
 191              redirect($this->list_questions_url(), get_string('alldoneredirecting', 'quiz_grading'));
 192          }
 193  
 194          $this->display_grading_interface($slot, $questionid, $grade,
 195                  $pagesize, $page, $shownames, $showcustomfields, $order, $counts);
 196          return true;
 197      }
 198  
 199      /**
 200       * Get the JOIN conditions needed so we only show attempts by relevant users.
 201       *
 202       * @return qubaid_join
 203       */
 204      protected function get_qubaids_condition() {
 205  
 206          $where = "quiza.quiz = :mangrquizid AND
 207                  quiza.preview = 0 AND
 208                  quiza.state = :statefinished";
 209          $params = ['mangrquizid' => $this->cm->instance, 'statefinished' => quiz_attempt::FINISHED];
 210  
 211          $usersjoin = '';
 212          $currentgroup = groups_get_activity_group($this->cm, true);
 213          $enrolleduserscount = count_enrolled_users($this->context,
 214                  ['mod/quiz:reviewmyattempts', 'mod/quiz:attempt'], $currentgroup);
 215          if ($currentgroup) {
 216              $userssql = get_enrolled_sql($this->context,
 217                      ['mod/quiz:reviewmyattempts', 'mod/quiz:attempt'], $currentgroup);
 218              if ($enrolleduserscount < 1) {
 219                  $where .= ' AND quiza.userid = 0';
 220              } else {
 221                  $usersjoin = "JOIN ({$userssql[0]}) AS enr ON quiza.userid = enr.id";
 222                  $params += $userssql[1];
 223              }
 224          }
 225  
 226          return new qubaid_join("{quiz_attempts} quiza $usersjoin ", 'quiza.uniqueid', $where, $params);
 227      }
 228  
 229      /**
 230       * Load the quiz_attempts rows corresponding to a list of question_usage ids.
 231       *
 232       * @param int[] $qubaids the question_usage ids of the quiz_attempts to load.
 233       * @return array quiz attempts, with added user name fields.
 234       */
 235      protected function load_attempts_by_usage_ids($qubaids) {
 236          global $DB;
 237  
 238          list($asql, $params) = $DB->get_in_or_equal($qubaids);
 239          $params[] = quiz_attempt::FINISHED;
 240          $params[] = $this->quiz->id;
 241  
 242          $fields = 'quiza.*, ';
 243          $userfieldsapi = \core_user\fields::for_identity($this->context)->with_name();
 244          $userfieldssql = $userfieldsapi->get_sql('u', false, '', 'userid', false);
 245          $fields .= $userfieldssql->selects;
 246          foreach ($userfieldsapi->get_required_fields([\core_user\fields::PURPOSE_IDENTITY]) as $userfield) {
 247              $this->extrauserfields[] = s($userfield);
 248          }
 249          $params = array_merge($userfieldssql->params, $params);
 250          $attemptsbyid = $DB->get_records_sql("
 251                  SELECT $fields
 252                  FROM {quiz_attempts} quiza
 253                  JOIN {user} u ON u.id = quiza.userid
 254                  {$userfieldssql->joins}
 255                  WHERE quiza.uniqueid $asql AND quiza.state = ? AND quiza.quiz = ?",
 256                  $params);
 257  
 258          $attempts = [];
 259          foreach ($attemptsbyid as $attempt) {
 260              $attempts[$attempt->uniqueid] = $attempt;
 261          }
 262          return $attempts;
 263      }
 264  
 265      /**
 266       * Get the URL of the front page of the report that lists all the questions.
 267       *
 268       * @return moodle_url the URL.
 269       */
 270      protected function base_url() {
 271          return new moodle_url('/mod/quiz/report.php',
 272                  ['id' => $this->cm->id, 'mode' => 'grading']);
 273      }
 274  
 275      /**
 276       * Get the URL of the front page of the report that lists all the questions.
 277       *
 278       * @param bool $includeauto if not given, use the current setting, otherwise,
 279       *      force a particular value of includeauto in the URL.
 280       * @return moodle_url the URL.
 281       */
 282      protected function list_questions_url($includeauto = null) {
 283          $url = $this->base_url();
 284  
 285          $url->params($this->viewoptions);
 286  
 287          if (!is_null($includeauto)) {
 288              $url->param('includeauto', $includeauto);
 289          }
 290  
 291          return $url;
 292      }
 293  
 294      /**
 295       * Get the URL to grade a batch of question attempts.
 296       *
 297       * @param int $slot
 298       * @param int $questionid
 299       * @param string $grade
 300       * @param int|bool $page = true, link to current page. false = omit page.
 301       *      number = link to specific page.
 302       * @return moodle_url
 303       */
 304      protected function grade_question_url($slot, $questionid, $grade, $page = true) {
 305          $url = $this->base_url();
 306          $url->params(['slot' => $slot, 'qid' => $questionid, 'grade' => $grade]);
 307          $url->params($this->viewoptions);
 308  
 309          if (!$page) {
 310              $url->remove_params('page');
 311          } else if (is_integer($page)) {
 312              $url->param('page', $page);
 313          }
 314  
 315          return $url;
 316      }
 317  
 318      /**
 319       * Renders the contents of one cell of the table on the index view.
 320       *
 321       * @param stdClass $counts counts of different types of attempt for this slot.
 322       * @param string $type the type of count to format.
 323       * @param string $gradestring get_string identifier for the grading link text, if required.
 324       * @return string HTML.
 325       */
 326      protected function format_count_for_table($counts, $type, $gradestring) {
 327          $result = $counts->$type;
 328          if ($counts->$type > 0) {
 329              $gradeurl = $this->grade_question_url($counts->slot, $counts->questionid, $type);
 330              $result .= $this->renderer->render_grade_link($counts, $type, $gradestring, $gradeurl);
 331          }
 332          return $result;
 333      }
 334  
 335      /**
 336       * Display the report front page which summarises the number of attempts to grade.
 337       *
 338       * @param bool $includeauto whether to show automatically-graded questions.
 339       */
 340      protected function display_index($includeauto) {
 341          global $PAGE, $OUTPUT;
 342  
 343          $this->print_header_and_tabs($this->cm, $this->course, $this->quiz, 'grading');
 344  
 345          if ($groupmode = groups_get_activity_groupmode($this->cm)) {
 346              // Groups is being used.
 347              groups_print_activity_menu($this->cm, $this->list_questions_url());
 348          }
 349          // Get the current group for the user looking at the report.
 350          $currentgroup = $this->get_current_group($this->cm, $this->course, $this->context);
 351          if ($currentgroup == self::NO_GROUPS_ALLOWED) {
 352              echo $OUTPUT->notification(get_string('notingroup'));
 353              return;
 354          }
 355          $statecounts = $this->get_question_state_summary(array_keys($this->questions));
 356          if ($includeauto) {
 357              $linktext = get_string('hideautomaticallygraded', 'quiz_grading');
 358          } else {
 359              $linktext = get_string('alsoshowautomaticallygraded', 'quiz_grading');
 360          }
 361          echo $this->renderer->render_display_index_heading($linktext, $this->list_questions_url(!$includeauto));
 362          $data = [];
 363          $header = [];
 364  
 365          $header[] = get_string('qno', 'quiz_grading');
 366          $header[] = get_string('qtypeveryshort', 'question');
 367          $header[] = get_string('questionname', 'quiz_grading');
 368          $header[] = get_string('tograde', 'quiz_grading');
 369          $header[] = get_string('alreadygraded', 'quiz_grading');
 370          if ($includeauto) {
 371              $header[] = get_string('automaticallygraded', 'quiz_grading');
 372          }
 373          $header[] = get_string('total', 'quiz_grading');
 374  
 375          foreach ($statecounts as $counts) {
 376              if ($counts->all == 0) {
 377                  continue;
 378              }
 379              if (!$includeauto && $counts->needsgrading == 0 && $counts->manuallygraded == 0) {
 380                  continue;
 381              }
 382  
 383              $row = [];
 384  
 385              $row[] = $this->questions[$counts->slot]->number;
 386  
 387              $row[] = $PAGE->get_renderer('question', 'bank')->qtype_icon($this->questions[$counts->slot]->qtype);
 388  
 389              $row[] = format_string($counts->name);
 390  
 391              $row[] = $this->format_count_for_table($counts, 'needsgrading', 'grade');
 392  
 393              $row[] = $this->format_count_for_table($counts, 'manuallygraded', 'updategrade');
 394  
 395              if ($includeauto) {
 396                  $row[] = $this->format_count_for_table($counts, 'autograded', 'updategrade');
 397              }
 398  
 399              $row[] = $this->format_count_for_table($counts, 'all', 'gradeall');
 400  
 401              $data[] = $row;
 402          }
 403          echo $this->renderer->render_questions_table($includeauto, $data, $header);
 404      }
 405  
 406      /**
 407       * Display the UI for grading attempts at one question.
 408       *
 409       * @param int $slot identifies which question to grade.
 410       * @param int $questionid identifies which question to grade.
 411       * @param string $grade type of attempts to grade.
 412       * @param int $pagesize number of questions to show per page.
 413       * @param int $page current page number.
 414       * @param bool $shownames whether student names should be shown.
 415       * @param bool $showcustomfields whether custom field values should be shown.
 416       * @param string $order preferred order of attempts.
 417       * @param stdClass $counts object that stores the number of each type of attempt.
 418       */
 419      protected function display_grading_interface($slot, $questionid, $grade,
 420              $pagesize, $page, $shownames, $showcustomfields, $order, $counts) {
 421  
 422          if ($pagesize * $page >= $counts->$grade) {
 423              $page = 0;
 424          }
 425  
 426          // Prepare the options form.
 427          $hidden = [
 428              'id' => $this->cm->id,
 429              'mode' => 'grading',
 430              'slot' => $slot,
 431              'qid' => $questionid,
 432              'page' => $page,
 433          ];
 434          if (array_key_exists('includeauto', $this->viewoptions)) {
 435              $hidden['includeauto'] = $this->viewoptions['includeauto'];
 436          }
 437          $mform = new quiz_grading_settings_form($hidden, $counts, $shownames, $showcustomfields, $this->context);
 438  
 439          // Tell the form the current settings.
 440          $settings = new stdClass();
 441          $settings->grade = $grade;
 442          $settings->pagesize = $pagesize;
 443          $settings->order = $order;
 444          $mform->set_data($settings);
 445  
 446          if ($mform->is_submitted()) {
 447              if ($mform->is_validated()) {
 448                  // If the form was submitted and validated, save the user preferences, and
 449                  // redirect to a cleaned-up GET URL.
 450                  set_user_preference('quiz_grading_pagesize', $pagesize);
 451                  set_user_preference('quiz_grading_order', $order);
 452                  redirect($this->grade_question_url($slot, $questionid, $grade, $page));
 453              } else {
 454                  // Set the pagesize back to the previous value, so the report page can continue the render
 455                  // and the form can show the validation.
 456                  $pagesize = get_user_preferences('quiz_grading_pagesize', self::DEFAULT_PAGE_SIZE);
 457              }
 458          }
 459  
 460          list($qubaids, $count) = $this->get_usage_ids_where_question_in_state(
 461                  $grade, $slot, $questionid, $order, $page, $pagesize);
 462          $attempts = $this->load_attempts_by_usage_ids($qubaids);
 463  
 464          // Question info.
 465          $questioninfo = new stdClass();
 466          $questioninfo->number = $this->questions[$slot]->number;
 467          $questioninfo->questionname = format_string($counts->name);
 468  
 469          // Paging info.
 470          $paginginfo = new stdClass();
 471          $paginginfo->from = $page * $pagesize + 1;
 472          $paginginfo->to = min(($page + 1) * $pagesize, $count);
 473          $paginginfo->of = $count;
 474          $qubaidlist = implode(',', $qubaids);
 475  
 476          $this->print_header_and_tabs($this->cm, $this->course, $this->quiz, 'grading');
 477  
 478          $gradequestioncontent = '';
 479          foreach ($qubaids as $qubaid) {
 480              $attempt = $attempts[$qubaid];
 481              $quba = question_engine::load_questions_usage_by_activity($qubaid);
 482              $displayoptions = quiz_get_review_options($this->quiz, $attempt, $this->context);
 483              $displayoptions->generalfeedback = question_display_options::HIDDEN;
 484              $displayoptions->history = question_display_options::HIDDEN;
 485              $displayoptions->manualcomment = question_display_options::EDITABLE;
 486  
 487              $gradequestioncontent .= $this->renderer->render_grade_question(
 488                      $quba,
 489                      $slot,
 490                      $displayoptions,
 491                      $this->questions[$slot]->number,
 492                      $this->get_question_heading($attempt, $shownames, $showcustomfields)
 493              );
 494          }
 495  
 496          $pagingbar = new stdClass();
 497          $pagingbar->count = $count;
 498          $pagingbar->page = $page;
 499          $pagingbar->pagesize = $pagesize;
 500          $pagingbar->pagesize = $pagesize;
 501          $pagingbar->order = $order;
 502          $pagingbar->pagingurl = $this->grade_question_url($slot, $questionid, $grade, false);
 503  
 504          $hiddeninputs = [
 505                  'qubaids' => $qubaidlist,
 506                  'slots' => $slot,
 507                  'sesskey' => sesskey()
 508          ];
 509  
 510          echo $this->renderer->render_grading_interface(
 511                  $questioninfo,
 512                  $this->list_questions_url(),
 513                  $mform,
 514                  $paginginfo,
 515                  $pagingbar,
 516                  $this->grade_question_url($slot, $questionid, $grade, $page),
 517                  $hiddeninputs,
 518                  $gradequestioncontent
 519          );
 520      }
 521  
 522      /**
 523       * When saving a grading page, are all the submitted marks valid?
 524       *
 525       * @return bool true if all valid, else false.
 526       */
 527      protected function validate_submitted_marks() {
 528  
 529          $qubaids = optional_param('qubaids', null, PARAM_SEQUENCE);
 530          if (!$qubaids) {
 531              return false;
 532          }
 533          $qubaids = clean_param_array(explode(',', $qubaids), PARAM_INT);
 534  
 535          $slots = optional_param('slots', '', PARAM_SEQUENCE);
 536          if (!$slots) {
 537              $slots = [];
 538          } else {
 539              $slots = explode(',', $slots);
 540          }
 541  
 542          foreach ($qubaids as $qubaid) {
 543              foreach ($slots as $slot) {
 544                  if (!question_engine::is_manual_grade_in_range($qubaid, $slot)) {
 545                      return false;
 546                  }
 547              }
 548          }
 549  
 550          return true;
 551      }
 552  
 553      /**
 554       * Save all submitted marks to the database.
 555       *
 556       * @return bool returns true if some attempts or all are graded. False, if none of the attempts are graded.
 557       */
 558      protected function process_submitted_data(): bool {
 559          global $DB;
 560  
 561          $qubaids = optional_param('qubaids', null, PARAM_SEQUENCE);
 562          $assumedslotforevents = optional_param('slot', null, PARAM_INT);
 563  
 564          if (!$qubaids) {
 565              return false;
 566          }
 567  
 568          $qubaids = clean_param_array(explode(',', $qubaids), PARAM_INT);
 569          $attempts = $this->load_attempts_by_usage_ids($qubaids);
 570          $events = [];
 571  
 572          $transaction = $DB->start_delegated_transaction();
 573          $attemptsgraded = false;
 574          foreach ($qubaids as $qubaid) {
 575              $attempt = $attempts[$qubaid];
 576              $attemptobj = new quiz_attempt($attempt, $this->quiz, $this->cm, $this->course);
 577  
 578              // State of the attempt before grades are changed.
 579              $attemptoldtstate = $attemptobj->get_question_state($assumedslotforevents);
 580  
 581              $attemptobj->process_submitted_actions(time());
 582  
 583              // Get attempt state after grades are changed.
 584              $attemptnewtstate = $attemptobj->get_question_state($assumedslotforevents);
 585  
 586              // Check if any attempts are graded.
 587              if (!$attemptsgraded && $attemptoldtstate->is_graded() != $attemptnewtstate->is_graded()) {
 588                  $attemptsgraded = true;
 589              }
 590  
 591              // Add the event we will trigger later.
 592              $params = [
 593                  'objectid' => $attemptobj->get_question_attempt($assumedslotforevents)->get_question_id(),
 594                  'courseid' => $attemptobj->get_courseid(),
 595                  'context' => context_module::instance($attemptobj->get_cmid()),
 596                  'other' => [
 597                      'quizid' => $attemptobj->get_quizid(),
 598                      'attemptid' => $attemptobj->get_attemptid(),
 599                      'slot' => $assumedslotforevents,
 600                  ],
 601              ];
 602              $events[] = \mod_quiz\event\question_manually_graded::create($params);
 603          }
 604          $transaction->allow_commit();
 605  
 606          // Trigger events for all the questions we manually marked.
 607          foreach ($events as $event) {
 608              $event->trigger();
 609          }
 610  
 611          return $attemptsgraded;
 612      }
 613  
 614      /**
 615       * Load information about the number of attempts at various questions in each
 616       * summarystate.
 617       *
 618       * The results are returned as an two dimensional array $qubaid => $slot => $dataobject
 619       *
 620       * @param array $slots A list of slots for the questions you want to konw about.
 621       * @return array The array keys are slot,qestionid. The values are objects with
 622       * fields $slot, $questionid, $inprogress, $name, $needsgrading, $autograded,
 623       * $manuallygraded and $all.
 624       */
 625      protected function get_question_state_summary($slots) {
 626          $dm = new question_engine_data_mapper();
 627          return $dm->load_questions_usages_question_state_summary(
 628                  $this->get_qubaids_condition(), $slots);
 629      }
 630  
 631      /**
 632       * Get a list of usage ids where the question with slot $slot, and optionally
 633       * also with question id $questionid, is in summary state $summarystate. Also
 634       * return the total count of such states.
 635       *
 636       * Only a subset of the ids can be returned by using $orderby, $limitfrom and
 637       * $limitnum. A special value 'random' can be passed as $orderby, in which case
 638       * $limitfrom is ignored.
 639       *
 640       * @param int $slot The slot for the questions you want to konw about.
 641       * @param int $questionid (optional) Only return attempts that were of this specific question.
 642       * @param string $summarystate 'all', 'needsgrading', 'autograded' or 'manuallygraded'.
 643       * @param string $orderby 'random', 'date', 'student' or 'idnumber'.
 644       * @param int $page implements paging of the results.
 645       *      Ignored if $orderby = random or $pagesize is null.
 646       * @param int $pagesize implements paging of the results. null = all.
 647       * @return array with two elements, an array of usage ids, and a count of the total number.
 648       */
 649      protected function get_usage_ids_where_question_in_state($summarystate, $slot,
 650              $questionid = null, $orderby = 'random', $page = 0, $pagesize = null) {
 651          $dm = new question_engine_data_mapper();
 652          $extraselect = '';
 653          if ($pagesize && $orderby != 'random') {
 654              $limitfrom = $page * $pagesize;
 655          } else {
 656              $limitfrom = 0;
 657          }
 658  
 659          $qubaids = $this->get_qubaids_condition();
 660  
 661          $params = [];
 662          $userfieldsapi = \core_user\fields::for_identity($this->context)->with_name();
 663          $userfieldssql = $userfieldsapi->get_sql('u', true, '', 'userid', true);
 664          $params = array_merge($params, $userfieldssql->params);
 665          $customfields = [];
 666          foreach ($userfieldsapi->get_required_fields([\core_user\fields::PURPOSE_IDENTITY]) as $field) {
 667              $customfields[] = $field;
 668          }
 669          if ($orderby === 'date') {
 670              list($statetest, $params) = $dm->in_summary_state_test(
 671                      'manuallygraded', false, 'mangrstate');
 672              $extraselect = "(
 673                      SELECT MAX(sortqas.timecreated)
 674                      FROM {question_attempt_steps} sortqas
 675                      WHERE sortqas.questionattemptid = qa.id
 676                          AND sortqas.state $statetest
 677                      ) as tcreated";
 678              $orderby = "tcreated";
 679          } else if ($orderby === 'studentfirstname' || $orderby === 'studentlastname' || in_array($orderby, $customfields)) {
 680              $qubaids->from .= " JOIN {user} u ON quiza.userid = u.id {$userfieldssql->joins}";
 681              // For name sorting, map orderby form value to
 682              // actual column names; 'idnumber' maps naturally.
 683              if ($orderby === "studentlastname") {
 684                  $orderby = "u.lastname, u.firstname";
 685              } else if ($orderby === "studentfirstname") {
 686                  $orderby = "u.firstname, u.lastname";
 687              } else if (in_array($orderby, $customfields)) { // Sort order by current custom user field.
 688                  $orderby = $userfieldssql->mappings[$orderby];
 689              }
 690          }
 691  
 692          return $dm->load_questions_usages_where_question_in_state($qubaids, $summarystate,
 693                  $slot, $questionid, $orderby, $params, $limitfrom, $pagesize, $extraselect);
 694      }
 695  
 696      /**
 697       * Initialise some parts of $PAGE and start output.
 698       *
 699       * @param stdClass $cm the course_module information.
 700       * @param stdClass $course the course settings.
 701       * @param stdClass $quiz the quiz settings.
 702       * @param string $reportmode the report name.
 703       */
 704      public function print_header_and_tabs($cm, $course, $quiz, $reportmode = 'overview') {
 705          global $PAGE;
 706          $this->renderer = $PAGE->get_renderer('quiz_grading');
 707          parent::print_header_and_tabs($cm, $course, $quiz, $reportmode);
 708      }
 709  
 710      /**
 711       * Get question heading.
 712       *
 713       * @param stdClass $attempt An instance of quiz_attempt.
 714       * @param bool $shownames True to show the student first/lastnames.
 715       * @param bool $showcustomfields Whether custom field values should be shown.
 716       * @return string The string text for the question heading.
 717       */
 718      protected function get_question_heading(stdClass $attempt, bool $shownames, bool $showcustomfields): string {
 719          global $DB;
 720          $a = new stdClass();
 721          $a->attempt = $attempt->attempt;
 722          $a->fullname = fullname($attempt);
 723  
 724          $customfields = [];
 725          foreach ($this->extrauserfields as $field) {
 726              if (strval($attempt->{$field}) !== '') {
 727                  $customfields[] = s($attempt->{$field});
 728              }
 729          }
 730  
 731          $a->customfields = implode(', ', $customfields);
 732  
 733          if ($shownames && $showcustomfields) {
 734              return get_string('gradingattemptwithcustomfields', 'quiz_grading', $a);
 735          } else if ($shownames) {
 736              return get_string('gradingattempt', 'quiz_grading', $a);
 737          } else if ($showcustomfields) {
 738              $a->fullname = $a->customfields;
 739              return get_string('gradingattempt', 'quiz_grading', $a);
 740          } else {
 741              return '';
 742          }
 743      }
 744  }