c9e18840eff471775b0f0cdca03b426caa7dcc11
[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      * Store new metadata for an existing {@link question_attempt} in the database.
257      *
258      * Private method, only for use by other parts of the question engine.
259      *
260      * @param question_attempt $qa the question attempt to store meta data for.
261      * @param array $names the names of the metadata variables to store.
262      * @return array of question_attempt_step_data rows, that still need to be inserted.
263      */
264     public function insert_question_attempt_metadata(question_attempt $qa, array $names) {
265         $firststep = $qa->get_step(0);
267         $rows = array();
268         foreach ($names as $name) {
269             $data = new stdClass();
270             $data->attemptstepid = $firststep->get_id();
271             $data->name = ':_' . $name;
272             $data->value = $firststep->get_metadata_var($name);
273             $rows[] = $data;
274         }
276         return $rows;
277     }
279     /**
280      * Updates existing metadata for an existing {@link question_attempt} in the database.
281      *
282      * Private method, only for use by other parts of the question engine.
283      *
284      * @param question_attempt $qa the question attempt to store meta data for.
285      * @param array $names the names of the metadata variables to store.
286      * @return array of question_attempt_step_data rows, that still need to be inserted.
287      */
288     public function update_question_attempt_metadata(question_attempt $qa, array $names) {
289         global $DB;
290         list($condition, $params) = $DB->get_in_or_equal($names);
291         $params[] = $qa->get_step(0)->get_id();
292         $DB->delete_records_select('question_attempt_step_data',
293                 'name ' . $condition . ' AND attemptstepid = ?', $params);
294         return $this->insert_question_attempt_metadata($qa, $names);
295     }
297     /**
298      * Load a {@link question_attempt_step} from the database.
299      *
300      * Private method, only for use by other parts of the question engine.
301      *
302      * @param int $stepid the id of the step to load.
303      * @param question_attempt_step the step that was loaded.
304      */
305     public function load_question_attempt_step($stepid) {
306         $records = $this->db->get_recordset_sql("
307 SELECT
308     quba.contextid,
309     COALESCE(q.qtype, 'missingtype') AS qtype,
310     qas.id AS attemptstepid,
311     qas.questionattemptid,
312     qas.sequencenumber,
313     qas.state,
314     qas.fraction,
315     qas.timecreated,
316     qas.userid,
317     qasd.name,
318     qasd.value
320 FROM      {question_attempt_steps}     qas
321 JOIN      {question_attempts}          qa   ON qa.id              = qas.questionattemptid
322 JOIN      {question_usages}            quba ON quba.id            = qa.questionusageid
323 LEFT JOIN {question}                   q    ON q.id               = qa.questionid
324 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
326 WHERE
327     qas.id = :stepid
328         ", array('stepid' => $stepid));
330         if (!$records->valid()) {
331             throw new coding_exception('Failed to load question_attempt_step ' . $stepid);
332         }
334         $step = question_attempt_step::load_from_records($records, $stepid);
335         $records->close();
337         return $step;
338     }
340     /**
341      * Load a {@link question_attempt} from the database, including all its
342      * steps.
343      *
344      * Normally, you should use {@link question_engine::load_questions_usage_by_activity()}
345      * but there may be rare occasions where for performance reasons, you only
346      * wish to load one qa, in which case you may call this method.
347      *
348      * @param int $questionattemptid the id of the question attempt to load.
349      * @param question_attempt the question attempt that was loaded.
350      */
351     public function load_question_attempt($questionattemptid) {
352         $records = $this->db->get_recordset_sql("
353 SELECT
354     quba.contextid,
355     quba.preferredbehaviour,
356     qa.id AS questionattemptid,
357     qa.questionusageid,
358     qa.slot,
359     qa.behaviour,
360     qa.questionid,
361     qa.variant,
362     qa.maxmark,
363     qa.minfraction,
364     qa.maxfraction,
365     qa.flagged,
366     qa.questionsummary,
367     qa.rightanswer,
368     qa.responsesummary,
369     qa.timemodified,
370     qas.id AS attemptstepid,
371     qas.sequencenumber,
372     qas.state,
373     qas.fraction,
374     qas.timecreated,
375     qas.userid,
376     qasd.name,
377     qasd.value
379 FROM      {question_attempts}          qa
380 JOIN      {question_usages}            quba ON quba.id               = qa.questionusageid
381 LEFT JOIN {question_attempt_steps}     qas  ON qas.questionattemptid = qa.id
382 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid    = qas.id
384 WHERE
385     qa.id = :questionattemptid
387 ORDER BY
388     qas.sequencenumber
389         ", array('questionattemptid' => $questionattemptid));
391         if (!$records->valid()) {
392             throw new coding_exception('Failed to load question_attempt ' . $questionattemptid);
393         }
395         $record = $records->current();
396         $qa = question_attempt::load_from_records($records, $questionattemptid,
397                 new question_usage_null_observer(), $record->preferredbehaviour);
398         $records->close();
400         return $qa;
401     }
403     /**
404      * Load a {@link question_usage_by_activity} from the database, including
405      * all its {@link question_attempt}s and all their steps.
406      *
407      * You should call {@link question_engine::load_questions_usage_by_activity()}
408      * rather than calling this method directly.
409      *
410      * @param int $qubaid the id of the usage to load.
411      * @param question_usage_by_activity the usage that was loaded.
412      */
413     public function load_questions_usage_by_activity($qubaid) {
414         $records = $this->db->get_recordset_sql("
415 SELECT
416     quba.id AS qubaid,
417     quba.contextid,
418     quba.component,
419     quba.preferredbehaviour,
420     qa.id AS questionattemptid,
421     qa.questionusageid,
422     qa.slot,
423     qa.behaviour,
424     qa.questionid,
425     qa.variant,
426     qa.maxmark,
427     qa.minfraction,
428     qa.maxfraction,
429     qa.flagged,
430     qa.questionsummary,
431     qa.rightanswer,
432     qa.responsesummary,
433     qa.timemodified,
434     qas.id AS attemptstepid,
435     qas.sequencenumber,
436     qas.state,
437     qas.fraction,
438     qas.timecreated,
439     qas.userid,
440     qasd.name,
441     qasd.value
443 FROM      {question_usages}            quba
444 LEFT JOIN {question_attempts}          qa   ON qa.questionusageid    = quba.id
445 LEFT JOIN {question_attempt_steps}     qas  ON qas.questionattemptid = qa.id
446 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid    = qas.id
448 WHERE
449     quba.id = :qubaid
451 ORDER BY
452     qa.slot,
453     qas.sequencenumber
454     ", array('qubaid' => $qubaid));
456         if (!$records->valid()) {
457             throw new coding_exception('Failed to load questions_usage_by_activity ' . $qubaid);
458         }
460         $quba = question_usage_by_activity::load_from_records($records, $qubaid);
461         $records->close();
463         return $quba;
464     }
466     /**
467      * Load all {@link question_usage_by_activity} from the database for one qubaid_condition
468      * Include all its {@link question_attempt}s and all their steps.
469      *
470      * This method may be called publicly.
471      *
472      * @param qubaid_condition $qubaids the condition that tells us which usages to load.
473      * @return question_usage_by_activity[] the usages that were loaded.
474      */
475     public function load_questions_usages_by_activity($qubaids) {
476         $records = $this->db->get_recordset_sql("
477 SELECT
478     quba.id AS qubaid,
479     quba.contextid,
480     quba.component,
481     quba.preferredbehaviour,
482     qa.id AS questionattemptid,
483     qa.questionusageid,
484     qa.slot,
485     qa.behaviour,
486     qa.questionid,
487     qa.variant,
488     qa.maxmark,
489     qa.minfraction,
490     qa.maxfraction,
491     qa.flagged,
492     qa.questionsummary,
493     qa.rightanswer,
494     qa.responsesummary,
495     qa.timemodified,
496     qas.id AS attemptstepid,
497     qas.sequencenumber,
498     qas.state,
499     qas.fraction,
500     qas.timecreated,
501     qas.userid,
502     qasd.name,
503     qasd.value
505 FROM      {question_usages}            quba
506 LEFT JOIN {question_attempts}          qa   ON qa.questionusageid    = quba.id
507 LEFT JOIN {question_attempt_steps}     qas  ON qas.questionattemptid = qa.id
508 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid    = qas.id
510 WHERE
511     quba.id {$qubaids->usage_id_in()}
513 ORDER BY
514     quba.id,
515     qa.slot,
516     qas.sequencenumber
517     ", $qubaids->usage_id_in_params());
519         if (!$records->valid()) {
520             throw new coding_exception('Failed to load questions_usages_by_activity for qubaid_condition :' . $qubaids);
521         }
523         $qubas = array();
524         do {
525             $record = $records->current();
526             $qubas[$record->qubaid] = question_usage_by_activity::load_from_records($records, $record->qubaid);
527         } while ($records->valid());
529         $records->close();
531         return $qubas;
532     }
534     /**
535      * Load information about the latest state of each question from the database.
536      *
537      * This method may be called publicly.
538      *
539      * @param qubaid_condition $qubaids used to restrict which usages are included
540      *                                  in the query. See {@link qubaid_condition}.
541      * @param array            $slots   A list of slots for the questions you want to know about.
542      * @param string|null      $fields
543      * @return array of records. See the SQL in this function to see the fields available.
544      */
545     public function load_questions_usages_latest_steps(qubaid_condition $qubaids, $slots, $fields = null) {
546         list($slottest, $params) = $this->db->get_in_or_equal($slots, SQL_PARAMS_NAMED, 'slot');
548         if ($fields === null) {
549             $fields = "qas.id,
550     qa.id AS questionattemptid,
551     qa.questionusageid,
552     qa.slot,
553     qa.behaviour,
554     qa.questionid,
555     qa.variant,
556     qa.maxmark,
557     qa.minfraction,
558     qa.maxfraction,
559     qa.flagged,
560     qa.questionsummary,
561     qa.rightanswer,
562     qa.responsesummary,
563     qa.timemodified,
564     qas.id AS attemptstepid,
565     qas.sequencenumber,
566     qas.state,
567     qas.fraction,
568     qas.timecreated,
569     qas.userid";
571         }
573         $records = $this->db->get_records_sql("
574 SELECT
575     {$fields}
577 FROM {$qubaids->from_question_attempts('qa')}
578 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
579         AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
581 WHERE
582     {$qubaids->where()} AND
583     qa.slot $slottest
584         ", $params + $qubaids->from_where_params());
586         return $records;
587     }
589     /**
590      * Load summary information about the state of each question in a group of
591      * attempts. This is used, for example, by the quiz manual grading report,
592      * to show how many attempts at each question need to be graded.
593      *
594      * This method may be called publicly.
595      *
596      * @param qubaid_condition $qubaids used to restrict which usages are included
597      * in the query. See {@link qubaid_condition}.
598      * @param array $slots A list of slots for the questions you want to konw about.
599      * @return array The array keys are slot,qestionid. The values are objects with
600      * fields $slot, $questionid, $inprogress, $name, $needsgrading, $autograded,
601      * $manuallygraded and $all.
602      */
603     public function load_questions_usages_question_state_summary(
604             qubaid_condition $qubaids, $slots) {
605         list($slottest, $params) = $this->db->get_in_or_equal($slots, SQL_PARAMS_NAMED, 'slot');
607         $rs = $this->db->get_recordset_sql("
608 SELECT
609     qa.slot,
610     qa.questionid,
611     q.name,
612     CASE qas.state
613         {$this->full_states_to_summary_state_sql()}
614     END AS summarystate,
615     COUNT(1) AS numattempts
617 FROM {$qubaids->from_question_attempts('qa')}
618 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
619         AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
620 JOIN {question} q ON q.id = qa.questionid
622 WHERE
623     {$qubaids->where()} AND
624     qa.slot $slottest
626 GROUP BY
627     qa.slot,
628     qa.questionid,
629     q.name,
630     q.id,
631     CASE qas.state
632         {$this->full_states_to_summary_state_sql()}
633     END
635 ORDER BY
636     qa.slot,
637     qa.questionid,
638     q.name,
639     q.id
640         ", $params + $qubaids->from_where_params());
642         $results = array();
643         foreach ($rs as $row) {
644             $index = $row->slot . ',' . $row->questionid;
646             if (!array_key_exists($index, $results)) {
647                 $res = new stdClass();
648                 $res->slot = $row->slot;
649                 $res->questionid = $row->questionid;
650                 $res->name = $row->name;
651                 $res->inprogress = 0;
652                 $res->needsgrading = 0;
653                 $res->autograded = 0;
654                 $res->manuallygraded = 0;
655                 $res->all = 0;
656                 $results[$index] = $res;
657             }
659             $results[$index]->{$row->summarystate} = $row->numattempts;
660             $results[$index]->all += $row->numattempts;
661         }
662         $rs->close();
664         return $results;
665     }
667     /**
668      * Get a list of usage ids where the question with slot $slot, and optionally
669      * also with question id $questionid, is in summary state $summarystate. Also
670      * return the total count of such states.
671      *
672      * Only a subset of the ids can be returned by using $orderby, $limitfrom and
673      * $limitnum. A special value 'random' can be passed as $orderby, in which case
674      * $limitfrom is ignored.
675      *
676      * This method may be called publicly.
677      *
678      * @param qubaid_condition $qubaids used to restrict which usages are included
679      * in the query. See {@link qubaid_condition}.
680      * @param int $slot The slot for the questions you want to konw about.
681      * @param int $questionid (optional) Only return attempts that were of this specific question.
682      * @param string $summarystate the summary state of interest, or 'all'.
683      * @param string $orderby the column to order by.
684      * @param array $params any params required by any of the SQL fragments.
685      * @param int $limitfrom implements paging of the results.
686      *      Ignored if $orderby = random or $limitnum is null.
687      * @param int $limitnum implements paging of the results. null = all.
688      * @return array with two elements, an array of usage ids, and a count of the total number.
689      */
690     public function load_questions_usages_where_question_in_state(
691             qubaid_condition $qubaids, $summarystate, $slot, $questionid = null,
692             $orderby = 'random', $params = array(), $limitfrom = 0, $limitnum = null) {
694         $extrawhere = '';
695         if ($questionid) {
696             $extrawhere .= ' AND qa.questionid = :questionid';
697             $params['questionid'] = $questionid;
698         }
699         if ($summarystate != 'all') {
700             list($test, $sparams) = $this->in_summary_state_test($summarystate);
701             $extrawhere .= ' AND qas.state ' . $test;
702             $params += $sparams;
703         }
705         if ($orderby == 'random') {
706             $sqlorderby = '';
707         } else if ($orderby) {
708             $sqlorderby = 'ORDER BY ' . $orderby;
709         } else {
710             $sqlorderby = '';
711         }
713         // We always want the total count, as well as the partcular list of ids
714         // based on the paging and sort order. Because the list of ids is never
715         // going to be too ridiculously long. My worst-case scenario is
716         // 10,000 students in the course, each doing 5 quiz attempts. That
717         // is a 50,000 element int => int array, which PHP seems to use 5MB
718         // memory to store on a 64 bit server.
719         $qubaidswhere = $qubaids->where(); // Must call this before params.
720         $params += $qubaids->from_where_params();
721         $params['slot'] = $slot;
723         $qubaids = $this->db->get_records_sql_menu("
724 SELECT
725     qa.questionusageid,
726     1
728 FROM {$qubaids->from_question_attempts('qa')}
729 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
730         AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
731 JOIN {question} q ON q.id = qa.questionid
733 WHERE
734     {$qubaidswhere} AND
735     qa.slot = :slot
736     $extrawhere
738 $sqlorderby
739         ", $params);
741         $qubaids = array_keys($qubaids);
742         $count = count($qubaids);
744         if ($orderby == 'random') {
745             shuffle($qubaids);
746             $limitfrom = 0;
747         }
749         if (!is_null($limitnum)) {
750             $qubaids = array_slice($qubaids, $limitfrom, $limitnum);
751         }
753         return array($qubaids, $count);
754     }
756     /**
757      * Load the average mark, and number of attempts, for each slot in a set of
758      * question usages..
759      *
760      * This method may be called publicly.
761      *
762      * @param qubaid_condition $qubaids used to restrict which usages are included
763      * in the query. See {@link qubaid_condition}.
764      * @param array $slots if null, load info for all quesitions, otherwise only
765      * load the averages for the specified questions.
766      * @return array of objects with fields ->slot, ->averagefraction and ->numaveraged.
767      */
768     public function load_average_marks(qubaid_condition $qubaids, $slots = null) {
769         if (!empty($slots)) {
770             list($slottest, $slotsparams) = $this->db->get_in_or_equal(
771                     $slots, SQL_PARAMS_NAMED, 'slot');
772             $slotwhere = " AND qa.slot {$slottest}";
773         } else {
774             $slotwhere = '';
775             $slotsparams = array();
776         }
778         list($statetest, $stateparams) = $this->db->get_in_or_equal(array(
779                 (string) question_state::$gaveup,
780                 (string) question_state::$gradedwrong,
781                 (string) question_state::$gradedpartial,
782                 (string) question_state::$gradedright,
783                 (string) question_state::$mangaveup,
784                 (string) question_state::$mangrwrong,
785                 (string) question_state::$mangrpartial,
786                 (string) question_state::$mangrright), SQL_PARAMS_NAMED, 'st');
788         return $this->db->get_records_sql("
789 SELECT
790     qa.slot,
791     AVG(COALESCE(qas.fraction, 0)) AS averagefraction,
792     COUNT(1) AS numaveraged
794 FROM {$qubaids->from_question_attempts('qa')}
795 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
796         AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
798 WHERE
799     {$qubaids->where()}
800     $slotwhere
801     AND qas.state $statetest
803 GROUP BY qa.slot
805 ORDER BY qa.slot
806         ", $slotsparams + $stateparams + $qubaids->from_where_params());
807     }
809     /**
810      * Load all the attempts at a given queston from a set of question_usages.
811      * steps.
812      *
813      * This method may be called publicly.
814      *
815      * @param int $questionid the question to load all the attempts fors.
816      * @param qubaid_condition $qubaids used to restrict which usages are included
817      * in the query. See {@link qubaid_condition}.
818      * @return question_attempt[] array of question_attempts that were loaded.
819      */
820     public function load_attempts_at_question($questionid, qubaid_condition $qubaids) {
821         $sql = "
822 SELECT
823     quba.contextid,
824     quba.preferredbehaviour,
825     qa.id AS questionattemptid,
826     qa.questionusageid,
827     qa.slot,
828     qa.behaviour,
829     qa.questionid,
830     qa.variant,
831     qa.maxmark,
832     qa.minfraction,
833     qa.maxfraction,
834     qa.flagged,
835     qa.questionsummary,
836     qa.rightanswer,
837     qa.responsesummary,
838     qa.timemodified,
839     qas.id AS attemptstepid,
840     qas.sequencenumber,
841     qas.state,
842     qas.fraction,
843     qas.timecreated,
844     qas.userid,
845     qasd.name,
846     qasd.value
848 FROM {$qubaids->from_question_attempts('qa')}
849 JOIN {question_usages} quba ON quba.id = qa.questionusageid
850 LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
851 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
853 WHERE
854     {$qubaids->where()} AND
855     qa.questionid = :questionid
857 ORDER BY
858     quba.id,
859     qa.id,
860     qas.sequencenumber";
862         // For qubaid_list must call this after calling methods that generate sql.
863         $params = $qubaids->from_where_params();
864         $params['questionid'] = $questionid;
866         $records = $this->db->get_recordset_sql($sql, $params);
868         $questionattempts = array();
869         while ($records->valid()) {
870             $record = $records->current();
871             $questionattempts[$record->questionattemptid] =
872                     question_attempt::load_from_records($records,
873                     $record->questionattemptid, new question_usage_null_observer(),
874                     $record->preferredbehaviour);
875         }
876         $records->close();
878         return $questionattempts;
879     }
881     /**
882      * Update a question_usages row to refect any changes in a usage (but not
883      * any of its question_attempts.
884      *
885      * You should not call this method directly. You should use
886      * @link question_engine::save_questions_usage_by_activity()}.
887      *
888      * @param question_usage_by_activity $quba the usage that has changed.
889      */
890     public function update_questions_usage_by_activity(question_usage_by_activity $quba) {
891         $record = new stdClass();
892         $record->id = $quba->get_id();
893         $record->contextid = $quba->get_owning_context()->id;
894         $record->component = $quba->get_owning_component();
895         $record->preferredbehaviour = $quba->get_preferred_behaviour();
897         $this->db->update_record('question_usages', $record);
898     }
900     /**
901      * Update a question_attempts row to refect any changes in a question_attempt
902      * (but not any of its steps).
903      *
904      * You should not call this method directly. You should use
905      * @link question_engine::save_questions_usage_by_activity()}.
906      *
907      * @param question_attempt $qa the question attempt that has changed.
908      */
909     public function update_question_attempt(question_attempt $qa) {
910         $record = new stdClass();
911         $record->id = $qa->get_database_id();
912         $record->slot = $qa->get_slot();
913         $record->variant = $qa->get_variant();
914         $record->maxmark = $qa->get_max_mark();
915         $record->minfraction = $qa->get_min_fraction();
916         $record->maxfraction = $qa->get_max_fraction();
917         $record->flagged = $qa->is_flagged();
918         $record->questionsummary = $qa->get_question_summary();
919         $record->rightanswer = $qa->get_right_answer_summary();
920         $record->responsesummary = $qa->get_response_summary();
921         $record->timemodified = time();
923         $this->db->update_record('question_attempts', $record);
924     }
926     /**
927      * Delete a question_usage_by_activity and all its associated
928      *
929      * You should not call this method directly. You should use
930      * @link question_engine::delete_questions_usage_by_activities()}.
931      *
932      * {@link question_attempts} and {@link question_attempt_steps} from the
933      * database.
934      * @param qubaid_condition $qubaids identifies which question useages to delete.
935      */
936     public function delete_questions_usage_by_activities(qubaid_condition $qubaids) {
937         $where = "qa.questionusageid {$qubaids->usage_id_in()}";
938         $params = $qubaids->usage_id_in_params();
940         $contextids = $this->db->get_records_sql_menu("
941                 SELECT DISTINCT contextid, 1
942                 FROM {question_usages}
943                 WHERE id {$qubaids->usage_id_in()}", $qubaids->usage_id_in_params());
944         foreach ($contextids as $contextid => $notused) {
945             $this->delete_response_files($contextid, "IN (
946                     SELECT qas.id
947                     FROM {question_attempts} qa
948                     JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
949                     WHERE $where)", $params);
950         }
952         if ($this->db->get_dbfamily() == 'mysql') {
953             $this->delete_usage_records_for_mysql($qubaids);
954             return;
955         }
957         $this->db->delete_records_select('question_attempt_step_data', "attemptstepid IN (
958                 SELECT qas.id
959                 FROM {question_attempts} qa
960                 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
961                 WHERE $where)", $params);
963         $this->db->delete_records_select('question_attempt_steps', "questionattemptid IN (
964                 SELECT qa.id
965                 FROM {question_attempts} qa
966                 WHERE $where)", $params);
968         $this->db->delete_records_select('question_attempts',
969                 "{question_attempts}.questionusageid {$qubaids->usage_id_in()}",
970                 $qubaids->usage_id_in_params());
972         $this->db->delete_records_select('question_usages',
973                 "{question_usages}.id {$qubaids->usage_id_in()}", $qubaids->usage_id_in_params());
974     }
976     /**
977      * This function is a work-around for poor MySQL performance with
978      * DELETE FROM x WHERE id IN (SELECT ...). We have to use a non-standard
979      * syntax to get good performance. See MDL-29520.
980      * @param qubaid_condition $qubaids identifies which question useages to delete.
981      */
982     protected function delete_usage_records_for_mysql(qubaid_condition $qubaids) {
983         $qubaidtest = $qubaids->usage_id_in();
984         if (strpos($qubaidtest, 'question_usages') !== false &&
985                 strpos($qubaidtest, 'IN (SELECT') === 0) {
986             // This horrible hack is required by MDL-29847. It comes from
987             // http://www.xaprb.com/blog/2006/06/23/how-to-select-from-an-update-target-in-mysql/
988             $qubaidtest = 'IN (SELECT * FROM ' . substr($qubaidtest, 3) . ' AS hack_subquery_alias)';
989         }
991         // TODO once MDL-29589 is fixed, eliminate this method, and instead use the new $DB API.
992         $this->db->execute('
993                 DELETE qu, qa, qas, qasd
994                   FROM {question_usages}            qu
995                   JOIN {question_attempts}          qa   ON qa.questionusageid = qu.id
996              LEFT JOIN {question_attempt_steps}     qas  ON qas.questionattemptid = qa.id
997              LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
998                  WHERE qu.id ' . $qubaidtest,
999                 $qubaids->usage_id_in_params());
1000     }
1002     /**
1003      * Delete all the steps for a question attempt.
1004      *
1005      * Private method, only for use by other parts of the question engine.
1006      *
1007      * @param int $qaids question_attempt id.
1008      * @param context $context the context that the $quba belongs to.
1009      */
1010     public function delete_steps($stepids, $context) {
1011         if (empty($stepids)) {
1012             return;
1013         }
1014         list($test, $params) = $this->db->get_in_or_equal($stepids, SQL_PARAMS_NAMED);
1016         $this->delete_response_files($context->id, $test, $params);
1018         $this->db->delete_records_select('question_attempt_step_data',
1019                 "attemptstepid {$test}", $params);
1020         $this->db->delete_records_select('question_attempt_steps',
1021                 "id {$test}", $params);
1022     }
1024     /**
1025      * Delete all the files belonging to the response variables in the gives
1026      * question attempt steps.
1027      * @param int $contextid the context these attempts belong to.
1028      * @param string $itemidstest a bit of SQL that can be used in a
1029      *      WHERE itemid $itemidstest clause. Must use named params.
1030      * @param array $params any query parameters used in $itemidstest.
1031      */
1032     protected function delete_response_files($contextid, $itemidstest, $params) {
1033         $fs = get_file_storage();
1034         foreach (question_engine::get_all_response_file_areas() as $filearea) {
1035             $fs->delete_area_files_select($contextid, 'question', $filearea,
1036                     $itemidstest, $params);
1037         }
1038     }
1040     /**
1041      * Delete all the previews for a given question.
1042      *
1043      * Private method, only for use by other parts of the question engine.
1044      *
1045      * @param int $questionid question id.
1046      */
1047     public function delete_previews($questionid) {
1048         $previews = $this->db->get_records_sql_menu("
1049                 SELECT DISTINCT quba.id, 1
1050                 FROM {question_usages} quba
1051                 JOIN {question_attempts} qa ON qa.questionusageid = quba.id
1052                 WHERE quba.component = 'core_question_preview' AND
1053                     qa.questionid = ?", array($questionid));
1054         if (empty($previews)) {
1055             return;
1056         }
1057         $this->delete_questions_usage_by_activities(new qubaid_list($previews));
1058     }
1060     /**
1061      * Update the flagged state of a question in the database.
1062      *
1063      * You should call {@link question_engine::update_flag()()}
1064      * rather than calling this method directly.
1065      *
1066      * @param int $qubaid the question usage id.
1067      * @param int $questionid the question id.
1068      * @param int $sessionid the question_attempt id.
1069      * @param bool $newstate the new state of the flag. true = flagged.
1070      */
1071     public function update_question_attempt_flag($qubaid, $questionid, $qaid, $slot, $newstate) {
1072         if (!$this->db->record_exists('question_attempts', array('id' => $qaid,
1073                 'questionusageid' => $qubaid, 'questionid' => $questionid, 'slot' => $slot))) {
1074             throw new moodle_exception('errorsavingflags', 'question');
1075         }
1077         $this->db->set_field('question_attempts', 'flagged', $newstate, array('id' => $qaid));
1078     }
1080     /**
1081      * Get all the WHEN 'x' THEN 'y' terms needed to convert the question_attempt_steps.state
1082      * column to a summary state. Use this like
1083      * CASE qas.state {$this->full_states_to_summary_state_sql()} END AS summarystate,
1084      * @param string SQL fragment.
1085      */
1086     protected function full_states_to_summary_state_sql() {
1087         $sql = '';
1088         foreach (question_state::get_all() as $state) {
1089             $sql .= "WHEN '{$state}' THEN '{$state->get_summary_state()}'\n";
1090         }
1091         return $sql;
1092     }
1094     /**
1095      * Get the SQL needed to test that question_attempt_steps.state is in a
1096      * state corresponding to $summarystate.
1097      *
1098      * This method may be called publicly.
1099      *
1100      * @param string $summarystate one of
1101      * inprogress, needsgrading, manuallygraded or autograded
1102      * @param bool $equal if false, do a NOT IN test. Default true.
1103      * @return string SQL fragment.
1104      */
1105     public function in_summary_state_test($summarystate, $equal = true, $prefix = 'summarystates') {
1106         $states = question_state::get_all_for_summary_state($summarystate);
1107         return $this->db->get_in_or_equal(array_map('strval', $states),
1108                 SQL_PARAMS_NAMED, $prefix, $equal);
1109     }
1111     /**
1112      * Change the maxmark for the question_attempt with number in usage $slot
1113      * for all the specified question_attempts.
1114      *
1115      * You should call {@link question_engine::set_max_mark_in_attempts()}
1116      * rather than calling this method directly.
1117      *
1118      * @param qubaid_condition $qubaids Selects which usages are updated.
1119      * @param int $slot the number is usage to affect.
1120      * @param number $newmaxmark the new max mark to set.
1121      */
1122     public function set_max_mark_in_attempts(qubaid_condition $qubaids, $slot, $newmaxmark) {
1123         if ($this->db->get_dbfamily() == 'mysql') {
1124             // MySQL's query optimiser completely fails to cope with the
1125             // set_field_select call below, so we have to give it a clue. See MDL-32616.
1126             // TODO MDL-29589 encapsulate this MySQL-specific code with a $DB method.
1127             $this->db->execute("
1128                     UPDATE " . $qubaids->from_question_attempts('qa') . "
1129                        SET qa.maxmark = :newmaxmark
1130                      WHERE " . $qubaids->where() . "
1131                        AND slot = :slot
1132                     ", $qubaids->from_where_params() + array('newmaxmark' => $newmaxmark, 'slot' => $slot));
1133             return;
1134         }
1136         // Normal databases.
1137         $this->db->set_field_select('question_attempts', 'maxmark', $newmaxmark,
1138                 "questionusageid {$qubaids->usage_id_in()} AND slot = :slot",
1139                 $qubaids->usage_id_in_params() + array('slot' => $slot));
1140     }
1142     /**
1143      * Return a subquery that computes the sum of the marks for all the questions
1144      * in a usage. Which useage to compute the sum for is controlled bu the $qubaid
1145      * parameter.
1146      *
1147      * See {@link quiz_update_all_attempt_sumgrades()} for an example of the usage of
1148      * this method.
1149      *
1150      * This method may be called publicly.
1151      *
1152      * @param string $qubaid SQL fragment that controls which usage is summed.
1153      * This will normally be the name of a column in the outer query. Not that this
1154      * SQL fragment must not contain any placeholders.
1155      * @return string SQL code for the subquery.
1156      */
1157     public function sum_usage_marks_subquery($qubaid) {
1158         // To explain the COALESCE in the following SQL: SUM(lots of NULLs) gives
1159         // NULL, while SUM(one 0.0 and lots of NULLS) gives 0.0. We don't want that.
1160         // We always want to return a number, so the COALESCE is there to turn the
1161         // NULL total into a 0.
1162         return "SELECT COALESCE(SUM(qa.maxmark * qas.fraction), 0)
1163             FROM {question_attempts} qa
1164             JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
1165                     AND qas.sequencenumber = (
1166                             SELECT MAX(summarks_qas.sequencenumber)
1167                               FROM {question_attempt_steps} summarks_qas
1168                              WHERE summarks_qas.questionattemptid = qa.id
1169             )
1170             WHERE qa.questionusageid = $qubaid
1171             HAVING COUNT(CASE
1172                 WHEN qas.state = 'needsgrading' AND qa.maxmark > 0 THEN 1
1173                 ELSE NULL
1174             END) = 0";
1175     }
1177     /**
1178      * Get a subquery that returns the latest step of every qa in some qubas.
1179      * Currently, this is only used by the quiz reports. See
1180      * {@link quiz_attempts_report_table::add_latest_state_join()}.
1181      *
1182      * This method may be called publicly.
1183      *
1184      * @param string $alias alias to use for this inline-view.
1185      * @param qubaid_condition $qubaids restriction on which question_usages we
1186      *      are interested in. This is important for performance.
1187      * @return array with two elements, the SQL fragment and any params requried.
1188      */
1189     public function question_attempt_latest_state_view($alias, qubaid_condition $qubaids) {
1190         return array("(
1191                 SELECT {$alias}qa.id AS questionattemptid,
1192                        {$alias}qa.questionusageid,
1193                        {$alias}qa.slot,
1194                        {$alias}qa.behaviour,
1195                        {$alias}qa.questionid,
1196                        {$alias}qa.variant,
1197                        {$alias}qa.maxmark,
1198                        {$alias}qa.minfraction,
1199                        {$alias}qa.maxfraction,
1200                        {$alias}qa.flagged,
1201                        {$alias}qa.questionsummary,
1202                        {$alias}qa.rightanswer,
1203                        {$alias}qa.responsesummary,
1204                        {$alias}qa.timemodified,
1205                        {$alias}qas.id AS attemptstepid,
1206                        {$alias}qas.sequencenumber,
1207                        {$alias}qas.state,
1208                        {$alias}qas.fraction,
1209                        {$alias}qas.timecreated,
1210                        {$alias}qas.userid
1212                   FROM {$qubaids->from_question_attempts($alias . 'qa')}
1213                   JOIN {question_attempt_steps} {$alias}qas ON {$alias}qas.questionattemptid = {$alias}qa.id
1214                             AND {$alias}qas.sequencenumber = {$this->latest_step_for_qa_subquery($alias . 'qa.id')}
1215                  WHERE {$qubaids->where()}
1216             ) {$alias}", $qubaids->from_where_params());
1217     }
1219     protected function latest_step_for_qa_subquery($questionattemptid = 'qa.id') {
1220         return "(
1221                 SELECT MAX(sequencenumber)
1222                 FROM {question_attempt_steps}
1223                 WHERE questionattemptid = $questionattemptid
1224             )";
1225     }
1227     /**
1228      * Are any of these questions are currently in use?
1229      *
1230      * You should call {@link question_engine::questions_in_use()}
1231      * rather than calling this method directly.
1232      *
1233      * @param array $questionids of question ids.
1234      * @param qubaid_condition $qubaids ids of the usages to consider.
1235      * @return bool whether any of these questions are being used by any of
1236      *      those usages.
1237      */
1238     public function questions_in_use(array $questionids, qubaid_condition $qubaids) {
1239         list($test, $params) = $this->db->get_in_or_equal($questionids);
1240         return $this->db->record_exists_select('question_attempts',
1241                 'questionid ' . $test . ' AND questionusageid ' .
1242                 $qubaids->usage_id_in(), $params + $qubaids->usage_id_in_params());
1243     }
1245     /**
1246      * Get the number of times each variant has been used for each question in a list
1247      * in a set of usages.
1248      * @param array $questionids of question ids.
1249      * @param qubaid_condition $qubaids ids of the usages to consider.
1250      * @return array questionid => variant number => num uses.
1251      */
1252     public function load_used_variants(array $questionids, qubaid_condition $qubaids) {
1253         list($test, $params) = $this->db->get_in_or_equal($questionids, SQL_PARAMS_NAMED, 'qid');
1254         $recordset = $this->db->get_recordset_sql("
1255                 SELECT qa.questionid, qa.variant, COUNT(1) AS usescount
1256                   FROM " . $qubaids->from_question_attempts('qa') . "
1257                  WHERE qa.questionid $test
1258                    AND " . $qubaids->where() . "
1259               GROUP BY qa.questionid, qa.variant
1260               ORDER BY COUNT(1) ASC
1261                 ", $params + $qubaids->from_where_params());
1263         $usedvariants = array_combine($questionids, array_fill(0, count($questionids), array()));
1264         foreach ($recordset as $row) {
1265             $usedvariants[$row->questionid][$row->variant] = $row->usescount;
1266         }
1267         $recordset->close();
1268         return $usedvariants;
1269     }
1273 /**
1274  * Implementation of the unit of work pattern for the question engine.
1275  *
1276  * See http://martinfowler.com/eaaCatalog/unitOfWork.html. This tracks all the
1277  * changes to a {@link question_usage_by_activity}, and its constituent parts,
1278  * so that the changes can be saved to the database when {@link save()} is called.
1279  *
1280  * @copyright  2009 The Open University
1281  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1282  */
1283 class question_engine_unit_of_work implements question_usage_observer {
1284     /** @var question_usage_by_activity the usage being tracked. */
1285     protected $quba;
1287     /** @var boolean whether any of the fields of the usage have been changed. */
1288     protected $modified = false;
1290     /**
1291      * @var array list of slot => {@link question_attempt}s that
1292      * have been added to the usage.
1293      */
1294     protected $attemptsadded = array();
1296     /**
1297      * @var array list of slot => {@link question_attempt}s that
1298      * were already in the usage, and which have been modified.
1299      */
1300     protected $attemptsmodified = array();
1302     /**
1303      * @var array of array(question_attempt_step, question_attempt id, seq number)
1304      * of steps that have been added to question attempts in this usage.
1305      */
1306     protected $stepsadded = array();
1308     /**
1309      * @var array of array(question_attempt_step, question_attempt id, seq number)
1310      * of steps that have been modified in their attempt.
1311      */
1312     protected $stepsmodified = array();
1314     /**
1315      * @var array list of question_attempt_step.id => question_attempt_step of steps
1316      * that were previously stored in the database, but which are no longer required.
1317      */
1318     protected $stepsdeleted = array();
1320     /**
1321      * @var array int slot => string name => question_attempt.
1322      */
1323     protected $metadataadded = array();
1325     /**
1326      * @var array int slot => string name => question_attempt.
1327      */
1328     protected $metadatamodified = array();
1330     /**
1331      * Constructor.
1332      * @param question_usage_by_activity $quba the usage to track.
1333      */
1334     public function __construct(question_usage_by_activity $quba) {
1335         $this->quba = $quba;
1336     }
1338     public function notify_modified() {
1339         $this->modified = true;
1340     }
1342     public function notify_attempt_added(question_attempt $qa) {
1343         $this->attemptsadded[$qa->get_slot()] = $qa;
1344     }
1346     public function notify_attempt_modified(question_attempt $qa) {
1347         $slot = $qa->get_slot();
1348         if (!array_key_exists($slot, $this->attemptsadded)) {
1349             $this->attemptsmodified[$slot] = $qa;
1350         }
1351     }
1353     public function notify_attempt_moved(question_attempt $qa, $oldslot) {
1354         $newslot = $qa->get_slot();
1356         if (array_key_exists($oldslot, $this->attemptsadded)) {
1357             unset($this->attemptsadded[$oldslot]);
1358             $this->attemptsadded[$newslot] = $qa;
1359             return;
1360         }
1362         if (array_key_exists($oldslot, $this->attemptsmodified)) {
1363             unset($this->attemptsmodified[$oldslot]);
1364         }
1365         $this->attemptsmodified[$newslot] = $qa;
1367         if (array_key_exists($oldslot, $this->metadataadded)) {
1368             $this->metadataadded[$newslot] = $this->metadataadded[$oldslot];
1369             unset($this->metadataadded[$oldslot]);
1370         }
1371         if (array_key_exists($oldslot, $this->metadatamodified)) {
1372             $this->metadatamodified[$newslot] = $this->metadatamodified[$oldslot];
1373             unset($this->metadatamodified[$oldslot]);
1374         }
1375     }
1377     public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq) {
1378         if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
1379             return;
1380         }
1382         if (($key = $this->is_step_added($step)) !== false) {
1383             return;
1384         }
1386         if (($key = $this->is_step_modified($step)) !== false) {
1387             throw new coding_exception('Cannot add a step that has already been modified.');
1388         }
1390         if (($key = $this->is_step_deleted($step)) !== false) {
1391             unset($this->stepsdeleted[$step->get_id()]);
1392             $this->stepsmodified[] = array($step, $qa->get_database_id(), $seq);
1393             return;
1394         }
1396         $stepid = $step->get_id();
1397         if ($stepid) {
1398             if (array_key_exists($stepid, $this->stepsdeleted)) {
1399                 unset($this->stepsdeleted[$stepid]);
1400             }
1401             $this->stepsmodified[] = array($step, $qa->get_database_id(), $seq);
1403         } else {
1404             $this->stepsadded[] = array($step, $qa->get_database_id(), $seq);
1405         }
1406     }
1408     public function notify_step_modified(question_attempt_step $step, question_attempt $qa, $seq) {
1409         if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
1410             return;
1411         }
1413         if (($key = $this->is_step_added($step)) !== false) {
1414             return;
1415         }
1417         if (($key = $this->is_step_deleted($step)) !== false) {
1418             throw new coding_exception('Cannot modify a step after it has been deleted.');
1419         }
1421         $stepid = $step->get_id();
1422         if (empty($stepid)) {
1423             throw new coding_exception('Cannot modify a step that has never been stored in the database.');
1424         }
1426         $this->stepsmodified[] = array($step, $qa->get_database_id(), $seq);
1427     }
1429     public function notify_step_deleted(question_attempt_step $step, question_attempt $qa) {
1430         if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
1431             return;
1432         }
1434         if (($key = $this->is_step_added($step)) !== false) {
1435             unset($this->stepsadded[$key]);
1436             return;
1437         }
1439         if (($key = $this->is_step_modified($step)) !== false) {
1440             unset($this->stepsmodified[$key]);
1441         }
1443         $stepid = $step->get_id();
1444         if (empty($stepid)) {
1445             return; // Was never in the database.
1446         }
1448         $this->stepsdeleted[$stepid] = $step;
1449     }
1451     public function notify_metadata_added(question_attempt $qa, $name) {
1452         if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
1453             return;
1454         }
1456         if ($this->is_step_added($qa->get_step(0)) !== false) {
1457             return;
1458         }
1460         if (isset($this->metadataadded[$qa->get_slot()][$name])) {
1461             return;
1462         }
1464         $this->metadataadded[$qa->get_slot()][$name] = $qa;
1465     }
1467     public function notify_metadata_modified(question_attempt $qa, $name) {
1468         if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
1469             return;
1470         }
1472         if ($this->is_step_added($qa->get_step(0)) !== false) {
1473             return;
1474         }
1476         if (isset($this->metadataadded[$qa->get_slot()][$name])) {
1477             return;
1478         }
1480         if (isset($this->metadatamodified[$qa->get_slot()][$name])) {
1481             return;
1482         }
1484         $this->metadatamodified[$qa->get_slot()][$name] = $qa;
1485     }
1487     /**
1488      * @param question_attempt_step $step a step
1489      * @return int|false if the step is in the list of steps to be added, return
1490      *      the key, otherwise return false.
1491      */
1492     protected function is_step_added(question_attempt_step $step) {
1493         foreach ($this->stepsadded as $key => $data) {
1494             list($addedstep, $qaid, $seq) = $data;
1495             if ($addedstep === $step) {
1496                 return $key;
1497             }
1498         }
1499         return false;
1500     }
1502     /**
1503      * @param question_attempt_step $step a step
1504      * @return int|false if the step is in the list of steps to be modified, return
1505      *      the key, otherwise return false.
1506      */
1507     protected function is_step_modified(question_attempt_step $step) {
1508         foreach ($this->stepsmodified as $key => $data) {
1509             list($modifiedstep, $qaid, $seq) = $data;
1510             if ($modifiedstep === $step) {
1511                 return $key;
1512             }
1513         }
1514         return false;
1515     }
1517     /**
1518      * @param question_attempt_step $step a step
1519      * @return bool whether the step is in the list of steps to be deleted.
1520      */
1521     protected function is_step_deleted(question_attempt_step $step) {
1522         foreach ($this->stepsdeleted as $deletedstep) {
1523             if ($deletedstep === $step) {
1524                 return true;
1525             }
1526         }
1527         return false;
1528     }
1530     /**
1531      * Write all the changes we have recorded to the database.
1532      * @param question_engine_data_mapper $dm the mapper to use to update the database.
1533      */
1534     public function save(question_engine_data_mapper $dm) {
1535         $dm->delete_steps(array_keys($this->stepsdeleted), $this->quba->get_owning_context());
1537         // Initially an array of array of question_attempt_step_objects.
1538         // Built as a nested array for efficiency, then flattened.
1539         $stepdata = array();
1541         foreach ($this->stepsmodified as $stepinfo) {
1542             list($step, $questionattemptid, $seq) = $stepinfo;
1543             $stepdata[] = $dm->update_question_attempt_step(
1544                     $step, $questionattemptid, $seq, $this->quba->get_owning_context());
1545         }
1547         foreach ($this->stepsadded as $stepinfo) {
1548             list($step, $questionattemptid, $seq) = $stepinfo;
1549             $stepdata[] = $dm->insert_question_attempt_step(
1550                     $step, $questionattemptid, $seq, $this->quba->get_owning_context());
1551         }
1553         foreach ($this->attemptsmodified as $qa) {
1554             $dm->update_question_attempt($qa);
1555         }
1557         foreach ($this->attemptsadded as $qa) {
1558             $stepdata[] = $dm->insert_question_attempt(
1559                     $qa, $this->quba->get_owning_context());
1560         }
1562         foreach ($this->metadataadded as $info) {
1563             $qa = reset($info);
1564             $stepdata[] = $dm->insert_question_attempt_metadata($qa, array_keys($info));
1565         }
1567         foreach ($this->metadatamodified as $info) {
1568             $qa = reset($info);
1569             $stepdata[] = $dm->update_question_attempt_metadata($qa, array_keys($info));
1570         }
1572         if ($this->modified) {
1573             $dm->update_questions_usage_by_activity($this->quba);
1574         }
1576         if ($stepdata) {
1577             $dm->insert_all_step_data(call_user_func_array('array_merge', $stepdata));
1578         }
1580         $this->stepsdeleted = array();
1581         $this->stepsmodified = array();
1582         $this->stepsadded = array();
1583         $this->attemptsdeleted = array();
1584         $this->attemptsadded = array();
1585         $this->attemptsmodified = array();
1586         $this->modified = false;
1587     }
1591 /**
1592  * The interface implemented by {@link question_file_saver} and {@link question_file_loader}.
1593  *
1594  * @copyright  2012 The Open University
1595  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1596  */
1597 interface question_response_files {
1598     /**
1599      * Get the files that were submitted.
1600      * @return array of stored_files objects.
1601      */
1602     public function get_files();
1606 /**
1607  * This class represents the promise to save some files from a particular draft
1608  * file area into a particular file area. It is used beause the necessary
1609  * information about what to save is to hand in the
1610  * {@link question_attempt::process_response_files()} method, but we don't know
1611  * if this question attempt will actually be saved in the database until later,
1612  * when the {@link question_engine_unit_of_work} is saved, if it is.
1613  *
1614  * @copyright  2011 The Open University
1615  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1616  */
1617 class question_file_saver implements question_response_files {
1618     /** @var int the id of the draft file area to save files from. */
1619     protected $draftitemid;
1620     /** @var string the owning component name. */
1621     protected $component;
1622     /** @var string the file area name. */
1623     protected $filearea;
1625     /**
1626      * @var string the value to store in the question_attempt_step_data to
1627      * represent these files.
1628      */
1629     protected $value = null;
1631     /**
1632      * Constuctor.
1633      * @param int $draftitemid the draft area to save the files from.
1634      * @param string $component the component for the file area to save into.
1635      * @param string $filearea the name of the file area to save into.
1636      */
1637     public function __construct($draftitemid, $component, $filearea, $text = null) {
1638         $this->draftitemid = $draftitemid;
1639         $this->component = $component;
1640         $this->filearea = $filearea;
1641         $this->value = $this->compute_value($draftitemid, $text);
1642     }
1644     /**
1645      * Compute the value that should be stored in the question_attempt_step_data
1646      * table. Contains a hash that (almost) uniquely encodes all the files.
1647      * @param int $draftitemid the draft file area itemid.
1648      * @param string $text optional content containing file links.
1649      */
1650     protected function compute_value($draftitemid, $text) {
1651         global $USER;
1653         $fs = get_file_storage();
1654         $usercontext = context_user::instance($USER->id);
1656         $files = $fs->get_area_files($usercontext->id, 'user', 'draft',
1657                 $draftitemid, 'sortorder, filepath, filename', false);
1659         $string = '';
1660         foreach ($files as $file) {
1661             $string .= $file->get_filepath() . $file->get_filename() . '|' .
1662                     $file->get_contenthash() . '|';
1663         }
1664         $hash = md5($string);
1666         if (is_null($text)) {
1667             if ($string) {
1668                 return $hash;
1669             } else {
1670                 return '';
1671             }
1672         }
1674         // We add the file hash so a simple string comparison will say if the
1675         // files have been changed. First strip off any existing file hash.
1676         if ($text !== '') {
1677             $text = preg_replace('/\s*<!-- File hash: \w+ -->\s*$/', '', $text);
1678             $text = file_rewrite_urls_to_pluginfile($text, $draftitemid);
1679             if ($string) {
1680                 $text .= '<!-- File hash: ' . $hash . ' -->';
1681             }
1682         }
1683         return $text;
1684     }
1686     public function __toString() {
1687         return $this->value;
1688     }
1690     /**
1691      * Actually save the files.
1692      * @param integer $itemid the item id for the file area to save into.
1693      */
1694     public function save_files($itemid, $context) {
1695         file_save_draft_area_files($this->draftitemid, $context->id,
1696                 $this->component, $this->filearea, $itemid);
1697     }
1699     /**
1700      * Get the files that were submitted.
1701      * @return array of stored_files objects.
1702      */
1703     public function get_files() {
1704         global $USER;
1706         $fs = get_file_storage();
1707         $usercontext = context_user::instance($USER->id);
1709         return $fs->get_area_files($usercontext->id, 'user', 'draft',
1710                 $this->draftitemid, 'sortorder, filepath, filename', false);
1711     }
1715 /**
1716  * This class is the mirror image of {@link question_file_saver}. It allows
1717  * files to be accessed again later (e.g. when re-grading) using that same
1718  * API as when doing the original grading.
1719  *
1720  * @copyright  2012 The Open University
1721  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1722  */
1723 class question_file_loader implements question_response_files {
1724     /** @var question_attempt_step the step that these files belong to. */
1725     protected $step;
1727     /** @var string the field name for these files - which is used to construct the file area name. */
1728     protected $name;
1730     /**
1731      * @var string the value to stored in the question_attempt_step_data to
1732      * represent these files.
1733      */
1734     protected $value;
1736     /** @var int the context id that the files belong to. */
1737     protected $contextid;
1739     /**
1740      * Constuctor.
1741      * @param question_attempt_step $step the step that these files belong to.
1742      * @param string $name string the field name for these files - which is used to construct the file area name.
1743      * @param string $value the value to stored in the question_attempt_step_data to
1744      *      represent these files.
1745      * @param int $contextid the context id that the files belong to.
1746      */
1747     public function __construct(question_attempt_step $step, $name, $value, $contextid) {
1748         $this->step = $step;
1749         $this->name = $name;
1750         $this->value = $value;
1751         $this->contextid = $contextid;
1752     }
1754     public function __toString() {
1755         return $this->value;
1756     }
1758     /**
1759      * Get the files that were submitted.
1760      * @return array of stored_files objects.
1761      */
1762     public function get_files() {
1763         return $this->step->get_qt_files($this->name, $this->contextid);
1764     }
1766     /**
1767      * Copy these files into a draft area, and return the corresponding
1768      * {@link question_file_saver} that can save them again.
1769      *
1770      * This is used by {@link question_attempt::start_based_on()}, which is used
1771      * (for example) by the quizzes 'Each attempt builds on last' feature.
1772      *
1773      * @return question_file_saver that can re-save these files again.
1774      */
1775     public function get_question_file_saver() {
1777         // There are three possibilities here for what $value will look like:
1778         // 1) some HTML content followed by an MD5 hash in a HTML comment;
1779         // 2) a plain MD5 hash;
1780         // 3) or some real content, without any hash.
1781         // The problem is that 3) is ambiguous in the case where a student writes
1782         // a response that looks exactly like an MD5 hash. For attempts made now,
1783         // we avoid case 3) by always going for case 1) or 2) (except when the
1784         // response is blank. However, there may be case 3) data in the database
1785         // so we need to handle it as best we can.
1786         if (preg_match('/\s*<!-- File hash: [0-9a-zA-Z]{32} -->\s*$/', $this->value)) {
1787             $value = preg_replace('/\s*<!-- File hash: [0-9a-zA-Z]{32} -->\s*$/', '', $this->value);
1789         } else if (preg_match('/^[0-9a-zA-Z]{32}$/', $this->value)) {
1790             $value = null;
1792         } else {
1793             $value = $this->value;
1794         }
1796         list($draftid, $text) = $this->step->prepare_response_files_draft_itemid_with_text(
1797                 $this->name, $this->contextid, $value);
1798         return new question_file_saver($draftid, 'question', 'response_' . $this->name, $text);
1799     }
1803 /**
1804  * This class represents a restriction on the set of question_usage ids to include
1805  * in a larger database query. Depending of the how you are going to restrict the
1806  * list of usages, construct an appropriate subclass.
1807  *
1808  * If $qubaids is an instance of this class, example usage might be
1809  *
1810  * SELECT qa.id, qa.maxmark
1811  * FROM $qubaids->from_question_attempts('qa')
1812  * WHERE $qubaids->where() AND qa.slot = 1
1813  *
1814  * @copyright  2010 The Open University
1815  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1816  */
1817 abstract class qubaid_condition {
1819     /**
1820      * @return string the SQL that needs to go in the FROM clause when trying
1821      * to select records from the 'question_attempts' table based on the
1822      * qubaid_condition.
1823      */
1824     public abstract function from_question_attempts($alias);
1826     /** @return string the SQL that needs to go in the where clause. */
1827     public abstract function where();
1829     /**
1830      * @return the params needed by a query that uses
1831      * {@link from_question_attempts()} and {@link where()}.
1832      */
1833     public abstract function from_where_params();
1835     /**
1836      * @return string SQL that can use used in a WHERE qubaid IN (...) query.
1837      * This method returns the "IN (...)" part.
1838      */
1839     public abstract function usage_id_in();
1841     /**
1842      * @return the params needed by a query that uses {@link usage_id_in()}.
1843      */
1844     public abstract function usage_id_in_params();
1846     /**
1847      * @return string 40-character hash code that uniquely identifies the combination of properties and class name of this qubaid
1848      *                  condition.
1849      */
1850     public function get_hash_code() {
1851         return sha1(serialize($this));
1852     }
1856 /**
1857  * This class represents a restriction on the set of question_usage ids to include
1858  * in a larger database query based on an explicit list of ids.
1859  *
1860  * @copyright  2010 The Open University
1861  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1862  */
1863 class qubaid_list extends qubaid_condition {
1864     /** @var array of ids. */
1865     protected $qubaids;
1866     protected $columntotest = null;
1867     protected $params;
1869     /**
1870      * Constructor.
1871      * @param array $qubaids of question usage ids.
1872      */
1873     public function __construct(array $qubaids) {
1874         $this->qubaids = $qubaids;
1875     }
1877     public function from_question_attempts($alias) {
1878         $this->columntotest = $alias . '.questionusageid';
1879         return '{question_attempts} ' . $alias;
1880     }
1882     public function where() {
1883         global $DB;
1885         if (is_null($this->columntotest)) {
1886             throw new coding_exception('Must call from_question_attempts before where().');
1887         }
1888         if (empty($this->qubaids)) {
1889             $this->params = array();
1890             return '1 = 0';
1891         }
1893         return $this->columntotest . ' ' . $this->usage_id_in();
1894     }
1896     public function from_where_params() {
1897         return $this->params;
1898     }
1900     public function usage_id_in() {
1901         global $DB;
1903         if (empty($this->qubaids)) {
1904             $this->params = array();
1905             return '= 0';
1906         }
1907         list($where, $this->params) = $DB->get_in_or_equal(
1908                 $this->qubaids, SQL_PARAMS_NAMED, 'qubaid');
1909         return $where;
1910     }
1912     public function usage_id_in_params() {
1913         return $this->params;
1914     }
1918 /**
1919  * This class represents a restriction on the set of question_usage ids to include
1920  * in a larger database query based on JOINing to some other tables.
1921  *
1922  * The general form of the query is something like
1923  *
1924  * SELECT qa.id, qa.maxmark
1925  * FROM $from
1926  * JOIN {question_attempts} qa ON qa.questionusageid = $usageidcolumn
1927  * WHERE $where AND qa.slot = 1
1928  *
1929  * where $from, $usageidcolumn and $where are the arguments to the constructor.
1930  *
1931  * @copyright  2010 The Open University
1932  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1933  */
1934 class qubaid_join extends qubaid_condition {
1935     public $from;
1936     public $usageidcolumn;
1937     public $where;
1938     public $params;
1940     /**
1941      * Constructor. The meaning of the arguments is explained in the class comment.
1942      * @param string $from SQL fragemnt to go in the FROM clause.
1943      * @param string $usageidcolumn the column in $from that should be
1944      * made equal to the usageid column in the JOIN clause.
1945      * @param string $where SQL fragment to go in the where clause.
1946      * @param array $params required by the SQL. You must use named parameters.
1947      */
1948     public function __construct($from, $usageidcolumn, $where = '', $params = array()) {
1949         $this->from = $from;
1950         $this->usageidcolumn = $usageidcolumn;
1951         $this->params = $params;
1952         if (empty($where)) {
1953             $where = '1 = 1';
1954         }
1955         $this->where = $where;
1956     }
1958     public function from_question_attempts($alias) {
1959         return "{$this->from}
1960                 JOIN {question_attempts} {$alias} ON " .
1961                         "{$alias}.questionusageid = $this->usageidcolumn";
1962     }
1964     public function where() {
1965         return $this->where;
1966     }
1968     public function from_where_params() {
1969         return $this->params;
1970     }
1972     public function usage_id_in() {
1973         return "IN (SELECT {$this->usageidcolumn} FROM {$this->from} WHERE {$this->where})";
1974     }
1976     public function usage_id_in_params() {
1977         return $this->params;
1978     }