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