Merge branch 'wip-MDL-45602-master' of git://github.com/marinaglancy/moodle
[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  * (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  */
48 defined('MOODLE_INTERNAL') || die();
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;
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     }
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();
92         $newid = $this->db->insert_record('question_usages', $record);
93         $quba->set_id_from_database($newid);
95         // Initially an array of array of question_attempt_step_objects.
96         // Built as a nested array for efficiency, then flattened.
97         $stepdata = array();
99         foreach ($quba->get_attempt_iterator() as $qa) {
100             $stepdata[] = $this->insert_question_attempt($qa, $quba->get_owning_context());
101         }
103         $stepdata = call_user_func_array('array_merge', $stepdata);
104         if ($stepdata) {
105             $this->insert_all_step_data($stepdata);
106         }
107     }
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);
143         // Initially an array of array of question_attempt_step_objects.
144         // Built as a nested array for efficiency, then flattened.
145         $stepdata = array();
147         foreach ($qa->get_step_iterator() as $seq => $step) {
148             $stepdata[] = $this->insert_question_attempt_step($step, $record->id, $seq, $context);
149         }
151         return call_user_func_array('array_merge', $stepdata);
152     }
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     }
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             }
189             $data = new stdClass();
190             $data->attemptstepid = $stepid;
191             $data->name = $name;
192             $data->value = $value;
193             $rows[] = $data;
194         }
195         return $rows;
196     }
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     }
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) {
226         $record = $this->make_step_record($step, $questionattemptid, $seq);
227         $record->id = $this->db->insert_record('question_attempt_steps', $record);
229         return $this->prepare_step_data($step, $record->id, $context);
230     }
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) {
246         $record = $this->make_step_record($step, $questionattemptid, $seq);
247         $record->id = $step->get_id();
248         $this->db->update_record('question_attempt_steps', $record);
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     }
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
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
284 WHERE
285     qas.id = :stepid
286         ", array('stepid' => $stepid));
288         if (!$records->valid()) {
289             throw new coding_exception('Failed to load question_attempt_step ' . $stepid);
290         }
292         $step = question_attempt_step::load_from_records($records, $stepid);
293         $records->close();
295         return $step;
296     }
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
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
342 WHERE
343     qa.id = :questionattemptid
345 ORDER BY
346     qas.sequencenumber
347         ", array('questionattemptid' => $questionattemptid));
349         if (!$records->valid()) {
350             throw new coding_exception('Failed to load question_attempt ' . $questionattemptid);
351         }
353         $record = $records->current();
354         $qa = question_attempt::load_from_records($records, $questionattemptid,
355                 new question_usage_null_observer(), $record->preferredbehaviour);
356         $records->close();
358         return $qa;
359     }
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
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
406 WHERE
407     quba.id = :qubaid
409 ORDER BY
410     qa.slot,
411     qas.sequencenumber
412     ", array('qubaid' => $qubaid));
414         if (!$records->valid()) {
415             throw new coding_exception('Failed to load questions_usage_by_activity ' . $qubaid);
416         }
418         $quba = question_usage_by_activity::load_from_records($records, $qubaid);
419         $records->close();
421         return $quba;
422     }
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
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
468 WHERE
469     quba.id {$qubaids->usage_id_in()}
471 ORDER BY
472     quba.id,
473     qa.slot,
474     qas.sequencenumber
475     ", $qubaids->usage_id_in_params());
477         if (!$records->valid()) {
478             throw new coding_exception('Failed to load questions_usages_by_activity for qubaid_condition :' . $qubaids);
479         }
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());
487         $records->close();
489         return $qubas;
490     }
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');
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";
529         }
531         $records = $this->db->get_records_sql("
532 SELECT
533     {$fields}
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()}
539 WHERE
540     {$qubaids->where()} AND
541     qa.slot $slottest
542         ", $params + $qubaids->from_where_params());
544         return $records;
545     }
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');
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
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
580 WHERE
581     {$qubaids->where()} AND
582     qa.slot $slottest
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
593 ORDER BY
594     qa.slot,
595     qa.questionid,
596     q.name,
597     q.id
598         ", $params + $qubaids->from_where_params());
600         $results = array();
601         foreach ($rs as $row) {
602             $index = $row->slot . ',' . $row->questionid;
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             }
617             $results[$index]->{$row->summarystate} = $row->numattempts;
618             $results[$index]->all += $row->numattempts;
619         }
620         $rs->close();
622         return $results;
623     }
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) {
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         }
663         if ($orderby == 'random') {
664             $sqlorderby = '';
665         } else if ($orderby) {
666             $sqlorderby = 'ORDER BY ' . $orderby;
667         } else {
668             $sqlorderby = '';
669         }
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;
681         $qubaids = $this->db->get_records_sql_menu("
682 SELECT
683     qa.questionusageid,
684     1
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
691 WHERE
692     {$qubaidswhere} AND
693     qa.slot = :slot
694     $extrawhere
696 $sqlorderby
697         ", $params);
699         $qubaids = array_keys($qubaids);
700         $count = count($qubaids);
702         if ($orderby == 'random') {
703             shuffle($qubaids);
704             $limitfrom = 0;
705         }
707         if (!is_null($limitnum)) {
708             $qubaids = array_slice($qubaids, $limitfrom, $limitnum);
709         }
711         return array($qubaids, $count);
712     }
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         }
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');
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
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()}
756 WHERE
757     {$qubaids->where()}
758     $slotwhere
759     AND qas.state $statetest
761 GROUP BY qa.slot
763 ORDER BY qa.slot
764         ", $slotsparams + $stateparams + $qubaids->from_where_params());
765     }
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
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
811 WHERE
812     {$qubaids->where()} AND
813     qa.questionid = :questionid
815 ORDER BY
816     quba.id,
817     qa.id,
818     qas.sequencenumber";
820         // For qubaid_list must call this after calling methods that generate sql.
821         $params = $qubaids->from_where_params();
822         $params['questionid'] = $questionid;
824         $records = $this->db->get_recordset_sql($sql, $params);
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();
836         return $questionattempts;
837     }
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();
855         $this->db->update_record('question_usages', $record);
856     }
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->maxmark = $qa->get_max_mark();
871         $record->minfraction = $qa->get_min_fraction();
872         $record->maxfraction = $qa->get_max_fraction();
873         $record->flagged = $qa->is_flagged();
874         $record->questionsummary = $qa->get_question_summary();
875         $record->rightanswer = $qa->get_right_answer_summary();
876         $record->responsesummary = $qa->get_response_summary();
877         $record->timemodified = time();
879         $this->db->update_record('question_attempts', $record);
880     }
882     /**
883      * Delete a question_usage_by_activity and all its associated
884      *
885      * You should not call this method directly. You should use
886      * @link question_engine::delete_questions_usage_by_activities()}.
887      *
888      * {@link question_attempts} and {@link question_attempt_steps} from the
889      * database.
890      * @param qubaid_condition $qubaids identifies which question useages to delete.
891      */
892     public function delete_questions_usage_by_activities(qubaid_condition $qubaids) {
893         $where = "qa.questionusageid {$qubaids->usage_id_in()}";
894         $params = $qubaids->usage_id_in_params();
896         $contextids = $this->db->get_records_sql_menu("
897                 SELECT DISTINCT contextid, 1
898                 FROM {question_usages}
899                 WHERE id {$qubaids->usage_id_in()}", $qubaids->usage_id_in_params());
900         foreach ($contextids as $contextid => $notused) {
901             $this->delete_response_files($contextid, "IN (
902                     SELECT qas.id
903                     FROM {question_attempts} qa
904                     JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
905                     WHERE $where)", $params);
906         }
908         if ($this->db->get_dbfamily() == 'mysql') {
909             $this->delete_usage_records_for_mysql($qubaids);
910             return;
911         }
913         $this->db->delete_records_select('question_attempt_step_data', "attemptstepid IN (
914                 SELECT qas.id
915                 FROM {question_attempts} qa
916                 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
917                 WHERE $where)", $params);
919         $this->db->delete_records_select('question_attempt_steps', "questionattemptid IN (
920                 SELECT qa.id
921                 FROM {question_attempts} qa
922                 WHERE $where)", $params);
924         $this->db->delete_records_select('question_attempts',
925                 "{question_attempts}.questionusageid {$qubaids->usage_id_in()}",
926                 $qubaids->usage_id_in_params());
928         $this->db->delete_records_select('question_usages',
929                 "{question_usages}.id {$qubaids->usage_id_in()}", $qubaids->usage_id_in_params());
930     }
932     /**
933      * This function is a work-around for poor MySQL performance with
934      * DELETE FROM x WHERE id IN (SELECT ...). We have to use a non-standard
935      * syntax to get good performance. See MDL-29520.
936      * @param qubaid_condition $qubaids identifies which question useages to delete.
937      */
938     protected function delete_usage_records_for_mysql(qubaid_condition $qubaids) {
939         $qubaidtest = $qubaids->usage_id_in();
940         if (strpos($qubaidtest, 'question_usages') !== false &&
941                 strpos($qubaidtest, 'IN (SELECT') === 0) {
942             // This horrible hack is required by MDL-29847. It comes from
943             // http://www.xaprb.com/blog/2006/06/23/how-to-select-from-an-update-target-in-mysql/
944             $qubaidtest = 'IN (SELECT * FROM ' . substr($qubaidtest, 3) . ' AS hack_subquery_alias)';
945         }
947         // TODO once MDL-29589 is fixed, eliminate this method, and instead use the new $DB API.
948         $this->db->execute('
949                 DELETE qu, qa, qas, qasd
950                   FROM {question_usages}            qu
951                   JOIN {question_attempts}          qa   ON qa.questionusageid = qu.id
952              LEFT JOIN {question_attempt_steps}     qas  ON qas.questionattemptid = qa.id
953              LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
954                  WHERE qu.id ' . $qubaidtest,
955                 $qubaids->usage_id_in_params());
956     }
958     /**
959      * Delete all the steps for a question attempt.
960      *
961      * Private method, only for use by other parts of the question engine.
962      *
963      * @param int $qaids question_attempt id.
964      * @param context $context the context that the $quba belongs to.
965      */
966     public function delete_steps($stepids, $context) {
967         if (empty($stepids)) {
968             return;
969         }
970         list($test, $params) = $this->db->get_in_or_equal($stepids, SQL_PARAMS_NAMED);
972         $this->delete_response_files($context->id, $test, $params);
974         $this->db->delete_records_select('question_attempt_step_data',
975                 "attemptstepid {$test}", $params);
976         $this->db->delete_records_select('question_attempt_steps',
977                 "id {$test}", $params);
978     }
980     /**
981      * Delete all the files belonging to the response variables in the gives
982      * question attempt steps.
983      * @param int $contextid the context these attempts belong to.
984      * @param string $itemidstest a bit of SQL that can be used in a
985      *      WHERE itemid $itemidstest clause. Must use named params.
986      * @param array $params any query parameters used in $itemidstest.
987      */
988     protected function delete_response_files($contextid, $itemidstest, $params) {
989         $fs = get_file_storage();
990         foreach (question_engine::get_all_response_file_areas() as $filearea) {
991             $fs->delete_area_files_select($contextid, 'question', $filearea,
992                     $itemidstest, $params);
993         }
994     }
996     /**
997      * Delete all the previews for a given question.
998      *
999      * Private method, only for use by other parts of the question engine.
1000      *
1001      * @param int $questionid question id.
1002      */
1003     public function delete_previews($questionid) {
1004         $previews = $this->db->get_records_sql_menu("
1005                 SELECT DISTINCT quba.id, 1
1006                 FROM {question_usages} quba
1007                 JOIN {question_attempts} qa ON qa.questionusageid = quba.id
1008                 WHERE quba.component = 'core_question_preview' AND
1009                     qa.questionid = ?", array($questionid));
1010         if (empty($previews)) {
1011             return;
1012         }
1013         $this->delete_questions_usage_by_activities(new qubaid_list($previews));
1014     }
1016     /**
1017      * Update the flagged state of a question in the database.
1018      *
1019      * You should call {@link question_engine::update_flag()()}
1020      * rather than calling this method directly.
1021      *
1022      * @param int $qubaid the question usage id.
1023      * @param int $questionid the question id.
1024      * @param int $sessionid the question_attempt id.
1025      * @param bool $newstate the new state of the flag. true = flagged.
1026      */
1027     public function update_question_attempt_flag($qubaid, $questionid, $qaid, $slot, $newstate) {
1028         if (!$this->db->record_exists('question_attempts', array('id' => $qaid,
1029                 'questionusageid' => $qubaid, 'questionid' => $questionid, 'slot' => $slot))) {
1030             throw new moodle_exception('errorsavingflags', 'question');
1031         }
1033         $this->db->set_field('question_attempts', 'flagged', $newstate, array('id' => $qaid));
1034     }
1036     /**
1037      * Get all the WHEN 'x' THEN 'y' terms needed to convert the question_attempt_steps.state
1038      * column to a summary state. Use this like
1039      * CASE qas.state {$this->full_states_to_summary_state_sql()} END AS summarystate,
1040      * @param string SQL fragment.
1041      */
1042     protected function full_states_to_summary_state_sql() {
1043         $sql = '';
1044         foreach (question_state::get_all() as $state) {
1045             $sql .= "WHEN '{$state}' THEN '{$state->get_summary_state()}'\n";
1046         }
1047         return $sql;
1048     }
1050     /**
1051      * Get the SQL needed to test that question_attempt_steps.state is in a
1052      * state corresponding to $summarystate.
1053      *
1054      * This method may be called publicly.
1055      *
1056      * @param string $summarystate one of
1057      * inprogress, needsgrading, manuallygraded or autograded
1058      * @param bool $equal if false, do a NOT IN test. Default true.
1059      * @return string SQL fragment.
1060      */
1061     public function in_summary_state_test($summarystate, $equal = true, $prefix = 'summarystates') {
1062         $states = question_state::get_all_for_summary_state($summarystate);
1063         return $this->db->get_in_or_equal(array_map('strval', $states),
1064                 SQL_PARAMS_NAMED, $prefix, $equal);
1065     }
1067     /**
1068      * Change the maxmark for the question_attempt with number in usage $slot
1069      * for all the specified question_attempts.
1070      *
1071      * You should call {@link question_engine::set_max_mark_in_attempts()}
1072      * rather than calling this method directly.
1073      *
1074      * @param qubaid_condition $qubaids Selects which usages are updated.
1075      * @param int $slot the number is usage to affect.
1076      * @param number $newmaxmark the new max mark to set.
1077      */
1078     public function set_max_mark_in_attempts(qubaid_condition $qubaids, $slot, $newmaxmark) {
1079         $this->db->set_field_select('question_attempts', 'maxmark', $newmaxmark,
1080                 "questionusageid {$qubaids->usage_id_in()} AND slot = :slot",
1081                 $qubaids->usage_id_in_params() + array('slot' => $slot));
1082     }
1084     /**
1085      * Return a subquery that computes the sum of the marks for all the questions
1086      * in a usage. Which useage to compute the sum for is controlled bu the $qubaid
1087      * parameter.
1088      *
1089      * See {@link quiz_update_all_attempt_sumgrades()} for an example of the usage of
1090      * this method.
1091      *
1092      * This method may be called publicly.
1093      *
1094      * @param string $qubaid SQL fragment that controls which usage is summed.
1095      * This will normally be the name of a column in the outer query. Not that this
1096      * SQL fragment must not contain any placeholders.
1097      * @return string SQL code for the subquery.
1098      */
1099     public function sum_usage_marks_subquery($qubaid) {
1100         // To explain the COALESCE in the following SQL: SUM(lots of NULLs) gives
1101         // NULL, while SUM(one 0.0 and lots of NULLS) gives 0.0. We don't want that.
1102         // We always want to return a number, so the COALESCE is there to turn the
1103         // NULL total into a 0.
1104         return "SELECT COALESCE(SUM(qa.maxmark * qas.fraction), 0)
1105             FROM {question_attempts} qa
1106             JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
1107                     AND qas.sequencenumber = (
1108                             SELECT MAX(summarks_qas.sequencenumber)
1109                               FROM {question_attempt_steps} summarks_qas
1110                              WHERE summarks_qas.questionattemptid = qa.id
1111             )
1112             WHERE qa.questionusageid = $qubaid
1113             HAVING COUNT(CASE
1114                 WHEN qas.state = 'needsgrading' AND qa.maxmark > 0 THEN 1
1115                 ELSE NULL
1116             END) = 0";
1117     }
1119     /**
1120      * Get a subquery that returns the latest step of every qa in some qubas.
1121      * Currently, this is only used by the quiz reports. See
1122      * {@link quiz_attempts_report_table::add_latest_state_join()}.
1123      *
1124      * This method may be called publicly.
1125      *
1126      * @param string $alias alias to use for this inline-view.
1127      * @param qubaid_condition $qubaids restriction on which question_usages we
1128      *      are interested in. This is important for performance.
1129      * @return array with two elements, the SQL fragment and any params requried.
1130      */
1131     public function question_attempt_latest_state_view($alias, qubaid_condition $qubaids) {
1132         return array("(
1133                 SELECT {$alias}qa.id AS questionattemptid,
1134                        {$alias}qa.questionusageid,
1135                        {$alias}qa.slot,
1136                        {$alias}qa.behaviour,
1137                        {$alias}qa.questionid,
1138                        {$alias}qa.variant,
1139                        {$alias}qa.maxmark,
1140                        {$alias}qa.minfraction,
1141                        {$alias}qa.maxfraction,
1142                        {$alias}qa.flagged,
1143                        {$alias}qa.questionsummary,
1144                        {$alias}qa.rightanswer,
1145                        {$alias}qa.responsesummary,
1146                        {$alias}qa.timemodified,
1147                        {$alias}qas.id AS attemptstepid,
1148                        {$alias}qas.sequencenumber,
1149                        {$alias}qas.state,
1150                        {$alias}qas.fraction,
1151                        {$alias}qas.timecreated,
1152                        {$alias}qas.userid
1154                   FROM {$qubaids->from_question_attempts($alias . 'qa')}
1155                   JOIN {question_attempt_steps} {$alias}qas ON {$alias}qas.questionattemptid = {$alias}qa.id
1156                             AND {$alias}qas.sequencenumber = {$this->latest_step_for_qa_subquery($alias . 'qa.id')}
1157                  WHERE {$qubaids->where()}
1158             ) {$alias}", $qubaids->from_where_params());
1159     }
1161     protected function latest_step_for_qa_subquery($questionattemptid = 'qa.id') {
1162         return "(
1163                 SELECT MAX(sequencenumber)
1164                 FROM {question_attempt_steps}
1165                 WHERE questionattemptid = $questionattemptid
1166             )";
1167     }
1169     /**
1170      * Are any of these questions are currently in use?
1171      *
1172      * You should call {@link question_engine::questions_in_use()}
1173      * rather than calling this method directly.
1174      *
1175      * @param array $questionids of question ids.
1176      * @param qubaid_condition $qubaids ids of the usages to consider.
1177      * @return bool whether any of these questions are being used by any of
1178      *      those usages.
1179      */
1180     public function questions_in_use(array $questionids, qubaid_condition $qubaids) {
1181         list($test, $params) = $this->db->get_in_or_equal($questionids);
1182         return $this->db->record_exists_select('question_attempts',
1183                 'questionid ' . $test . ' AND questionusageid ' .
1184                 $qubaids->usage_id_in(), $params + $qubaids->usage_id_in_params());
1185     }
1189 /**
1190  * Implementation of the unit of work pattern for the question engine.
1191  *
1192  * See http://martinfowler.com/eaaCatalog/unitOfWork.html. This tracks all the
1193  * changes to a {@link question_usage_by_activity}, and its constituent parts,
1194  * so that the changes can be saved to the database when {@link save()} is called.
1195  *
1196  * @copyright  2009 The Open University
1197  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1198  */
1199 class question_engine_unit_of_work implements question_usage_observer {
1200     /** @var question_usage_by_activity the usage being tracked. */
1201     protected $quba;
1203     /** @var boolean whether any of the fields of the usage have been changed. */
1204     protected $modified = false;
1206     /**
1207      * @var array list of slot => {@link question_attempt}s that
1208      * were already in the usage, and which have been modified.
1209      */
1210     protected $attemptsmodified = array();
1212     /**
1213      * @var array list of slot => {@link question_attempt}s that
1214      * have been added to the usage.
1215      */
1216     protected $attemptsadded = array();
1218     /**
1219      * @var array of array(question_attempt_step, question_attempt id, seq number)
1220      * of steps that have been added to question attempts in this usage.
1221      */
1222     protected $stepsadded = array();
1224     /**
1225      * @var array of array(question_attempt_step, question_attempt id, seq number)
1226      * of steps that have been modified in their attempt.
1227      */
1228     protected $stepsmodified = array();
1230     /**
1231      * @var array list of question_attempt_step.id => question_attempt_step of steps
1232      * that were previously stored in the database, but which are no longer required.
1233      */
1234     protected $stepsdeleted = array();
1236     /**
1237      * Constructor.
1238      * @param question_usage_by_activity $quba the usage to track.
1239      */
1240     public function __construct(question_usage_by_activity $quba) {
1241         $this->quba = $quba;
1242     }
1244     public function notify_modified() {
1245         $this->modified = true;
1246     }
1248     public function notify_attempt_modified(question_attempt $qa) {
1249         $slot = $qa->get_slot();
1250         if (!array_key_exists($slot, $this->attemptsadded)) {
1251             $this->attemptsmodified[$slot] = $qa;
1252         }
1253     }
1255     public function notify_attempt_added(question_attempt $qa) {
1256         $this->attemptsadded[$qa->get_slot()] = $qa;
1257     }
1259     public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq) {
1260         if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
1261             return;
1262         }
1264         if (($key = $this->is_step_added($step)) !== false) {
1265             return;
1266         }
1268         if (($key = $this->is_step_modified($step)) !== false) {
1269             throw new coding_exception('Cannot add a step that has already been modified.');
1270         }
1272         if (($key = $this->is_step_deleted($step)) !== false) {
1273             unset($this->stepsdeleted[$step->get_id()]);
1274             $this->stepsmodified[] = array($step, $qa->get_database_id(), $seq);
1275             return;
1276         }
1278         $stepid = $step->get_id();
1279         if ($stepid) {
1280             if (array_key_exists($stepid, $this->stepsdeleted)) {
1281                 unset($this->stepsdeleted[$stepid]);
1282             }
1283             $this->stepsmodified[] = array($step, $qa->get_database_id(), $seq);
1285         } else {
1286             $this->stepsadded[] = array($step, $qa->get_database_id(), $seq);
1287         }
1288     }
1290     public function notify_step_modified(question_attempt_step $step, question_attempt $qa, $seq) {
1291         if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
1292             return;
1293         }
1295         if (($key = $this->is_step_added($step)) !== false) {
1296             return;
1297         }
1299         if (($key = $this->is_step_deleted($step)) !== false) {
1300             throw new coding_exception('Cannot modify a step after it has been deleted.');
1301         }
1303         $stepid = $step->get_id();
1304         if (empty($stepid)) {
1305             throw new coding_exception('Cannot modify a step that has never been stored in the database.');
1306         }
1308         $this->stepsmodified[] = array($step, $qa->get_database_id(), $seq);
1309     }
1311     public function notify_step_deleted(question_attempt_step $step, question_attempt $qa) {
1312         if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
1313             return;
1314         }
1316         if (($key = $this->is_step_added($step)) !== false) {
1317             unset($this->stepsadded[$key]);
1318             return;
1319         }
1321         if (($key = $this->is_step_modified($step)) !== false) {
1322             unset($this->stepsmodified[$key]);
1323         }
1325         $stepid = $step->get_id();
1326         if (empty($stepid)) {
1327             return; // Was never in the database.
1328         }
1330         $this->stepsdeleted[$stepid] = $step;
1331     }
1333     /**
1334      * @param question_attempt_step $step a step
1335      * @return int|false if the step is in the list of steps to be added, return
1336      *      the key, otherwise return false.
1337      */
1338     protected function is_step_added(question_attempt_step $step) {
1339         foreach ($this->stepsadded as $key => $data) {
1340             list($addedstep, $qaid, $seq) = $data;
1341             if ($addedstep === $step) {
1342                 return $key;
1343             }
1344         }
1345         return false;
1346     }
1348     /**
1349      * @param question_attempt_step $step a step
1350      * @return int|false if the step is in the list of steps to be modified, return
1351      *      the key, otherwise return false.
1352      */
1353     protected function is_step_modified(question_attempt_step $step) {
1354         foreach ($this->stepsmodified as $key => $data) {
1355             list($modifiedstep, $qaid, $seq) = $data;
1356             if ($modifiedstep === $step) {
1357                 return $key;
1358             }
1359         }
1360         return false;
1361     }
1363     /**
1364      * @param question_attempt_step $step a step
1365      * @return bool whether the step is in the list of steps to be deleted.
1366      */
1367     protected function is_step_deleted(question_attempt_step $step) {
1368         foreach ($this->stepsdeleted as $deletedstep) {
1369             if ($deletedstep === $step) {
1370                 return true;
1371             }
1372         }
1373         return false;
1374     }
1376     /**
1377      * Write all the changes we have recorded to the database.
1378      * @param question_engine_data_mapper $dm the mapper to use to update the database.
1379      */
1380     public function save(question_engine_data_mapper $dm) {
1381         $dm->delete_steps(array_keys($this->stepsdeleted), $this->quba->get_owning_context());
1383         // Initially an array of array of question_attempt_step_objects.
1384         // Built as a nested array for efficiency, then flattened.
1385         $stepdata = array();
1387         foreach ($this->stepsmodified as $stepinfo) {
1388             list($step, $questionattemptid, $seq) = $stepinfo;
1389             $stepdata[] = $dm->update_question_attempt_step(
1390                     $step, $questionattemptid, $seq, $this->quba->get_owning_context());
1391         }
1393         foreach ($this->stepsadded as $stepinfo) {
1394             list($step, $questionattemptid, $seq) = $stepinfo;
1395             $stepdata[] = $dm->insert_question_attempt_step(
1396                     $step, $questionattemptid, $seq, $this->quba->get_owning_context());
1397         }
1399         foreach ($this->attemptsadded as $qa) {
1400             $stepdata[] = $dm->insert_question_attempt(
1401                     $qa, $this->quba->get_owning_context());
1402         }
1404         foreach ($this->attemptsmodified as $qa) {
1405             $dm->update_question_attempt($qa);
1406         }
1408         if ($this->modified) {
1409             $dm->update_questions_usage_by_activity($this->quba);
1410         }
1412         if (!$stepdata) {
1413             return;
1414         }
1415         $dm->insert_all_step_data(call_user_func_array('array_merge', $stepdata));
1416     }
1420 /**
1421  * The interface implemented by {@link question_file_saver} and {@link question_file_loader}.
1422  *
1423  * @copyright  2012 The Open University
1424  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1425  */
1426 interface question_response_files {
1427     /**
1428      * Get the files that were submitted.
1429      * @return array of stored_files objects.
1430      */
1431     public function get_files();
1435 /**
1436  * This class represents the promise to save some files from a particular draft
1437  * file area into a particular file area. It is used beause the necessary
1438  * information about what to save is to hand in the
1439  * {@link question_attempt::process_response_files()} method, but we don't know
1440  * if this question attempt will actually be saved in the database until later,
1441  * when the {@link question_engine_unit_of_work} is saved, if it is.
1442  *
1443  * @copyright  2011 The Open University
1444  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1445  */
1446 class question_file_saver implements question_response_files {
1447     /** @var int the id of the draft file area to save files from. */
1448     protected $draftitemid;
1449     /** @var string the owning component name. */
1450     protected $component;
1451     /** @var string the file area name. */
1452     protected $filearea;
1454     /**
1455      * @var string the value to store in the question_attempt_step_data to
1456      * represent these files.
1457      */
1458     protected $value = null;
1460     /**
1461      * Constuctor.
1462      * @param int $draftitemid the draft area to save the files from.
1463      * @param string $component the component for the file area to save into.
1464      * @param string $filearea the name of the file area to save into.
1465      */
1466     public function __construct($draftitemid, $component, $filearea, $text = null) {
1467         $this->draftitemid = $draftitemid;
1468         $this->component = $component;
1469         $this->filearea = $filearea;
1470         $this->value = $this->compute_value($draftitemid, $text);
1471     }
1473     /**
1474      * Compute the value that should be stored in the question_attempt_step_data
1475      * table. Contains a hash that (almost) uniquely encodes all the files.
1476      * @param int $draftitemid the draft file area itemid.
1477      * @param string $text optional content containing file links.
1478      */
1479     protected function compute_value($draftitemid, $text) {
1480         global $USER;
1482         $fs = get_file_storage();
1483         $usercontext = context_user::instance($USER->id);
1485         $files = $fs->get_area_files($usercontext->id, 'user', 'draft',
1486                 $draftitemid, 'sortorder, filepath, filename', false);
1488         $string = '';
1489         foreach ($files as $file) {
1490             $string .= $file->get_filepath() . $file->get_filename() . '|' .
1491                     $file->get_contenthash() . '|';
1492         }
1493         $hash = md5($string);
1495         if (is_null($text)) {
1496             if ($string) {
1497                 return $hash;
1498             } else {
1499                 return '';
1500             }
1501         }
1503         // We add the file hash so a simple string comparison will say if the
1504         // files have been changed. First strip off any existing file hash.
1505         if ($text !== '') {
1506             $text = preg_replace('/\s*<!-- File hash: \w+ -->\s*$/', '', $text);
1507             $text = file_rewrite_urls_to_pluginfile($text, $draftitemid);
1508             if ($string) {
1509                 $text .= '<!-- File hash: ' . $hash . ' -->';
1510             }
1511         }
1512         return $text;
1513     }
1515     public function __toString() {
1516         return $this->value;
1517     }
1519     /**
1520      * Actually save the files.
1521      * @param integer $itemid the item id for the file area to save into.
1522      */
1523     public function save_files($itemid, $context) {
1524         file_save_draft_area_files($this->draftitemid, $context->id,
1525                 $this->component, $this->filearea, $itemid);
1526     }
1528     /**
1529      * Get the files that were submitted.
1530      * @return array of stored_files objects.
1531      */
1532     public function get_files() {
1533         global $USER;
1535         $fs = get_file_storage();
1536         $usercontext = context_user::instance($USER->id);
1538         return $fs->get_area_files($usercontext->id, 'user', 'draft',
1539                 $this->draftitemid, 'sortorder, filepath, filename', false);
1540     }
1544 /**
1545  * This class is the mirror image of {@link question_file_saver}. It allows
1546  * files to be accessed again later (e.g. when re-grading) using that same
1547  * API as when doing the original grading.
1548  *
1549  * @copyright  2012 The Open University
1550  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1551  */
1552 class question_file_loader implements question_response_files {
1553     /** @var question_attempt_step the step that these files belong to. */
1554     protected $step;
1556     /** @var string the field name for these files - which is used to construct the file area name. */
1557     protected $name;
1559     /**
1560     * @var string the value to stored in the question_attempt_step_data to
1561      * represent these files.
1562     */
1563     protected $value;
1565     /** @var int the context id that the files belong to. */
1566     protected $contextid;
1568     /**
1569      * Constuctor.
1570      * @param question_attempt_step $step the step that these files belong to.
1571      * @param string $name string the field name for these files - which is used to construct the file area name.
1572      * @param string $value the value to stored in the question_attempt_step_data to
1573      *      represent these files.
1574      * @param int $contextid the context id that the files belong to.
1575      */
1576     public function __construct(question_attempt_step $step, $name, $value, $contextid) {
1577         $this->step = $step;
1578         $this->name = $name;
1579         $this->value = $value;
1580         $this->contextid = $contextid;
1581     }
1583     public function __toString() {
1584         return $this->value;
1585     }
1587     /**
1588      * Get the files that were submitted.
1589      * @return array of stored_files objects.
1590      */
1591     public function get_files() {
1592         return $this->step->get_qt_files($this->name, $this->contextid);
1593     }
1595     /**
1596      * Copy these files into a draft area, and return the corresponding
1597      * {@link question_file_saver} that can save them again.
1598      *
1599      * This is used by {@link question_attempt::start_based_on()}, which is used
1600      * (for example) by the quizzes 'Each attempt builds on last' feature.
1601      *
1602      * @return question_file_saver that can re-save these files again.
1603      */
1604     public function get_question_file_saver() {
1606         // There are three possibilities here for what $value will look like:
1607         // 1) some HTML content followed by an MD5 hash in a HTML comment;
1608         // 2) a plain MD5 hash;
1609         // 3) or some real content, without any hash.
1610         // The problem is that 3) is ambiguous in the case where a student writes
1611         // a response that looks exactly like an MD5 hash. For attempts made now,
1612         // we avoid case 3) by always going for case 1) or 2) (except when the
1613         // response is blank. However, there may be case 3) data in the database
1614         // so we need to handle it as best we can.
1615         if (preg_match('/\s*<!-- File hash: [0-9a-zA-Z]{32} -->\s*$/', $this->value)) {
1616             $value = preg_replace('/\s*<!-- File hash: [0-9a-zA-Z]{32} -->\s*$/', '', $this->value);
1618         } else if (preg_match('/^[0-9a-zA-Z]{32}$/', $this->value)) {
1619             $value = null;
1621         } else {
1622             $value = $this->value;
1623         }
1625         list($draftid, $text) = $this->step->prepare_response_files_draft_itemid_with_text(
1626                 $this->name, $this->contextid, $value);
1627         return new question_file_saver($draftid, 'question', 'response_' . $this->name, $text);
1628     }
1632 /**
1633  * This class represents a restriction on the set of question_usage ids to include
1634  * in a larger database query. Depending of the how you are going to restrict the
1635  * list of usages, construct an appropriate subclass.
1636  *
1637  * If $qubaids is an instance of this class, example usage might be
1638  *
1639  * SELECT qa.id, qa.maxmark
1640  * FROM $qubaids->from_question_attempts('qa')
1641  * WHERE $qubaids->where() AND qa.slot = 1
1642  *
1643  * @copyright  2010 The Open University
1644  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1645  */
1646 abstract class qubaid_condition {
1648     /**
1649      * @return string the SQL that needs to go in the FROM clause when trying
1650      * to select records from the 'question_attempts' table based on the
1651      * qubaid_condition.
1652      */
1653     public abstract function from_question_attempts($alias);
1655     /** @return string the SQL that needs to go in the where clause. */
1656     public abstract function where();
1658     /**
1659      * @return the params needed by a query that uses
1660      * {@link from_question_attempts()} and {@link where()}.
1661      */
1662     public abstract function from_where_params();
1664     /**
1665      * @return string SQL that can use used in a WHERE qubaid IN (...) query.
1666      * This method returns the "IN (...)" part.
1667      */
1668     public abstract function usage_id_in();
1670     /**
1671      * @return the params needed by a query that uses {@link usage_id_in()}.
1672      */
1673     public abstract function usage_id_in_params();
1675     /**
1676      * @return string 40-character hash code that uniquely identifies the combination of properties and class name of this qubaid
1677      *                  condition.
1678      */
1679     public function get_hash_code() {
1680         return sha1(serialize($this));
1681     }
1685 /**
1686  * This class represents a restriction on the set of question_usage ids to include
1687  * in a larger database query based on an explicit list of ids.
1688  *
1689  * @copyright  2010 The Open University
1690  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1691  */
1692 class qubaid_list extends qubaid_condition {
1693     /** @var array of ids. */
1694     protected $qubaids;
1695     protected $columntotest = null;
1696     protected $params;
1698     /**
1699      * Constructor.
1700      * @param array $qubaids of question usage ids.
1701      */
1702     public function __construct(array $qubaids) {
1703         $this->qubaids = $qubaids;
1704     }
1706     public function from_question_attempts($alias) {
1707         $this->columntotest = $alias . '.questionusageid';
1708         return '{question_attempts} ' . $alias;
1709     }
1711     public function where() {
1712         global $DB;
1714         if (is_null($this->columntotest)) {
1715             throw new coding_exception('Must call from_question_attempts before where().');
1716         }
1717         if (empty($this->qubaids)) {
1718             $this->params = array();
1719             return '1 = 0';
1720         }
1722         return $this->columntotest . ' ' . $this->usage_id_in();
1723     }
1725     public function from_where_params() {
1726         return $this->params;
1727     }
1729     public function usage_id_in() {
1730         global $DB;
1732         if (empty($this->qubaids)) {
1733             $this->params = array();
1734             return '= 0';
1735         }
1736         list($where, $this->params) = $DB->get_in_or_equal(
1737                 $this->qubaids, SQL_PARAMS_NAMED, 'qubaid');
1738         return $where;
1739     }
1741     public function usage_id_in_params() {
1742         return $this->params;
1743     }
1747 /**
1748  * This class represents a restriction on the set of question_usage ids to include
1749  * in a larger database query based on JOINing to some other tables.
1750  *
1751  * The general form of the query is something like
1752  *
1753  * SELECT qa.id, qa.maxmark
1754  * FROM $from
1755  * JOIN {question_attempts} qa ON qa.questionusageid = $usageidcolumn
1756  * WHERE $where AND qa.slot = 1
1757  *
1758  * where $from, $usageidcolumn and $where are the arguments to the constructor.
1759  *
1760  * @copyright  2010 The Open University
1761  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1762  */
1763 class qubaid_join extends qubaid_condition {
1764     public $from;
1765     public $usageidcolumn;
1766     public $where;
1767     public $params;
1769     /**
1770      * Constructor. The meaning of the arguments is explained in the class comment.
1771      * @param string $from SQL fragemnt to go in the FROM clause.
1772      * @param string $usageidcolumn the column in $from that should be
1773      * made equal to the usageid column in the JOIN clause.
1774      * @param string $where SQL fragment to go in the where clause.
1775      * @param array $params required by the SQL. You must use named parameters.
1776      */
1777     public function __construct($from, $usageidcolumn, $where = '', $params = array()) {
1778         $this->from = $from;
1779         $this->usageidcolumn = $usageidcolumn;
1780         $this->params = $params;
1781         if (empty($where)) {
1782             $where = '1 = 1';
1783         }
1784         $this->where = $where;
1785     }
1787     public function from_question_attempts($alias) {
1788         return "{$this->from}
1789                 JOIN {question_attempts} {$alias} ON " .
1790                         "{$alias}.questionusageid = $this->usageidcolumn";
1791     }
1793     public function where() {
1794         return $this->where;
1795     }
1797     public function from_where_params() {
1798         return $this->params;
1799     }
1801     public function usage_id_in() {
1802         return "IN (SELECT {$this->usageidcolumn} FROM {$this->from} WHERE {$this->where})";
1803     }
1805     public function usage_id_in_params() {
1806         return $this->params;
1807     }