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 * A note for future reference. This code is pretty efficient but there are some
29 * potential optimisations that could be contemplated, at the cost of making the
32 * 1. (This is probably not worth doing.) In the unit-of-work save method, we
33 * could get all the ids for steps due to be deleted or modified,
34 * and delete all the question_attempt_step_data for all of those steps in one
35 * query. That would save one DB query for each ->stepsupdated. However that number
36 * is 0 except when re-grading, and when regrading, there are many more inserts
37 * into question_attempt_step_data than deletes, so it is really hardly worth it.
39 * @package core_question
40 * @copyright 2009 The Open University
41 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
45 defined('MOODLE_INTERNAL') ||
die();
49 * This class controls the loading and saving of question engine data to and from
52 * @copyright 2009 The Open University
53 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
55 class question_engine_data_mapper
{
57 * @var moodle_database normally points to global $DB, but I prefer not to
58 * use globals if I can help it.
63 * @param moodle_database $db a database connectoin. Defaults to global $DB.
65 public function __construct(moodle_database
$db = null) {
75 * Store an entire {@link question_usage_by_activity} in the database,
76 * including all the question_attempts that comprise it.
78 * You should not call this method directly. You should use
79 * @link question_engine::save_questions_usage_by_activity()}.
81 * @param question_usage_by_activity $quba the usage to store.
83 public function insert_questions_usage_by_activity(question_usage_by_activity
$quba) {
84 $record = new stdClass();
85 $record->contextid
= $quba->get_owning_context()->id
;
86 $record->component
= $quba->get_owning_component();
87 $record->preferredbehaviour
= $quba->get_preferred_behaviour();
89 $newid = $this->db
->insert_record('question_usages', $record);
90 $quba->set_id_from_database($newid);
92 // Initially an array of array of question_attempt_step_objects.
93 // Built as a nested array for efficiency, then flattened.
96 foreach ($quba->get_attempt_iterator() as $qa) {
97 $stepdata[] = $this->insert_question_attempt($qa, $quba->get_owning_context());
100 $this->insert_all_step_data($this->combine_step_data($stepdata));
102 $quba->set_observer(new question_engine_unit_of_work($quba));
106 * Store an entire {@link question_attempt} in the database,
107 * including all the question_attempt_steps that comprise it.
109 * You should not call this method directly. You should use
110 * @link question_engine::save_questions_usage_by_activity()}.
112 * @param question_attempt $qa the question attempt to store.
113 * @param context $context the context of the owning question_usage_by_activity.
114 * @return array of question_attempt_step_data rows, that still need to be inserted.
116 public function insert_question_attempt(question_attempt
$qa, $context) {
117 $record = new stdClass();
118 $record->questionusageid
= $qa->get_usage_id();
119 $record->slot
= $qa->get_slot();
120 $record->behaviour
= $qa->get_behaviour_name();
121 $record->questionid
= $qa->get_question_id();
122 $record->variant
= $qa->get_variant();
123 $record->maxmark
= $qa->get_max_mark();
124 $record->minfraction
= $qa->get_min_fraction();
125 $record->maxfraction
= $qa->get_max_fraction();
126 $record->flagged
= $qa->is_flagged();
127 $record->questionsummary
= $qa->get_question_summary();
128 if (core_text
::strlen($record->questionsummary
) > question_bank
::MAX_SUMMARY_LENGTH
) {
129 // It seems some people write very long quesions! MDL-30760
130 $record->questionsummary
= core_text
::substr($record->questionsummary
,
131 0, question_bank
::MAX_SUMMARY_LENGTH
- 3) . '...';
133 $record->rightanswer
= $qa->get_right_answer_summary();
134 $record->responsesummary
= $qa->get_response_summary();
135 $record->timemodified
= time();
136 $record->id
= $this->db
->insert_record('question_attempts', $record);
137 $qa->set_database_id($record->id
);
139 // Initially an array of array of question_attempt_step_objects.
140 // Built as a nested array for efficiency, then flattened.
143 foreach ($qa->get_step_iterator() as $seq => $step) {
144 $stepdata[] = $this->insert_question_attempt_step($step, $record->id
, $seq, $context);
147 return $this->combine_step_data($stepdata);
151 * Helper method used by insert_question_attempt_step and update_question_attempt_step
152 * @param question_attempt_step $step the step to store.
153 * @param int $questionattemptid the question attept id this step belongs to.
154 * @param int $seq the sequence number of this stop.
155 * @return stdClass data to insert into the database.
157 protected function make_step_record(question_attempt_step
$step, $questionattemptid, $seq) {
158 $record = new stdClass();
159 $record->questionattemptid
= $questionattemptid;
160 $record->sequencenumber
= $seq;
161 $record->state
= (string) $step->get_state();
162 $record->fraction
= $step->get_fraction();
163 $record->timecreated
= $step->get_timecreated();
164 $record->userid
= $step->get_user_id();
169 * Take an array of arrays, and flatten it, even if the outer array is empty.
171 * Only public so it can be called from the unit of work. Not part of the
172 * public API of this class.
174 * @param array $stepdata array of zero or more arrays.
175 * @return array made by concatenating all the separate arrays.
177 public function combine_step_data(array $stepdata): array {
178 if (empty($stepdata)) {
181 return call_user_func_array('array_merge', $stepdata);
185 * Helper method used by insert_question_attempt_step and update_question_attempt_step
186 * @param question_attempt_step $step the step to store.
187 * @param int $stepid the id of the step.
188 * @param context $context the context of the owning question_usage_by_activity.
189 * @return array of question_attempt_step_data rows, that still need to be inserted.
191 protected function prepare_step_data(question_attempt_step
$step, $stepid, $context) {
193 foreach ($step->get_all_data() as $name => $value) {
194 if ($value instanceof question_file_saver
) {
195 $value->save_files($stepid, $context);
197 if ($value instanceof question_response_files
) {
198 $value = (string) $value;
201 $data = new stdClass();
202 $data->attemptstepid
= $stepid;
204 $data->value
= $value;
211 * Insert a lot of records into question_attempt_step_data in one go.
213 * Private method, only for use by other parts of the question engine.
215 * @param array $rows the rows to insert.
217 public function insert_all_step_data(array $rows) {
221 $this->db
->insert_records('question_attempt_step_data', $rows);
225 * Store a {@link question_attempt_step} in the database.
227 * Private method, only for use by other parts of the question engine.
229 * @param question_attempt_step $step the step to store.
230 * @param int $questionattemptid the question attept id this step belongs to.
231 * @param int $seq the sequence number of this stop.
232 * @param context $context the context of the owning question_usage_by_activity.
233 * @return array of question_attempt_step_data rows, that still need to be inserted.
235 public function insert_question_attempt_step(question_attempt_step
$step,
236 $questionattemptid, $seq, $context) {
238 $record = $this->make_step_record($step, $questionattemptid, $seq);
239 $record->id
= $this->db
->insert_record('question_attempt_steps', $record);
241 return $this->prepare_step_data($step, $record->id
, $context);
245 * Update a {@link question_attempt_step} in the database.
247 * Private method, only for use by other parts of the question engine.
249 * @param question_attempt_step $step the step to store.
250 * @param int $questionattemptid the question attept id this step belongs to.
251 * @param int $seq the sequence number of this stop.
252 * @param context $context the context of the owning question_usage_by_activity.
253 * @return array of question_attempt_step_data rows, that still need to be inserted.
255 public function update_question_attempt_step(question_attempt_step
$step,
256 $questionattemptid, $seq, $context) {
258 $record = $this->make_step_record($step, $questionattemptid, $seq);
259 $record->id
= $step->get_id();
260 $this->db
->update_record('question_attempt_steps', $record);
262 $this->db
->delete_records('question_attempt_step_data',
263 array('attemptstepid' => $record->id
));
264 return $this->prepare_step_data($step, $record->id
, $context);
268 * Store new metadata for an existing {@link question_attempt} in the database.
270 * Private method, only for use by other parts of the question engine.
272 * @param question_attempt $qa the question attempt to store meta data for.
273 * @param array $names the names of the metadata variables to store.
274 * @return array of question_attempt_step_data rows, that still need to be inserted.
276 public function insert_question_attempt_metadata(question_attempt
$qa, array $names) {
277 $firststep = $qa->get_step(0);
280 foreach ($names as $name) {
281 $data = new stdClass();
282 $data->attemptstepid
= $firststep->get_id();
283 $data->name
= ':_' . $name;
284 $data->value
= $firststep->get_metadata_var($name);
292 * Updates existing metadata for an existing {@link question_attempt} in the database.
294 * Private method, only for use by other parts of the question engine.
296 * @param question_attempt $qa the question attempt to store meta data for.
297 * @param array $names the names of the metadata variables to store.
298 * @return array of question_attempt_step_data rows, that still need to be inserted.
300 public function update_question_attempt_metadata(question_attempt
$qa, array $names) {
305 // Use case-sensitive function sql_equal() and not get_in_or_equal().
306 // Some databases may use case-insensitive collation, we don't want to delete 'X' instead of 'x'.
308 $params = [$qa->get_step(0)->get_id()];
309 foreach ($names as $name) {
310 $sqls[] = $DB->sql_equal('name', '?');
313 $DB->delete_records_select('question_attempt_step_data',
314 'attemptstepid = ? AND (' . join(' OR ', $sqls) . ')', $params);
315 return $this->insert_question_attempt_metadata($qa, $names);
319 * Load a {@link question_attempt_step} from the database.
321 * Private method, only for use by other parts of the question engine.
323 * @param int $stepid the id of the step to load.
324 * @return question_attempt_step the step that was loaded.
326 public function load_question_attempt_step($stepid) {
327 $records = $this->db
->get_recordset_sql("
330 COALESCE(q.qtype, 'missingtype') AS qtype,
331 qas.id AS attemptstepid,
332 qas.questionattemptid,
341 FROM {question_attempt_steps} qas
342 JOIN {question_attempts} qa ON qa.id = qas.questionattemptid
343 JOIN {question_usages} quba ON quba.id = qa.questionusageid
344 LEFT JOIN {question} q ON q.id = qa.questionid
345 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
349 ", array('stepid' => $stepid));
351 if (!$records->valid()) {
352 throw new coding_exception('Failed to load question_attempt_step ' . $stepid);
355 $step = question_attempt_step
::load_from_records($records, $stepid);
362 * Load a {@link question_attempt} from the database, including all its
365 * Normally, you should use {@link question_engine::load_questions_usage_by_activity()}
366 * but there may be rare occasions where for performance reasons, you only
367 * wish to load one qa, in which case you may call this method.
369 * @param int $questionattemptid the id of the question attempt to load.
370 * @return question_attempt the question attempt that was loaded.
372 public function load_question_attempt($questionattemptid) {
373 $records = $this->db
->get_recordset_sql("
376 quba.preferredbehaviour,
377 qa.id AS questionattemptid,
391 qas.id AS attemptstepid,
400 FROM {question_attempts} qa
401 JOIN {question_usages} quba ON quba.id = qa.questionusageid
402 LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
403 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
406 qa.id = :questionattemptid
410 ", array('questionattemptid' => $questionattemptid));
412 if (!$records->valid()) {
413 throw new coding_exception('Failed to load question_attempt ' . $questionattemptid);
416 $record = $records->current();
417 $qa = question_attempt
::load_from_records($records, $questionattemptid,
418 new question_usage_null_observer(), $record->preferredbehaviour
);
425 * Load a {@link question_usage_by_activity} from the database, including
426 * all its {@link question_attempt}s and all their steps.
428 * You should call {@link question_engine::load_questions_usage_by_activity()}
429 * rather than calling this method directly.
431 * @param int $qubaid the id of the usage to load.
432 * @return question_usage_by_activity the usage that was loaded.
434 public function load_questions_usage_by_activity($qubaid) {
435 $records = $this->db
->get_recordset_sql("
440 quba.preferredbehaviour,
441 qa.id AS questionattemptid,
455 qas.id AS attemptstepid,
464 FROM {question_usages} quba
465 LEFT JOIN {question_attempts} qa ON qa.questionusageid = quba.id
466 LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
467 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
475 ", array('qubaid' => $qubaid));
477 if (!$records->valid()) {
478 throw new coding_exception('Failed to load questions_usage_by_activity ' . $qubaid);
481 $quba = question_usage_by_activity
::load_from_records($records, $qubaid);
488 * Load all {@link question_usage_by_activity} from the database for one qubaid_condition
489 * Include all its {@link question_attempt}s and all their steps.
491 * This method may be called publicly.
493 * @param qubaid_condition $qubaids the condition that tells us which usages to load.
494 * @return question_usage_by_activity[] the usages that were loaded.
496 public function load_questions_usages_by_activity($qubaids) {
497 $records = $this->db
->get_recordset_sql("
502 quba.preferredbehaviour,
503 qa.id AS questionattemptid,
517 qas.id AS attemptstepid,
526 FROM {question_usages} quba
527 LEFT JOIN {question_attempts} qa ON qa.questionusageid = quba.id
528 LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
529 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
532 quba.id {$qubaids->usage_id_in()}
538 ", $qubaids->usage_id_in_params());
541 while ($records->valid()) {
542 $record = $records->current();
543 $qubas[$record->qubaid
] = question_usage_by_activity
::load_from_records($records, $record->qubaid
);
552 * Load information about the latest state of each question from the database.
554 * This method may be called publicly.
556 * @param qubaid_condition $qubaids used to restrict which usages are included
557 * in the query. See {@link qubaid_condition}.
558 * @param array|null $slots (optional) list of slots for which to return information. Default all slots.
559 * @param string|null $fields
560 * @return array of records. See the SQL in this function to see the fields available.
562 public function load_questions_usages_latest_steps(qubaid_condition
$qubaids, $slots = null, $fields = null) {
563 if ($slots !== null) {
564 [$slottest, $params] = $this->db
->get_in_or_equal($slots, SQL_PARAMS_NAMED
, 'slot');
565 $slotwhere = " AND qa.slot {$slottest}";
571 if ($fields === null) {
573 qa.id AS questionattemptid,
587 qas.id AS attemptstepid,
596 $records = $this->db
->get_records_sql("
600 FROM {$qubaids->from_question_attempts('qa')}
601 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
602 AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
607 ", $params +
$qubaids->from_where_params());
613 * Load summary information about the state of each question in a group of
614 * attempts. This is used, for example, by the quiz manual grading report,
615 * to show how many attempts at each question need to be graded.
617 * This method may be called publicly.
619 * @param qubaid_condition $qubaids used to restrict which usages are included
620 * in the query. See {@link qubaid_condition}.
621 * @param array|null $slots (optional) list of slots for which to return information. Default all slots.
622 * @return array The array keys are 'slot,questionid'. The values are objects with
623 * fields $slot, $questionid, $inprogress, $name, $needsgrading, $autograded,
624 * $manuallygraded and $all.
626 public function load_questions_usages_question_state_summary(qubaid_condition
$qubaids, $slots = null) {
627 if ($slots !== null) {
628 [$slottest, $params] = $this->db
->get_in_or_equal($slots, SQL_PARAMS_NAMED
, 'slot');
629 $slotwhere = " AND qa.slot {$slottest}";
635 $rs = $this->db
->get_recordset_sql("
641 {$this->full_states_to_summary_state_sql()}
643 COUNT(1) AS numattempts
645 FROM {$qubaids->from_question_attempts('qa')}
646 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
647 AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
648 JOIN {question} q ON q.id = qa.questionid
660 {$this->full_states_to_summary_state_sql()}
668 ", $params +
$qubaids->from_where_params());
671 foreach ($rs as $row) {
672 $index = $row->slot
. ',' . $row->questionid
;
674 if (!array_key_exists($index, $results)) {
675 $res = new stdClass();
676 $res->slot
= $row->slot
;
677 $res->questionid
= $row->questionid
;
678 $res->name
= $row->name
;
679 $res->inprogress
= 0;
680 $res->needsgrading
= 0;
681 $res->autograded
= 0;
682 $res->manuallygraded
= 0;
684 $results[$index] = $res;
687 $results[$index]->{$row->summarystate
} = $row->numattempts
;
688 $results[$index]->all +
= $row->numattempts
;
696 * Get a list of usage ids where the question with slot $slot, and optionally
697 * also with question id $questionid, is in summary state $summarystate. Also
698 * return the total count of such states.
700 * Only a subset of the ids can be returned by using $orderby, $limitfrom and
701 * $limitnum. A special value 'random' can be passed as $orderby, in which case
702 * $limitfrom is ignored.
704 * This method may be called publicly.
706 * @param qubaid_condition $qubaids used to restrict which usages are included
707 * in the query. See {@link qubaid_condition}.
708 * @param int $slot The slot for the questions you want to know about.
709 * @param int $questionid (optional) Only return attempts that were of this specific question.
710 * @param string $summarystate the summary state of interest, or 'all'.
711 * @param string $orderby the column to order by.
712 * @param array $params any params required by any of the SQL fragments.
713 * @param int $limitfrom implements paging of the results.
714 * Ignored if $orderby = random or $limitnum is null.
715 * @param int $limitnum implements paging of the results. null = all.
716 * @param string $extraselect anything passed here will be added to the SELECT list, use this to return extra data.
717 * @return array with two elements, an array of usage ids, and a count of the total number.
719 public function load_questions_usages_where_question_in_state(
720 qubaid_condition
$qubaids, $summarystate, $slot, $questionid = null,
721 $orderby = 'random', $params = array(), $limitfrom = 0, $limitnum = null, $extraselect = '') {
725 $extrawhere .= ' AND qa.questionid = :questionid';
726 $params['questionid'] = $questionid;
728 if ($summarystate !== 'all') {
729 list($test, $sparams) = $this->in_summary_state_test($summarystate);
730 $extrawhere .= ' AND qas.state ' . $test;
734 if (!empty($extraselect)) {
735 $extraselect = ', ' . $extraselect;
738 if ($orderby === 'random') {
740 } else if ($orderby) {
741 $sqlorderby = 'ORDER BY ' . $orderby;
746 // We always want the total count, as well as the partcular list of ids
747 // based on the paging and sort order. Because the list of ids is never
748 // going to be too ridiculously long. My worst-case scenario is
749 // 10,000 students in the course, each doing 5 quiz attempts. That
750 // is a 50,000 element int => int array, which PHP seems to use 5MB
751 // memory to store on a 64 bit server.
752 $qubaidswhere = $qubaids->where(); // Must call this before params.
753 $params +
= $qubaids->from_where_params();
754 $params['slot'] = $slot;
755 $sql = "SELECT qa.questionusageid,
758 FROM {$qubaids->from_question_attempts('qa')}
759 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
760 AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
761 JOIN {question} q ON q.id = qa.questionid
762 WHERE {$qubaidswhere}
767 $qubaids = $this->db
->get_records_sql_menu($sql, $params);
769 $qubaids = array_keys($qubaids);
770 $count = count($qubaids);
772 if ($orderby === 'random') {
777 if (!is_null($limitnum)) {
778 $qubaids = array_slice($qubaids, $limitfrom, $limitnum);
781 return array($qubaids, $count);
785 * Load the average mark, and number of attempts, for each slot in a set of
788 * This method may be called publicly.
790 * @param qubaid_condition $qubaids used to restrict which usages are included
791 * in the query. See {@link qubaid_condition}.
792 * @param array|null $slots if null, load info for all quesitions, otherwise only
793 * load the averages for the specified questions.
794 * @return array of objects with fields ->slot, ->averagefraction and ->numaveraged.
796 public function load_average_marks(qubaid_condition
$qubaids, $slots = null) {
797 if (!empty($slots)) {
798 list($slottest, $slotsparams) = $this->db
->get_in_or_equal(
799 $slots, SQL_PARAMS_NAMED
, 'slot');
800 $slotwhere = " AND qa.slot {$slottest}";
803 $slotsparams = array();
806 list($statetest, $stateparams) = $this->db
->get_in_or_equal(array(
807 (string) question_state
::$gaveup,
808 (string) question_state
::$gradedwrong,
809 (string) question_state
::$gradedpartial,
810 (string) question_state
::$gradedright,
811 (string) question_state
::$mangaveup,
812 (string) question_state
::$mangrwrong,
813 (string) question_state
::$mangrpartial,
814 (string) question_state
::$mangrright), SQL_PARAMS_NAMED
, 'st');
816 return $this->db
->get_records_sql("
819 AVG(COALESCE(qas.fraction, 0)) AS averagefraction,
820 COUNT(1) AS numaveraged
822 FROM {$qubaids->from_question_attempts('qa')}
823 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
824 AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
829 AND qas.state $statetest
834 ", $slotsparams +
$stateparams +
$qubaids->from_where_params());
838 * Load all the attempts at a given queston from a set of question_usages.
841 * This method may be called publicly.
843 * @param int $questionid the question to load all the attempts fors.
844 * @param qubaid_condition $qubaids used to restrict which usages are included
845 * in the query. See {@link qubaid_condition}.
846 * @return question_attempt[] array of question_attempts that were loaded.
848 public function load_attempts_at_question($questionid, qubaid_condition
$qubaids) {
852 quba.preferredbehaviour,
853 qa.id AS questionattemptid,
867 qas.id AS attemptstepid,
876 FROM {$qubaids->from_question_attempts('qa')}
877 JOIN {question_usages} quba ON quba.id = qa.questionusageid
878 LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
879 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
882 {$qubaids->where()} AND
883 qa.questionid = :questionid
890 // For qubaid_list must call this after calling methods that generate sql.
891 $params = $qubaids->from_where_params();
892 $params['questionid'] = $questionid;
894 $records = $this->db
->get_recordset_sql($sql, $params);
896 $questionattempts = array();
897 while ($records->valid()) {
898 $record = $records->current();
899 $questionattempts[$record->questionattemptid
] =
900 question_attempt
::load_from_records($records,
901 $record->questionattemptid
, new question_usage_null_observer(),
902 $record->preferredbehaviour
);
906 return $questionattempts;
910 * Update a question_usages row to refect any changes in a usage (but not
911 * any of its question_attempts.
913 * You should not call this method directly. You should use
914 * @link question_engine::save_questions_usage_by_activity()}.
916 * @param question_usage_by_activity $quba the usage that has changed.
918 public function update_questions_usage_by_activity(question_usage_by_activity
$quba) {
919 $record = new stdClass();
920 $record->id
= $quba->get_id();
921 $record->contextid
= $quba->get_owning_context()->id
;
922 $record->component
= $quba->get_owning_component();
923 $record->preferredbehaviour
= $quba->get_preferred_behaviour();
925 $this->db
->update_record('question_usages', $record);
929 * Update a question_attempts row to refect any changes in a question_attempt
930 * (but not any of its steps).
932 * You should not call this method directly. You should use
933 * @link question_engine::save_questions_usage_by_activity()}.
935 * @param question_attempt $qa the question attempt that has changed.
937 public function update_question_attempt(question_attempt
$qa) {
938 $record = new stdClass();
939 $record->id
= $qa->get_database_id();
940 $record->slot
= $qa->get_slot();
941 $record->questionid
= $qa->get_question(false)->id
;
942 $record->variant
= $qa->get_variant();
943 $record->maxmark
= $qa->get_max_mark();
944 $record->minfraction
= $qa->get_min_fraction();
945 $record->maxfraction
= $qa->get_max_fraction();
946 $record->flagged
= $qa->is_flagged();
947 $record->questionsummary
= $qa->get_question_summary();
948 $record->rightanswer
= $qa->get_right_answer_summary();
949 $record->responsesummary
= $qa->get_response_summary();
950 $record->timemodified
= time();
952 $this->db
->update_record('question_attempts', $record);
956 * Delete a question_usage_by_activity and all its associated
958 * You should not call this method directly. You should use
959 * @link question_engine::delete_questions_usage_by_activities()}.
961 * {@link question_attempts} and {@link question_attempt_steps} from the
963 * @param qubaid_condition $qubaids identifies which question useages to delete.
965 public function delete_questions_usage_by_activities(qubaid_condition
$qubaids) {
966 $where = "qa.questionusageid {$qubaids->usage_id_in()}";
967 $params = $qubaids->usage_id_in_params();
969 $contextids = $this->db
->get_records_sql_menu("
970 SELECT DISTINCT contextid, 1
971 FROM {question_usages}
972 WHERE id {$qubaids->usage_id_in()}", $qubaids->usage_id_in_params());
973 foreach ($contextids as $contextid => $notused) {
974 $this->delete_response_files($contextid, "IN (
976 FROM {question_attempts} qa
977 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
978 WHERE $where)", $params);
981 if ($this->db
->get_dbfamily() == 'mysql') {
982 $this->delete_usage_records_for_mysql($qubaids);
986 $this->db
->delete_records_select('question_attempt_step_data', "attemptstepid IN (
988 FROM {question_attempts} qa
989 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
990 WHERE $where)", $params);
992 $this->db
->delete_records_select('question_attempt_steps', "questionattemptid IN (
994 FROM {question_attempts} qa
995 WHERE $where)", $params);
997 $this->db
->delete_records_select('question_attempts',
998 "{question_attempts}.questionusageid {$qubaids->usage_id_in()}",
999 $qubaids->usage_id_in_params());
1001 $this->db
->delete_records_select('question_usages',
1002 "{question_usages}.id {$qubaids->usage_id_in()}", $qubaids->usage_id_in_params());
1006 * This function is a work-around for poor MySQL performance with
1007 * DELETE FROM x WHERE id IN (SELECT ...). We have to use a non-standard
1008 * syntax to get good performance. See MDL-29520.
1009 * @param qubaid_condition $qubaids identifies which question useages to delete.
1011 protected function delete_usage_records_for_mysql(qubaid_condition
$qubaids) {
1012 // Get the list of question attempts to delete and delete them in chunks.
1013 $allids = $this->db
->get_records_sql_menu("
1014 SELECT DISTINCT id, id AS id2
1015 FROM {question_usages}
1016 WHERE id " . $qubaids->usage_id_in(),
1017 $qubaids->usage_id_in_params());
1019 foreach (array_chunk($allids, 1000) as $todelete) {
1020 list($idsql, $idparams) = $this->db
->get_in_or_equal($todelete);
1021 $this->db
->execute('
1022 DELETE qu, qa, qas, qasd
1023 FROM {question_usages} qu
1024 JOIN {question_attempts} qa ON qa.questionusageid = qu.id
1025 LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
1026 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
1027 WHERE qu.id ' . $idsql,
1033 * Delete some steps of a question attempt.
1035 * Private method, only for use by other parts of the question engine.
1037 * @param array $stepids array of step ids to delete.
1038 * @param context $context the context that the $quba belongs to.
1040 public function delete_steps($stepids, $context) {
1041 if (empty($stepids)) {
1044 list($test, $params) = $this->db
->get_in_or_equal($stepids, SQL_PARAMS_NAMED
);
1046 $this->delete_response_files($context->id
, $test, $params);
1048 $this->db
->delete_records_select('question_attempt_step_data',
1049 "attemptstepid {$test}", $params);
1050 $this->db
->delete_records_select('question_attempt_steps',
1051 "id {$test}", $params);
1055 * Delete all the files belonging to the response variables in the gives
1056 * question attempt steps.
1057 * @param int $contextid the context these attempts belong to.
1058 * @param string $itemidstest a bit of SQL that can be used in a
1059 * WHERE itemid $itemidstest clause. Must use named params.
1060 * @param array $params any query parameters used in $itemidstest.
1062 protected function delete_response_files($contextid, $itemidstest, $params) {
1063 $fs = get_file_storage();
1064 foreach (question_engine
::get_all_response_file_areas() as $filearea) {
1065 $fs->delete_area_files_select($contextid, 'question', $filearea,
1066 $itemidstest, $params);
1071 * Delete all the previews for a given question.
1073 * Private method, only for use by other parts of the question engine.
1075 * @param int $questionid question id.
1077 public function delete_previews($questionid) {
1078 $previews = $this->db
->get_records_sql_menu("
1079 SELECT DISTINCT quba.id, 1
1080 FROM {question_usages} quba
1081 JOIN {question_attempts} qa ON qa.questionusageid = quba.id
1082 WHERE quba.component = 'core_question_preview' AND
1083 qa.questionid = ?", array($questionid));
1084 if (empty($previews)) {
1087 $this->delete_questions_usage_by_activities(new qubaid_list($previews));
1091 * Update the flagged state of a question in the database.
1093 * You should call {@link question_engine::update_flag()()}
1094 * rather than calling this method directly.
1096 * @param int $qubaid the question usage id.
1097 * @param int $questionid the question id.
1098 * @param int $qaid the question_attempt id.
1099 * @param int $slot the slot number of the question attempt to update.
1100 * @param bool $newstate the new state of the flag. true = flagged.
1102 public function update_question_attempt_flag($qubaid, $questionid, $qaid, $slot, $newstate) {
1103 if (!$this->db
->record_exists('question_attempts', array('id' => $qaid,
1104 'questionusageid' => $qubaid, 'questionid' => $questionid, 'slot' => $slot))) {
1105 throw new moodle_exception('errorsavingflags', 'question');
1108 $this->db
->set_field('question_attempts', 'flagged', $newstate, array('id' => $qaid));
1112 * Get all the WHEN 'x' THEN 'y' terms needed to convert the question_attempt_steps.state
1113 * column to a summary state. Use this like
1114 * CASE qas.state {$this->full_states_to_summary_state_sql()} END AS summarystate,
1116 * @return string SQL fragment.
1118 protected function full_states_to_summary_state_sql() {
1120 foreach (question_state
::get_all() as $state) {
1121 $sql .= "WHEN '{$state}' THEN '{$state->get_summary_state()}'\n";
1127 * Get the SQL needed to test that question_attempt_steps.state is in a
1128 * state corresponding to $summarystate.
1130 * This method may be called publicly.
1132 * @param string $summarystate one of
1133 * inprogress, needsgrading, manuallygraded or autograded
1134 * @param bool $equal if false, do a NOT IN test. Default true.
1135 * @param string $prefix used in the call to $DB->get_in_or_equal().
1136 * @return array as returned by $DB->get_in_or_equal().
1138 public function in_summary_state_test($summarystate, $equal = true, $prefix = 'summarystates') {
1139 $states = question_state
::get_all_for_summary_state($summarystate);
1140 return $this->db
->get_in_or_equal(array_map('strval', $states),
1141 SQL_PARAMS_NAMED
, $prefix, $equal);
1145 * Change the maxmark for the question_attempt with number in usage $slot
1146 * for all the specified question_attempts.
1148 * You should call {@link question_engine::set_max_mark_in_attempts()}
1149 * rather than calling this method directly.
1151 * @param qubaid_condition $qubaids Selects which usages are updated.
1152 * @param int $slot the number is usage to affect.
1153 * @param number $newmaxmark the new max mark to set.
1155 public function set_max_mark_in_attempts(qubaid_condition
$qubaids, $slot, $newmaxmark) {
1156 if ($this->db
->get_dbfamily() == 'mysql') {
1157 // MySQL's query optimiser completely fails to cope with the
1158 // set_field_select call below, so we have to give it a clue. See MDL-32616.
1159 // TODO MDL-29589 encapsulate this MySQL-specific code with a $DB method.
1160 $this->db
->execute("
1161 UPDATE " . $qubaids->from_question_attempts('qa') . "
1162 SET qa.maxmark = :newmaxmark
1163 WHERE " . $qubaids->where() . "
1165 ", $qubaids->from_where_params() +
array('newmaxmark' => $newmaxmark, 'slot' => $slot));
1169 // Normal databases.
1170 $this->db
->set_field_select('question_attempts', 'maxmark', $newmaxmark,
1171 "questionusageid {$qubaids->usage_id_in()} AND slot = :slot",
1172 $qubaids->usage_id_in_params() +
array('slot' => $slot));
1176 * Return a sub-query that computes the sum of the marks for all the questions
1177 * in a usage. Which usage to compute the sum for is controlled by the $qubaid
1180 * See {@see \mod_quiz\grade_calculator::recompute_all_attempt_sumgrades()} for an example of the usage of
1183 * This method may be called publicly.
1185 * @param string $qubaid SQL fragment that controls which usage is summed.
1186 * This will normally be the name of a column in the outer query. Not that this
1187 * SQL fragment must not contain any placeholders.
1188 * @return string SQL code for the subquery.
1190 public function sum_usage_marks_subquery($qubaid) {
1191 // To explain the COALESCE in the following SQL: SUM(lots of NULLs) gives
1192 // NULL, while SUM(one 0.0 and lots of NULLS) gives 0.0. We don't want that.
1193 // We always want to return a number, so the COALESCE is there to turn the
1194 // NULL total into a 0.
1195 return "SELECT COALESCE(SUM(qa.maxmark * qas.fraction), 0)
1196 FROM {question_attempts} qa
1197 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
1198 AND qas.sequencenumber = (
1199 SELECT MAX(summarks_qas.sequencenumber)
1200 FROM {question_attempt_steps} summarks_qas
1201 WHERE summarks_qas.questionattemptid = qa.id
1203 WHERE qa.questionusageid = $qubaid
1205 WHEN qas.state = 'needsgrading' AND qa.maxmark > 0 THEN 1
1211 * Get a subquery that returns the latest step of every qa in some qubas.
1212 * Currently, this is only used by the quiz reports. See
1213 * {@see \mod_quiz\local\reports\attempts_report_table::add_latest_state_join()}.
1215 * This method may be called publicly.
1217 * @param string $alias alias to use for this inline-view.
1218 * @param qubaid_condition $qubaids restriction on which question_usages we
1219 * are interested in. This is important for performance.
1220 * @return array with two elements, the SQL fragment and any params requried.
1222 public function question_attempt_latest_state_view($alias, qubaid_condition
$qubaids) {
1224 SELECT {$alias}qa.id AS questionattemptid,
1225 {$alias}qa.questionusageid,
1227 {$alias}qa.behaviour,
1228 {$alias}qa.questionid,
1231 {$alias}qa.minfraction,
1232 {$alias}qa.maxfraction,
1234 {$alias}qa.questionsummary,
1235 {$alias}qa.rightanswer,
1236 {$alias}qa.responsesummary,
1237 {$alias}qa.timemodified,
1238 {$alias}qas.id AS attemptstepid,
1239 {$alias}qas.sequencenumber,
1241 {$alias}qas.fraction,
1242 {$alias}qas.timecreated,
1245 FROM {$qubaids->from_question_attempts($alias . 'qa')}
1246 JOIN {question_attempt_steps} {$alias}qas ON {$alias}qas.questionattemptid = {$alias}qa.id
1247 AND {$alias}qas.sequencenumber = {$this->latest_step_for_qa_subquery($alias . 'qa.id')}
1248 WHERE {$qubaids->where()}
1249 ) {$alias}", $qubaids->from_where_params());
1252 protected function latest_step_for_qa_subquery($questionattemptid = 'qa.id') {
1254 SELECT MAX(sequencenumber)
1255 FROM {question_attempt_steps}
1256 WHERE questionattemptid = $questionattemptid
1261 * Are any of these questions are currently in use?
1263 * You should call {@link question_engine::questions_in_use()}
1264 * rather than calling this method directly.
1266 * @param array $questionids of question ids.
1267 * @param qubaid_condition $qubaids ids of the usages to consider.
1268 * @return bool whether any of these questions are being used by any of
1271 public function questions_in_use(array $questionids, qubaid_condition
$qubaids) {
1272 list($test, $params) = $this->db
->get_in_or_equal($questionids);
1273 return $this->db
->record_exists_select('question_attempts',
1274 'questionid ' . $test . ' AND questionusageid ' .
1275 $qubaids->usage_id_in(), $params +
$qubaids->usage_id_in_params());
1279 * Get the number of times each variant has been used for each question in a list
1280 * in a set of usages.
1281 * @param array $questionids of question ids.
1282 * @param qubaid_condition $qubaids ids of the usages to consider.
1283 * @return array questionid => variant number => num uses.
1285 public function load_used_variants(array $questionids, qubaid_condition
$qubaids) {
1286 list($test, $params) = $this->db
->get_in_or_equal($questionids, SQL_PARAMS_NAMED
, 'qid');
1287 $recordset = $this->db
->get_recordset_sql("
1288 SELECT qa.questionid, qa.variant, COUNT(1) AS usescount
1289 FROM " . $qubaids->from_question_attempts('qa') . "
1290 WHERE qa.questionid $test
1291 AND " . $qubaids->where() . "
1292 GROUP BY qa.questionid, qa.variant
1293 ORDER BY COUNT(1) ASC
1294 ", $params +
$qubaids->from_where_params());
1296 $usedvariants = array_combine($questionids, array_fill(0, count($questionids), array()));
1297 foreach ($recordset as $row) {
1298 $usedvariants[$row->questionid
][$row->variant
] = $row->usescount
;
1300 $recordset->close();
1301 return $usedvariants;
1307 * Implementation of the unit of work pattern for the question engine.
1309 * See http://martinfowler.com/eaaCatalog/unitOfWork.html. This tracks all the
1310 * changes to a {@link question_usage_by_activity}, and its constituent parts,
1311 * so that the changes can be saved to the database when {@link save()} is called.
1313 * @copyright 2009 The Open University
1314 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1316 class question_engine_unit_of_work
implements question_usage_observer
{
1317 /** @var question_usage_by_activity the usage being tracked. */
1320 /** @var boolean whether any of the fields of the usage have been changed. */
1321 protected $modified = false;
1324 * @var question_attempt[] list of slot => {@link question_attempt}s that
1325 * have been added to the usage.
1327 protected $attemptsadded = array();
1330 * @var question_attempt[] list of slot => {@link question_attempt}s that
1331 * were already in the usage, and which have been modified.
1333 protected $attemptsmodified = array();
1336 * @var question_attempt[] list of slot => {@link question_attempt}s that
1337 * have been added to the usage.
1339 protected $attemptsdeleted = array();
1342 * @var array of array(question_attempt_step, question_attempt id, seq number)
1343 * of steps that have been added to question attempts in this usage.
1345 protected $stepsadded = array();
1348 * @var array of array(question_attempt_step, question_attempt id, seq number)
1349 * of steps that have been modified in their attempt.
1351 protected $stepsmodified = array();
1354 * @var question_attempt_step[] list of question_attempt_step.id => question_attempt_step of steps
1355 * that were previously stored in the database, but which are no longer required.
1357 protected $stepsdeleted = array();
1360 * @var array int slot => string name => question_attempt.
1362 protected $metadataadded = array();
1365 * @var array int slot => string name => question_attempt.
1367 protected $metadatamodified = array();
1371 * @param question_usage_by_activity $quba the usage to track.
1373 public function __construct(question_usage_by_activity
$quba) {
1374 $this->quba
= $quba;
1377 public function notify_modified() {
1378 $this->modified
= true;
1381 public function notify_attempt_added(question_attempt
$qa) {
1382 $this->attemptsadded
[$qa->get_slot()] = $qa;
1385 public function notify_attempt_modified(question_attempt
$qa) {
1386 $slot = $qa->get_slot();
1387 if (!array_key_exists($slot, $this->attemptsadded
)) {
1388 $this->attemptsmodified
[$slot] = $qa;
1392 public function notify_attempt_moved(question_attempt
$qa, $oldslot) {
1393 $newslot = $qa->get_slot();
1395 if (array_key_exists($oldslot, $this->attemptsadded
)) {
1396 unset($this->attemptsadded
[$oldslot]);
1397 $this->attemptsadded
[$newslot] = $qa;
1401 if (array_key_exists($oldslot, $this->attemptsmodified
)) {
1402 unset($this->attemptsmodified
[$oldslot]);
1404 $this->attemptsmodified
[$newslot] = $qa;
1406 if (array_key_exists($oldslot, $this->metadataadded
)) {
1407 $this->metadataadded
[$newslot] = $this->metadataadded
[$oldslot];
1408 unset($this->metadataadded
[$oldslot]);
1410 if (array_key_exists($oldslot, $this->metadatamodified
)) {
1411 $this->metadatamodified
[$newslot] = $this->metadatamodified
[$oldslot];
1412 unset($this->metadatamodified
[$oldslot]);
1416 public function notify_step_added(question_attempt_step
$step, question_attempt
$qa, $seq) {
1417 if (array_key_exists($qa->get_slot(), $this->attemptsadded
)) {
1421 if (($key = $this->is_step_added($step)) !== false) {
1425 if (($key = $this->is_step_modified($step)) !== false) {
1426 throw new coding_exception('Cannot add a step that has already been modified.');
1429 if (($key = $this->is_step_deleted($step)) !== false) {
1430 unset($this->stepsdeleted
[$step->get_id()]);
1431 $this->stepsmodified
[] = array($step, $qa->get_database_id(), $seq);
1435 $stepid = $step->get_id();
1437 if (array_key_exists($stepid, $this->stepsdeleted
)) {
1438 unset($this->stepsdeleted
[$stepid]);
1440 $this->stepsmodified
[] = array($step, $qa->get_database_id(), $seq);
1443 $this->stepsadded
[] = array($step, $qa->get_database_id(), $seq);
1447 public function notify_step_modified(question_attempt_step
$step, question_attempt
$qa, $seq) {
1448 if (array_key_exists($qa->get_slot(), $this->attemptsadded
)) {
1452 if (($key = $this->is_step_added($step)) !== false) {
1456 if (($key = $this->is_step_deleted($step)) !== false) {
1457 throw new coding_exception('Cannot modify a step after it has been deleted.');
1460 $stepid = $step->get_id();
1461 if (empty($stepid)) {
1462 throw new coding_exception('Cannot modify a step that has never been stored in the database.');
1465 $this->stepsmodified
[] = array($step, $qa->get_database_id(), $seq);
1468 public function notify_step_deleted(question_attempt_step
$step, question_attempt
$qa) {
1469 if (array_key_exists($qa->get_slot(), $this->attemptsadded
)) {
1473 if (($key = $this->is_step_added($step)) !== false) {
1474 unset($this->stepsadded
[$key]);
1478 if (($key = $this->is_step_modified($step)) !== false) {
1479 unset($this->stepsmodified
[$key]);
1482 $stepid = $step->get_id();
1483 if (empty($stepid)) {
1484 return; // Was never in the database.
1487 $this->stepsdeleted
[$stepid] = $step;
1490 public function notify_metadata_added(question_attempt
$qa, $name) {
1491 if (array_key_exists($qa->get_slot(), $this->attemptsadded
)) {
1495 if ($this->is_step_added($qa->get_step(0)) !== false) {
1499 if (isset($this->metadataadded
[$qa->get_slot()][$name])) {
1503 $this->metadataadded
[$qa->get_slot()][$name] = $qa;
1506 public function notify_metadata_modified(question_attempt
$qa, $name) {
1507 if (array_key_exists($qa->get_slot(), $this->attemptsadded
)) {
1511 if ($this->is_step_added($qa->get_step(0)) !== false) {
1515 if (isset($this->metadataadded
[$qa->get_slot()][$name])) {
1519 if (isset($this->metadatamodified
[$qa->get_slot()][$name])) {
1523 $this->metadatamodified
[$qa->get_slot()][$name] = $qa;
1527 * Determine if a step is new. If so get its array key.
1529 * @param question_attempt_step $step a step
1530 * @return int|false if the step is in the list of steps to be added, return
1531 * the key, otherwise return false.
1533 protected function is_step_added(question_attempt_step
$step) {
1534 foreach ($this->stepsadded
as $key => $data) {
1535 list($addedstep) = $data;
1536 if ($addedstep === $step) {
1544 * Determine if a step is modified. If so get its array key.
1546 * @param question_attempt_step $step a step
1547 * @return int|false if the step is in the list of steps to be modified, return
1548 * the key, otherwise return false.
1550 protected function is_step_modified(question_attempt_step
$step) {
1551 foreach ($this->stepsmodified
as $key => $data) {
1552 list($modifiedstep) = $data;
1553 if ($modifiedstep === $step) {
1561 * @param question_attempt_step $step a step
1562 * @return bool whether the step is in the list of steps to be deleted.
1564 protected function is_step_deleted(question_attempt_step
$step) {
1565 foreach ($this->stepsdeleted
as $deletedstep) {
1566 if ($deletedstep === $step) {
1574 * Write all the changes we have recorded to the database.
1575 * @param question_engine_data_mapper $dm the mapper to use to update the database.
1577 public function save(question_engine_data_mapper
$dm) {
1578 $dm->delete_steps(array_keys($this->stepsdeleted
), $this->quba
->get_owning_context());
1580 // Initially an array of array of question_attempt_step_objects.
1581 // Built as a nested array for efficiency, then flattened.
1582 $stepdata = array();
1584 foreach ($this->stepsmodified
as $stepinfo) {
1585 list($step, $questionattemptid, $seq) = $stepinfo;
1586 $stepdata[] = $dm->update_question_attempt_step(
1587 $step, $questionattemptid, $seq, $this->quba
->get_owning_context());
1590 foreach ($this->stepsadded
as $stepinfo) {
1591 list($step, $questionattemptid, $seq) = $stepinfo;
1592 $stepdata[] = $dm->insert_question_attempt_step(
1593 $step, $questionattemptid, $seq, $this->quba
->get_owning_context());
1596 foreach ($this->attemptsmodified
as $qa) {
1597 $dm->update_question_attempt($qa);
1600 foreach ($this->attemptsadded
as $qa) {
1601 $stepdata[] = $dm->insert_question_attempt(
1602 $qa, $this->quba
->get_owning_context());
1605 foreach ($this->metadataadded
as $info) {
1607 $stepdata[] = $dm->insert_question_attempt_metadata($qa, array_keys($info));
1610 foreach ($this->metadatamodified
as $info) {
1612 $stepdata[] = $dm->update_question_attempt_metadata($qa, array_keys($info));
1615 if ($this->modified
) {
1616 $dm->update_questions_usage_by_activity($this->quba
);
1619 $dm->insert_all_step_data($dm->combine_step_data($stepdata));
1621 $this->stepsdeleted
= array();
1622 $this->stepsmodified
= array();
1623 $this->stepsadded
= array();
1624 $this->attemptsdeleted
= array();
1625 $this->attemptsadded
= array();
1626 $this->attemptsmodified
= array();
1627 $this->modified
= false;
1633 * The interface implemented by {@link question_file_saver} and {@link question_file_loader}.
1635 * @copyright 2012 The Open University
1636 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1638 interface question_response_files
{
1640 * Get the files that were submitted.
1641 * @return array of stored_files objects.
1643 public function get_files();
1648 * This class represents the promise to save some files from a particular draft
1649 * file area into a particular file area. It is used beause the necessary
1650 * information about what to save is to hand in the
1651 * {@link question_attempt::process_response_files()} method, but we don't know
1652 * if this question attempt will actually be saved in the database until later,
1653 * when the {@link question_engine_unit_of_work} is saved, if it is.
1655 * @copyright 2011 The Open University
1656 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1658 class question_file_saver
implements question_response_files
{
1659 /** @var int the id of the draft file area to save files from. */
1660 protected $draftitemid;
1661 /** @var string the owning component name. */
1662 protected $component;
1663 /** @var string the file area name. */
1664 protected $filearea;
1667 * @var string the value to store in the question_attempt_step_data to
1668 * represent these files.
1670 protected $value = null;
1675 * @param int $draftitemid the draft area to save the files from.
1676 * @param string $component the component for the file area to save into.
1677 * @param string $uncleanedfilearea the name of the file area to save into - but before it has been cleaned up.
1678 * @param string $text optional content containing file links.
1680 public function __construct($draftitemid, $component, $uncleanedfilearea, $text = null) {
1681 $this->draftitemid
= $draftitemid;
1682 $this->component
= $component;
1683 $this->filearea
= self
::clean_file_area_name($uncleanedfilearea);
1684 $this->value
= $this->compute_value($draftitemid, $text);
1688 * Compute the value that should be stored in the question_attempt_step_data table.
1690 * Contains a hash that (almost) uniquely encodes all the files.
1692 * @param int $draftitemid the draft file area itemid.
1693 * @param string $text optional content containing file links.
1694 * @return string the value.
1696 protected function compute_value($draftitemid, $text) {
1699 $fs = get_file_storage();
1700 $usercontext = context_user
::instance($USER->id
);
1702 $files = $fs->get_area_files($usercontext->id
, 'user', 'draft',
1703 $draftitemid, 'sortorder, filepath, filename', false);
1706 foreach ($files as $file) {
1707 $string .= $file->get_filepath() . $file->get_filename() . '|' .
1708 $file->get_contenthash() . '|';
1710 $hash = md5($string);
1712 if (is_null($text)) {
1720 // We add the file hash so a simple string comparison will say if the
1721 // files have been changed. First strip off any existing file hash.
1723 $text = preg_replace('/\s*<!-- File hash: \w+ -->\s*$/', '', $text);
1724 $text = file_rewrite_urls_to_pluginfile($text, $draftitemid);
1726 $text .= '<!-- File hash: ' . $hash . ' -->';
1732 public function __toString() {
1733 return $this->value
;
1737 * Actually save the files.
1739 * @param integer $itemid the item id for the file area to save into.
1740 * @param context $context the context where the files should be saved.
1742 public function save_files($itemid, $context) {
1743 file_save_draft_area_files($this->draftitemid
, $context->id
,
1744 $this->component
, $this->filearea
, $itemid);
1748 * Clean up a possible file area name to ensure that it matches the required rules.
1750 * @param string $uncleanedfilearea the proposed file area name (e.g. 'response_-attachments').
1751 * @return string a similar valid file area name. E.g: response_attachments.
1753 public static function clean_file_area_name(string $uncleanedfilearea): string {
1754 $filearea = $uncleanedfilearea;
1755 if ($filearea !== clean_param($filearea, PARAM_AREA
)) {
1756 // Only lowercase ascii letters, numbers and underscores are allowed.
1757 // Remove the invalid character in the filearea string.
1758 $filearea = preg_replace('~[^a-z0-9_]~', '', core_text
::strtolower($filearea));
1759 // Replace multiple underscore to a single underscore.
1760 $filearea = preg_replace('~_+~', '_', $filearea);
1761 // If, after attempted cleaning, the filearea is not valid, throw a clear error to avoid subtle bugs.
1762 if ($filearea !== clean_param($filearea, PARAM_AREA
)) {
1763 throw new coding_exception('Name ' . $filearea .
1764 ' cannot be used with question_file_saver because it does not match the rules for file area names');
1771 * Get the files that were submitted.
1772 * @return array of stored_files objects.
1774 public function get_files() {
1777 $fs = get_file_storage();
1778 $usercontext = context_user
::instance($USER->id
);
1780 return $fs->get_area_files($usercontext->id
, 'user', 'draft',
1781 $this->draftitemid
, 'sortorder, filepath, filename', false);
1787 * This class is the mirror image of {@link question_file_saver}. It allows
1788 * files to be accessed again later (e.g. when re-grading) using that same
1789 * API as when doing the original grading.
1791 * @copyright 2012 The Open University
1792 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1794 class question_file_loader
implements question_response_files
{
1795 /** @var question_attempt_step the step that these files belong to. */
1798 /** @var string the field name for these files - which is used to construct the file area name. */
1802 * @var string the value to stored in the question_attempt_step_data to
1803 * represent these files.
1807 /** @var int the context id that the files belong to. */
1808 protected $contextid;
1812 * @param question_attempt_step $step the step that these files belong to.
1813 * @param string $name string the field name for these files - which is used to construct the file area name.
1814 * @param string $value the value to stored in the question_attempt_step_data to
1815 * represent these files.
1816 * @param int $contextid the context id that the files belong to.
1818 public function __construct(question_attempt_step
$step, $name, $value, $contextid) {
1819 $this->step
= $step;
1820 $this->name
= $name;
1821 $this->value
= $value;
1822 $this->contextid
= $contextid;
1825 public function __toString() {
1826 return $this->value
;
1830 * Get the files that were submitted.
1831 * @return array of stored_files objects.
1833 public function get_files() {
1834 return $this->step
->get_qt_files($this->name
, $this->contextid
);
1838 * Copy these files into a draft area, and return the corresponding
1839 * {@link question_file_saver} that can save them again.
1841 * This is used by {@link question_attempt::start_based_on()}, which is used
1842 * (for example) by the quizzes 'Each attempt builds on last' feature.
1844 * @return question_file_saver that can re-save these files again.
1846 public function get_question_file_saver() {
1848 // There are three possibilities here for what $value will look like:
1849 // 1) some HTML content followed by an MD5 hash in a HTML comment;
1850 // 2) a plain MD5 hash;
1851 // 3) or some real content, without any hash.
1852 // The problem is that 3) is ambiguous in the case where a student writes
1853 // a response that looks exactly like an MD5 hash. For attempts made now,
1854 // we avoid case 3) by always going for case 1) or 2) (except when the
1855 // response is blank. However, there may be case 3) data in the database
1856 // so we need to handle it as best we can.
1857 if (preg_match('/\s*<!-- File hash: [0-9a-zA-Z]{32} -->\s*$/', $this->value
)) {
1858 $value = preg_replace('/\s*<!-- File hash: [0-9a-zA-Z]{32} -->\s*$/', '', $this->value
);
1860 } else if (preg_match('/^[0-9a-zA-Z]{32}$/', $this->value
)) {
1864 $value = $this->value
;
1867 list($draftid, $text) = $this->step
->prepare_response_files_draft_itemid_with_text(
1868 $this->name
, $this->contextid
, $value);
1869 return new question_file_saver($draftid, 'question', 'response_' . $this->name
, $text);
1875 * This class represents a restriction on the set of question_usage ids to include
1876 * in a larger database query. Depending of the how you are going to restrict the
1877 * list of usages, construct an appropriate subclass.
1879 * If $qubaids is an instance of this class, example usage might be
1881 * SELECT qa.id, qa.maxmark
1882 * FROM $qubaids->from_question_attempts('qa')
1883 * WHERE $qubaids->where() AND qa.slot = 1
1885 * @copyright 2010 The Open University
1886 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1888 abstract class qubaid_condition
{
1891 * Get the SQL fragment to go in a FROM clause.
1893 * The SQL that needs to go in the FROM clause when trying
1894 * to select records from the 'question_attempts' table based on this
1897 * @param string $alias
1898 * @return string SQL fragment.
1900 public abstract function from_question_attempts($alias);
1902 /** @return string the SQL that needs to go in the where clause. */
1903 public abstract function where();
1906 * @return array the params needed by a query that uses
1907 * {@link from_question_attempts()} and {@link where()}.
1909 public abstract function from_where_params();
1912 * @return string SQL that can use used in a WHERE qubaid IN (...) query.
1913 * This method returns the "IN (...)" part.
1915 public abstract function usage_id_in();
1918 * @return array the params needed by a query that uses {@link usage_id_in()}.
1920 public abstract function usage_id_in_params();
1923 * @return string 40-character hash code that uniquely identifies the combination of properties and class name of this qubaid
1926 public function get_hash_code() {
1927 return sha1(serialize($this));
1933 * This class represents a restriction on the set of question_usage ids to include
1934 * in a larger database query based on an explicit list of ids.
1936 * @copyright 2010 The Open University
1937 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1939 class qubaid_list
extends qubaid_condition
{
1940 /** @var array of ids. */
1942 protected $columntotest = null;
1947 * @param array $qubaids of question usage ids.
1949 public function __construct(array $qubaids) {
1950 $this->qubaids
= $qubaids;
1953 public function from_question_attempts($alias) {
1954 $this->columntotest
= $alias . '.questionusageid';
1955 return '{question_attempts} ' . $alias;
1958 public function where() {
1959 if (is_null($this->columntotest
)) {
1960 throw new coding_exception('Must call from_question_attempts before where().');
1962 if (empty($this->qubaids
)) {
1963 $this->params
= array();
1967 return $this->columntotest
. ' ' . $this->usage_id_in();
1970 public function from_where_params() {
1971 return $this->params
;
1974 public function usage_id_in() {
1977 if (empty($this->qubaids
)) {
1978 $this->params
= array();
1981 list($where, $this->params
) = $DB->get_in_or_equal(
1982 $this->qubaids
, SQL_PARAMS_NAMED
, 'qubaid');
1986 public function usage_id_in_params() {
1987 return $this->params
;
1993 * This class represents a restriction on the set of question_usage ids to include
1994 * in a larger database query based on JOINing to some other tables.
1996 * The general form of the query is something like
1998 * SELECT qa.id, qa.maxmark
2000 * JOIN {question_attempts} qa ON qa.questionusageid = $usageidcolumn
2001 * WHERE $where AND qa.slot = 1
2003 * where $from, $usageidcolumn and $where are the arguments to the constructor.
2005 * @copyright 2010 The Open University
2006 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
2008 class qubaid_join
extends qubaid_condition
{
2010 public $usageidcolumn;
2015 * Constructor. The meaning of the arguments is explained in the class comment.
2016 * @param string $from SQL fragemnt to go in the FROM clause.
2017 * @param string $usageidcolumn the column in $from that should be
2018 * made equal to the usageid column in the JOIN clause.
2019 * @param string $where SQL fragment to go in the where clause.
2020 * @param array $params required by the SQL. You must use named parameters.
2022 public function __construct($from, $usageidcolumn, $where = '', $params = array()) {
2023 $this->from
= $from;
2024 $this->usageidcolumn
= $usageidcolumn;
2025 $this->params
= $params;
2026 if (empty($where)) {
2029 $this->where
= $where;
2032 public function from_question_attempts($alias) {
2033 return "{$this->from}
2034 JOIN {question_attempts} {$alias} ON " .
2035 "{$alias}.questionusageid = $this->usageidcolumn";
2038 public function where() {
2039 return $this->where
;
2042 public function from_where_params() {
2043 return $this->params
;
2046 public function usage_id_in() {
2047 return "IN (SELECT {$this->usageidcolumn} FROM {$this->from} WHERE {$this->where})";
2050 public function usage_id_in_params() {
2051 return $this->params
;