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 * Note that many of the methods of this class should be considered private to
21 * the question engine. They should be accessed through the
22 * {@link question_engine} class. For example, you should call
23 * {@link question_engine::save_questions_usage_by_activity()} rather than
24 * {@link question_engine_data_mapper::insert_questions_usage_by_activity()}.
25 * The exception to this is some of the reporting methods, like
26 * {@link question_engine_data_mapper::load_attempts_at_question()}.
28 * (TODO, probably we should split this class up, so that it has no public
29 * methods. They should all be moved to a new public class.)
31 * A note for future reference. This code is pretty efficient but there are some
32 * potential optimisations that could be contemplated, at the cost of making the
35 * 1. (This is probably not worth doing.) In the unit-of-work save method, we
36 * could get all the ids for steps due to be deleted or modified,
37 * and delete all the question_attempt_step_data for all of those steps in one
38 * query. That would save one DB query for each ->stepsupdated. However that number
39 * is 0 except when re-grading, and when regrading, there are many more inserts
40 * into question_attempt_step_data than deletes, so it is really hardly worth it.
42 * @package core_question
43 * @copyright 2009 The Open University
44 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
48 defined('MOODLE_INTERNAL') ||
die();
52 * This class controls the loading and saving of question engine data to and from
55 * @copyright 2009 The Open University
56 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
58 class question_engine_data_mapper
{
60 * @var moodle_database normally points to global $DB, but I prefer not to
61 * use globals if I can help it.
66 * @param moodle_database $db a database connectoin. Defaults to global $DB.
68 public function __construct(moodle_database
$db = null) {
78 * Store an entire {@link question_usage_by_activity} in the database,
79 * including all the question_attempts that comprise it.
81 * You should not call this method directly. You should use
82 * @link question_engine::save_questions_usage_by_activity()}.
84 * @param question_usage_by_activity $quba the usage to store.
86 public function insert_questions_usage_by_activity(question_usage_by_activity
$quba) {
87 $record = new stdClass();
88 $record->contextid
= $quba->get_owning_context()->id
;
89 $record->component
= $quba->get_owning_component();
90 $record->preferredbehaviour
= $quba->get_preferred_behaviour();
92 $newid = $this->db
->insert_record('question_usages', $record);
93 $quba->set_id_from_database($newid);
95 // Initially an array of array of question_attempt_step_objects.
96 // Built as a nested array for efficiency, then flattened.
99 foreach ($quba->get_attempt_iterator() as $qa) {
100 $stepdata[] = $this->insert_question_attempt($qa, $quba->get_owning_context());
103 $stepdata = call_user_func_array('array_merge', $stepdata);
105 $this->insert_all_step_data($stepdata);
110 * Store an entire {@link question_attempt} in the database,
111 * including all the question_attempt_steps that comprise it.
113 * You should not call this method directly. You should use
114 * @link question_engine::save_questions_usage_by_activity()}.
116 * @param question_attempt $qa the question attempt to store.
117 * @param context $context the context of the owning question_usage_by_activity.
118 * @return array of question_attempt_step_data rows, that still need to be inserted.
120 public function insert_question_attempt(question_attempt
$qa, $context) {
121 $record = new stdClass();
122 $record->questionusageid
= $qa->get_usage_id();
123 $record->slot
= $qa->get_slot();
124 $record->behaviour
= $qa->get_behaviour_name();
125 $record->questionid
= $qa->get_question()->id
;
126 $record->variant
= $qa->get_variant();
127 $record->maxmark
= $qa->get_max_mark();
128 $record->minfraction
= $qa->get_min_fraction();
129 $record->maxfraction
= $qa->get_max_fraction();
130 $record->flagged
= $qa->is_flagged();
131 $record->questionsummary
= $qa->get_question_summary();
132 if (core_text
::strlen($record->questionsummary
) > question_bank
::MAX_SUMMARY_LENGTH
) {
133 // It seems some people write very long quesions! MDL-30760
134 $record->questionsummary
= core_text
::substr($record->questionsummary
,
135 0, question_bank
::MAX_SUMMARY_LENGTH
- 3) . '...';
137 $record->rightanswer
= $qa->get_right_answer_summary();
138 $record->responsesummary
= $qa->get_response_summary();
139 $record->timemodified
= time();
140 $record->id
= $this->db
->insert_record('question_attempts', $record);
141 $qa->set_database_id($record->id
);
143 // Initially an array of array of question_attempt_step_objects.
144 // Built as a nested array for efficiency, then flattened.
147 foreach ($qa->get_step_iterator() as $seq => $step) {
148 $stepdata[] = $this->insert_question_attempt_step($step, $record->id
, $seq, $context);
151 return call_user_func_array('array_merge', $stepdata);
155 * Helper method used by insert_question_attempt_step and update_question_attempt_step
156 * @param question_attempt_step $step the step to store.
157 * @param int $questionattemptid the question attept id this step belongs to.
158 * @param int $seq the sequence number of this stop.
159 * @return stdClass data to insert into the database.
161 protected function make_step_record(question_attempt_step
$step, $questionattemptid, $seq) {
162 $record = new stdClass();
163 $record->questionattemptid
= $questionattemptid;
164 $record->sequencenumber
= $seq;
165 $record->state
= (string) $step->get_state();
166 $record->fraction
= $step->get_fraction();
167 $record->timecreated
= $step->get_timecreated();
168 $record->userid
= $step->get_user_id();
173 * Helper method used by insert_question_attempt_step and update_question_attempt_step
174 * @param question_attempt_step $step the step to store.
175 * @param int $stepid the id of the step.
176 * @param context $context the context of the owning question_usage_by_activity.
177 * @return array of question_attempt_step_data rows, that still need to be inserted.
179 protected function prepare_step_data(question_attempt_step
$step, $stepid, $context) {
181 foreach ($step->get_all_data() as $name => $value) {
182 if ($value instanceof question_file_saver
) {
183 $value->save_files($stepid, $context);
185 if ($value instanceof question_response_files
) {
186 $value = (string) $value;
189 $data = new stdClass();
190 $data->attemptstepid
= $stepid;
192 $data->value
= $value;
199 * Insert a lot of records into question_attempt_step_data in one go.
201 * Private method, only for use by other parts of the question engine.
203 * @param array $rows the rows to insert.
205 public function insert_all_step_data(array $rows) {
209 $this->db
->insert_records('question_attempt_step_data', $rows);
213 * Store a {@link question_attempt_step} in the database.
215 * Private method, only for use by other parts of the question engine.
217 * @param question_attempt_step $step the step to store.
218 * @param int $questionattemptid the question attept id this step belongs to.
219 * @param int $seq the sequence number of this stop.
220 * @param context $context the context of the owning question_usage_by_activity.
221 * @return array of question_attempt_step_data rows, that still need to be inserted.
223 public function insert_question_attempt_step(question_attempt_step
$step,
224 $questionattemptid, $seq, $context) {
226 $record = $this->make_step_record($step, $questionattemptid, $seq);
227 $record->id
= $this->db
->insert_record('question_attempt_steps', $record);
229 return $this->prepare_step_data($step, $record->id
, $context);
233 * Update a {@link question_attempt_step} in the database.
235 * Private method, only for use by other parts of the question engine.
237 * @param question_attempt_step $qa the step to store.
238 * @param int $questionattemptid the question attept id this step belongs to.
239 * @param int $seq the sequence number of this stop.
240 * @param context $context the context of the owning question_usage_by_activity.
241 * @return array of question_attempt_step_data rows, that still need to be inserted.
243 public function update_question_attempt_step(question_attempt_step
$step,
244 $questionattemptid, $seq, $context) {
246 $record = $this->make_step_record($step, $questionattemptid, $seq);
247 $record->id
= $step->get_id();
248 $this->db
->update_record('question_attempt_steps', $record);
250 $this->db
->delete_records('question_attempt_step_data',
251 array('attemptstepid' => $record->id
));
252 return $this->prepare_step_data($step, $record->id
, $context);
256 * Store new metadata for an existing {@link question_attempt} in the database.
258 * Private method, only for use by other parts of the question engine.
260 * @param question_attempt $qa the question attempt to store meta data for.
261 * @param array $names the names of the metadata variables to store.
262 * @return array of question_attempt_step_data rows, that still need to be inserted.
264 public function insert_question_attempt_metadata(question_attempt
$qa, array $names) {
265 $firststep = $qa->get_step(0);
268 foreach ($names as $name) {
269 $data = new stdClass();
270 $data->attemptstepid
= $firststep->get_id();
271 $data->name
= ':_' . $name;
272 $data->value
= $firststep->get_metadata_var($name);
280 * Updates existing metadata for an existing {@link question_attempt} in the database.
282 * Private method, only for use by other parts of the question engine.
284 * @param question_attempt $qa the question attempt to store meta data for.
285 * @param array $names the names of the metadata variables to store.
286 * @return array of question_attempt_step_data rows, that still need to be inserted.
288 public function update_question_attempt_metadata(question_attempt
$qa, array $names) {
293 // Use case-sensitive function sql_equal() and not get_in_or_equal().
294 // Some databases may use case-insensitive collation, we don't want to delete 'X' instead of 'x'.
296 $params = [$qa->get_step(0)->get_id()];
297 foreach ($names as $name) {
298 $sqls[] = $DB->sql_equal('name', '?');
301 $DB->delete_records_select('question_attempt_step_data',
302 'attemptstepid = ? AND (' . join(' OR ', $sqls) . ')', $params);
303 return $this->insert_question_attempt_metadata($qa, $names);
307 * Load a {@link question_attempt_step} from the database.
309 * Private method, only for use by other parts of the question engine.
311 * @param int $stepid the id of the step to load.
312 * @param question_attempt_step the step that was loaded.
314 public function load_question_attempt_step($stepid) {
315 $records = $this->db
->get_recordset_sql("
318 COALESCE(q.qtype, 'missingtype') AS qtype,
319 qas.id AS attemptstepid,
320 qas.questionattemptid,
329 FROM {question_attempt_steps} qas
330 JOIN {question_attempts} qa ON qa.id = qas.questionattemptid
331 JOIN {question_usages} quba ON quba.id = qa.questionusageid
332 LEFT JOIN {question} q ON q.id = qa.questionid
333 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
337 ", array('stepid' => $stepid));
339 if (!$records->valid()) {
340 throw new coding_exception('Failed to load question_attempt_step ' . $stepid);
343 $step = question_attempt_step
::load_from_records($records, $stepid);
350 * Load a {@link question_attempt} from the database, including all its
353 * Normally, you should use {@link question_engine::load_questions_usage_by_activity()}
354 * but there may be rare occasions where for performance reasons, you only
355 * wish to load one qa, in which case you may call this method.
357 * @param int $questionattemptid the id of the question attempt to load.
358 * @param question_attempt the question attempt that was loaded.
360 public function load_question_attempt($questionattemptid) {
361 $records = $this->db
->get_recordset_sql("
364 quba.preferredbehaviour,
365 qa.id AS questionattemptid,
379 qas.id AS attemptstepid,
388 FROM {question_attempts} qa
389 JOIN {question_usages} quba ON quba.id = qa.questionusageid
390 LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
391 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
394 qa.id = :questionattemptid
398 ", array('questionattemptid' => $questionattemptid));
400 if (!$records->valid()) {
401 throw new coding_exception('Failed to load question_attempt ' . $questionattemptid);
404 $record = $records->current();
405 $qa = question_attempt
::load_from_records($records, $questionattemptid,
406 new question_usage_null_observer(), $record->preferredbehaviour
);
413 * Load a {@link question_usage_by_activity} from the database, including
414 * all its {@link question_attempt}s and all their steps.
416 * You should call {@link question_engine::load_questions_usage_by_activity()}
417 * rather than calling this method directly.
419 * @param int $qubaid the id of the usage to load.
420 * @param question_usage_by_activity the usage that was loaded.
422 public function load_questions_usage_by_activity($qubaid) {
423 $records = $this->db
->get_recordset_sql("
428 quba.preferredbehaviour,
429 qa.id AS questionattemptid,
443 qas.id AS attemptstepid,
452 FROM {question_usages} quba
453 LEFT JOIN {question_attempts} qa ON qa.questionusageid = quba.id
454 LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
455 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
463 ", array('qubaid' => $qubaid));
465 if (!$records->valid()) {
466 throw new coding_exception('Failed to load questions_usage_by_activity ' . $qubaid);
469 $quba = question_usage_by_activity
::load_from_records($records, $qubaid);
476 * Load all {@link question_usage_by_activity} from the database for one qubaid_condition
477 * Include all its {@link question_attempt}s and all their steps.
479 * This method may be called publicly.
481 * @param qubaid_condition $qubaids the condition that tells us which usages to load.
482 * @return question_usage_by_activity[] the usages that were loaded.
484 public function load_questions_usages_by_activity($qubaids) {
485 $records = $this->db
->get_recordset_sql("
490 quba.preferredbehaviour,
491 qa.id AS questionattemptid,
505 qas.id AS attemptstepid,
514 FROM {question_usages} quba
515 LEFT JOIN {question_attempts} qa ON qa.questionusageid = quba.id
516 LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
517 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
520 quba.id {$qubaids->usage_id_in()}
526 ", $qubaids->usage_id_in_params());
528 if (!$records->valid()) {
529 throw new coding_exception('Failed to load questions_usages_by_activity for qubaid_condition :' . $qubaids);
534 $record = $records->current();
535 $qubas[$record->qubaid
] = question_usage_by_activity
::load_from_records($records, $record->qubaid
);
536 } while ($records->valid());
544 * Load information about the latest state of each question from the database.
546 * This method may be called publicly.
548 * @param qubaid_condition $qubaids used to restrict which usages are included
549 * in the query. See {@link qubaid_condition}.
550 * @param array $slots A list of slots for the questions you want to know about.
551 * @param string|null $fields
552 * @return array of records. See the SQL in this function to see the fields available.
554 public function load_questions_usages_latest_steps(qubaid_condition
$qubaids, $slots, $fields = null) {
555 list($slottest, $params) = $this->db
->get_in_or_equal($slots, SQL_PARAMS_NAMED
, 'slot');
557 if ($fields === null) {
559 qa.id AS questionattemptid,
573 qas.id AS attemptstepid,
582 $records = $this->db
->get_records_sql("
586 FROM {$qubaids->from_question_attempts('qa')}
587 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
588 AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
591 {$qubaids->where()} AND
593 ", $params +
$qubaids->from_where_params());
599 * Load summary information about the state of each question in a group of
600 * attempts. This is used, for example, by the quiz manual grading report,
601 * to show how many attempts at each question need to be graded.
603 * This method may be called publicly.
605 * @param qubaid_condition $qubaids used to restrict which usages are included
606 * in the query. See {@link qubaid_condition}.
607 * @param array $slots A list of slots for the questions you want to konw about.
608 * @return array The array keys are slot,qestionid. The values are objects with
609 * fields $slot, $questionid, $inprogress, $name, $needsgrading, $autograded,
610 * $manuallygraded and $all.
612 public function load_questions_usages_question_state_summary(
613 qubaid_condition
$qubaids, $slots) {
614 list($slottest, $params) = $this->db
->get_in_or_equal($slots, SQL_PARAMS_NAMED
, 'slot');
616 $rs = $this->db
->get_recordset_sql("
622 {$this->full_states_to_summary_state_sql()}
624 COUNT(1) AS numattempts
626 FROM {$qubaids->from_question_attempts('qa')}
627 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
628 AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
629 JOIN {question} q ON q.id = qa.questionid
632 {$qubaids->where()} AND
641 {$this->full_states_to_summary_state_sql()}
649 ", $params +
$qubaids->from_where_params());
652 foreach ($rs as $row) {
653 $index = $row->slot
. ',' . $row->questionid
;
655 if (!array_key_exists($index, $results)) {
656 $res = new stdClass();
657 $res->slot
= $row->slot
;
658 $res->questionid
= $row->questionid
;
659 $res->name
= $row->name
;
660 $res->inprogress
= 0;
661 $res->needsgrading
= 0;
662 $res->autograded
= 0;
663 $res->manuallygraded
= 0;
665 $results[$index] = $res;
668 $results[$index]->{$row->summarystate
} = $row->numattempts
;
669 $results[$index]->all +
= $row->numattempts
;
677 * Get a list of usage ids where the question with slot $slot, and optionally
678 * also with question id $questionid, is in summary state $summarystate. Also
679 * return the total count of such states.
681 * Only a subset of the ids can be returned by using $orderby, $limitfrom and
682 * $limitnum. A special value 'random' can be passed as $orderby, in which case
683 * $limitfrom is ignored.
685 * This method may be called publicly.
687 * @param qubaid_condition $qubaids used to restrict which usages are included
688 * in the query. See {@link qubaid_condition}.
689 * @param int $slot The slot for the questions you want to konw about.
690 * @param int $questionid (optional) Only return attempts that were of this specific question.
691 * @param string $summarystate the summary state of interest, or 'all'.
692 * @param string $orderby the column to order by.
693 * @param array $params any params required by any of the SQL fragments.
694 * @param int $limitfrom implements paging of the results.
695 * Ignored if $orderby = random or $limitnum is null.
696 * @param int $limitnum implements paging of the results. null = all.
697 * @return array with two elements, an array of usage ids, and a count of the total number.
699 public function load_questions_usages_where_question_in_state(
700 qubaid_condition
$qubaids, $summarystate, $slot, $questionid = null,
701 $orderby = 'random', $params = array(), $limitfrom = 0, $limitnum = null) {
705 $extrawhere .= ' AND qa.questionid = :questionid';
706 $params['questionid'] = $questionid;
708 if ($summarystate != 'all') {
709 list($test, $sparams) = $this->in_summary_state_test($summarystate);
710 $extrawhere .= ' AND qas.state ' . $test;
714 if ($orderby == 'random') {
716 } else if ($orderby) {
717 $sqlorderby = 'ORDER BY ' . $orderby;
722 // We always want the total count, as well as the partcular list of ids
723 // based on the paging and sort order. Because the list of ids is never
724 // going to be too ridiculously long. My worst-case scenario is
725 // 10,000 students in the course, each doing 5 quiz attempts. That
726 // is a 50,000 element int => int array, which PHP seems to use 5MB
727 // memory to store on a 64 bit server.
728 $qubaidswhere = $qubaids->where(); // Must call this before params.
729 $params +
= $qubaids->from_where_params();
730 $params['slot'] = $slot;
732 $qubaids = $this->db
->get_records_sql_menu("
737 FROM {$qubaids->from_question_attempts('qa')}
738 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
739 AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
740 JOIN {question} q ON q.id = qa.questionid
750 $qubaids = array_keys($qubaids);
751 $count = count($qubaids);
753 if ($orderby == 'random') {
758 if (!is_null($limitnum)) {
759 $qubaids = array_slice($qubaids, $limitfrom, $limitnum);
762 return array($qubaids, $count);
766 * Load the average mark, and number of attempts, for each slot in a set of
769 * This method may be called publicly.
771 * @param qubaid_condition $qubaids used to restrict which usages are included
772 * in the query. See {@link qubaid_condition}.
773 * @param array $slots if null, load info for all quesitions, otherwise only
774 * load the averages for the specified questions.
775 * @return array of objects with fields ->slot, ->averagefraction and ->numaveraged.
777 public function load_average_marks(qubaid_condition
$qubaids, $slots = null) {
778 if (!empty($slots)) {
779 list($slottest, $slotsparams) = $this->db
->get_in_or_equal(
780 $slots, SQL_PARAMS_NAMED
, 'slot');
781 $slotwhere = " AND qa.slot {$slottest}";
784 $slotsparams = array();
787 list($statetest, $stateparams) = $this->db
->get_in_or_equal(array(
788 (string) question_state
::$gaveup,
789 (string) question_state
::$gradedwrong,
790 (string) question_state
::$gradedpartial,
791 (string) question_state
::$gradedright,
792 (string) question_state
::$mangaveup,
793 (string) question_state
::$mangrwrong,
794 (string) question_state
::$mangrpartial,
795 (string) question_state
::$mangrright), SQL_PARAMS_NAMED
, 'st');
797 return $this->db
->get_records_sql("
800 AVG(COALESCE(qas.fraction, 0)) AS averagefraction,
801 COUNT(1) AS numaveraged
803 FROM {$qubaids->from_question_attempts('qa')}
804 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
805 AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
810 AND qas.state $statetest
815 ", $slotsparams +
$stateparams +
$qubaids->from_where_params());
819 * Load all the attempts at a given queston from a set of question_usages.
822 * This method may be called publicly.
824 * @param int $questionid the question to load all the attempts fors.
825 * @param qubaid_condition $qubaids used to restrict which usages are included
826 * in the query. See {@link qubaid_condition}.
827 * @return question_attempt[] array of question_attempts that were loaded.
829 public function load_attempts_at_question($questionid, qubaid_condition
$qubaids) {
833 quba.preferredbehaviour,
834 qa.id AS questionattemptid,
848 qas.id AS attemptstepid,
857 FROM {$qubaids->from_question_attempts('qa')}
858 JOIN {question_usages} quba ON quba.id = qa.questionusageid
859 LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
860 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
863 {$qubaids->where()} AND
864 qa.questionid = :questionid
871 // For qubaid_list must call this after calling methods that generate sql.
872 $params = $qubaids->from_where_params();
873 $params['questionid'] = $questionid;
875 $records = $this->db
->get_recordset_sql($sql, $params);
877 $questionattempts = array();
878 while ($records->valid()) {
879 $record = $records->current();
880 $questionattempts[$record->questionattemptid
] =
881 question_attempt
::load_from_records($records,
882 $record->questionattemptid
, new question_usage_null_observer(),
883 $record->preferredbehaviour
);
887 return $questionattempts;
891 * Update a question_usages row to refect any changes in a usage (but not
892 * any of its question_attempts.
894 * You should not call this method directly. You should use
895 * @link question_engine::save_questions_usage_by_activity()}.
897 * @param question_usage_by_activity $quba the usage that has changed.
899 public function update_questions_usage_by_activity(question_usage_by_activity
$quba) {
900 $record = new stdClass();
901 $record->id
= $quba->get_id();
902 $record->contextid
= $quba->get_owning_context()->id
;
903 $record->component
= $quba->get_owning_component();
904 $record->preferredbehaviour
= $quba->get_preferred_behaviour();
906 $this->db
->update_record('question_usages', $record);
910 * Update a question_attempts row to refect any changes in a question_attempt
911 * (but not any of its steps).
913 * You should not call this method directly. You should use
914 * @link question_engine::save_questions_usage_by_activity()}.
916 * @param question_attempt $qa the question attempt that has changed.
918 public function update_question_attempt(question_attempt
$qa) {
919 $record = new stdClass();
920 $record->id
= $qa->get_database_id();
921 $record->slot
= $qa->get_slot();
922 $record->variant
= $qa->get_variant();
923 $record->maxmark
= $qa->get_max_mark();
924 $record->minfraction
= $qa->get_min_fraction();
925 $record->maxfraction
= $qa->get_max_fraction();
926 $record->flagged
= $qa->is_flagged();
927 $record->questionsummary
= $qa->get_question_summary();
928 $record->rightanswer
= $qa->get_right_answer_summary();
929 $record->responsesummary
= $qa->get_response_summary();
930 $record->timemodified
= time();
932 $this->db
->update_record('question_attempts', $record);
936 * Delete a question_usage_by_activity and all its associated
938 * You should not call this method directly. You should use
939 * @link question_engine::delete_questions_usage_by_activities()}.
941 * {@link question_attempts} and {@link question_attempt_steps} from the
943 * @param qubaid_condition $qubaids identifies which question useages to delete.
945 public function delete_questions_usage_by_activities(qubaid_condition
$qubaids) {
946 $where = "qa.questionusageid {$qubaids->usage_id_in()}";
947 $params = $qubaids->usage_id_in_params();
949 $contextids = $this->db
->get_records_sql_menu("
950 SELECT DISTINCT contextid, 1
951 FROM {question_usages}
952 WHERE id {$qubaids->usage_id_in()}", $qubaids->usage_id_in_params());
953 foreach ($contextids as $contextid => $notused) {
954 $this->delete_response_files($contextid, "IN (
956 FROM {question_attempts} qa
957 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
958 WHERE $where)", $params);
961 if ($this->db
->get_dbfamily() == 'mysql') {
962 $this->delete_usage_records_for_mysql($qubaids);
966 $this->db
->delete_records_select('question_attempt_step_data', "attemptstepid IN (
968 FROM {question_attempts} qa
969 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
970 WHERE $where)", $params);
972 $this->db
->delete_records_select('question_attempt_steps', "questionattemptid IN (
974 FROM {question_attempts} qa
975 WHERE $where)", $params);
977 $this->db
->delete_records_select('question_attempts',
978 "{question_attempts}.questionusageid {$qubaids->usage_id_in()}",
979 $qubaids->usage_id_in_params());
981 $this->db
->delete_records_select('question_usages',
982 "{question_usages}.id {$qubaids->usage_id_in()}", $qubaids->usage_id_in_params());
986 * This function is a work-around for poor MySQL performance with
987 * DELETE FROM x WHERE id IN (SELECT ...). We have to use a non-standard
988 * syntax to get good performance. See MDL-29520.
989 * @param qubaid_condition $qubaids identifies which question useages to delete.
991 protected function delete_usage_records_for_mysql(qubaid_condition
$qubaids) {
992 // Get the list of question attempts to delete and delete them in chunks.
993 $allids = $this->db
->get_records_sql_menu("
994 SELECT DISTINCT id, id AS id2
995 FROM {question_usages}
996 WHERE id " . $qubaids->usage_id_in(),
997 $qubaids->usage_id_in_params());
999 foreach (array_chunk($allids, 1000) as $todelete) {
1000 list($idsql, $idparams) = $this->db
->get_in_or_equal($todelete);
1001 $this->db
->execute('
1002 DELETE qu, qa, qas, qasd
1003 FROM {question_usages} qu
1004 JOIN {question_attempts} qa ON qa.questionusageid = qu.id
1005 LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
1006 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
1007 WHERE qu.id ' . $idsql,
1013 * Delete all the steps for a question attempt.
1015 * Private method, only for use by other parts of the question engine.
1017 * @param int $qaids question_attempt id.
1018 * @param context $context the context that the $quba belongs to.
1020 public function delete_steps($stepids, $context) {
1021 if (empty($stepids)) {
1024 list($test, $params) = $this->db
->get_in_or_equal($stepids, SQL_PARAMS_NAMED
);
1026 $this->delete_response_files($context->id
, $test, $params);
1028 $this->db
->delete_records_select('question_attempt_step_data',
1029 "attemptstepid {$test}", $params);
1030 $this->db
->delete_records_select('question_attempt_steps',
1031 "id {$test}", $params);
1035 * Delete all the files belonging to the response variables in the gives
1036 * question attempt steps.
1037 * @param int $contextid the context these attempts belong to.
1038 * @param string $itemidstest a bit of SQL that can be used in a
1039 * WHERE itemid $itemidstest clause. Must use named params.
1040 * @param array $params any query parameters used in $itemidstest.
1042 protected function delete_response_files($contextid, $itemidstest, $params) {
1043 $fs = get_file_storage();
1044 foreach (question_engine
::get_all_response_file_areas() as $filearea) {
1045 $fs->delete_area_files_select($contextid, 'question', $filearea,
1046 $itemidstest, $params);
1051 * Delete all the previews for a given question.
1053 * Private method, only for use by other parts of the question engine.
1055 * @param int $questionid question id.
1057 public function delete_previews($questionid) {
1058 $previews = $this->db
->get_records_sql_menu("
1059 SELECT DISTINCT quba.id, 1
1060 FROM {question_usages} quba
1061 JOIN {question_attempts} qa ON qa.questionusageid = quba.id
1062 WHERE quba.component = 'core_question_preview' AND
1063 qa.questionid = ?", array($questionid));
1064 if (empty($previews)) {
1067 $this->delete_questions_usage_by_activities(new qubaid_list($previews));
1071 * Update the flagged state of a question in the database.
1073 * You should call {@link question_engine::update_flag()()}
1074 * rather than calling this method directly.
1076 * @param int $qubaid the question usage id.
1077 * @param int $questionid the question id.
1078 * @param int $sessionid the question_attempt id.
1079 * @param bool $newstate the new state of the flag. true = flagged.
1081 public function update_question_attempt_flag($qubaid, $questionid, $qaid, $slot, $newstate) {
1082 if (!$this->db
->record_exists('question_attempts', array('id' => $qaid,
1083 'questionusageid' => $qubaid, 'questionid' => $questionid, 'slot' => $slot))) {
1084 throw new moodle_exception('errorsavingflags', 'question');
1087 $this->db
->set_field('question_attempts', 'flagged', $newstate, array('id' => $qaid));
1091 * Get all the WHEN 'x' THEN 'y' terms needed to convert the question_attempt_steps.state
1092 * column to a summary state. Use this like
1093 * CASE qas.state {$this->full_states_to_summary_state_sql()} END AS summarystate,
1094 * @param string SQL fragment.
1096 protected function full_states_to_summary_state_sql() {
1098 foreach (question_state
::get_all() as $state) {
1099 $sql .= "WHEN '{$state}' THEN '{$state->get_summary_state()}'\n";
1105 * Get the SQL needed to test that question_attempt_steps.state is in a
1106 * state corresponding to $summarystate.
1108 * This method may be called publicly.
1110 * @param string $summarystate one of
1111 * inprogress, needsgrading, manuallygraded or autograded
1112 * @param bool $equal if false, do a NOT IN test. Default true.
1113 * @return string SQL fragment.
1115 public function in_summary_state_test($summarystate, $equal = true, $prefix = 'summarystates') {
1116 $states = question_state
::get_all_for_summary_state($summarystate);
1117 return $this->db
->get_in_or_equal(array_map('strval', $states),
1118 SQL_PARAMS_NAMED
, $prefix, $equal);
1122 * Change the maxmark for the question_attempt with number in usage $slot
1123 * for all the specified question_attempts.
1125 * You should call {@link question_engine::set_max_mark_in_attempts()}
1126 * rather than calling this method directly.
1128 * @param qubaid_condition $qubaids Selects which usages are updated.
1129 * @param int $slot the number is usage to affect.
1130 * @param number $newmaxmark the new max mark to set.
1132 public function set_max_mark_in_attempts(qubaid_condition
$qubaids, $slot, $newmaxmark) {
1133 if ($this->db
->get_dbfamily() == 'mysql') {
1134 // MySQL's query optimiser completely fails to cope with the
1135 // set_field_select call below, so we have to give it a clue. See MDL-32616.
1136 // TODO MDL-29589 encapsulate this MySQL-specific code with a $DB method.
1137 $this->db
->execute("
1138 UPDATE " . $qubaids->from_question_attempts('qa') . "
1139 SET qa.maxmark = :newmaxmark
1140 WHERE " . $qubaids->where() . "
1142 ", $qubaids->from_where_params() +
array('newmaxmark' => $newmaxmark, 'slot' => $slot));
1146 // Normal databases.
1147 $this->db
->set_field_select('question_attempts', 'maxmark', $newmaxmark,
1148 "questionusageid {$qubaids->usage_id_in()} AND slot = :slot",
1149 $qubaids->usage_id_in_params() +
array('slot' => $slot));
1153 * Return a subquery that computes the sum of the marks for all the questions
1154 * in a usage. Which useage to compute the sum for is controlled bu the $qubaid
1157 * See {@link quiz_update_all_attempt_sumgrades()} for an example of the usage of
1160 * This method may be called publicly.
1162 * @param string $qubaid SQL fragment that controls which usage is summed.
1163 * This will normally be the name of a column in the outer query. Not that this
1164 * SQL fragment must not contain any placeholders.
1165 * @return string SQL code for the subquery.
1167 public function sum_usage_marks_subquery($qubaid) {
1168 // To explain the COALESCE in the following SQL: SUM(lots of NULLs) gives
1169 // NULL, while SUM(one 0.0 and lots of NULLS) gives 0.0. We don't want that.
1170 // We always want to return a number, so the COALESCE is there to turn the
1171 // NULL total into a 0.
1172 return "SELECT COALESCE(SUM(qa.maxmark * qas.fraction), 0)
1173 FROM {question_attempts} qa
1174 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
1175 AND qas.sequencenumber = (
1176 SELECT MAX(summarks_qas.sequencenumber)
1177 FROM {question_attempt_steps} summarks_qas
1178 WHERE summarks_qas.questionattemptid = qa.id
1180 WHERE qa.questionusageid = $qubaid
1182 WHEN qas.state = 'needsgrading' AND qa.maxmark > 0 THEN 1
1188 * Get a subquery that returns the latest step of every qa in some qubas.
1189 * Currently, this is only used by the quiz reports. See
1190 * {@link quiz_attempts_report_table::add_latest_state_join()}.
1192 * This method may be called publicly.
1194 * @param string $alias alias to use for this inline-view.
1195 * @param qubaid_condition $qubaids restriction on which question_usages we
1196 * are interested in. This is important for performance.
1197 * @return array with two elements, the SQL fragment and any params requried.
1199 public function question_attempt_latest_state_view($alias, qubaid_condition
$qubaids) {
1201 SELECT {$alias}qa.id AS questionattemptid,
1202 {$alias}qa.questionusageid,
1204 {$alias}qa.behaviour,
1205 {$alias}qa.questionid,
1208 {$alias}qa.minfraction,
1209 {$alias}qa.maxfraction,
1211 {$alias}qa.questionsummary,
1212 {$alias}qa.rightanswer,
1213 {$alias}qa.responsesummary,
1214 {$alias}qa.timemodified,
1215 {$alias}qas.id AS attemptstepid,
1216 {$alias}qas.sequencenumber,
1218 {$alias}qas.fraction,
1219 {$alias}qas.timecreated,
1222 FROM {$qubaids->from_question_attempts($alias . 'qa')}
1223 JOIN {question_attempt_steps} {$alias}qas ON {$alias}qas.questionattemptid = {$alias}qa.id
1224 AND {$alias}qas.sequencenumber = {$this->latest_step_for_qa_subquery($alias . 'qa.id')}
1225 WHERE {$qubaids->where()}
1226 ) {$alias}", $qubaids->from_where_params());
1229 protected function latest_step_for_qa_subquery($questionattemptid = 'qa.id') {
1231 SELECT MAX(sequencenumber)
1232 FROM {question_attempt_steps}
1233 WHERE questionattemptid = $questionattemptid
1238 * Are any of these questions are currently in use?
1240 * You should call {@link question_engine::questions_in_use()}
1241 * rather than calling this method directly.
1243 * @param array $questionids of question ids.
1244 * @param qubaid_condition $qubaids ids of the usages to consider.
1245 * @return bool whether any of these questions are being used by any of
1248 public function questions_in_use(array $questionids, qubaid_condition
$qubaids) {
1249 list($test, $params) = $this->db
->get_in_or_equal($questionids);
1250 return $this->db
->record_exists_select('question_attempts',
1251 'questionid ' . $test . ' AND questionusageid ' .
1252 $qubaids->usage_id_in(), $params +
$qubaids->usage_id_in_params());
1256 * Get the number of times each variant has been used for each question in a list
1257 * in a set of usages.
1258 * @param array $questionids of question ids.
1259 * @param qubaid_condition $qubaids ids of the usages to consider.
1260 * @return array questionid => variant number => num uses.
1262 public function load_used_variants(array $questionids, qubaid_condition
$qubaids) {
1263 list($test, $params) = $this->db
->get_in_or_equal($questionids, SQL_PARAMS_NAMED
, 'qid');
1264 $recordset = $this->db
->get_recordset_sql("
1265 SELECT qa.questionid, qa.variant, COUNT(1) AS usescount
1266 FROM " . $qubaids->from_question_attempts('qa') . "
1267 WHERE qa.questionid $test
1268 AND " . $qubaids->where() . "
1269 GROUP BY qa.questionid, qa.variant
1270 ORDER BY COUNT(1) ASC
1271 ", $params +
$qubaids->from_where_params());
1273 $usedvariants = array_combine($questionids, array_fill(0, count($questionids), array()));
1274 foreach ($recordset as $row) {
1275 $usedvariants[$row->questionid
][$row->variant
] = $row->usescount
;
1277 $recordset->close();
1278 return $usedvariants;
1284 * Implementation of the unit of work pattern for the question engine.
1286 * See http://martinfowler.com/eaaCatalog/unitOfWork.html. This tracks all the
1287 * changes to a {@link question_usage_by_activity}, and its constituent parts,
1288 * so that the changes can be saved to the database when {@link save()} is called.
1290 * @copyright 2009 The Open University
1291 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1293 class question_engine_unit_of_work
implements question_usage_observer
{
1294 /** @var question_usage_by_activity the usage being tracked. */
1297 /** @var boolean whether any of the fields of the usage have been changed. */
1298 protected $modified = false;
1301 * @var array list of slot => {@link question_attempt}s that
1302 * have been added to the usage.
1304 protected $attemptsadded = array();
1307 * @var array list of slot => {@link question_attempt}s that
1308 * were already in the usage, and which have been modified.
1310 protected $attemptsmodified = array();
1313 * @var array of array(question_attempt_step, question_attempt id, seq number)
1314 * of steps that have been added to question attempts in this usage.
1316 protected $stepsadded = array();
1319 * @var array of array(question_attempt_step, question_attempt id, seq number)
1320 * of steps that have been modified in their attempt.
1322 protected $stepsmodified = array();
1325 * @var array list of question_attempt_step.id => question_attempt_step of steps
1326 * that were previously stored in the database, but which are no longer required.
1328 protected $stepsdeleted = array();
1331 * @var array int slot => string name => question_attempt.
1333 protected $metadataadded = array();
1336 * @var array int slot => string name => question_attempt.
1338 protected $metadatamodified = array();
1342 * @param question_usage_by_activity $quba the usage to track.
1344 public function __construct(question_usage_by_activity
$quba) {
1345 $this->quba
= $quba;
1348 public function notify_modified() {
1349 $this->modified
= true;
1352 public function notify_attempt_added(question_attempt
$qa) {
1353 $this->attemptsadded
[$qa->get_slot()] = $qa;
1356 public function notify_attempt_modified(question_attempt
$qa) {
1357 $slot = $qa->get_slot();
1358 if (!array_key_exists($slot, $this->attemptsadded
)) {
1359 $this->attemptsmodified
[$slot] = $qa;
1363 public function notify_attempt_moved(question_attempt
$qa, $oldslot) {
1364 $newslot = $qa->get_slot();
1366 if (array_key_exists($oldslot, $this->attemptsadded
)) {
1367 unset($this->attemptsadded
[$oldslot]);
1368 $this->attemptsadded
[$newslot] = $qa;
1372 if (array_key_exists($oldslot, $this->attemptsmodified
)) {
1373 unset($this->attemptsmodified
[$oldslot]);
1375 $this->attemptsmodified
[$newslot] = $qa;
1377 if (array_key_exists($oldslot, $this->metadataadded
)) {
1378 $this->metadataadded
[$newslot] = $this->metadataadded
[$oldslot];
1379 unset($this->metadataadded
[$oldslot]);
1381 if (array_key_exists($oldslot, $this->metadatamodified
)) {
1382 $this->metadatamodified
[$newslot] = $this->metadatamodified
[$oldslot];
1383 unset($this->metadatamodified
[$oldslot]);
1387 public function notify_step_added(question_attempt_step
$step, question_attempt
$qa, $seq) {
1388 if (array_key_exists($qa->get_slot(), $this->attemptsadded
)) {
1392 if (($key = $this->is_step_added($step)) !== false) {
1396 if (($key = $this->is_step_modified($step)) !== false) {
1397 throw new coding_exception('Cannot add a step that has already been modified.');
1400 if (($key = $this->is_step_deleted($step)) !== false) {
1401 unset($this->stepsdeleted
[$step->get_id()]);
1402 $this->stepsmodified
[] = array($step, $qa->get_database_id(), $seq);
1406 $stepid = $step->get_id();
1408 if (array_key_exists($stepid, $this->stepsdeleted
)) {
1409 unset($this->stepsdeleted
[$stepid]);
1411 $this->stepsmodified
[] = array($step, $qa->get_database_id(), $seq);
1414 $this->stepsadded
[] = array($step, $qa->get_database_id(), $seq);
1418 public function notify_step_modified(question_attempt_step
$step, question_attempt
$qa, $seq) {
1419 if (array_key_exists($qa->get_slot(), $this->attemptsadded
)) {
1423 if (($key = $this->is_step_added($step)) !== false) {
1427 if (($key = $this->is_step_deleted($step)) !== false) {
1428 throw new coding_exception('Cannot modify a step after it has been deleted.');
1431 $stepid = $step->get_id();
1432 if (empty($stepid)) {
1433 throw new coding_exception('Cannot modify a step that has never been stored in the database.');
1436 $this->stepsmodified
[] = array($step, $qa->get_database_id(), $seq);
1439 public function notify_step_deleted(question_attempt_step
$step, question_attempt
$qa) {
1440 if (array_key_exists($qa->get_slot(), $this->attemptsadded
)) {
1444 if (($key = $this->is_step_added($step)) !== false) {
1445 unset($this->stepsadded
[$key]);
1449 if (($key = $this->is_step_modified($step)) !== false) {
1450 unset($this->stepsmodified
[$key]);
1453 $stepid = $step->get_id();
1454 if (empty($stepid)) {
1455 return; // Was never in the database.
1458 $this->stepsdeleted
[$stepid] = $step;
1461 public function notify_metadata_added(question_attempt
$qa, $name) {
1462 if (array_key_exists($qa->get_slot(), $this->attemptsadded
)) {
1466 if ($this->is_step_added($qa->get_step(0)) !== false) {
1470 if (isset($this->metadataadded
[$qa->get_slot()][$name])) {
1474 $this->metadataadded
[$qa->get_slot()][$name] = $qa;
1477 public function notify_metadata_modified(question_attempt
$qa, $name) {
1478 if (array_key_exists($qa->get_slot(), $this->attemptsadded
)) {
1482 if ($this->is_step_added($qa->get_step(0)) !== false) {
1486 if (isset($this->metadataadded
[$qa->get_slot()][$name])) {
1490 if (isset($this->metadatamodified
[$qa->get_slot()][$name])) {
1494 $this->metadatamodified
[$qa->get_slot()][$name] = $qa;
1498 * @param question_attempt_step $step a step
1499 * @return int|false if the step is in the list of steps to be added, return
1500 * the key, otherwise return false.
1502 protected function is_step_added(question_attempt_step
$step) {
1503 foreach ($this->stepsadded
as $key => $data) {
1504 list($addedstep, $qaid, $seq) = $data;
1505 if ($addedstep === $step) {
1513 * @param question_attempt_step $step a step
1514 * @return int|false if the step is in the list of steps to be modified, return
1515 * the key, otherwise return false.
1517 protected function is_step_modified(question_attempt_step
$step) {
1518 foreach ($this->stepsmodified
as $key => $data) {
1519 list($modifiedstep, $qaid, $seq) = $data;
1520 if ($modifiedstep === $step) {
1528 * @param question_attempt_step $step a step
1529 * @return bool whether the step is in the list of steps to be deleted.
1531 protected function is_step_deleted(question_attempt_step
$step) {
1532 foreach ($this->stepsdeleted
as $deletedstep) {
1533 if ($deletedstep === $step) {
1541 * Write all the changes we have recorded to the database.
1542 * @param question_engine_data_mapper $dm the mapper to use to update the database.
1544 public function save(question_engine_data_mapper
$dm) {
1545 $dm->delete_steps(array_keys($this->stepsdeleted
), $this->quba
->get_owning_context());
1547 // Initially an array of array of question_attempt_step_objects.
1548 // Built as a nested array for efficiency, then flattened.
1549 $stepdata = array();
1551 foreach ($this->stepsmodified
as $stepinfo) {
1552 list($step, $questionattemptid, $seq) = $stepinfo;
1553 $stepdata[] = $dm->update_question_attempt_step(
1554 $step, $questionattemptid, $seq, $this->quba
->get_owning_context());
1557 foreach ($this->stepsadded
as $stepinfo) {
1558 list($step, $questionattemptid, $seq) = $stepinfo;
1559 $stepdata[] = $dm->insert_question_attempt_step(
1560 $step, $questionattemptid, $seq, $this->quba
->get_owning_context());
1563 foreach ($this->attemptsmodified
as $qa) {
1564 $dm->update_question_attempt($qa);
1567 foreach ($this->attemptsadded
as $qa) {
1568 $stepdata[] = $dm->insert_question_attempt(
1569 $qa, $this->quba
->get_owning_context());
1572 foreach ($this->metadataadded
as $info) {
1574 $stepdata[] = $dm->insert_question_attempt_metadata($qa, array_keys($info));
1577 foreach ($this->metadatamodified
as $info) {
1579 $stepdata[] = $dm->update_question_attempt_metadata($qa, array_keys($info));
1582 if ($this->modified
) {
1583 $dm->update_questions_usage_by_activity($this->quba
);
1587 $dm->insert_all_step_data(call_user_func_array('array_merge', $stepdata));
1590 $this->stepsdeleted
= array();
1591 $this->stepsmodified
= array();
1592 $this->stepsadded
= array();
1593 $this->attemptsdeleted
= array();
1594 $this->attemptsadded
= array();
1595 $this->attemptsmodified
= array();
1596 $this->modified
= false;
1602 * The interface implemented by {@link question_file_saver} and {@link question_file_loader}.
1604 * @copyright 2012 The Open University
1605 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1607 interface question_response_files
{
1609 * Get the files that were submitted.
1610 * @return array of stored_files objects.
1612 public function get_files();
1617 * This class represents the promise to save some files from a particular draft
1618 * file area into a particular file area. It is used beause the necessary
1619 * information about what to save is to hand in the
1620 * {@link question_attempt::process_response_files()} method, but we don't know
1621 * if this question attempt will actually be saved in the database until later,
1622 * when the {@link question_engine_unit_of_work} is saved, if it is.
1624 * @copyright 2011 The Open University
1625 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1627 class question_file_saver
implements question_response_files
{
1628 /** @var int the id of the draft file area to save files from. */
1629 protected $draftitemid;
1630 /** @var string the owning component name. */
1631 protected $component;
1632 /** @var string the file area name. */
1633 protected $filearea;
1636 * @var string the value to store in the question_attempt_step_data to
1637 * represent these files.
1639 protected $value = null;
1643 * @param int $draftitemid the draft area to save the files from.
1644 * @param string $component the component for the file area to save into.
1645 * @param string $filearea the name of the file area to save into.
1647 public function __construct($draftitemid, $component, $filearea, $text = null) {
1648 $this->draftitemid
= $draftitemid;
1649 $this->component
= $component;
1650 $this->filearea
= $filearea;
1651 $this->value
= $this->compute_value($draftitemid, $text);
1655 * Compute the value that should be stored in the question_attempt_step_data
1656 * table. Contains a hash that (almost) uniquely encodes all the files.
1657 * @param int $draftitemid the draft file area itemid.
1658 * @param string $text optional content containing file links.
1660 protected function compute_value($draftitemid, $text) {
1663 $fs = get_file_storage();
1664 $usercontext = context_user
::instance($USER->id
);
1666 $files = $fs->get_area_files($usercontext->id
, 'user', 'draft',
1667 $draftitemid, 'sortorder, filepath, filename', false);
1670 foreach ($files as $file) {
1671 $string .= $file->get_filepath() . $file->get_filename() . '|' .
1672 $file->get_contenthash() . '|';
1674 $hash = md5($string);
1676 if (is_null($text)) {
1684 // We add the file hash so a simple string comparison will say if the
1685 // files have been changed. First strip off any existing file hash.
1687 $text = preg_replace('/\s*<!-- File hash: \w+ -->\s*$/', '', $text);
1688 $text = file_rewrite_urls_to_pluginfile($text, $draftitemid);
1690 $text .= '<!-- File hash: ' . $hash . ' -->';
1696 public function __toString() {
1697 return $this->value
;
1701 * Actually save the files.
1702 * @param integer $itemid the item id for the file area to save into.
1704 public function save_files($itemid, $context) {
1705 file_save_draft_area_files($this->draftitemid
, $context->id
,
1706 $this->component
, $this->filearea
, $itemid);
1710 * Get the files that were submitted.
1711 * @return array of stored_files objects.
1713 public function get_files() {
1716 $fs = get_file_storage();
1717 $usercontext = context_user
::instance($USER->id
);
1719 return $fs->get_area_files($usercontext->id
, 'user', 'draft',
1720 $this->draftitemid
, 'sortorder, filepath, filename', false);
1726 * This class is the mirror image of {@link question_file_saver}. It allows
1727 * files to be accessed again later (e.g. when re-grading) using that same
1728 * API as when doing the original grading.
1730 * @copyright 2012 The Open University
1731 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1733 class question_file_loader
implements question_response_files
{
1734 /** @var question_attempt_step the step that these files belong to. */
1737 /** @var string the field name for these files - which is used to construct the file area name. */
1741 * @var string the value to stored in the question_attempt_step_data to
1742 * represent these files.
1746 /** @var int the context id that the files belong to. */
1747 protected $contextid;
1751 * @param question_attempt_step $step the step that these files belong to.
1752 * @param string $name string the field name for these files - which is used to construct the file area name.
1753 * @param string $value the value to stored in the question_attempt_step_data to
1754 * represent these files.
1755 * @param int $contextid the context id that the files belong to.
1757 public function __construct(question_attempt_step
$step, $name, $value, $contextid) {
1758 $this->step
= $step;
1759 $this->name
= $name;
1760 $this->value
= $value;
1761 $this->contextid
= $contextid;
1764 public function __toString() {
1765 return $this->value
;
1769 * Get the files that were submitted.
1770 * @return array of stored_files objects.
1772 public function get_files() {
1773 return $this->step
->get_qt_files($this->name
, $this->contextid
);
1777 * Copy these files into a draft area, and return the corresponding
1778 * {@link question_file_saver} that can save them again.
1780 * This is used by {@link question_attempt::start_based_on()}, which is used
1781 * (for example) by the quizzes 'Each attempt builds on last' feature.
1783 * @return question_file_saver that can re-save these files again.
1785 public function get_question_file_saver() {
1787 // There are three possibilities here for what $value will look like:
1788 // 1) some HTML content followed by an MD5 hash in a HTML comment;
1789 // 2) a plain MD5 hash;
1790 // 3) or some real content, without any hash.
1791 // The problem is that 3) is ambiguous in the case where a student writes
1792 // a response that looks exactly like an MD5 hash. For attempts made now,
1793 // we avoid case 3) by always going for case 1) or 2) (except when the
1794 // response is blank. However, there may be case 3) data in the database
1795 // so we need to handle it as best we can.
1796 if (preg_match('/\s*<!-- File hash: [0-9a-zA-Z]{32} -->\s*$/', $this->value
)) {
1797 $value = preg_replace('/\s*<!-- File hash: [0-9a-zA-Z]{32} -->\s*$/', '', $this->value
);
1799 } else if (preg_match('/^[0-9a-zA-Z]{32}$/', $this->value
)) {
1803 $value = $this->value
;
1806 list($draftid, $text) = $this->step
->prepare_response_files_draft_itemid_with_text(
1807 $this->name
, $this->contextid
, $value);
1808 return new question_file_saver($draftid, 'question', 'response_' . $this->name
, $text);
1814 * This class represents a restriction on the set of question_usage ids to include
1815 * in a larger database query. Depending of the how you are going to restrict the
1816 * list of usages, construct an appropriate subclass.
1818 * If $qubaids is an instance of this class, example usage might be
1820 * SELECT qa.id, qa.maxmark
1821 * FROM $qubaids->from_question_attempts('qa')
1822 * WHERE $qubaids->where() AND qa.slot = 1
1824 * @copyright 2010 The Open University
1825 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1827 abstract class qubaid_condition
{
1830 * @return string the SQL that needs to go in the FROM clause when trying
1831 * to select records from the 'question_attempts' table based on the
1834 public abstract function from_question_attempts($alias);
1836 /** @return string the SQL that needs to go in the where clause. */
1837 public abstract function where();
1840 * @return the params needed by a query that uses
1841 * {@link from_question_attempts()} and {@link where()}.
1843 public abstract function from_where_params();
1846 * @return string SQL that can use used in a WHERE qubaid IN (...) query.
1847 * This method returns the "IN (...)" part.
1849 public abstract function usage_id_in();
1852 * @return the params needed by a query that uses {@link usage_id_in()}.
1854 public abstract function usage_id_in_params();
1857 * @return string 40-character hash code that uniquely identifies the combination of properties and class name of this qubaid
1860 public function get_hash_code() {
1861 return sha1(serialize($this));
1867 * This class represents a restriction on the set of question_usage ids to include
1868 * in a larger database query based on an explicit list of ids.
1870 * @copyright 2010 The Open University
1871 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1873 class qubaid_list
extends qubaid_condition
{
1874 /** @var array of ids. */
1876 protected $columntotest = null;
1881 * @param array $qubaids of question usage ids.
1883 public function __construct(array $qubaids) {
1884 $this->qubaids
= $qubaids;
1887 public function from_question_attempts($alias) {
1888 $this->columntotest
= $alias . '.questionusageid';
1889 return '{question_attempts} ' . $alias;
1892 public function where() {
1895 if (is_null($this->columntotest
)) {
1896 throw new coding_exception('Must call from_question_attempts before where().');
1898 if (empty($this->qubaids
)) {
1899 $this->params
= array();
1903 return $this->columntotest
. ' ' . $this->usage_id_in();
1906 public function from_where_params() {
1907 return $this->params
;
1910 public function usage_id_in() {
1913 if (empty($this->qubaids
)) {
1914 $this->params
= array();
1917 list($where, $this->params
) = $DB->get_in_or_equal(
1918 $this->qubaids
, SQL_PARAMS_NAMED
, 'qubaid');
1922 public function usage_id_in_params() {
1923 return $this->params
;
1929 * This class represents a restriction on the set of question_usage ids to include
1930 * in a larger database query based on JOINing to some other tables.
1932 * The general form of the query is something like
1934 * SELECT qa.id, qa.maxmark
1936 * JOIN {question_attempts} qa ON qa.questionusageid = $usageidcolumn
1937 * WHERE $where AND qa.slot = 1
1939 * where $from, $usageidcolumn and $where are the arguments to the constructor.
1941 * @copyright 2010 The Open University
1942 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1944 class qubaid_join
extends qubaid_condition
{
1946 public $usageidcolumn;
1951 * Constructor. The meaning of the arguments is explained in the class comment.
1952 * @param string $from SQL fragemnt to go in the FROM clause.
1953 * @param string $usageidcolumn the column in $from that should be
1954 * made equal to the usageid column in the JOIN clause.
1955 * @param string $where SQL fragment to go in the where clause.
1956 * @param array $params required by the SQL. You must use named parameters.
1958 public function __construct($from, $usageidcolumn, $where = '', $params = array()) {
1959 $this->from
= $from;
1960 $this->usageidcolumn
= $usageidcolumn;
1961 $this->params
= $params;
1962 if (empty($where)) {
1965 $this->where
= $where;
1968 public function from_question_attempts($alias) {
1969 return "{$this->from}
1970 JOIN {question_attempts} {$alias} ON " .
1971 "{$alias}.questionusageid = $this->usageidcolumn";
1974 public function where() {
1975 return $this->where
;
1978 public function from_where_params() {
1979 return $this->params
;
1982 public function usage_id_in() {
1983 return "IN (SELECT {$this->usageidcolumn} FROM {$this->from} WHERE {$this->where})";
1986 public function usage_id_in_params() {
1987 return $this->params
;