MDL-20636 Essay questions can now handle files in the HTML editor. #216
[moodle.git] / question / engine / datalib.php
1 <?php
3 // This file is part of Moodle - http://moodle.org/
4 //
5 // Moodle is free software: you can redistribute it and/or modify
6 // it under the terms of the GNU General Public License as published by
7 // the Free Software Foundation, either version 3 of the License, or
8 // (at your option) any later version.
9 //
10 // Moodle is distributed in the hope that it will be useful,
11 // but WITHOUT ANY WARRANTY; without even the implied warranty of
12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 // GNU General Public License for more details.
14 //
15 // You should have received a copy of the GNU General Public License
16 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
18 /**
19  * Code for loading and saving question attempts to and from the database.
20  *
21  * @package    moodlecore
22  * @subpackage questionengine
23  * @copyright  2009 The Open University
24  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25  */
28 defined('MOODLE_INTERNAL') || die();
31 /**
32  * This class controls the loading and saving of question engine data to and from
33  * the database.
34  *
35  * @copyright  2009 The Open University
36  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
37  */
38 class question_engine_data_mapper {
39     /**
40      * @var moodle_database normally points to global $DB, but I prefer not to
41      * use globals if I can help it.
42      */
43     protected $db;
45     /**
46      * @param moodle_database $db a database connectoin. Defaults to global $DB.
47      */
48     public function __construct($db = null) {
49         if (is_null($db)) {
50             global $DB;
51             $this->db = $DB;
52         } else {
53             $this->db = $db;
54         }
55     }
57     /**
58      * Store an entire {@link question_usage_by_activity} in the database,
59      * including all the question_attempts that comprise it.
60      * @param question_usage_by_activity $quba the usage to store.
61      */
62     public function insert_questions_usage_by_activity(question_usage_by_activity $quba) {
63         $record = new stdClass();
64         $record->contextid = $quba->get_owning_context()->id;
65         $record->component = $quba->get_owning_component();
66         $record->preferredbehaviour = $quba->get_preferred_behaviour();
68         $newid = $this->db->insert_record('question_usages', $record);
69         $quba->set_id_from_database($newid);
71         foreach ($quba->get_attempt_iterator() as $qa) {
72             $this->insert_question_attempt($qa, $quba->get_owning_context());
73         }
74     }
76     /**
77      * Store an entire {@link question_attempt} in the database,
78      * including all the question_attempt_steps that comprise it.
79      * @param question_attempt $qa the question attempt to store.
80      * @param object $context the context of the owning question_usage_by_activity.
81      */
82     public function insert_question_attempt(question_attempt $qa, $context) {
83         $record = new stdClass();
84         $record->questionusageid = $qa->get_usage_id();
85         $record->slot = $qa->get_slot();
86         $record->behaviour = $qa->get_behaviour_name();
87         $record->questionid = $qa->get_question()->id;
88         $record->maxmark = $qa->get_max_mark();
89         $record->minfraction = $qa->get_min_fraction();
90         $record->flagged = $qa->is_flagged();
91         $record->questionsummary = $qa->get_question_summary();
92         $record->rightanswer = $qa->get_right_answer_summary();
93         $record->responsesummary = $qa->get_response_summary();
94         $record->timemodified = time();
95         $record->id = $this->db->insert_record('question_attempts', $record);
97         foreach ($qa->get_step_iterator() as $seq => $step) {
98             $this->insert_question_attempt_step($step, $record->id, $seq, $context);
99         }
100     }
102     /**
103      * Store a {@link question_attempt_step} in the database.
104      * @param question_attempt_step $qa the step to store.
105      * @param int $questionattemptid the question attept id this step belongs to.
106      * @param int $seq the sequence number of this stop.
107      * @param object $context the context of the owning question_usage_by_activity.
108      */
109     public function insert_question_attempt_step(question_attempt_step $step,
110             $questionattemptid, $seq, $context) {
111         $record = new stdClass();
112         $record->questionattemptid = $questionattemptid;
113         $record->sequencenumber = $seq;
114         $record->state = '' . $step->get_state();
115         $record->fraction = $step->get_fraction();
116         $record->timecreated = $step->get_timecreated();
117         $record->userid = $step->get_user_id();
119         $record->id = $this->db->insert_record('question_attempt_steps', $record);
121         foreach ($step->get_all_data() as $name => $value) {
122             if ($value instanceof question_file_saver) {
123                 $value->save_files($record->id, $context);
124             }
126             $data = new stdClass();
127             $data->attemptstepid = $record->id;
128             $data->name = $name;
129             $data->value = $value;
130             $this->db->insert_record('question_attempt_step_data', $data, false);
131         }
132     }
134     /**
135      * Load a {@link question_attempt_step} from the database.
136      * @param int $stepid the id of the step to load.
137      * @param question_attempt_step the step that was loaded.
138      */
139     public function load_question_attempt_step($stepid) {
140         $records = $this->db->get_records_sql("
141 SELECT
142     COALESCE(qasd.id, -1 * qas.id) AS id,
143     qas.id AS attemptstepid,
144     qas.questionattemptid,
145     qas.sequencenumber,
146     qas.state,
147     qas.fraction,
148     qas.timecreated,
149     qas.userid,
150     qasd.name,
151     qasd.value
153 FROM {question_attempt_steps} qas
154 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
156 WHERE
157     qas.id = :stepid
158         ", array('stepid' => $stepid));
160         if (!$records) {
161             throw new coding_exception('Failed to load question_attempt_step ' . $stepid);
162         }
164         return question_attempt_step::load_from_records($records, $stepid);
165     }
167     /**
168      * Load a {@link question_attempt} from the database, including all its
169      * steps.
170      * @param int $questionattemptid the id of the question attempt to load.
171      * @param question_attempt the question attempt that was loaded.
172      */
173     public function load_question_attempt($questionattemptid) {
174         $records = $this->db->get_records_sql("
175 SELECT
176     COALESCE(qasd.id, -1 * qas.id) AS id,
177     quba.contextid,
178     quba.preferredbehaviour,
179     qa.id AS questionattemptid,
180     qa.questionusageid,
181     qa.slot,
182     qa.behaviour,
183     qa.questionid,
184     qa.maxmark,
185     qa.minfraction,
186     qa.flagged,
187     qa.questionsummary,
188     qa.rightanswer,
189     qa.responsesummary,
190     qa.timemodified,
191     qas.id AS attemptstepid,
192     qas.sequencenumber,
193     qas.state,
194     qas.fraction,
195     qas.timecreated,
196     qas.userid,
197     qasd.name,
198     qasd.value
200 FROM      {question_attempts           qa
201 JOIN      {question_usages}            quba ON quba.id               = qa.questionusageid
202 LEFT JOIN {question_attempt_steps}     qas  ON qas.questionattemptid = qa.id
203 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid    = qas.id
205 WHERE
206     qa.id = :questionattemptid
208 ORDER BY
209     qas.sequencenumber
210         ", array('questionattemptid' => $questionattemptid));
212         if (!$records) {
213             throw new coding_exception('Failed to load question_attempt ' . $questionattemptid);
214         }
216         $record = current($records);
217         return question_attempt::load_from_records($records, $questionattemptid,
218                 new question_usage_null_observer(), $record->preferredbehaviour);
219     }
221     /**
222      * Load a {@link question_usage_by_activity} from the database, including
223      * all its {@link question_attempt}s and all their steps.
224      * @param int $qubaid the id of the usage to load.
225      * @param question_usage_by_activity the usage that was loaded.
226      */
227     public function load_questions_usage_by_activity($qubaid) {
228         $records = $this->db->get_records_sql("
229 SELECT
230     COALESCE(qasd.id, -1 * qas.id) AS id,
231     quba.id AS qubaid,
232     quba.contextid,
233     quba.component,
234     quba.preferredbehaviour,
235     qa.id AS questionattemptid,
236     qa.questionusageid,
237     qa.slot,
238     qa.behaviour,
239     qa.questionid,
240     qa.maxmark,
241     qa.minfraction,
242     qa.flagged,
243     qa.questionsummary,
244     qa.rightanswer,
245     qa.responsesummary,
246     qa.timemodified,
247     qas.id AS attemptstepid,
248     qas.sequencenumber,
249     qas.state,
250     qas.fraction,
251     qas.timecreated,
252     qas.userid,
253     qasd.name,
254     qasd.value
256 FROM      {question_usages}            quba
257 LEFT JOIN {question_attempts}          qa   ON qa.questionusageid    = quba.id
258 LEFT JOIN {question_attempt_steps}     qas  ON qas.questionattemptid = qa.id
259 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid    = qas.id
261 WHERE
262     quba.id = :qubaid
264 ORDER BY
265     qa.slot,
266     qas.sequencenumber
267     ", array('qubaid' => $qubaid));
269         if (!$records) {
270             throw new coding_exception('Failed to load questions_usage_by_activity ' . $qubaid);
271         }
273         return question_usage_by_activity::load_from_records($records, $qubaid);
274     }
276     /**
277      * Load information about the latest state of each question from the database.
278      *
279      * @param qubaid_condition $qubaids used to restrict which usages are included
280      * in the query. See {@link qubaid_condition}.
281      * @param array $slots A list of slots for the questions you want to konw about.
282      * @return array of records. See the SQL in this function to see the fields available.
283      */
284     public function load_questions_usages_latest_steps(qubaid_condition $qubaids, $slots) {
285         list($slottest, $params) = $this->db->get_in_or_equal($slots, SQL_PARAMS_NAMED, 'slot0000');
287         $records = $this->db->get_records_sql("
288 SELECT
289     qas.id,
290     qa.id AS questionattemptid,
291     qa.questionusageid,
292     qa.slot,
293     qa.behaviour,
294     qa.questionid,
295     qa.maxmark,
296     qa.minfraction,
297     qa.flagged,
298     qa.questionsummary,
299     qa.rightanswer,
300     qa.responsesummary,
301     qa.timemodified,
302     qas.id AS attemptstepid,
303     qas.sequencenumber,
304     qas.state,
305     qas.fraction,
306     qas.timecreated,
307     qas.userid
309 FROM {$qubaids->from_question_attempts('qa')}
310 JOIN {question_attempt_steps} qas ON
311         qas.id = {$this->latest_step_for_qa_subquery()}
313 WHERE
314     {$qubaids->where()} AND
315     qa.slot $slottest
316         ", $params + $qubaids->from_where_params());
318         return $records;
319     }
321     /**
322      * Load summary information about the state of each question in a group of
323      * attempts. This is used, for example, by the quiz manual grading report,
324      * to show how many attempts at each question need to be graded.
325      *
326      * @param qubaid_condition $qubaids used to restrict which usages are included
327      * in the query. See {@link qubaid_condition}.
328      * @param array $slots A list of slots for the questions you want to konw about.
329      * @return array The array keys are slot,qestionid. The values are objects with
330      * fields $slot, $questionid, $inprogress, $name, $needsgrading, $autograded,
331      * $manuallygraded and $all.
332      */
333     public function load_questions_usages_question_state_summary(qubaid_condition $qubaids, $slots) {
334         list($slottest, $params) = $this->db->get_in_or_equal($slots, SQL_PARAMS_NAMED, 'slot0000');
336         $rs = $this->db->get_recordset_sql("
337 SELECT
338     qa.slot,
339     qa.questionid,
340     q.name,
341     CASE qas.state
342         {$this->full_states_to_summary_state_sql()}
343     END AS summarystate,
344     COUNT(1) AS numattempts
346 FROM {$qubaids->from_question_attempts('qa')}
347 JOIN {question_attempt_steps} qas ON
348         qas.id = {$this->latest_step_for_qa_subquery()}
349 JOIN {question} q ON q.id = qa.questionid
351 WHERE
352     {$qubaids->where()} AND
353     qa.slot $slottest
355 GROUP BY
356     qa.slot,
357     qa.questionid,
358     q.name,
359     q.id,
360     summarystate
362 ORDER BY
363     qa.slot,
364     qa.questionid,
365     q.name,
366     q.id
367         ", $params + $qubaids->from_where_params());
369         $results = array();
370         foreach ($rs as $row) {
371             $index = $row->slot . ',' . $row->questionid;
373             if (!array_key_exists($index, $results)) {
374                 $res = new stdClass();
375                 $res->slot = $row->slot;
376                 $res->questionid = $row->questionid;
377                 $res->name = $row->name;
378                 $res->inprogress = 0;
379                 $res->needsgrading = 0;
380                 $res->autograded = 0;
381                 $res->manuallygraded = 0;
382                 $res->all = 0;
383                 $results[$index] = $res;
384             }
386             $results[$index]->{$row->summarystate} = $row->numattempts;
387             $results[$index]->all += $row->numattempts;
388         }
389         $rs->close();
391         return $results;
392     }
394     /**
395      * Get a list of usage ids where the question with slot $slot, and optionally
396      * also with question id $questionid, is in summary state $summarystate. Also
397      * return the total count of such states.
398      *
399      * Only a subset of the ids can be returned by using $orderby, $limitfrom and
400      * $limitnum. A special value 'random' can be passed as $orderby, in which case
401      * $limitfrom is ignored.
402      *
403      * @param qubaid_condition $qubaids used to restrict which usages are included
404      * in the query. See {@link qubaid_condition}.
405      * @param int $slot The slot for the questions you want to konw about.
406      * @param int $questionid (optional) Only return attempts that were of this specific question.
407      * @param string $summarystate the summary state of interest, or 'all'.
408      * @param string $orderby the column to order by.
409      * @param array $params any params required by any of the SQL fragments.
410      * @param int $limitfrom implements paging of the results.
411      *      Ignored if $orderby = random or $limitnum is null.
412      * @param int $limitnum implements paging of the results. null = all.
413      * @return array with two elements, an array of usage ids, and a count of the total number.
414      */
415     public function load_questions_usages_where_question_in_state(
416             qubaid_condition $qubaids, $summarystate, $slot, $questionid = null,
417             $orderby = 'random', $params, $limitfrom = 0, $limitnum = null) {
419         $extrawhere = '';
420         if ($questionid) {
421             $extrawhere .= ' AND qa.questionid = :questionid';
422             $params['questionid'] = $questionid;
423         }
424         if ($summarystate != 'all') {
425             list($test, $sparams) = $this->in_summary_state_test($summarystate);
426             $extrawhere .= ' AND qas.state ' . $test;
427             $params += $sparams;
428         }
430         if ($orderby == 'random') {
431             $sqlorderby = '';
432         } else if ($orderby) {
433             $sqlorderby = 'ORDER BY ' . $orderby;
434         } else {
435             $sqlorderby = '';
436         }
438         // We always want the total count, as well as the partcular list of ids,
439         // based on the paging and sort order. Becuase the list of ids is never
440         // going to be too rediculously long. My worst-case scenario is
441         // 10,000 students in the coures, each doing 5 quiz attempts. That
442         // is a 50,000 element int => int array, which PHP seems to use 5MB
443         // memeory to store on a 64 bit server.
444         $params += $qubaids->from_where_params();
445         $params['slot'] = $slot;
446         $qubaids = $this->db->get_records_sql_menu("
447 SELECT
448     qa.questionusageid,
449     1
451 FROM {$qubaids->from_question_attempts('qa')}
452 JOIN {question_attempt_steps} qas ON
453         qas.id = {$this->latest_step_for_qa_subquery()}
454 JOIN {question} q ON q.id = qa.questionid
456 WHERE
457     {$qubaids->where()} AND
458     qa.slot = :slot
459     $extrawhere
461 $sqlorderby
462         ", $params);
464         $qubaids = array_keys($qubaids);
465         $count = count($qubaids);
467         if ($orderby == 'random') {
468             shuffle($qubaids);
469             $limitfrom = 0;
470         }
472         if (!is_null($limitnum)) {
473             $qubaids = array_slice($qubaids, $limitfrom, $limitnum);
474         }
476         return array($qubaids, $count);
477     }
479     /**
480      * Load a {@link question_usage_by_activity} from the database, including
481      * all its {@link question_attempt}s and all their steps.
482      * @param qubaid_condition $qubaids used to restrict which usages are included
483      * in the query. See {@link qubaid_condition}.
484      * @param array $slots if null, load info for all quesitions, otherwise only
485      * load the averages for the specified questions.
486      */
487     public function load_average_marks(qubaid_condition $qubaids, $slots = null) {
488         if (!empty($slots)) {
489             list($slottest, $slotsparams) = $this->db->get_in_or_equal($slots, SQL_PARAMS_NAMED, 'slot0000');
490             $slotwhere = " AND qa.slot $slottest";
491         } else {
492             $slotwhere = '';
493             $params = array();
494         }
496         list($statetest, $stateparams) = $this->db->get_in_or_equal(array(
497                 question_state::$gaveup,
498                 question_state::$gradedwrong,
499                 question_state::$gradedpartial,
500                 question_state::$gradedright,
501                 question_state::$mangaveup,
502                 question_state::$mangrwrong,
503                 question_state::$mangrpartial,
504                 question_state::$mangrright), SQL_PARAMS_NAMED, 'st00');
506         return $this->db->get_records_sql("
507 SELECT
508     qa.slot,
509     AVG(COALESCE(qas.fraction, 0)) AS averagefraction,
510     COUNT(1) AS numaveraged
512 FROM {$qubaids->from_question_attempts('qa')}
513 JOIN {question_attempt_steps} qas ON
514         qas.id = {$this->latest_step_for_qa_subquery()}
516 WHERE
517     {$qubaids->where()}
518     $slotwhere
519     AND qas.state $statetest
521 GROUP BY qa.slot
523 ORDER BY qa.slot
524         ", $slotsparams + $stateparams + $qubaids->from_where_params());
525     }
527     /**
528      * Load a {@link question_attempt} from the database, including all its
529      * steps.
530      * @param int $questionid the question to load all the attempts fors.
531      * @param qubaid_condition $qubaids used to restrict which usages are included
532      * in the query. See {@link qubaid_condition}.
533      * @return array of question_attempts.
534      */
535     public function load_attempts_at_question($questionid, qubaid_condition $qubaids) {
536         global $DB;
538         $params = $qubaids->from_where_params();
539         $params['questionid'] = $questionid;
541         $records = $DB->get_records_sql("
542 SELECT
543     COALESCE(qasd.id, -1 * qas.id) AS id,
544     quba.contextid,
545     quba.preferredbehaviour,
546     qa.id AS questionattemptid,
547     qa.questionusageid,
548     qa.slot,
549     qa.behaviour,
550     qa.questionid,
551     qa.maxmark,
552     qa.minfraction,
553     qa.flagged,
554     qa.questionsummary,
555     qa.rightanswer,
556     qa.responsesummary,
557     qa.timemodified,
558     qas.id AS attemptstepid,
559     qas.sequencenumber,
560     qas.state,
561     qas.fraction,
562     qas.timecreated,
563     qas.userid,
564     qasd.name,
565     qasd.value
567 FROM {$qubaids->from_question_attempts('qa')}
568 JOIN {question_usages} quba ON quba.id = qa.questionusageid
569 LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
570 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
572 WHERE
573     {$qubaids->where()} AND
574     qa.questionid = :questionid
576 ORDER BY
577     quba.id,
578     qa.id,
579     qas.sequencenumber
580         ", $params);
582         if (!$records) {
583             return array();
584         }
586         $questionattempts = array();
587         $record = current($records);
588         while ($record) {
589             $questionattempts[$record->questionattemptid] =
590                     question_attempt::load_from_records($records,
591                     $record->questionattemptid, new question_usage_null_observer(),
592                     $record->preferredbehaviour);
593             $record = current($records);
594         }
595         return $questionattempts;
596     }
598     /**
599      * Update a question_usages row to refect any changes in a usage (but not
600      * any of its question_attempts.
601      * @param question_usage_by_activity $quba the usage that has changed.
602      */
603     public function update_questions_usage_by_activity(question_usage_by_activity $quba) {
604         $record = new stdClass();
605         $record->id = $quba->get_id();
606         $record->contextid = $quba->get_owning_context()->id;
607         $record->component = $quba->get_owning_component();
608         $record->preferredbehaviour = $quba->get_preferred_behaviour();
610         $this->db->update_record('question_usages', $record);
611     }
613     /**
614      * Update a question_attempts row to refect any changes in a question_attempt
615      * (but not any of its steps).
616      * @param question_attempt $qa the question attempt that has changed.
617      */
618     public function update_question_attempt(question_attempt $qa) {
619         $record = new stdClass();
620         $record->id = $qa->get_database_id();
621         $record->maxmark = $qa->get_max_mark();
622         $record->minfraction = $qa->get_min_fraction();
623         $record->flagged = $qa->is_flagged();
624         $record->questionsummary = $qa->get_question_summary();
625         $record->rightanswer = $qa->get_right_answer_summary();
626         $record->responsesummary = $qa->get_response_summary();
627         $record->timemodified = time();
629         $this->db->update_record('question_attempts', $record);
630     }
632     /**
633      * Delete a question_usage_by_activity and all its associated
634      * {@link question_attempts} and {@link question_attempt_steps} from the
635      * database.
636      * @param qubaid_condition $qubaids identifies which question useages to delete.
637      */
638     public function delete_questions_usage_by_activities(qubaid_condition $qubaids) {
639         $where = "qa.questionusageid {$qubaids->usage_id_in()}";
640         $params = $qubaids->usage_id_in_params();
642         $contextids = $this->db->get_records_sql_menu("
643                 SELECT DISTINCT contextid, 1
644                 FROM {question_usages}
645                 WHERE id {$qubaids->usage_id_in()}", $params);
646         foreach ($contextids as $contextid => $notused) {
647             $this->delete_response_files($contextid, "IN (
648                     SELECT qas.id
649                     FROM {question_attempts} qa
650                     JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
651                     WHERE $where)", $params);
652         }
654         $this->db->delete_records_select('question_attempt_step_data', "attemptstepid IN (
655                 SELECT qas.id
656                 FROM {question_attempts} qa
657                 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
658                 WHERE $where)", $params);
660         $this->db->delete_records_select('question_attempt_steps', "questionattemptid IN (
661                 SELECT qa.id
662                 FROM {question_attempts} qa
663                 WHERE $where)", $params);
665         $this->db->delete_records_select('question_attempts',
666                 "{question_attempts}.questionusageid {$qubaids->usage_id_in()}", $params);
668         $this->db->delete_records_select('question_usages',
669                 "{question_usages}.id {$qubaids->usage_id_in()}", $params);
670     }
672     /**
673      * Delete all the steps for a question attempt.
674      * @param int $qaids question_attempt id.
675      */
676     public function delete_steps_for_question_attempts($qaids, $context) {
677         if (empty($qaids)) {
678             return;
679         }
680         list($test, $params) = $this->db->get_in_or_equal($qaids, SQL_PARAMS_NAMED);
682         $this->delete_response_files($context->id, "IN (
683                 SELECT id
684                 FROM question_attempt_step
685                 WHERE questionattemptid $test)", $params);
687         $this->db->delete_records_select('question_attempt_step_data', "attemptstepid IN (
688                 SELECT qas.id
689                 FROM {question_attempt_steps} qas
690                 WHERE questionattemptid $test)", $params);
691         $this->db->delete_records_select('question_attempt_steps', 'questionattemptid ' . $test, $params);
692     }
694     /**
695      * Delete all the files belonging to the response variables in the gives
696      * question attempt steps.
697      * @param int $contextid the context these attempts belong to.
698      * @param string $itemidstest a bit of SQL that can be used in a
699      *      WHERE itemid $itemidstest clause. Must use named params.
700      * @param array $params any query parameters used in $itemidstest.
701      */
702     protected function delete_response_files($contextid, $itemidstest, $params) {
703         $fs = get_file_storage();
704         foreach ($this->get_all_response_file_areas() as $filearea) {
705             $fs->delete_area_files_select($contextid, 'question', $filearea,
706                     $itemidstest, $params);
707         }
708     }
710     /**
711      * Delete all the previews for a given question.
712      * @param int $questionid question id.
713      */
714     public function delete_previews($questionid) {
715         $previews = $this->db->get_records_sql_menu("
716                 SELECT DISTINCT quba.id, 1
717                 FROM {question_usages} quba
718                 JOIN {question_attempts} qa ON qa.questionusageid = quba.id
719                 WHERE quba.component = 'core_question_preview' AND
720                     qa.questionid = ?", array($questionid));
721         if (empty($previews)) {
722             return;
723         }
724         $this->delete_questions_usage_by_activities(new qubaid_list($previews));
725     }
727     /**
728      * Update the flagged state of a question in the database.
729      * @param int $qubaid the question usage id.
730      * @param int $questionid the question id.
731      * @param int $sessionid the question_attempt id.
732      * @param bool $newstate the new state of the flag. true = flagged.
733      */
734     public function update_question_attempt_flag($qubaid, $questionid, $qaid, $slot, $newstate) {
735         if (!$this->db->record_exists('question_attempts', array('id' => $qaid,
736                 'questionusageid' => $qubaid, 'questionid' => $questionid, 'slot' => $slot))) {
737             throw new moodle_exception('errorsavingflags', 'question');
738         }
740         $this->db->set_field('question_attempts', 'flagged', $newstate, array('id' => $qaid));
741     }
743     /**
744      * Get all the WHEN 'x' THEN 'y' terms needed to convert the question_attempt_steps.state
745      * column to a summary state. Use this like
746      * CASE qas.state {$this->full_states_to_summary_state_sql()} END AS summarystate,
747      * @param string SQL fragment.
748      */
749     protected function full_states_to_summary_state_sql() {
750         $sql = '';
751         foreach (question_state::get_all() as $state) {
752             $sql .= "WHEN '$state' THEN '{$state->get_summary_state()}'\n";
753         }
754         return $sql;
755     }
757     /**
758      * Get the SQL needed to test that question_attempt_steps.state is in a
759      * state corresponding to $summarystate.
760      * @param string $summarystate one of
761      * inprogress, needsgrading, manuallygraded or autograded
762      * @param bool $equal if false, do a NOT IN test. Default true.
763      * @return string SQL fragment.
764      */
765     public function in_summary_state_test($summarystate, $equal = true, $prefix = 'summarystates') {
766         $states = question_state::get_all_for_summary_state($summarystate);
767         return $this->db->get_in_or_equal($states, SQL_PARAMS_NAMED, $prefix . '00', $equal);
768     }
770     /**
771      * Change the maxmark for the question_attempt with number in usage $slot
772      * for all the specified question_attempts.
773      * @param qubaid_condition $qubaids Selects which usages are updated.
774      * @param int $slot the number is usage to affect.
775      * @param number $newmaxmark the new max mark to set.
776      */
777     public function set_max_mark_in_attempts(qubaid_condition $qubaids, $slot, $newmaxmark) {
778         $this->db->set_field_select('question_attempts', 'maxmark', $newmaxmark,
779                 "questionusageid {$qubaids->usage_id_in()} AND slot = :slot",
780                 $qubaids->usage_id_in_params() + array('slot' => $slot));
781     }
783     /**
784      * Return a subquery that computes the sum of the marks for all the questions
785      * in a usage. Which useage to compute the sum for is controlled bu the $qubaid
786      * parameter.
787      *
788      * See {@link quiz_update_all_attempt_sumgrades()} for an example of the usage of
789      * this method.
790      *
791      * @param string $qubaid SQL fragment that controls which usage is summed.
792      * This will normally be the name of a column in the outer query. Not that this
793      * SQL fragment must not contain any placeholders.
794      * @return string SQL code for the subquery.
795      */
796     public function sum_usage_marks_subquery($qubaid) {
797         return "SELECT SUM(qa.maxmark * qas.fraction)
798             FROM {question_attempts} qa
799             JOIN (
800                 SELECT summarks_qa.id AS questionattemptid, MAX(summarks_qas.id) AS latestid
801                 FROM {question_attempt_steps} summarks_qas
802                 JOIN {question_attempts} summarks_qa ON summarks_qa.id = summarks_qas.questionattemptid
803                 WHERE summarks_qa.questionusageid = $qubaid
804                 GROUP BY summarks_qa.id
805             ) lateststepid ON lateststepid.questionattemptid = qa.id
806             JOIN {question_attempt_steps} qas ON qas.id = lateststepid.latestid
807             WHERE qa.questionusageid = $qubaid
808             HAVING COUNT(CASE WHEN qas.state = 'needsgrading' AND qa.maxmark > 0 THEN 1 ELSE NULL END) = 0";
809     }
811     public function question_attempt_latest_state_view($alias) {
812         return "(
813                 SELECT
814                     {$alias}qa.id AS questionattemptid,
815                     {$alias}qa.questionusageid,
816                     {$alias}qa.slot,
817                     {$alias}qa.behaviour,
818                     {$alias}qa.questionid,
819                     {$alias}qa.maxmark,
820                     {$alias}qa.minfraction,
821                     {$alias}qa.flagged,
822                     {$alias}qa.questionsummary,
823                     {$alias}qa.rightanswer,
824                     {$alias}qa.responsesummary,
825                     {$alias}qa.timemodified,
826                     {$alias}qas.id AS attemptstepid,
827                     {$alias}qas.sequencenumber,
828                     {$alias}qas.state,
829                     {$alias}qas.fraction,
830                     {$alias}qas.timecreated,
831                     {$alias}qas.userid
833                 FROM {question_attempts} {$alias}qa
834                 JOIN {question_attempt_steps} {$alias}qas ON
835                         {$alias}qas.id = {$this->latest_step_for_qa_subquery($alias . 'qa.id')}
836             ) $alias";
837     }
839     protected function latest_step_for_qa_subquery($questionattemptid = 'qa.id') {
840         return "(
841                 SELECT MAX(id)
842                 FROM {question_attempt_steps}
843                 WHERE questionattemptid = $questionattemptid
844             )";
845     }
847     /**
848      * @param array $questionids of question ids.
849      * @param qubaid_condition $qubaids ids of the usages to consider.
850      * @return boolean whether any of these questions are being used by any of
851      *      those usages.
852      */
853     public function questions_in_use(array $questionids, qubaid_condition $qubaids) {
854         list($test, $params) = $this->db->get_in_or_equal($questionids);
855         return $this->db->record_exists_select('question_attempts',
856                 'questionid ' . $test . ' AND questionusageid ' .
857                 $qubaids->usage_id_in(), $params + $qubaids->usage_id_in_params());
858     }
860     /**
861      * @return array all the file area names that may contain response files.
862      */
863     public static function get_all_response_file_areas() {
864         $variables = array();
865         foreach (question_bank::get_all_qtypes() as $qtype) {
866             $variables += $qtype->response_file_areas();
867         }
869         $areas = array();
870         foreach (array_unique($variables) as $variable) {
871             $areas[] = 'response_' . $variable;
872         }
873         return $areas;
874     }
878 /**
879  * Implementation of the unit of work pattern for the question engine.
880  *
881  * See http://martinfowler.com/eaaCatalog/unitOfWork.html. This tracks all the
882  * changes to a {@link question_usage_by_activity}, and its constituent parts,
883  * so that the changes can be saved to the database when {@link save()} is called.
884  *
885  * @copyright  2009 The Open University
886  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
887  */
888 class question_engine_unit_of_work implements question_usage_observer {
889     /** @var question_usage_by_activity the usage being tracked. */
890     protected $quba;
892     /** @var boolean whether any of the fields of the usage have been changed. */
893     protected $modified = false;
895     /**
896      * @var array list of number in usage => {@link question_attempt}s that
897      * were already in the usage, and which have been modified.
898      */
899     protected $attemptsmodified = array();
901     /**
902      * @var array list of number in usage => {@link question_attempt}s that
903      * have been added to the usage.
904      */
905     protected $attemptsadded = array();
907     /**
908      * @var array list of question attempt ids to delete the steps for, before
909      * inserting new steps.
910      */
911     protected $attemptstodeletestepsfor = array();
913     /**
914      * @var array list of array(question_attempt_step, question_attempt id, seq number)
915      * of steps that have been added to question attempts in this usage.
916      */
917     protected $stepsadded = array();
919     /**
920      * Constructor.
921      * @param question_usage_by_activity $quba the usage to track.
922      */
923     public function __construct(question_usage_by_activity $quba) {
924         $this->quba = $quba;
925     }
927     public function notify_modified() {
928         $this->modified = true;
929     }
931     public function notify_attempt_modified(question_attempt $qa) {
932         $no = $qa->get_slot();
933         if (!array_key_exists($no, $this->attemptsadded)) {
934             $this->attemptsmodified[$no] = $qa;
935         }
936     }
938     public function notify_attempt_added(question_attempt $qa) {
939         $this->attemptsadded[$qa->get_slot()] = $qa;
940     }
942     public function notify_delete_attempt_steps(question_attempt $qa) {
944         if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
945             return;
946         }
948         $qaid = $qa->get_database_id();
949         foreach ($this->stepsadded as $key => $stepinfo) {
950             if ($stepinfo[1] == $qaid) {
951                 unset($this->stepsadded[$key]);
952             }
953         }
955         $this->attemptstodeletestepsfor[$qaid] = 1;
956     }
958     public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq) {
959         if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
960             return;
961         }
962         $this->stepsadded[] = array($step, $qa->get_database_id(), $seq);
963     }
965     /**
966      * Write all the changes we have recorded to the database.
967      * @param question_engine_data_mapper $dm the mapper to use to update the database.
968      */
969     public function save(question_engine_data_mapper $dm) {
970         $dm->delete_steps_for_question_attempts(array_keys($this->attemptstodeletestepsfor),
971                 $this->quba->get_owning_context());
973         foreach ($this->stepsadded as $stepinfo) {
974             list($step, $questionattemptid, $seq) = $stepinfo;
975             $dm->insert_question_attempt_step($step, $questionattemptid, $seq,
976                     $this->quba->get_owning_context());
977         }
979         foreach ($this->attemptsadded as $qa) {
980             $dm->insert_question_attempt($qa, $this->quba->get_owning_context());
981         }
983         foreach ($this->attemptsmodified as $qa) {
984             $dm->update_question_attempt($qa);
985         }
987         if ($this->modified) {
988             $dm->update_questions_usage_by_activity($this->quba);
989         }
990     }
994 /**
995  * This class represents the promise to save some files from a particular draft
996  * file area into a particular file area. It is used beause the necessary
997  * information about what to save is to hand in the
998  * {@link question_attempt::process_response_files()} method, but we don't know
999  * if this question attempt will actually be saved in the database until later,
1000  * when the {@link question_engine_unit_of_work} is saved, if it is.
1001  *
1002  * @copyright  2011 The Open University
1003  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1004  */
1005 class question_file_saver {
1006     /** @var int the id of the draft file area to save files from. */
1007     protected $draftitemid;
1008     /** @var string the owning component name. */
1009     protected $component;
1010     /** @var string the file area name. */
1011     protected $filearea;
1013     /**
1014      * @var string the value to store in the question_attempt_step_data to
1015      * represent these files.
1016      */
1017     protected $value = null;
1019     /**
1020      * Constuctor.
1021      * @param int $draftitemid the draft area to save the files from.
1022      * @param string $component the component for the file area to save into.
1023      * @param string $filearea the name of the file area to save into.
1024      */
1025     public function __construct($draftitemid, $component, $filearea, $text = null) {
1026         $this->draftitemid = $draftitemid;
1027         $this->component = $component;
1028         $this->filearea = $filearea;
1029         $this->value = $this->compute_value($draftitemid, $text);
1030     }
1032     /**
1033      * Compute the value that should be stored in the question_attempt_step_data
1034      * table. Contains a hash that (almost) uniquely encodes all the files.
1035      * @param int $draftitemid the draft file area itemid.
1036      * @param string $text optional content containing file links.
1037      */
1038     protected function compute_value($draftitemid, $text) {
1039         global $USER;
1041         $fs = get_file_storage();
1042         $usercontext = get_context_instance(CONTEXT_USER, $USER->id);
1044         $files = $fs->get_area_files($usercontext->id, 'user', 'draft',
1045                 $draftitemid, 'sortorder, filepath, filename', false);
1047         $string = '';
1048         foreach ($files as $file) {
1049             $string .= $file->get_filepath() . $file->get_filename() . '|' .
1050                     $file->get_contenthash() . '|';
1051         }
1053         if ($string) {
1054             $hash = md5($string);
1055         } else {
1056             $hash = '';
1057         }
1059         if (is_null($text)) {
1060             return $hash;
1061         }
1063         // We add the file hash so a simple string comparison will say if the
1064         // files have been changed. First strip off any existing file hash.
1065         $text = preg_replace('/\s*<!-- File hash: \w+ -->\s*$/', '', $text);
1066         $text = file_rewrite_urls_to_pluginfile($text, $draftitemid);
1067         if ($hash) {
1068             $text .= '<!-- File hash: ' . $hash . ' -->';
1069         }
1070         return $text;
1071     }
1073     public function __toString() {
1074         return $this->value;
1075     }
1077     /**
1078      * Actually save the files.
1079      * @param integer $itemid the item id for the file area to save into.
1080      */
1081     public function save_files($itemid, $context) {
1082         file_save_draft_area_files($this->draftitemid, $context->id,
1083                 $this->component, $this->filearea, $itemid);
1084     }
1088 /**
1089  * This class represents a restriction on the set of question_usage ids to include
1090  * in a larger database query. Depending of the how you are going to restrict the
1091  * list of usages, construct an appropriate subclass.
1092  *
1093  * If $qubaids is an instance of this class, example usage might be
1094  *
1095  * SELECT qa.id, qa.maxmark
1096  * FROM $qubaids->from_question_attempts('qa')
1097  * WHERE $qubaids->where() AND qa.slot = 1
1098  *
1099  * @copyright  2010 The Open University
1100  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1101  */
1102 abstract class qubaid_condition {
1104     /**
1105      * @return string the SQL that needs to go in the FROM clause when trying
1106      * to select records from the 'question_attempts' table based on the
1107      * qubaid_condition.
1108      */
1109     public abstract function from_question_attempts($alias);
1111     /** @return string the SQL that needs to go in the where clause. */
1112     public abstract function where();
1114     /**
1115      * @return the params needed by a query that uses
1116      * {@link from_question_attempts()} and {@link where()}.
1117      */
1118     public abstract function from_where_params();
1120     /**
1121      * @return string SQL that can use used in a WHERE qubaid IN (...) query.
1122      * This method returns the "IN (...)" part.
1123      */
1124     public abstract function usage_id_in();
1126     /**
1127      * @return the params needed by a query that uses {@link usage_id_in()}.
1128      */
1129     public abstract function usage_id_in_params();
1133 /**
1134  * This class represents a restriction on the set of question_usage ids to include
1135  * in a larger database query based on an explicit list of ids.
1136  *
1137  * @copyright  2010 The Open University
1138  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1139  */
1140 class qubaid_list extends qubaid_condition {
1141     /** @var array of ids. */
1142     protected $qubaids;
1143     protected $columntotest = null;
1144     protected $params;
1146     /**
1147      * Constructor.
1148      * @param array $qubaids of question usage ids.
1149      */
1150     public function __construct(array $qubaids) {
1151         $this->qubaids = $qubaids;
1152     }
1154     public function from_question_attempts($alias) {
1155         $this->columntotest = $alias . '.questionusageid';
1156         return '{question_attempts} ' . $alias;
1157     }
1159     public function where() {
1160         global $DB;
1162         if (is_null($this->columntotest)) {
1163             throw new coding_exception('Must call from_question_attempts before where().');
1164         }
1165         if (empty($this->qubaids)) {
1166             $this->params = array();
1167             return '1 = 0';
1168         }
1169         list($where, $this->params) = $DB->get_in_or_equal($this->qubaids, SQL_PARAMS_NAMED, 'qubaid0000');
1171         return $this->columntotest . ' ' . $this->usage_id_in();
1172     }
1174     public function from_where_params() {
1175         return $this->params;
1176     }
1178     public function usage_id_in() {
1179         global $DB;
1181         if (empty($this->qubaids)) {
1182             return '= 0';
1183         }
1184         list($where, $this->params) = $DB->get_in_or_equal($this->qubaids, SQL_PARAMS_NAMED, 'qubaid0000');
1185         return $where;
1186     }
1188     public function usage_id_in_params() {
1189         return $this->params;
1190     }
1194 /**
1195  * This class represents a restriction on the set of question_usage ids to include
1196  * in a larger database query based on JOINing to some other tables.
1197  *
1198  * The general form of the query is something like
1199  *
1200  * SELECT qa.id, qa.maxmark
1201  * FROM $from
1202  * JOIN {question_attempts} qa ON qa.questionusageid = $usageidcolumn
1203  * WHERE $where AND qa.slot = 1
1204  *
1205  * where $from, $usageidcolumn and $where are the arguments to the constructor.
1206  *
1207  * @copyright  2010 The Open University
1208  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1209  */
1210 class qubaid_join extends qubaid_condition {
1211     public $from;
1212     public $usageidcolumn;
1213     public $where;
1214     public $params;
1216     /**
1217      * Constructor. The meaning of the arguments is explained in the class comment.
1218      * @param string $from SQL fragemnt to go in the FROM clause.
1219      * @param string $usageidcolumn the column in $from that should be
1220      * made equal to the usageid column in the JOIN clause.
1221      * @param string $where SQL fragment to go in the where clause.
1222      * @param array $params required by the SQL. You must use named parameters.
1223      */
1224     public function __construct($from, $usageidcolumn, $where = '', $params = array()) {
1225         $this->from = $from;
1226         $this->usageidcolumn = $usageidcolumn;
1227         $this->params = $params;
1228         if (empty($where)) {
1229             $where = '1 = 1';
1230         }
1231         $this->where = $where;
1232     }
1234     public function from_question_attempts($alias) {
1235         return "$this->from
1236                 JOIN {question_attempts} {$alias} ON " .
1237                         "{$alias}.questionusageid = $this->usageidcolumn";
1238     }
1240     public function where() {
1241         return $this->where;
1242     }
1244     public function from_where_params() {
1245         return $this->params;
1246     }
1248     public function usage_id_in() {
1249         return "IN (SELECT $this->usageidcolumn FROM $this->from WHERE $this->where)";
1250     }
1252     public function usage_id_in_params() {
1253         return $this->params;
1254     }