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