2 // This file is part of Moodle - http://moodle.org/
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.
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.
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/>.
18 * Code for loading and saving question attempts to and from the database.
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
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.
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.
40 * @subpackage questionengine
41 * @copyright 2009 The Open University
42 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
46 defined('MOODLE_INTERNAL') ||
die();
50 * This class controls the loading and saving of question engine data to and from
53 * @copyright 2009 The Open University
54 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
56 class question_engine_data_mapper
{
58 * @var moodle_database normally points to global $DB, but I prefer not to
59 * use globals if I can help it.
64 * @param moodle_database $db a database connectoin. Defaults to global $DB.
66 public function __construct(moodle_database
$db = null) {
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.
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());
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.
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) . '...';
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);
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.
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();
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.
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);
157 if ($value instanceof question_response_files
) {
158 $value = (string) $value;
161 $data = new stdClass();
162 $data->attemptstepid
= $stepid;
164 $data->value
= $value;
165 $this->db
->insert_record('question_attempt_step_data', $data, false);
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.
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);
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.
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);
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.
209 public function load_question_attempt_step($stepid) {
210 $records = $this->db
->get_recordset_sql("
213 COALLESCE(q.qtype, 'missingtype') AS qtype,
214 qas.id AS attemptstepid,
215 qas.questionattemptid,
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
232 ", array('stepid' => $stepid));
234 if (!$records->valid()) {
235 throw new coding_exception('Failed to load question_attempt_step ' . $stepid);
238 $step = question_attempt_step
::load_from_records($records, $stepid);
245 * Load a {@link question_attempt} from the database, including all its
247 * @param int $questionattemptid the id of the question attempt to load.
248 * @param question_attempt the question attempt that was loaded.
250 public function load_question_attempt($questionattemptid) {
251 $records = $this->db
->get_recordset_sql("
254 quba.preferredbehaviour,
255 qa.id AS questionattemptid,
269 qas.id AS attemptstepid,
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
284 qa.id = :questionattemptid
288 ", array('questionattemptid' => $questionattemptid));
290 if (!$records->valid()) {
291 throw new coding_exception('Failed to load question_attempt ' . $questionattemptid);
294 $record = $records->current();
295 $qa = question_attempt
::load_from_records($records, $questionattemptid,
296 new question_usage_null_observer(), $record->preferredbehaviour
);
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.
308 public function load_questions_usage_by_activity($qubaid) {
309 $records = $this->db
->get_recordset_sql("
314 quba.preferredbehaviour,
315 qa.id AS questionattemptid,
329 qas.id AS attemptstepid,
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
349 ", array('qubaid' => $qubaid));
351 if (!$records->valid()) {
352 throw new coding_exception('Failed to load questions_usage_by_activity ' . $qubaid);
355 $quba = question_usage_by_activity
::load_from_records($records, $qubaid);
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.
367 public function load_questions_usages_by_activity($qubaids) {
368 $records = $this->db
->get_recordset_sql("
373 quba.preferredbehaviour,
374 qa.id AS questionattemptid,
388 qas.id AS attemptstepid,
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
403 quba.id {$qubaids->usage_id_in()}
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);
417 $record = $records->current();
418 $qubas[$record->qubaid
] = question_usage_by_activity
::load_from_records($records, $record->qubaid
);
419 } while ($records->valid());
427 * Load information about the latest state of each question from the database.
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.
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) {
440 qa.id AS questionattemptid,
454 qas.id AS attemptstepid,
463 $records = $this->db
->get_records_sql("
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()}
472 {$qubaids->where()} AND
474 ", $params +
$qubaids->from_where_params());
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.
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.
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("
501 {$this->full_states_to_summary_state_sql()}
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
511 {$qubaids->where()} AND
520 {$this->full_states_to_summary_state_sql()}
528 ", $params +
$qubaids->from_where_params());
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;
544 $results[$index] = $res;
547 $results[$index]->{$row->summarystate
} = $row->numattempts
;
548 $results[$index]->all +
= $row->numattempts
;
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.
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.
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.
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) {
582 $extrawhere .= ' AND qa.questionid = :questionid';
583 $params['questionid'] = $questionid;
585 if ($summarystate != 'all') {
586 list($test, $sparams) = $this->in_summary_state_test($summarystate);
587 $extrawhere .= ' AND qas.state ' . $test;
591 if ($orderby == 'random') {
593 } else if ($orderby) {
594 $sqlorderby = 'ORDER BY ' . $orderby;
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("
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
627 $qubaids = array_keys($qubaids);
628 $count = count($qubaids);
630 if ($orderby == 'random') {
635 if (!is_null($limitnum)) {
636 $qubaids = array_slice($qubaids, $limitfrom, $limitnum);
639 return array($qubaids, $count);
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.
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";
657 $slotsparams = array();
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("
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()}
683 AND qas.state $statetest
688 ", $slotsparams +
$stateparams +
$qubaids->from_where_params());
692 * Load a {@link question_attempt} from the database, including all its
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.
700 public function load_attempts_at_question($questionid, qubaid_condition
$qubaids) {
704 quba.preferredbehaviour,
705 qa.id AS questionattemptid,
719 qas.id AS attemptstepid,
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
734 {$qubaids->where()} AND
735 qa.questionid = :questionid
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
);
758 return $questionattempts;
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.
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);
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.
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);
797 * Delete a question_usage_by_activity and all its associated
798 * {@link question_attempts} and {@link question_attempt_steps} from the
800 * @param qubaid_condition $qubaids identifies which question useages to delete.
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 (
813 FROM {question_attempts} qa
814 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
815 WHERE $where)", $params);
818 if ($this->db
->get_dbfamily() == 'mysql') {
819 $this->delete_usage_records_for_mysql($qubaids);
823 $this->db
->delete_records_select('question_attempt_step_data', "attemptstepid IN (
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 (
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());
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.
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)';
857 // TODO once MDL-29589 is fixed, eliminate this method, and instead use the new $DB API.
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());
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.
873 public function delete_steps($stepids, $context) {
874 if (empty($stepids)) {
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);
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.
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);
904 * Delete all the previews for a given question.
905 * @param int $questionid question id.
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)) {
917 $this->delete_questions_usage_by_activities(new qubaid_list($previews));
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.
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');
933 $this->db
->set_field('question_attempts', 'flagged', $newstate, array('id' => $qaid));
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.
942 protected function full_states_to_summary_state_sql() {
944 foreach (question_state
::get_all() as $state) {
945 $sql .= "WHEN '$state' THEN '{$state->get_summary_state()}'\n";
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.
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);
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.
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));
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
982 * See {@link quiz_update_all_attempt_sumgrades()} for an example of the usage of
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.
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
1003 WHERE qa.questionusageid = $qubaid
1005 WHEN qas.state = 'needsgrading' AND qa.maxmark > 0 THEN 1
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.
1019 public function question_attempt_latest_state_view($alias, qubaid_condition
$qubaids) {
1021 SELECT {$alias}qa.id AS questionattemptid,
1022 {$alias}qa.questionusageid,
1024 {$alias}qa.behaviour,
1025 {$alias}qa.questionid,
1028 {$alias}qa.minfraction,
1029 {$alias}qa.maxfraction,
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,
1038 {$alias}qas.fraction,
1039 {$alias}qas.timecreated,
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());
1049 protected function latest_step_for_qa_subquery($questionattemptid = 'qa.id') {
1051 SELECT MAX(sequencenumber)
1052 FROM {question_attempt_steps}
1053 WHERE questionattemptid = $questionattemptid
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
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());
1073 * Implementation of the unit of work pattern for the question engine.
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.
1079 * @copyright 2009 The Open University
1080 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1082 class question_engine_unit_of_work
implements question_usage_observer
{
1083 /** @var question_usage_by_activity the usage being tracked. */
1086 /** @var boolean whether any of the fields of the usage have been changed. */
1087 protected $modified = false;
1090 * @var array list of slot => {@link question_attempt}s that
1091 * were already in the usage, and which have been modified.
1093 protected $attemptsmodified = array();
1096 * @var array list of slot => {@link question_attempt}s that
1097 * have been added to the usage.
1099 protected $attemptsadded = array();
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.
1105 protected $stepsadded = array();
1108 * @var array of array(question_attempt_step, question_attempt id, seq number)
1109 * of steps that have been modified in their attempt.
1111 protected $stepsmodified = array();
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.
1117 protected $stepsdeleted = array();
1121 * @param question_usage_by_activity $quba the usage to track.
1123 public function __construct(question_usage_by_activity
$quba) {
1124 $this->quba
= $quba;
1127 public function notify_modified() {
1128 $this->modified
= true;
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;
1138 public function notify_attempt_added(question_attempt
$qa) {
1139 $this->attemptsadded
[$qa->get_slot()] = $qa;
1142 public function notify_step_added(question_attempt_step
$step, question_attempt
$qa, $seq) {
1143 if (array_key_exists($qa->get_slot(), $this->attemptsadded
)) {
1147 if (($key = $this->is_step_added($step)) !== false) {
1151 if (($key = $this->is_step_modified($step)) !== false) {
1152 throw new coding_exception('Cannot add a step that has already been modified.');
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);
1161 $stepid = $step->get_id();
1163 if (array_key_exists($stepid, $this->stepsdeleted
)) {
1164 unset($this->stepsdeleted
[$stepid]);
1166 $this->stepsmodified
[] = array($step, $qa->get_database_id(), $seq);
1169 $this->stepsadded
[] = array($step, $qa->get_database_id(), $seq);
1173 public function notify_step_modified(question_attempt_step
$step, question_attempt
$qa, $seq) {
1174 if (array_key_exists($qa->get_slot(), $this->attemptsadded
)) {
1178 if (($key = $this->is_step_added($step)) !== false) {
1182 if (($key = $this->is_step_deleted($step)) !== false) {
1183 throw new coding_exception('Cannot modify a step after it has been deleted.');
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.');
1191 $this->stepsmodified
[] = array($step, $qa->get_database_id(), $seq);
1194 public function notify_step_deleted(question_attempt_step
$step, question_attempt
$qa) {
1195 if (array_key_exists($qa->get_slot(), $this->attemptsadded
)) {
1199 if (($key = $this->is_step_added($step)) !== false) {
1200 unset($this->stepsadded
[$key]);
1204 if (($key = $this->is_step_modified($step)) !== false) {
1205 unset($this->stepsmodified
[$key]);
1208 $stepid = $step->get_id();
1209 if (empty($stepid)) {
1210 return; // Was never in the database.
1213 $this->stepsdeleted
[$stepid] = $step;
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.
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) {
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.
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) {
1247 * @param question_attempt_step $step a step
1248 * @return bool whether the step is in the list of steps to be deleted.
1250 protected function is_step_deleted(question_attempt_step
$step) {
1251 foreach ($this->stepsdeleted
as $deletedstep) {
1252 if ($deletedstep === $step) {
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.
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());
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());
1278 foreach ($this->attemptsadded
as $qa) {
1279 $dm->insert_question_attempt($qa, $this->quba
->get_owning_context());
1282 foreach ($this->attemptsmodified
as $qa) {
1283 $dm->update_question_attempt($qa);
1286 if ($this->modified
) {
1287 $dm->update_questions_usage_by_activity($this->quba
);
1294 * The interface implemented by {@link question_file_saver} and {@link question_file_loader}.
1296 * @copyright 2012 The Open University
1297 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1299 interface question_response_files
{
1301 * Get the files that were submitted.
1302 * @return array of stored_files objects.
1304 public function get_files();
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.
1316 * @copyright 2011 The Open University
1317 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
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;
1328 * @var string the value to store in the question_attempt_step_data to
1329 * represent these files.
1331 protected $value = null;
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.
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);
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.
1352 protected function compute_value($draftitemid, $text) {
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);
1362 foreach ($files as $file) {
1363 $string .= $file->get_filepath() . $file->get_filename() . '|' .
1364 $file->get_contenthash() . '|';
1366 $hash = md5($string);
1368 if (is_null($text)) {
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.
1379 $text = preg_replace('/\s*<!-- File hash: \w+ -->\s*$/', '', $text);
1380 $text = file_rewrite_urls_to_pluginfile($text, $draftitemid);
1382 $text .= '<!-- File hash: ' . $hash . ' -->';
1388 public function __toString() {
1389 return $this->value
;
1393 * Actually save the files.
1394 * @param integer $itemid the item id for the file area to save into.
1396 public function save_files($itemid, $context) {
1397 file_save_draft_area_files($this->draftitemid
, $context->id
,
1398 $this->component
, $this->filearea
, $itemid);
1402 * Get the files that were submitted.
1403 * @return array of stored_files objects.
1405 public function get_files() {
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);
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.
1422 * @copyright 2012 The Open University
1423 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1425 class question_file_loader
implements question_response_files
{
1426 /** @var question_attempt_step the step that these files belong to. */
1429 /** @var string the field name for these files - which is used to construct the file area name. */
1433 * @var string the value to stored in the question_attempt_step_data to
1434 * represent these files.
1438 /** @var int the context id that the files belong to. */
1439 protected $contextid;
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.
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;
1456 public function __toString() {
1457 return $this->value
;
1461 * Get the files that were submitted.
1462 * @return array of stored_files objects.
1464 public function get_files() {
1465 return $this->step
->get_qt_files($this->name
, $this->contextid
);
1469 * Copy these files into a draft area, and return the corresponding
1470 * {@link question_file_saver} that can save them again.
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.
1475 * @return question_file_saver that can re-save these files again.
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
)) {
1495 $value = $this->value
;
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);
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.
1510 * If $qubaids is an instance of this class, example usage might be
1512 * SELECT qa.id, qa.maxmark
1513 * FROM $qubaids->from_question_attempts('qa')
1514 * WHERE $qubaids->where() AND qa.slot = 1
1516 * @copyright 2010 The Open University
1517 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1519 abstract class qubaid_condition
{
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
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();
1532 * @return the params needed by a query that uses
1533 * {@link from_question_attempts()} and {@link where()}.
1535 public abstract function from_where_params();
1538 * @return string SQL that can use used in a WHERE qubaid IN (...) query.
1539 * This method returns the "IN (...)" part.
1541 public abstract function usage_id_in();
1544 * @return the params needed by a query that uses {@link usage_id_in()}.
1546 public abstract function usage_id_in_params();
1549 * @return string 40-character hash code that uniquely identifies the combination of properties and class name of this qubaid
1552 public function get_hash_code() {
1553 return sha1(serialize($this));
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.
1562 * @copyright 2010 The Open University
1563 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1565 class qubaid_list
extends qubaid_condition
{
1566 /** @var array of ids. */
1568 protected $columntotest = null;
1573 * @param array $qubaids of question usage ids.
1575 public function __construct(array $qubaids) {
1576 $this->qubaids
= $qubaids;
1579 public function from_question_attempts($alias) {
1580 $this->columntotest
= $alias . '.questionusageid';
1581 return '{question_attempts} ' . $alias;
1584 public function where() {
1587 if (is_null($this->columntotest
)) {
1588 throw new coding_exception('Must call from_question_attempts before where().');
1590 if (empty($this->qubaids
)) {
1591 $this->params
= array();
1595 return $this->columntotest
. ' ' . $this->usage_id_in();
1598 public function from_where_params() {
1599 return $this->params
;
1602 public function usage_id_in() {
1605 if (empty($this->qubaids
)) {
1606 $this->params
= array();
1609 list($where, $this->params
) = $DB->get_in_or_equal(
1610 $this->qubaids
, SQL_PARAMS_NAMED
, 'qubaid');
1614 public function usage_id_in_params() {
1615 return $this->params
;
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.
1624 * The general form of the query is something like
1626 * SELECT qa.id, qa.maxmark
1628 * JOIN {question_attempts} qa ON qa.questionusageid = $usageidcolumn
1629 * WHERE $where AND qa.slot = 1
1631 * where $from, $usageidcolumn and $where are the arguments to the constructor.
1633 * @copyright 2010 The Open University
1634 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1636 class qubaid_join
extends qubaid_condition
{
1638 public $usageidcolumn;
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.
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)) {
1657 $this->where
= $where;
1660 public function from_question_attempts($alias) {
1662 JOIN {question_attempts} {$alias} ON " .
1663 "{$alias}.questionusageid = $this->usageidcolumn";
1666 public function where() {
1667 return $this->where
;
1670 public function from_where_params() {
1671 return $this->params
;
1674 public function usage_id_in() {
1675 return "IN (SELECT $this->usageidcolumn FROM $this->from WHERE $this->where)";
1678 public function usage_id_in_params() {
1679 return $this->params
;