[ Index ]

PHP Cross Reference of moodle-2.8

title

Body

[close]

/question/engine/ -> datalib.php (source)

   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   * Code for loading and saving question attempts to and from the database.
  19   *
  20   * Note that many of the methods of this class should be considered private to
  21   * the question engine. They should be accessed through the
  22   * {@link question_engine} class. For example, you should call
  23   * {@link question_engine::save_questions_usage_by_activity()} rather than
  24   * {@link question_engine_data_mapper::insert_questions_usage_by_activity()}.
  25   * The exception to this is some of the reporting methods, like
  26   * {@link question_engine_data_mapper::load_attempts_at_question()}.
  27   *
  28   * (TODO, probably we should split this class up, so that it has no public
  29   * methods. They should all be moved to a new public class.)
  30   *
  31   * A note for future reference. This code is pretty efficient but there are some
  32   * potential optimisations that could be contemplated, at the cost of making the
  33   * code more complex:
  34   *
  35   * 1. (This is probably not worth doing.) In the unit-of-work save method, we
  36   *    could get all the ids for steps due to be deleted or modified,
  37   *    and delete all the question_attempt_step_data for all of those steps in one
  38   *    query. That would save one DB query for each ->stepsupdated. However that number
  39   *    is 0 except when re-grading, and when regrading, there are many more inserts
  40   *    into question_attempt_step_data than deletes, so it is really hardly worth it.
  41   *
  42   * @package    core_question
  43   * @copyright  2009 The Open University
  44   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  45   */
  46  
  47  
  48  defined('MOODLE_INTERNAL') || die();
  49  
  50  
  51  /**
  52   * This class controls the loading and saving of question engine data to and from
  53   * the database.
  54   *
  55   * @copyright  2009 The Open University
  56   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  57   */
  58  class question_engine_data_mapper {
  59      /**
  60       * @var moodle_database normally points to global $DB, but I prefer not to
  61       * use globals if I can help it.
  62       */
  63      protected $db;
  64  
  65      /**
  66       * @param moodle_database $db a database connectoin. Defaults to global $DB.
  67       */
  68      public function __construct(moodle_database $db = null) {
  69          if (is_null($db)) {
  70              global $DB;
  71              $this->db = $DB;
  72          } else {
  73              $this->db = $db;
  74          }
  75      }
  76  
  77      /**
  78       * Store an entire {@link question_usage_by_activity} in the database,
  79       * including all the question_attempts that comprise it.
  80       *
  81       * You should not call this method directly. You should use
  82       * @link question_engine::save_questions_usage_by_activity()}.
  83       *
  84       * @param question_usage_by_activity $quba the usage to store.
  85       */
  86      public function insert_questions_usage_by_activity(question_usage_by_activity $quba) {
  87          $record = new stdClass();
  88          $record->contextid = $quba->get_owning_context()->id;
  89          $record->component = $quba->get_owning_component();
  90          $record->preferredbehaviour = $quba->get_preferred_behaviour();
  91  
  92          $newid = $this->db->insert_record('question_usages', $record);
  93          $quba->set_id_from_database($newid);
  94  
  95          // Initially an array of array of question_attempt_step_objects.
  96          // Built as a nested array for efficiency, then flattened.
  97          $stepdata = array();
  98  
  99          foreach ($quba->get_attempt_iterator() as $qa) {
 100              $stepdata[] = $this->insert_question_attempt($qa, $quba->get_owning_context());
 101          }
 102  
 103          $stepdata = call_user_func_array('array_merge', $stepdata);
 104          if ($stepdata) {
 105              $this->insert_all_step_data($stepdata);
 106          }
 107      }
 108  
 109      /**
 110       * Store an entire {@link question_attempt} in the database,
 111       * including all the question_attempt_steps that comprise it.
 112       *
 113       * You should not call this method directly. You should use
 114       * @link question_engine::save_questions_usage_by_activity()}.
 115       *
 116       * @param question_attempt $qa the question attempt to store.
 117       * @param context $context the context of the owning question_usage_by_activity.
 118       * @return array of question_attempt_step_data rows, that still need to be inserted.
 119       */
 120      public function insert_question_attempt(question_attempt $qa, $context) {
 121          $record = new stdClass();
 122          $record->questionusageid = $qa->get_usage_id();
 123          $record->slot = $qa->get_slot();
 124          $record->behaviour = $qa->get_behaviour_name();
 125          $record->questionid = $qa->get_question()->id;
 126          $record->variant = $qa->get_variant();
 127          $record->maxmark = $qa->get_max_mark();
 128          $record->minfraction = $qa->get_min_fraction();
 129          $record->maxfraction = $qa->get_max_fraction();
 130          $record->flagged = $qa->is_flagged();
 131          $record->questionsummary = $qa->get_question_summary();
 132          if (core_text::strlen($record->questionsummary) > question_bank::MAX_SUMMARY_LENGTH) {
 133              // It seems some people write very long quesions! MDL-30760
 134              $record->questionsummary = core_text::substr($record->questionsummary,
 135                      0, question_bank::MAX_SUMMARY_LENGTH - 3) . '...';
 136          }
 137          $record->rightanswer = $qa->get_right_answer_summary();
 138          $record->responsesummary = $qa->get_response_summary();
 139          $record->timemodified = time();
 140          $record->id = $this->db->insert_record('question_attempts', $record);
 141          $qa->set_database_id($record->id);
 142  
 143          // Initially an array of array of question_attempt_step_objects.
 144          // Built as a nested array for efficiency, then flattened.
 145          $stepdata = array();
 146  
 147          foreach ($qa->get_step_iterator() as $seq => $step) {
 148              $stepdata[] = $this->insert_question_attempt_step($step, $record->id, $seq, $context);
 149          }
 150  
 151          return call_user_func_array('array_merge', $stepdata);
 152      }
 153  
 154      /**
 155       * Helper method used by insert_question_attempt_step and update_question_attempt_step
 156       * @param question_attempt_step $step the step to store.
 157       * @param int $questionattemptid the question attept id this step belongs to.
 158       * @param int $seq the sequence number of this stop.
 159       * @return stdClass data to insert into the database.
 160       */
 161      protected function make_step_record(question_attempt_step $step, $questionattemptid, $seq) {
 162          $record = new stdClass();
 163          $record->questionattemptid = $questionattemptid;
 164          $record->sequencenumber = $seq;
 165          $record->state = (string) $step->get_state();
 166          $record->fraction = $step->get_fraction();
 167          $record->timecreated = $step->get_timecreated();
 168          $record->userid = $step->get_user_id();
 169          return $record;
 170      }
 171  
 172      /**
 173       * Helper method used by insert_question_attempt_step and update_question_attempt_step
 174       * @param question_attempt_step $step the step to store.
 175       * @param int $stepid the id of the step.
 176       * @param context $context the context of the owning question_usage_by_activity.
 177       * @return array of question_attempt_step_data rows, that still need to be inserted.
 178       */
 179      protected function prepare_step_data(question_attempt_step $step, $stepid, $context) {
 180          $rows = array();
 181          foreach ($step->get_all_data() as $name => $value) {
 182              if ($value instanceof question_file_saver) {
 183                  $value->save_files($stepid, $context);
 184              }
 185              if ($value instanceof question_response_files) {
 186                  $value = (string) $value;
 187              }
 188  
 189              $data = new stdClass();
 190              $data->attemptstepid = $stepid;
 191              $data->name = $name;
 192              $data->value = $value;
 193              $rows[] = $data;
 194          }
 195          return $rows;
 196      }
 197  
 198      /**
 199       * Insert a lot of records into question_attempt_step_data in one go.
 200       *
 201       * Private method, only for use by other parts of the question engine.
 202       *
 203       * @param array $rows the rows to insert.
 204       */
 205      public function insert_all_step_data(array $rows) {
 206          if (!$rows) {
 207              return;
 208          }
 209          $this->db->insert_records('question_attempt_step_data', $rows);
 210      }
 211  
 212      /**
 213       * Store a {@link question_attempt_step} in the database.
 214       *
 215       * Private method, only for use by other parts of the question engine.
 216       *
 217       * @param question_attempt_step $step the step to store.
 218       * @param int $questionattemptid the question attept id this step belongs to.
 219       * @param int $seq the sequence number of this stop.
 220       * @param context $context the context of the owning question_usage_by_activity.
 221       * @return array of question_attempt_step_data rows, that still need to be inserted.
 222       */
 223      public function insert_question_attempt_step(question_attempt_step $step,
 224              $questionattemptid, $seq, $context) {
 225  
 226          $record = $this->make_step_record($step, $questionattemptid, $seq);
 227          $record->id = $this->db->insert_record('question_attempt_steps', $record);
 228  
 229          return $this->prepare_step_data($step, $record->id, $context);
 230      }
 231  
 232      /**
 233       * Update a {@link question_attempt_step} in the database.
 234       *
 235       * Private method, only for use by other parts of the question engine.
 236       *
 237       * @param question_attempt_step $qa the step to store.
 238       * @param int $questionattemptid the question attept id this step belongs to.
 239       * @param int $seq the sequence number of this stop.
 240       * @param context $context the context of the owning question_usage_by_activity.
 241       * @return array of question_attempt_step_data rows, that still need to be inserted.
 242       */
 243      public function update_question_attempt_step(question_attempt_step $step,
 244              $questionattemptid, $seq, $context) {
 245  
 246          $record = $this->make_step_record($step, $questionattemptid, $seq);
 247          $record->id = $step->get_id();
 248          $this->db->update_record('question_attempt_steps', $record);
 249  
 250          $this->db->delete_records('question_attempt_step_data',
 251                  array('attemptstepid' => $record->id));
 252          return $this->prepare_step_data($step, $record->id, $context);
 253      }
 254  
 255      /**
 256       * Load a {@link question_attempt_step} from the database.
 257       *
 258       * Private method, only for use by other parts of the question engine.
 259       *
 260       * @param int $stepid the id of the step to load.
 261       * @param question_attempt_step the step that was loaded.
 262       */
 263      public function load_question_attempt_step($stepid) {
 264          $records = $this->db->get_recordset_sql("
 265  SELECT
 266      quba.contextid,
 267      COALLESCE(q.qtype, 'missingtype') AS qtype,
 268      qas.id AS attemptstepid,
 269      qas.questionattemptid,
 270      qas.sequencenumber,
 271      qas.state,
 272      qas.fraction,
 273      qas.timecreated,
 274      qas.userid,
 275      qasd.name,
 276      qasd.value
 277  
 278  FROM      {question_attempt_steps}     qas
 279  JOIN      {question_attempts}          qa   ON qa.id              = qas.questionattemptid
 280  JOIN      {question_usages}            quba ON quba.id            = qa.questionusageid
 281  LEFT JOIN {question}                   q    ON q.id               = qa.questionid
 282  LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
 283  
 284  WHERE
 285      qas.id = :stepid
 286          ", array('stepid' => $stepid));
 287  
 288          if (!$records->valid()) {
 289              throw new coding_exception('Failed to load question_attempt_step ' . $stepid);
 290          }
 291  
 292          $step = question_attempt_step::load_from_records($records, $stepid);
 293          $records->close();
 294  
 295          return $step;
 296      }
 297  
 298      /**
 299       * Load a {@link question_attempt} from the database, including all its
 300       * steps.
 301       *
 302       * Normally, you should use {@link question_engine::load_questions_usage_by_activity()}
 303       * but there may be rare occasions where for performance reasons, you only
 304       * wish to load one qa, in which case you may call this method.
 305       *
 306       * @param int $questionattemptid the id of the question attempt to load.
 307       * @param question_attempt the question attempt that was loaded.
 308       */
 309      public function load_question_attempt($questionattemptid) {
 310          $records = $this->db->get_recordset_sql("
 311  SELECT
 312      quba.contextid,
 313      quba.preferredbehaviour,
 314      qa.id AS questionattemptid,
 315      qa.questionusageid,
 316      qa.slot,
 317      qa.behaviour,
 318      qa.questionid,
 319      qa.variant,
 320      qa.maxmark,
 321      qa.minfraction,
 322      qa.maxfraction,
 323      qa.flagged,
 324      qa.questionsummary,
 325      qa.rightanswer,
 326      qa.responsesummary,
 327      qa.timemodified,
 328      qas.id AS attemptstepid,
 329      qas.sequencenumber,
 330      qas.state,
 331      qas.fraction,
 332      qas.timecreated,
 333      qas.userid,
 334      qasd.name,
 335      qasd.value
 336  
 337  FROM      {question_attempts}          qa
 338  JOIN      {question_usages}            quba ON quba.id               = qa.questionusageid
 339  LEFT JOIN {question_attempt_steps}     qas  ON qas.questionattemptid = qa.id
 340  LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid    = qas.id
 341  
 342  WHERE
 343      qa.id = :questionattemptid
 344  
 345  ORDER BY
 346      qas.sequencenumber
 347          ", array('questionattemptid' => $questionattemptid));
 348  
 349          if (!$records->valid()) {
 350              throw new coding_exception('Failed to load question_attempt ' . $questionattemptid);
 351          }
 352  
 353          $record = $records->current();
 354          $qa = question_attempt::load_from_records($records, $questionattemptid,
 355                  new question_usage_null_observer(), $record->preferredbehaviour);
 356          $records->close();
 357  
 358          return $qa;
 359      }
 360  
 361      /**
 362       * Load a {@link question_usage_by_activity} from the database, including
 363       * all its {@link question_attempt}s and all their steps.
 364       *
 365       * You should call {@link question_engine::load_questions_usage_by_activity()}
 366       * rather than calling this method directly.
 367       *
 368       * @param int $qubaid the id of the usage to load.
 369       * @param question_usage_by_activity the usage that was loaded.
 370       */
 371      public function load_questions_usage_by_activity($qubaid) {
 372          $records = $this->db->get_recordset_sql("
 373  SELECT
 374      quba.id AS qubaid,
 375      quba.contextid,
 376      quba.component,
 377      quba.preferredbehaviour,
 378      qa.id AS questionattemptid,
 379      qa.questionusageid,
 380      qa.slot,
 381      qa.behaviour,
 382      qa.questionid,
 383      qa.variant,
 384      qa.maxmark,
 385      qa.minfraction,
 386      qa.maxfraction,
 387      qa.flagged,
 388      qa.questionsummary,
 389      qa.rightanswer,
 390      qa.responsesummary,
 391      qa.timemodified,
 392      qas.id AS attemptstepid,
 393      qas.sequencenumber,
 394      qas.state,
 395      qas.fraction,
 396      qas.timecreated,
 397      qas.userid,
 398      qasd.name,
 399      qasd.value
 400  
 401  FROM      {question_usages}            quba
 402  LEFT JOIN {question_attempts}          qa   ON qa.questionusageid    = quba.id
 403  LEFT JOIN {question_attempt_steps}     qas  ON qas.questionattemptid = qa.id
 404  LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid    = qas.id
 405  
 406  WHERE
 407      quba.id = :qubaid
 408  
 409  ORDER BY
 410      qa.slot,
 411      qas.sequencenumber
 412      ", array('qubaid' => $qubaid));
 413  
 414          if (!$records->valid()) {
 415              throw new coding_exception('Failed to load questions_usage_by_activity ' . $qubaid);
 416          }
 417  
 418          $quba = question_usage_by_activity::load_from_records($records, $qubaid);
 419          $records->close();
 420  
 421          return $quba;
 422      }
 423  
 424      /**
 425       * Load all {@link question_usage_by_activity} from the database for one qubaid_condition
 426       * Include all its {@link question_attempt}s and all their steps.
 427       *
 428       * This method may be called publicly.
 429       *
 430       * @param qubaid_condition $qubaids the condition that tells us which usages to load.
 431       * @return question_usage_by_activity[] the usages that were loaded.
 432       */
 433      public function load_questions_usages_by_activity($qubaids) {
 434          $records = $this->db->get_recordset_sql("
 435  SELECT
 436      quba.id AS qubaid,
 437      quba.contextid,
 438      quba.component,
 439      quba.preferredbehaviour,
 440      qa.id AS questionattemptid,
 441      qa.questionusageid,
 442      qa.slot,
 443      qa.behaviour,
 444      qa.questionid,
 445      qa.variant,
 446      qa.maxmark,
 447      qa.minfraction,
 448      qa.maxfraction,
 449      qa.flagged,
 450      qa.questionsummary,
 451      qa.rightanswer,
 452      qa.responsesummary,
 453      qa.timemodified,
 454      qas.id AS attemptstepid,
 455      qas.sequencenumber,
 456      qas.state,
 457      qas.fraction,
 458      qas.timecreated,
 459      qas.userid,
 460      qasd.name,
 461      qasd.value
 462  
 463  FROM      {question_usages}            quba
 464  LEFT JOIN {question_attempts}          qa   ON qa.questionusageid    = quba.id
 465  LEFT JOIN {question_attempt_steps}     qas  ON qas.questionattemptid = qa.id
 466  LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid    = qas.id
 467  
 468  WHERE
 469      quba.id {$qubaids->usage_id_in()}
 470  
 471  ORDER BY
 472      quba.id,
 473      qa.slot,
 474      qas.sequencenumber
 475      ", $qubaids->usage_id_in_params());
 476  
 477          if (!$records->valid()) {
 478              throw new coding_exception('Failed to load questions_usages_by_activity for qubaid_condition :' . $qubaids);
 479          }
 480  
 481          $qubas = array();
 482          do {
 483              $record = $records->current();
 484              $qubas[$record->qubaid] = question_usage_by_activity::load_from_records($records, $record->qubaid);
 485          } while ($records->valid());
 486  
 487          $records->close();
 488  
 489          return $qubas;
 490      }
 491  
 492      /**
 493       * Load information about the latest state of each question from the database.
 494       *
 495       * This method may be called publicly.
 496       *
 497       * @param qubaid_condition $qubaids used to restrict which usages are included
 498       *                                  in the query. See {@link qubaid_condition}.
 499       * @param array            $slots   A list of slots for the questions you want to know about.
 500       * @param string|null      $fields
 501       * @return array of records. See the SQL in this function to see the fields available.
 502       */
 503      public function load_questions_usages_latest_steps(qubaid_condition $qubaids, $slots, $fields = null) {
 504          list($slottest, $params) = $this->db->get_in_or_equal($slots, SQL_PARAMS_NAMED, 'slot');
 505  
 506          if ($fields === null) {
 507              $fields = "qas.id,
 508      qa.id AS questionattemptid,
 509      qa.questionusageid,
 510      qa.slot,
 511      qa.behaviour,
 512      qa.questionid,
 513      qa.variant,
 514      qa.maxmark,
 515      qa.minfraction,
 516      qa.maxfraction,
 517      qa.flagged,
 518      qa.questionsummary,
 519      qa.rightanswer,
 520      qa.responsesummary,
 521      qa.timemodified,
 522      qas.id AS attemptstepid,
 523      qas.sequencenumber,
 524      qas.state,
 525      qas.fraction,
 526      qas.timecreated,
 527      qas.userid";
 528  
 529          }
 530  
 531          $records = $this->db->get_records_sql("
 532  SELECT
 533      {$fields}
 534  
 535  FROM {$qubaids->from_question_attempts('qa')}
 536  JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
 537          AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
 538  
 539  WHERE
 540      {$qubaids->where()} AND
 541      qa.slot $slottest
 542          ", $params + $qubaids->from_where_params());
 543  
 544          return $records;
 545      }
 546  
 547      /**
 548       * Load summary information about the state of each question in a group of
 549       * attempts. This is used, for example, by the quiz manual grading report,
 550       * to show how many attempts at each question need to be graded.
 551       *
 552       * This method may be called publicly.
 553       *
 554       * @param qubaid_condition $qubaids used to restrict which usages are included
 555       * in the query. See {@link qubaid_condition}.
 556       * @param array $slots A list of slots for the questions you want to konw about.
 557       * @return array The array keys are slot,qestionid. The values are objects with
 558       * fields $slot, $questionid, $inprogress, $name, $needsgrading, $autograded,
 559       * $manuallygraded and $all.
 560       */
 561      public function load_questions_usages_question_state_summary(
 562              qubaid_condition $qubaids, $slots) {
 563          list($slottest, $params) = $this->db->get_in_or_equal($slots, SQL_PARAMS_NAMED, 'slot');
 564  
 565          $rs = $this->db->get_recordset_sql("
 566  SELECT
 567      qa.slot,
 568      qa.questionid,
 569      q.name,
 570      CASE qas.state
 571          {$this->full_states_to_summary_state_sql()}
 572      END AS summarystate,
 573      COUNT(1) AS numattempts
 574  
 575  FROM {$qubaids->from_question_attempts('qa')}
 576  JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
 577          AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
 578  JOIN {question} q ON q.id = qa.questionid
 579  
 580  WHERE
 581      {$qubaids->where()} AND
 582      qa.slot $slottest
 583  
 584  GROUP BY
 585      qa.slot,
 586      qa.questionid,
 587      q.name,
 588      q.id,
 589      CASE qas.state
 590          {$this->full_states_to_summary_state_sql()}
 591      END
 592  
 593  ORDER BY
 594      qa.slot,
 595      qa.questionid,
 596      q.name,
 597      q.id
 598          ", $params + $qubaids->from_where_params());
 599  
 600          $results = array();
 601          foreach ($rs as $row) {
 602              $index = $row->slot . ',' . $row->questionid;
 603  
 604              if (!array_key_exists($index, $results)) {
 605                  $res = new stdClass();
 606                  $res->slot = $row->slot;
 607                  $res->questionid = $row->questionid;
 608                  $res->name = $row->name;
 609                  $res->inprogress = 0;
 610                  $res->needsgrading = 0;
 611                  $res->autograded = 0;
 612                  $res->manuallygraded = 0;
 613                  $res->all = 0;
 614                  $results[$index] = $res;
 615              }
 616  
 617              $results[$index]->{$row->summarystate} = $row->numattempts;
 618              $results[$index]->all += $row->numattempts;
 619          }
 620          $rs->close();
 621  
 622          return $results;
 623      }
 624  
 625      /**
 626       * Get a list of usage ids where the question with slot $slot, and optionally
 627       * also with question id $questionid, is in summary state $summarystate. Also
 628       * return the total count of such states.
 629       *
 630       * Only a subset of the ids can be returned by using $orderby, $limitfrom and
 631       * $limitnum. A special value 'random' can be passed as $orderby, in which case
 632       * $limitfrom is ignored.
 633       *
 634       * This method may be called publicly.
 635       *
 636       * @param qubaid_condition $qubaids used to restrict which usages are included
 637       * in the query. See {@link qubaid_condition}.
 638       * @param int $slot The slot for the questions you want to konw about.
 639       * @param int $questionid (optional) Only return attempts that were of this specific question.
 640       * @param string $summarystate the summary state of interest, or 'all'.
 641       * @param string $orderby the column to order by.
 642       * @param array $params any params required by any of the SQL fragments.
 643       * @param int $limitfrom implements paging of the results.
 644       *      Ignored if $orderby = random or $limitnum is null.
 645       * @param int $limitnum implements paging of the results. null = all.
 646       * @return array with two elements, an array of usage ids, and a count of the total number.
 647       */
 648      public function load_questions_usages_where_question_in_state(
 649              qubaid_condition $qubaids, $summarystate, $slot, $questionid = null,
 650              $orderby = 'random', $params = array(), $limitfrom = 0, $limitnum = null) {
 651  
 652          $extrawhere = '';
 653          if ($questionid) {
 654              $extrawhere .= ' AND qa.questionid = :questionid';
 655              $params['questionid'] = $questionid;
 656          }
 657          if ($summarystate != 'all') {
 658              list($test, $sparams) = $this->in_summary_state_test($summarystate);
 659              $extrawhere .= ' AND qas.state ' . $test;
 660              $params += $sparams;
 661          }
 662  
 663          if ($orderby == 'random') {
 664              $sqlorderby = '';
 665          } else if ($orderby) {
 666              $sqlorderby = 'ORDER BY ' . $orderby;
 667          } else {
 668              $sqlorderby = '';
 669          }
 670  
 671          // We always want the total count, as well as the partcular list of ids
 672          // based on the paging and sort order. Because the list of ids is never
 673          // going to be too ridiculously long. My worst-case scenario is
 674          // 10,000 students in the course, each doing 5 quiz attempts. That
 675          // is a 50,000 element int => int array, which PHP seems to use 5MB
 676          // memory to store on a 64 bit server.
 677          $qubaidswhere = $qubaids->where(); // Must call this before params.
 678          $params += $qubaids->from_where_params();
 679          $params['slot'] = $slot;
 680  
 681          $qubaids = $this->db->get_records_sql_menu("
 682  SELECT
 683      qa.questionusageid,
 684      1
 685  
 686  FROM {$qubaids->from_question_attempts('qa')}
 687  JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
 688          AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
 689  JOIN {question} q ON q.id = qa.questionid
 690  
 691  WHERE
 692      {$qubaidswhere} AND
 693      qa.slot = :slot
 694      $extrawhere
 695  
 696  $sqlorderby
 697          ", $params);
 698  
 699          $qubaids = array_keys($qubaids);
 700          $count = count($qubaids);
 701  
 702          if ($orderby == 'random') {
 703              shuffle($qubaids);
 704              $limitfrom = 0;
 705          }
 706  
 707          if (!is_null($limitnum)) {
 708              $qubaids = array_slice($qubaids, $limitfrom, $limitnum);
 709          }
 710  
 711          return array($qubaids, $count);
 712      }
 713  
 714      /**
 715       * Load the average mark, and number of attempts, for each slot in a set of
 716       * question usages..
 717       *
 718       * This method may be called publicly.
 719       *
 720       * @param qubaid_condition $qubaids used to restrict which usages are included
 721       * in the query. See {@link qubaid_condition}.
 722       * @param array $slots if null, load info for all quesitions, otherwise only
 723       * load the averages for the specified questions.
 724       * @return array of objects with fields ->slot, ->averagefraction and ->numaveraged.
 725       */
 726      public function load_average_marks(qubaid_condition $qubaids, $slots = null) {
 727          if (!empty($slots)) {
 728              list($slottest, $slotsparams) = $this->db->get_in_or_equal(
 729                      $slots, SQL_PARAMS_NAMED, 'slot');
 730              $slotwhere = " AND qa.slot {$slottest}";
 731          } else {
 732              $slotwhere = '';
 733              $slotsparams = array();
 734          }
 735  
 736          list($statetest, $stateparams) = $this->db->get_in_or_equal(array(
 737                  (string) question_state::$gaveup,
 738                  (string) question_state::$gradedwrong,
 739                  (string) question_state::$gradedpartial,
 740                  (string) question_state::$gradedright,
 741                  (string) question_state::$mangaveup,
 742                  (string) question_state::$mangrwrong,
 743                  (string) question_state::$mangrpartial,
 744                  (string) question_state::$mangrright), SQL_PARAMS_NAMED, 'st');
 745  
 746          return $this->db->get_records_sql("
 747  SELECT
 748      qa.slot,
 749      AVG(COALESCE(qas.fraction, 0)) AS averagefraction,
 750      COUNT(1) AS numaveraged
 751  
 752  FROM {$qubaids->from_question_attempts('qa')}
 753  JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
 754          AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
 755  
 756  WHERE
 757      {$qubaids->where()}
 758      $slotwhere
 759      AND qas.state $statetest
 760  
 761  GROUP BY qa.slot
 762  
 763  ORDER BY qa.slot
 764          ", $slotsparams + $stateparams + $qubaids->from_where_params());
 765      }
 766  
 767      /**
 768       * Load all the attempts at a given queston from a set of question_usages.
 769       * steps.
 770       *
 771       * This method may be called publicly.
 772       *
 773       * @param int $questionid the question to load all the attempts fors.
 774       * @param qubaid_condition $qubaids used to restrict which usages are included
 775       * in the query. See {@link qubaid_condition}.
 776       * @return question_attempt[] array of question_attempts that were loaded.
 777       */
 778      public function load_attempts_at_question($questionid, qubaid_condition $qubaids) {
 779          $sql = "
 780  SELECT
 781      quba.contextid,
 782      quba.preferredbehaviour,
 783      qa.id AS questionattemptid,
 784      qa.questionusageid,
 785      qa.slot,
 786      qa.behaviour,
 787      qa.questionid,
 788      qa.variant,
 789      qa.maxmark,
 790      qa.minfraction,
 791      qa.maxfraction,
 792      qa.flagged,
 793      qa.questionsummary,
 794      qa.rightanswer,
 795      qa.responsesummary,
 796      qa.timemodified,
 797      qas.id AS attemptstepid,
 798      qas.sequencenumber,
 799      qas.state,
 800      qas.fraction,
 801      qas.timecreated,
 802      qas.userid,
 803      qasd.name,
 804      qasd.value
 805  
 806  FROM {$qubaids->from_question_attempts('qa')}
 807  JOIN {question_usages} quba ON quba.id = qa.questionusageid
 808  LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
 809  LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
 810  
 811  WHERE
 812      {$qubaids->where()} AND
 813      qa.questionid = :questionid
 814  
 815  ORDER BY
 816      quba.id,
 817      qa.id,
 818      qas.sequencenumber";
 819  
 820          // For qubaid_list must call this after calling methods that generate sql.
 821          $params = $qubaids->from_where_params();
 822          $params['questionid'] = $questionid;
 823  
 824          $records = $this->db->get_recordset_sql($sql, $params);
 825  
 826          $questionattempts = array();
 827          while ($records->valid()) {
 828              $record = $records->current();
 829              $questionattempts[$record->questionattemptid] =
 830                      question_attempt::load_from_records($records,
 831                      $record->questionattemptid, new question_usage_null_observer(),
 832                      $record->preferredbehaviour);
 833          }
 834          $records->close();
 835  
 836          return $questionattempts;
 837      }
 838  
 839      /**
 840       * Update a question_usages row to refect any changes in a usage (but not
 841       * any of its question_attempts.
 842       *
 843       * You should not call this method directly. You should use
 844       * @link question_engine::save_questions_usage_by_activity()}.
 845       *
 846       * @param question_usage_by_activity $quba the usage that has changed.
 847       */
 848      public function update_questions_usage_by_activity(question_usage_by_activity $quba) {
 849          $record = new stdClass();
 850          $record->id = $quba->get_id();
 851          $record->contextid = $quba->get_owning_context()->id;
 852          $record->component = $quba->get_owning_component();
 853          $record->preferredbehaviour = $quba->get_preferred_behaviour();
 854  
 855          $this->db->update_record('question_usages', $record);
 856      }
 857  
 858      /**
 859       * Update a question_attempts row to refect any changes in a question_attempt
 860       * (but not any of its steps).
 861       *
 862       * You should not call this method directly. You should use
 863       * @link question_engine::save_questions_usage_by_activity()}.
 864       *
 865       * @param question_attempt $qa the question attempt that has changed.
 866       */
 867      public function update_question_attempt(question_attempt $qa) {
 868          $record = new stdClass();
 869          $record->id = $qa->get_database_id();
 870          $record->variant = $qa->get_variant();
 871          $record->maxmark = $qa->get_max_mark();
 872          $record->minfraction = $qa->get_min_fraction();
 873          $record->maxfraction = $qa->get_max_fraction();
 874          $record->flagged = $qa->is_flagged();
 875          $record->questionsummary = $qa->get_question_summary();
 876          $record->rightanswer = $qa->get_right_answer_summary();
 877          $record->responsesummary = $qa->get_response_summary();
 878          $record->timemodified = time();
 879  
 880          $this->db->update_record('question_attempts', $record);
 881      }
 882  
 883      /**
 884       * Delete a question_usage_by_activity and all its associated
 885       *
 886       * You should not call this method directly. You should use
 887       * @link question_engine::delete_questions_usage_by_activities()}.
 888       *
 889       * {@link question_attempts} and {@link question_attempt_steps} from the
 890       * database.
 891       * @param qubaid_condition $qubaids identifies which question useages to delete.
 892       */
 893      public function delete_questions_usage_by_activities(qubaid_condition $qubaids) {
 894          $where = "qa.questionusageid {$qubaids->usage_id_in()}";
 895          $params = $qubaids->usage_id_in_params();
 896  
 897          $contextids = $this->db->get_records_sql_menu("
 898                  SELECT DISTINCT contextid, 1
 899                  FROM {question_usages}
 900                  WHERE id {$qubaids->usage_id_in()}", $qubaids->usage_id_in_params());
 901          foreach ($contextids as $contextid => $notused) {
 902              $this->delete_response_files($contextid, "IN (
 903                      SELECT qas.id
 904                      FROM {question_attempts} qa
 905                      JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
 906                      WHERE $where)", $params);
 907          }
 908  
 909          if ($this->db->get_dbfamily() == 'mysql') {
 910              $this->delete_usage_records_for_mysql($qubaids);
 911              return;
 912          }
 913  
 914          $this->db->delete_records_select('question_attempt_step_data', "attemptstepid IN (
 915                  SELECT qas.id
 916                  FROM {question_attempts} qa
 917                  JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
 918                  WHERE $where)", $params);
 919  
 920          $this->db->delete_records_select('question_attempt_steps', "questionattemptid IN (
 921                  SELECT qa.id
 922                  FROM {question_attempts} qa
 923                  WHERE $where)", $params);
 924  
 925          $this->db->delete_records_select('question_attempts',
 926                  "{question_attempts}.questionusageid {$qubaids->usage_id_in()}",
 927                  $qubaids->usage_id_in_params());
 928  
 929          $this->db->delete_records_select('question_usages',
 930                  "{question_usages}.id {$qubaids->usage_id_in()}", $qubaids->usage_id_in_params());
 931      }
 932  
 933      /**
 934       * This function is a work-around for poor MySQL performance with
 935       * DELETE FROM x WHERE id IN (SELECT ...). We have to use a non-standard
 936       * syntax to get good performance. See MDL-29520.
 937       * @param qubaid_condition $qubaids identifies which question useages to delete.
 938       */
 939      protected function delete_usage_records_for_mysql(qubaid_condition $qubaids) {
 940          $qubaidtest = $qubaids->usage_id_in();
 941          if (strpos($qubaidtest, 'question_usages') !== false &&
 942                  strpos($qubaidtest, 'IN (SELECT') === 0) {
 943              // This horrible hack is required by MDL-29847. It comes from
 944              // http://www.xaprb.com/blog/2006/06/23/how-to-select-from-an-update-target-in-mysql/
 945              $qubaidtest = 'IN (SELECT * FROM ' . substr($qubaidtest, 3) . ' AS hack_subquery_alias)';
 946          }
 947  
 948          // TODO once MDL-29589 is fixed, eliminate this method, and instead use the new $DB API.
 949          $this->db->execute('
 950                  DELETE qu, qa, qas, qasd
 951                    FROM {question_usages}            qu
 952                    JOIN {question_attempts}          qa   ON qa.questionusageid = qu.id
 953               LEFT JOIN {question_attempt_steps}     qas  ON qas.questionattemptid = qa.id
 954               LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
 955                   WHERE qu.id ' . $qubaidtest,
 956                  $qubaids->usage_id_in_params());
 957      }
 958  
 959      /**
 960       * Delete all the steps for a question attempt.
 961       *
 962       * Private method, only for use by other parts of the question engine.
 963       *
 964       * @param int $qaids question_attempt id.
 965       * @param context $context the context that the $quba belongs to.
 966       */
 967      public function delete_steps($stepids, $context) {
 968          if (empty($stepids)) {
 969              return;
 970          }
 971          list($test, $params) = $this->db->get_in_or_equal($stepids, SQL_PARAMS_NAMED);
 972  
 973          $this->delete_response_files($context->id, $test, $params);
 974  
 975          $this->db->delete_records_select('question_attempt_step_data',
 976                  "attemptstepid {$test}", $params);
 977          $this->db->delete_records_select('question_attempt_steps',
 978                  "id {$test}", $params);
 979      }
 980  
 981      /**
 982       * Delete all the files belonging to the response variables in the gives
 983       * question attempt steps.
 984       * @param int $contextid the context these attempts belong to.
 985       * @param string $itemidstest a bit of SQL that can be used in a
 986       *      WHERE itemid $itemidstest clause. Must use named params.
 987       * @param array $params any query parameters used in $itemidstest.
 988       */
 989      protected function delete_response_files($contextid, $itemidstest, $params) {
 990          $fs = get_file_storage();
 991          foreach (question_engine::get_all_response_file_areas() as $filearea) {
 992              $fs->delete_area_files_select($contextid, 'question', $filearea,
 993                      $itemidstest, $params);
 994          }
 995      }
 996  
 997      /**
 998       * Delete all the previews for a given question.
 999       *
1000       * Private method, only for use by other parts of the question engine.
1001       *
1002       * @param int $questionid question id.
1003       */
1004      public function delete_previews($questionid) {
1005          $previews = $this->db->get_records_sql_menu("
1006                  SELECT DISTINCT quba.id, 1
1007                  FROM {question_usages} quba
1008                  JOIN {question_attempts} qa ON qa.questionusageid = quba.id
1009                  WHERE quba.component = 'core_question_preview' AND
1010                      qa.questionid = ?", array($questionid));
1011          if (empty($previews)) {
1012              return;
1013          }
1014          $this->delete_questions_usage_by_activities(new qubaid_list($previews));
1015      }
1016  
1017      /**
1018       * Update the flagged state of a question in the database.
1019       *
1020       * You should call {@link question_engine::update_flag()()}
1021       * rather than calling this method directly.
1022       *
1023       * @param int $qubaid the question usage id.
1024       * @param int $questionid the question id.
1025       * @param int $sessionid the question_attempt id.
1026       * @param bool $newstate the new state of the flag. true = flagged.
1027       */
1028      public function update_question_attempt_flag($qubaid, $questionid, $qaid, $slot, $newstate) {
1029          if (!$this->db->record_exists('question_attempts', array('id' => $qaid,
1030                  'questionusageid' => $qubaid, 'questionid' => $questionid, 'slot' => $slot))) {
1031              throw new moodle_exception('errorsavingflags', 'question');
1032          }
1033  
1034          $this->db->set_field('question_attempts', 'flagged', $newstate, array('id' => $qaid));
1035      }
1036  
1037      /**
1038       * Get all the WHEN 'x' THEN 'y' terms needed to convert the question_attempt_steps.state
1039       * column to a summary state. Use this like
1040       * CASE qas.state {$this->full_states_to_summary_state_sql()} END AS summarystate,
1041       * @param string SQL fragment.
1042       */
1043      protected function full_states_to_summary_state_sql() {
1044          $sql = '';
1045          foreach (question_state::get_all() as $state) {
1046              $sql .= "WHEN '{$state}' THEN '{$state->get_summary_state()}'\n";
1047          }
1048          return $sql;
1049      }
1050  
1051      /**
1052       * Get the SQL needed to test that question_attempt_steps.state is in a
1053       * state corresponding to $summarystate.
1054       *
1055       * This method may be called publicly.
1056       *
1057       * @param string $summarystate one of
1058       * inprogress, needsgrading, manuallygraded or autograded
1059       * @param bool $equal if false, do a NOT IN test. Default true.
1060       * @return string SQL fragment.
1061       */
1062      public function in_summary_state_test($summarystate, $equal = true, $prefix = 'summarystates') {
1063          $states = question_state::get_all_for_summary_state($summarystate);
1064          return $this->db->get_in_or_equal(array_map('strval', $states),
1065                  SQL_PARAMS_NAMED, $prefix, $equal);
1066      }
1067  
1068      /**
1069       * Change the maxmark for the question_attempt with number in usage $slot
1070       * for all the specified question_attempts.
1071       *
1072       * You should call {@link question_engine::set_max_mark_in_attempts()}
1073       * rather than calling this method directly.
1074       *
1075       * @param qubaid_condition $qubaids Selects which usages are updated.
1076       * @param int $slot the number is usage to affect.
1077       * @param number $newmaxmark the new max mark to set.
1078       */
1079      public function set_max_mark_in_attempts(qubaid_condition $qubaids, $slot, $newmaxmark) {
1080          if ($this->db->get_dbfamily() == 'mysql') {
1081              // MySQL's query optimiser completely fails to cope with the
1082              // set_field_select call below, so we have to give it a clue. See MDL-32616.
1083              // TODO MDL-29589 encapsulate this MySQL-specific code with a $DB method.
1084              $this->db->execute("
1085                      UPDATE " . $qubaids->from_question_attempts('qa') . "
1086                         SET qa.maxmark = :newmaxmark
1087                       WHERE " . $qubaids->where() . "
1088                         AND slot = :slot
1089                      ", $qubaids->from_where_params() + array('newmaxmark' => $newmaxmark, 'slot' => $slot));
1090              return;
1091          }
1092  
1093          // Normal databases.
1094          $this->db->set_field_select('question_attempts', 'maxmark', $newmaxmark,
1095                  "questionusageid {$qubaids->usage_id_in()} AND slot = :slot",
1096                  $qubaids->usage_id_in_params() + array('slot' => $slot));
1097      }
1098  
1099      /**
1100       * Return a subquery that computes the sum of the marks for all the questions
1101       * in a usage. Which useage to compute the sum for is controlled bu the $qubaid
1102       * parameter.
1103       *
1104       * See {@link quiz_update_all_attempt_sumgrades()} for an example of the usage of
1105       * this method.
1106       *
1107       * This method may be called publicly.
1108       *
1109       * @param string $qubaid SQL fragment that controls which usage is summed.
1110       * This will normally be the name of a column in the outer query. Not that this
1111       * SQL fragment must not contain any placeholders.
1112       * @return string SQL code for the subquery.
1113       */
1114      public function sum_usage_marks_subquery($qubaid) {
1115          // To explain the COALESCE in the following SQL: SUM(lots of NULLs) gives
1116          // NULL, while SUM(one 0.0 and lots of NULLS) gives 0.0. We don't want that.
1117          // We always want to return a number, so the COALESCE is there to turn the
1118          // NULL total into a 0.
1119          return "SELECT COALESCE(SUM(qa.maxmark * qas.fraction), 0)
1120              FROM {question_attempts} qa
1121              JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
1122                      AND qas.sequencenumber = (
1123                              SELECT MAX(summarks_qas.sequencenumber)
1124                                FROM {question_attempt_steps} summarks_qas
1125                               WHERE summarks_qas.questionattemptid = qa.id
1126              )
1127              WHERE qa.questionusageid = $qubaid
1128              HAVING COUNT(CASE
1129                  WHEN qas.state = 'needsgrading' AND qa.maxmark > 0 THEN 1
1130                  ELSE NULL
1131              END) = 0";
1132      }
1133  
1134      /**
1135       * Get a subquery that returns the latest step of every qa in some qubas.
1136       * Currently, this is only used by the quiz reports. See
1137       * {@link quiz_attempts_report_table::add_latest_state_join()}.
1138       *
1139       * This method may be called publicly.
1140       *
1141       * @param string $alias alias to use for this inline-view.
1142       * @param qubaid_condition $qubaids restriction on which question_usages we
1143       *      are interested in. This is important for performance.
1144       * @return array with two elements, the SQL fragment and any params requried.
1145       */
1146      public function question_attempt_latest_state_view($alias, qubaid_condition $qubaids) {
1147          return array("(
1148                  SELECT {$alias}qa.id AS questionattemptid,
1149                         {$alias}qa.questionusageid,
1150                         {$alias}qa.slot,
1151                         {$alias}qa.behaviour,
1152                         {$alias}qa.questionid,
1153                         {$alias}qa.variant,
1154                         {$alias}qa.maxmark,
1155                         {$alias}qa.minfraction,
1156                         {$alias}qa.maxfraction,
1157                         {$alias}qa.flagged,
1158                         {$alias}qa.questionsummary,
1159                         {$alias}qa.rightanswer,
1160                         {$alias}qa.responsesummary,
1161                         {$alias}qa.timemodified,
1162                         {$alias}qas.id AS attemptstepid,
1163                         {$alias}qas.sequencenumber,
1164                         {$alias}qas.state,
1165                         {$alias}qas.fraction,
1166                         {$alias}qas.timecreated,
1167                         {$alias}qas.userid
1168  
1169                    FROM {$qubaids->from_question_attempts($alias . 'qa')}
1170                    JOIN {question_attempt_steps} {$alias}qas ON {$alias}qas.questionattemptid = {$alias}qa.id
1171                              AND {$alias}qas.sequencenumber = {$this->latest_step_for_qa_subquery($alias . 'qa.id')}
1172                   WHERE {$qubaids->where()}
1173              ) {$alias}", $qubaids->from_where_params());
1174      }
1175  
1176      protected function latest_step_for_qa_subquery($questionattemptid = 'qa.id') {
1177          return "(
1178                  SELECT MAX(sequencenumber)
1179                  FROM {question_attempt_steps}
1180                  WHERE questionattemptid = $questionattemptid
1181              )";
1182      }
1183  
1184      /**
1185       * Are any of these questions are currently in use?
1186       *
1187       * You should call {@link question_engine::questions_in_use()}
1188       * rather than calling this method directly.
1189       *
1190       * @param array $questionids of question ids.
1191       * @param qubaid_condition $qubaids ids of the usages to consider.
1192       * @return bool whether any of these questions are being used by any of
1193       *      those usages.
1194       */
1195      public function questions_in_use(array $questionids, qubaid_condition $qubaids) {
1196          list($test, $params) = $this->db->get_in_or_equal($questionids);
1197          return $this->db->record_exists_select('question_attempts',
1198                  'questionid ' . $test . ' AND questionusageid ' .
1199                  $qubaids->usage_id_in(), $params + $qubaids->usage_id_in_params());
1200      }
1201  }
1202  
1203  
1204  /**
1205   * Implementation of the unit of work pattern for the question engine.
1206   *
1207   * See http://martinfowler.com/eaaCatalog/unitOfWork.html. This tracks all the
1208   * changes to a {@link question_usage_by_activity}, and its constituent parts,
1209   * so that the changes can be saved to the database when {@link save()} is called.
1210   *
1211   * @copyright  2009 The Open University
1212   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1213   */
1214  class question_engine_unit_of_work implements question_usage_observer {
1215      /** @var question_usage_by_activity the usage being tracked. */
1216      protected $quba;
1217  
1218      /** @var boolean whether any of the fields of the usage have been changed. */
1219      protected $modified = false;
1220  
1221      /**
1222       * @var array list of slot => {@link question_attempt}s that
1223       * were already in the usage, and which have been modified.
1224       */
1225      protected $attemptsmodified = array();
1226  
1227      /**
1228       * @var array list of slot => {@link question_attempt}s that
1229       * have been added to the usage.
1230       */
1231      protected $attemptsadded = array();
1232  
1233      /**
1234       * @var array of array(question_attempt_step, question_attempt id, seq number)
1235       * of steps that have been added to question attempts in this usage.
1236       */
1237      protected $stepsadded = array();
1238  
1239      /**
1240       * @var array of array(question_attempt_step, question_attempt id, seq number)
1241       * of steps that have been modified in their attempt.
1242       */
1243      protected $stepsmodified = array();
1244  
1245      /**
1246       * @var array list of question_attempt_step.id => question_attempt_step of steps
1247       * that were previously stored in the database, but which are no longer required.
1248       */
1249      protected $stepsdeleted = array();
1250  
1251      /**
1252       * Constructor.
1253       * @param question_usage_by_activity $quba the usage to track.
1254       */
1255      public function __construct(question_usage_by_activity $quba) {
1256          $this->quba = $quba;
1257      }
1258  
1259      public function notify_modified() {
1260          $this->modified = true;
1261      }
1262  
1263      public function notify_attempt_modified(question_attempt $qa) {
1264          $slot = $qa->get_slot();
1265          if (!array_key_exists($slot, $this->attemptsadded)) {
1266              $this->attemptsmodified[$slot] = $qa;
1267          }
1268      }
1269  
1270      public function notify_attempt_added(question_attempt $qa) {
1271          $this->attemptsadded[$qa->get_slot()] = $qa;
1272      }
1273  
1274      public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq) {
1275          if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
1276              return;
1277          }
1278  
1279          if (($key = $this->is_step_added($step)) !== false) {
1280              return;
1281          }
1282  
1283          if (($key = $this->is_step_modified($step)) !== false) {
1284              throw new coding_exception('Cannot add a step that has already been modified.');
1285          }
1286  
1287          if (($key = $this->is_step_deleted($step)) !== false) {
1288              unset($this->stepsdeleted[$step->get_id()]);
1289              $this->stepsmodified[] = array($step, $qa->get_database_id(), $seq);
1290              return;
1291          }
1292  
1293          $stepid = $step->get_id();
1294          if ($stepid) {
1295              if (array_key_exists($stepid, $this->stepsdeleted)) {
1296                  unset($this->stepsdeleted[$stepid]);
1297              }
1298              $this->stepsmodified[] = array($step, $qa->get_database_id(), $seq);
1299  
1300          } else {
1301              $this->stepsadded[] = array($step, $qa->get_database_id(), $seq);
1302          }
1303      }
1304  
1305      public function notify_step_modified(question_attempt_step $step, question_attempt $qa, $seq) {
1306          if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
1307              return;
1308          }
1309  
1310          if (($key = $this->is_step_added($step)) !== false) {
1311              return;
1312          }
1313  
1314          if (($key = $this->is_step_deleted($step)) !== false) {
1315              throw new coding_exception('Cannot modify a step after it has been deleted.');
1316          }
1317  
1318          $stepid = $step->get_id();
1319          if (empty($stepid)) {
1320              throw new coding_exception('Cannot modify a step that has never been stored in the database.');
1321          }
1322  
1323          $this->stepsmodified[] = array($step, $qa->get_database_id(), $seq);
1324      }
1325  
1326      public function notify_step_deleted(question_attempt_step $step, question_attempt $qa) {
1327          if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
1328              return;
1329          }
1330  
1331          if (($key = $this->is_step_added($step)) !== false) {
1332              unset($this->stepsadded[$key]);
1333              return;
1334          }
1335  
1336          if (($key = $this->is_step_modified($step)) !== false) {
1337              unset($this->stepsmodified[$key]);
1338          }
1339  
1340          $stepid = $step->get_id();
1341          if (empty($stepid)) {
1342              return; // Was never in the database.
1343          }
1344  
1345          $this->stepsdeleted[$stepid] = $step;
1346      }
1347  
1348      /**
1349       * @param question_attempt_step $step a step
1350       * @return int|false if the step is in the list of steps to be added, return
1351       *      the key, otherwise return false.
1352       */
1353      protected function is_step_added(question_attempt_step $step) {
1354          foreach ($this->stepsadded as $key => $data) {
1355              list($addedstep, $qaid, $seq) = $data;
1356              if ($addedstep === $step) {
1357                  return $key;
1358              }
1359          }
1360          return false;
1361      }
1362  
1363      /**
1364       * @param question_attempt_step $step a step
1365       * @return int|false if the step is in the list of steps to be modified, return
1366       *      the key, otherwise return false.
1367       */
1368      protected function is_step_modified(question_attempt_step $step) {
1369          foreach ($this->stepsmodified as $key => $data) {
1370              list($modifiedstep, $qaid, $seq) = $data;
1371              if ($modifiedstep === $step) {
1372                  return $key;
1373              }
1374          }
1375          return false;
1376      }
1377  
1378      /**
1379       * @param question_attempt_step $step a step
1380       * @return bool whether the step is in the list of steps to be deleted.
1381       */
1382      protected function is_step_deleted(question_attempt_step $step) {
1383          foreach ($this->stepsdeleted as $deletedstep) {
1384              if ($deletedstep === $step) {
1385                  return true;
1386              }
1387          }
1388          return false;
1389      }
1390  
1391      /**
1392       * Write all the changes we have recorded to the database.
1393       * @param question_engine_data_mapper $dm the mapper to use to update the database.
1394       */
1395      public function save(question_engine_data_mapper $dm) {
1396          $dm->delete_steps(array_keys($this->stepsdeleted), $this->quba->get_owning_context());
1397  
1398          // Initially an array of array of question_attempt_step_objects.
1399          // Built as a nested array for efficiency, then flattened.
1400          $stepdata = array();
1401  
1402          foreach ($this->stepsmodified as $stepinfo) {
1403              list($step, $questionattemptid, $seq) = $stepinfo;
1404              $stepdata[] = $dm->update_question_attempt_step(
1405                      $step, $questionattemptid, $seq, $this->quba->get_owning_context());
1406          }
1407  
1408          foreach ($this->stepsadded as $stepinfo) {
1409              list($step, $questionattemptid, $seq) = $stepinfo;
1410              $stepdata[] = $dm->insert_question_attempt_step(
1411                      $step, $questionattemptid, $seq, $this->quba->get_owning_context());
1412          }
1413  
1414          foreach ($this->attemptsadded as $qa) {
1415              $stepdata[] = $dm->insert_question_attempt(
1416                      $qa, $this->quba->get_owning_context());
1417          }
1418  
1419          foreach ($this->attemptsmodified as $qa) {
1420              $dm->update_question_attempt($qa);
1421          }
1422  
1423          if ($this->modified) {
1424              $dm->update_questions_usage_by_activity($this->quba);
1425          }
1426  
1427          if (!$stepdata) {
1428              return;
1429          }
1430          $dm->insert_all_step_data(call_user_func_array('array_merge', $stepdata));
1431      }
1432  }
1433  
1434  
1435  /**
1436   * The interface implemented by {@link question_file_saver} and {@link question_file_loader}.
1437   *
1438   * @copyright  2012 The Open University
1439   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1440   */
1441  interface question_response_files {
1442      /**
1443       * Get the files that were submitted.
1444       * @return array of stored_files objects.
1445       */
1446      public function get_files();
1447  }
1448  
1449  
1450  /**
1451   * This class represents the promise to save some files from a particular draft
1452   * file area into a particular file area. It is used beause the necessary
1453   * information about what to save is to hand in the
1454   * {@link question_attempt::process_response_files()} method, but we don't know
1455   * if this question attempt will actually be saved in the database until later,
1456   * when the {@link question_engine_unit_of_work} is saved, if it is.
1457   *
1458   * @copyright  2011 The Open University
1459   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1460   */
1461  class question_file_saver implements question_response_files {
1462      /** @var int the id of the draft file area to save files from. */
1463      protected $draftitemid;
1464      /** @var string the owning component name. */
1465      protected $component;
1466      /** @var string the file area name. */
1467      protected $filearea;
1468  
1469      /**
1470       * @var string the value to store in the question_attempt_step_data to
1471       * represent these files.
1472       */
1473      protected $value = null;
1474  
1475      /**
1476       * Constuctor.
1477       * @param int $draftitemid the draft area to save the files from.
1478       * @param string $component the component for the file area to save into.
1479       * @param string $filearea the name of the file area to save into.
1480       */
1481      public function __construct($draftitemid, $component, $filearea, $text = null) {
1482          $this->draftitemid = $draftitemid;
1483          $this->component = $component;
1484          $this->filearea = $filearea;
1485          $this->value = $this->compute_value($draftitemid, $text);
1486      }
1487  
1488      /**
1489       * Compute the value that should be stored in the question_attempt_step_data
1490       * table. Contains a hash that (almost) uniquely encodes all the files.
1491       * @param int $draftitemid the draft file area itemid.
1492       * @param string $text optional content containing file links.
1493       */
1494      protected function compute_value($draftitemid, $text) {
1495          global $USER;
1496  
1497          $fs = get_file_storage();
1498          $usercontext = context_user::instance($USER->id);
1499  
1500          $files = $fs->get_area_files($usercontext->id, 'user', 'draft',
1501                  $draftitemid, 'sortorder, filepath, filename', false);
1502  
1503          $string = '';
1504          foreach ($files as $file) {
1505              $string .= $file->get_filepath() . $file->get_filename() . '|' .
1506                      $file->get_contenthash() . '|';
1507          }
1508          $hash = md5($string);
1509  
1510          if (is_null($text)) {
1511              if ($string) {
1512                  return $hash;
1513              } else {
1514                  return '';
1515              }
1516          }
1517  
1518          // We add the file hash so a simple string comparison will say if the
1519          // files have been changed. First strip off any existing file hash.
1520          if ($text !== '') {
1521              $text = preg_replace('/\s*<!-- File hash: \w+ -->\s*$/', '', $text);
1522              $text = file_rewrite_urls_to_pluginfile($text, $draftitemid);
1523              if ($string) {
1524                  $text .= '<!-- File hash: ' . $hash . ' -->';
1525              }
1526          }
1527          return $text;
1528      }
1529  
1530      public function __toString() {
1531          return $this->value;
1532      }
1533  
1534      /**
1535       * Actually save the files.
1536       * @param integer $itemid the item id for the file area to save into.
1537       */
1538      public function save_files($itemid, $context) {
1539          file_save_draft_area_files($this->draftitemid, $context->id,
1540                  $this->component, $this->filearea, $itemid);
1541      }
1542  
1543      /**
1544       * Get the files that were submitted.
1545       * @return array of stored_files objects.
1546       */
1547      public function get_files() {
1548          global $USER;
1549  
1550          $fs = get_file_storage();
1551          $usercontext = context_user::instance($USER->id);
1552  
1553          return $fs->get_area_files($usercontext->id, 'user', 'draft',
1554                  $this->draftitemid, 'sortorder, filepath, filename', false);
1555      }
1556  }
1557  
1558  
1559  /**
1560   * This class is the mirror image of {@link question_file_saver}. It allows
1561   * files to be accessed again later (e.g. when re-grading) using that same
1562   * API as when doing the original grading.
1563   *
1564   * @copyright  2012 The Open University
1565   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1566   */
1567  class question_file_loader implements question_response_files {
1568      /** @var question_attempt_step the step that these files belong to. */
1569      protected $step;
1570  
1571      /** @var string the field name for these files - which is used to construct the file area name. */
1572      protected $name;
1573  
1574      /**
1575      * @var string the value to stored in the question_attempt_step_data to
1576       * represent these files.
1577      */
1578      protected $value;
1579  
1580      /** @var int the context id that the files belong to. */
1581      protected $contextid;
1582  
1583      /**
1584       * Constuctor.
1585       * @param question_attempt_step $step the step that these files belong to.
1586       * @param string $name string the field name for these files - which is used to construct the file area name.
1587       * @param string $value the value to stored in the question_attempt_step_data to
1588       *      represent these files.
1589       * @param int $contextid the context id that the files belong to.
1590       */
1591      public function __construct(question_attempt_step $step, $name, $value, $contextid) {
1592          $this->step = $step;
1593          $this->name = $name;
1594          $this->value = $value;
1595          $this->contextid = $contextid;
1596      }
1597  
1598      public function __toString() {
1599          return $this->value;
1600      }
1601  
1602      /**
1603       * Get the files that were submitted.
1604       * @return array of stored_files objects.
1605       */
1606      public function get_files() {
1607          return $this->step->get_qt_files($this->name, $this->contextid);
1608      }
1609  
1610      /**
1611       * Copy these files into a draft area, and return the corresponding
1612       * {@link question_file_saver} that can save them again.
1613       *
1614       * This is used by {@link question_attempt::start_based_on()}, which is used
1615       * (for example) by the quizzes 'Each attempt builds on last' feature.
1616       *
1617       * @return question_file_saver that can re-save these files again.
1618       */
1619      public function get_question_file_saver() {
1620  
1621          // There are three possibilities here for what $value will look like:
1622          // 1) some HTML content followed by an MD5 hash in a HTML comment;
1623          // 2) a plain MD5 hash;
1624          // 3) or some real content, without any hash.
1625          // The problem is that 3) is ambiguous in the case where a student writes
1626          // a response that looks exactly like an MD5 hash. For attempts made now,
1627          // we avoid case 3) by always going for case 1) or 2) (except when the
1628          // response is blank. However, there may be case 3) data in the database
1629          // so we need to handle it as best we can.
1630          if (preg_match('/\s*<!-- File hash: [0-9a-zA-Z]{32} -->\s*$/', $this->value)) {
1631              $value = preg_replace('/\s*<!-- File hash: [0-9a-zA-Z]{32} -->\s*$/', '', $this->value);
1632  
1633          } else if (preg_match('/^[0-9a-zA-Z]{32}$/', $this->value)) {
1634              $value = null;
1635  
1636          } else {
1637              $value = $this->value;
1638          }
1639  
1640          list($draftid, $text) = $this->step->prepare_response_files_draft_itemid_with_text(
1641                  $this->name, $this->contextid, $value);
1642          return new question_file_saver($draftid, 'question', 'response_' . $this->name, $text);
1643      }
1644  }
1645  
1646  
1647  /**
1648   * This class represents a restriction on the set of question_usage ids to include
1649   * in a larger database query. Depending of the how you are going to restrict the
1650   * list of usages, construct an appropriate subclass.
1651   *
1652   * If $qubaids is an instance of this class, example usage might be
1653   *
1654   * SELECT qa.id, qa.maxmark
1655   * FROM $qubaids->from_question_attempts('qa')
1656   * WHERE $qubaids->where() AND qa.slot = 1
1657   *
1658   * @copyright  2010 The Open University
1659   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1660   */
1661  abstract class qubaid_condition {
1662  
1663      /**
1664       * @return string the SQL that needs to go in the FROM clause when trying
1665       * to select records from the 'question_attempts' table based on the
1666       * qubaid_condition.
1667       */
1668      public abstract function from_question_attempts($alias);
1669  
1670      /** @return string the SQL that needs to go in the where clause. */
1671      public abstract function where();
1672  
1673      /**
1674       * @return the params needed by a query that uses
1675       * {@link from_question_attempts()} and {@link where()}.
1676       */
1677      public abstract function from_where_params();
1678  
1679      /**
1680       * @return string SQL that can use used in a WHERE qubaid IN (...) query.
1681       * This method returns the "IN (...)" part.
1682       */
1683      public abstract function usage_id_in();
1684  
1685      /**
1686       * @return the params needed by a query that uses {@link usage_id_in()}.
1687       */
1688      public abstract function usage_id_in_params();
1689  
1690      /**
1691       * @return string 40-character hash code that uniquely identifies the combination of properties and class name of this qubaid
1692       *                  condition.
1693       */
1694      public function get_hash_code() {
1695          return sha1(serialize($this));
1696      }
1697  }
1698  
1699  
1700  /**
1701   * This class represents a restriction on the set of question_usage ids to include
1702   * in a larger database query based on an explicit list of ids.
1703   *
1704   * @copyright  2010 The Open University
1705   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1706   */
1707  class qubaid_list extends qubaid_condition {
1708      /** @var array of ids. */
1709      protected $qubaids;
1710      protected $columntotest = null;
1711      protected $params;
1712  
1713      /**
1714       * Constructor.
1715       * @param array $qubaids of question usage ids.
1716       */
1717      public function __construct(array $qubaids) {
1718          $this->qubaids = $qubaids;
1719      }
1720  
1721      public function from_question_attempts($alias) {
1722          $this->columntotest = $alias . '.questionusageid';
1723          return '{question_attempts} ' . $alias;
1724      }
1725  
1726      public function where() {
1727          global $DB;
1728  
1729          if (is_null($this->columntotest)) {
1730              throw new coding_exception('Must call from_question_attempts before where().');
1731          }
1732          if (empty($this->qubaids)) {
1733              $this->params = array();
1734              return '1 = 0';
1735          }
1736  
1737          return $this->columntotest . ' ' . $this->usage_id_in();
1738      }
1739  
1740      public function from_where_params() {
1741          return $this->params;
1742      }
1743  
1744      public function usage_id_in() {
1745          global $DB;
1746  
1747          if (empty($this->qubaids)) {
1748              $this->params = array();
1749              return '= 0';
1750          }
1751          list($where, $this->params) = $DB->get_in_or_equal(
1752                  $this->qubaids, SQL_PARAMS_NAMED, 'qubaid');
1753          return $where;
1754      }
1755  
1756      public function usage_id_in_params() {
1757          return $this->params;
1758      }
1759  }
1760  
1761  
1762  /**
1763   * This class represents a restriction on the set of question_usage ids to include
1764   * in a larger database query based on JOINing to some other tables.
1765   *
1766   * The general form of the query is something like
1767   *
1768   * SELECT qa.id, qa.maxmark
1769   * FROM $from
1770   * JOIN {question_attempts} qa ON qa.questionusageid = $usageidcolumn
1771   * WHERE $where AND qa.slot = 1
1772   *
1773   * where $from, $usageidcolumn and $where are the arguments to the constructor.
1774   *
1775   * @copyright  2010 The Open University
1776   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1777   */
1778  class qubaid_join extends qubaid_condition {
1779      public $from;
1780      public $usageidcolumn;
1781      public $where;
1782      public $params;
1783  
1784      /**
1785       * Constructor. The meaning of the arguments is explained in the class comment.
1786       * @param string $from SQL fragemnt to go in the FROM clause.
1787       * @param string $usageidcolumn the column in $from that should be
1788       * made equal to the usageid column in the JOIN clause.
1789       * @param string $where SQL fragment to go in the where clause.
1790       * @param array $params required by the SQL. You must use named parameters.
1791       */
1792      public function __construct($from, $usageidcolumn, $where = '', $params = array()) {
1793          $this->from = $from;
1794          $this->usageidcolumn = $usageidcolumn;
1795          $this->params = $params;
1796          if (empty($where)) {
1797              $where = '1 = 1';
1798          }
1799          $this->where = $where;
1800      }
1801  
1802      public function from_question_attempts($alias) {
1803          return "{$this->from}
1804                  JOIN {question_attempts} {$alias} ON " .
1805                          "{$alias}.questionusageid = $this->usageidcolumn";
1806      }
1807  
1808      public function where() {
1809          return $this->where;
1810      }
1811  
1812      public function from_where_params() {
1813          return $this->params;
1814      }
1815  
1816      public function usage_id_in() {
1817          return "IN (SELECT {$this->usageidcolumn} FROM {$this->from} WHERE {$this->where})";
1818      }
1819  
1820      public function usage_id_in_params() {
1821          return $this->params;
1822      }
1823  }


Generated: Fri Nov 28 20:29:05 2014 Cross-referenced by PHPXref 0.7.1