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