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
153 * @param question_attempt_step $step the step to store.
154 * @param int $questionattemptid the question attept id this step belongs to.
155 * @param int $seq the sequence number of this stop.
156 * @return stdClass data to insert into the database.
158 protected function make_step_record(question_attempt_step
$step, $questionattemptid, $seq) {
159 $record = new stdClass();
160 $record->questionattemptid
= $questionattemptid;
161 $record->sequencenumber
= $seq;
162 $record->state
= $step->get_state()?
->__toString();
163 $record->fraction
= $step->get_fraction();
164 $record->timecreated
= $step->get_timecreated();
165 $record->userid
= $step->get_user_id();
170 * Take an array of arrays, and flatten it, even if the outer array is empty.
172 * Only public so it can be called from the unit of work. Not part of the
173 * public API of this class.
175 * @param array $stepdata array of zero or more arrays.
176 * @return array made by concatenating all the separate arrays.
178 public function combine_step_data(array $stepdata): array {
179 if (empty($stepdata)) {
182 return call_user_func_array('array_merge', $stepdata);
186 * Helper method used by insert_question_attempt_step and update_question_attempt_step
187 * @param question_attempt_step $step the step to store.
188 * @param int $stepid the id of the step.
189 * @param context $context the context of the owning question_usage_by_activity.
190 * @return array of question_attempt_step_data rows, that still need to be inserted.
192 protected function prepare_step_data(question_attempt_step
$step, $stepid, $context) {
194 foreach ($step->get_all_data() as $name => $value) {
195 if ($value instanceof question_file_saver
) {
196 $value->save_files($stepid, $context);
198 if ($value instanceof question_response_files
) {
199 $value = (string) $value;
202 $data = new stdClass();
203 $data->attemptstepid
= $stepid;
205 $data->value
= $value;
212 * Insert a lot of records into question_attempt_step_data in one go.
214 * Private method, only for use by other parts of the question engine.
216 * @param array $rows the rows to insert.
218 public function insert_all_step_data(array $rows) {
222 $this->db
->insert_records('question_attempt_step_data', $rows);
226 * Store a {@link question_attempt_step} in the database.
228 * Private method, only for use by other parts of the question engine.
230 * @param question_attempt_step $step the step to store.
231 * @param int $questionattemptid the question attept id this step belongs to.
232 * @param int $seq the sequence number of this stop.
233 * @param context $context the context of the owning question_usage_by_activity.
234 * @return array of question_attempt_step_data rows, that still need to be inserted.
236 public function insert_question_attempt_step(question_attempt_step
$step,
237 $questionattemptid, $seq, $context) {
239 $record = $this->make_step_record($step, $questionattemptid, $seq);
240 $record->id
= $this->db
->insert_record('question_attempt_steps', $record);
242 return $this->prepare_step_data($step, $record->id
, $context);
246 * Update a {@link question_attempt_step} in the database.
248 * Private method, only for use by other parts of the question engine.
250 * @param question_attempt_step $step the step to store.
251 * @param int $questionattemptid the question attept id this step belongs to.
252 * @param int $seq the sequence number of this stop.
253 * @param context $context the context of the owning question_usage_by_activity.
254 * @return array of question_attempt_step_data rows, that still need to be inserted.
256 public function update_question_attempt_step(question_attempt_step
$step,
257 $questionattemptid, $seq, $context) {
259 $record = $this->make_step_record($step, $questionattemptid, $seq);
260 $record->id
= $step->get_id();
261 $this->db
->update_record('question_attempt_steps', $record);
263 $this->db
->delete_records('question_attempt_step_data',
264 array('attemptstepid' => $record->id
));
265 return $this->prepare_step_data($step, $record->id
, $context);
269 * Store new metadata for an existing {@link question_attempt} in the database.
271 * Private method, only for use by other parts of the question engine.
273 * @param question_attempt $qa the question attempt to store meta data for.
274 * @param array $names the names of the metadata variables to store.
275 * @return array of question_attempt_step_data rows, that still need to be inserted.
277 public function insert_question_attempt_metadata(question_attempt
$qa, array $names) {
278 $firststep = $qa->get_step(0);
281 foreach ($names as $name) {
282 $data = new stdClass();
283 $data->attemptstepid
= $firststep->get_id();
284 $data->name
= ':_' . $name;
285 $data->value
= $firststep->get_metadata_var($name);
293 * Updates existing metadata for an existing {@link question_attempt} in the database.
295 * Private method, only for use by other parts of the question engine.
297 * @param question_attempt $qa the question attempt to store meta data for.
298 * @param array $names the names of the metadata variables to store.
299 * @return array of question_attempt_step_data rows, that still need to be inserted.
301 public function update_question_attempt_metadata(question_attempt
$qa, array $names) {
306 // Use case-sensitive function sql_equal() and not get_in_or_equal().
307 // Some databases may use case-insensitive collation, we don't want to delete 'X' instead of 'x'.
309 $params = [$qa->get_step(0)->get_id()];
310 foreach ($names as $name) {
311 $sqls[] = $DB->sql_equal('name', '?');
314 $DB->delete_records_select('question_attempt_step_data',
315 'attemptstepid = ? AND (' . join(' OR ', $sqls) . ')', $params);
316 return $this->insert_question_attempt_metadata($qa, $names);
320 * Load a {@link question_attempt_step} from the database.
322 * Private method, only for use by other parts of the question engine.
324 * @param int $stepid the id of the step to load.
325 * @return question_attempt_step the step that was loaded.
327 public function load_question_attempt_step($stepid) {
328 $records = $this->db
->get_recordset_sql("
331 COALESCE(q.qtype, 'missingtype') AS qtype,
332 qas.id AS attemptstepid,
333 qas.questionattemptid,
342 FROM {question_attempt_steps} qas
343 JOIN {question_attempts} qa ON qa.id = qas.questionattemptid
344 JOIN {question_usages} quba ON quba.id = qa.questionusageid
345 LEFT JOIN {question} q ON q.id = qa.questionid
346 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
350 ", array('stepid' => $stepid));
352 if (!$records->valid()) {
353 throw new coding_exception('Failed to load question_attempt_step ' . $stepid);
356 $step = question_attempt_step
::load_from_records($records, $stepid);
363 * Load a {@link question_attempt} from the database, including all its
366 * Normally, you should use {@link question_engine::load_questions_usage_by_activity()}
367 * but there may be rare occasions where for performance reasons, you only
368 * wish to load one qa, in which case you may call this method.
370 * @param int $questionattemptid the id of the question attempt to load.
371 * @return question_attempt the question attempt that was loaded.
373 public function load_question_attempt($questionattemptid) {
374 $records = $this->db
->get_recordset_sql("
377 quba.preferredbehaviour,
378 qa.id AS questionattemptid,
392 qas.id AS attemptstepid,
401 FROM {question_attempts} qa
402 JOIN {question_usages} quba ON quba.id = qa.questionusageid
403 LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
404 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
407 qa.id = :questionattemptid
411 ", array('questionattemptid' => $questionattemptid));
413 if (!$records->valid()) {
414 throw new coding_exception('Failed to load question_attempt ' . $questionattemptid);
417 $record = $records->current();
418 $qa = question_attempt
::load_from_records($records, $questionattemptid,
419 new question_usage_null_observer(), $record->preferredbehaviour
);
426 * Load a {@link question_usage_by_activity} from the database, including
427 * all its {@link question_attempt}s and all their steps.
429 * You should call {@link question_engine::load_questions_usage_by_activity()}
430 * rather than calling this method directly.
432 * @param int $qubaid the id of the usage to load.
433 * @return question_usage_by_activity the usage that was loaded.
435 public function load_questions_usage_by_activity($qubaid) {
436 $records = $this->db
->get_recordset_sql("
441 quba.preferredbehaviour,
442 qa.id AS questionattemptid,
456 qas.id AS attemptstepid,
465 FROM {question_usages} quba
466 LEFT JOIN {question_attempts} qa ON qa.questionusageid = quba.id
467 LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
468 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
476 ", array('qubaid' => $qubaid));
478 if (!$records->valid()) {
479 throw new coding_exception('Failed to load questions_usage_by_activity ' . $qubaid);
482 $quba = question_usage_by_activity
::load_from_records($records, $qubaid);
489 * Load all {@link question_usage_by_activity} from the database for one qubaid_condition
490 * Include all its {@link question_attempt}s and all their steps.
492 * This method may be called publicly.
494 * @param qubaid_condition $qubaids the condition that tells us which usages to load.
495 * @return question_usage_by_activity[] the usages that were loaded.
497 public function load_questions_usages_by_activity($qubaids) {
498 $records = $this->db
->get_recordset_sql("
503 quba.preferredbehaviour,
504 qa.id AS questionattemptid,
518 qas.id AS attemptstepid,
527 FROM {question_usages} quba
528 LEFT JOIN {question_attempts} qa ON qa.questionusageid = quba.id
529 LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
530 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
533 quba.id {$qubaids->usage_id_in()}
539 ", $qubaids->usage_id_in_params());
542 while ($records->valid()) {
543 $record = $records->current();
544 $qubas[$record->qubaid
] = question_usage_by_activity
::load_from_records($records, $record->qubaid
);
553 * Load information about the latest state of each question from the database.
555 * This method may be called publicly.
557 * @param qubaid_condition $qubaids used to restrict which usages are included
558 * in the query. See {@link qubaid_condition}.
559 * @param array|null $slots (optional) list of slots for which to return information. Default all slots.
560 * @param string|null $fields
561 * @return array of records. See the SQL in this function to see the fields available.
563 public function load_questions_usages_latest_steps(qubaid_condition
$qubaids, $slots = null, $fields = null) {
566 } else if ($slots !== null) {
567 [$slottest, $params] = $this->db
->get_in_or_equal($slots, SQL_PARAMS_NAMED
, 'slot');
568 $slotwhere = " AND qa.slot {$slottest}";
574 if ($fields === null) {
576 qa.id AS questionattemptid,
590 qas.id AS attemptstepid,
599 $records = $this->db
->get_records_sql("
603 FROM {$qubaids->from_question_attempts('qa')}
604 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
605 AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
610 ", $params +
$qubaids->from_where_params());
616 * Load summary information about the state of each question in a group of
617 * attempts. This is used, for example, by the quiz manual grading report,
618 * to show how many attempts at each question need to be graded.
620 * This method may be called publicly.
622 * @param qubaid_condition $qubaids used to restrict which usages are included
623 * in the query. See {@link qubaid_condition}.
624 * @param array|null $slots (optional) list of slots for which to return information. Default all slots.
625 * @return array The array keys are 'slot,questionid'. The values are objects with
626 * fields $slot, $questionid, $inprogress, $name, $needsgrading, $autograded,
627 * $manuallygraded and $all.
629 public function load_questions_usages_question_state_summary(qubaid_condition
$qubaids, $slots = null) {
630 if ($slots !== null) {
631 [$slottest, $params] = $this->db
->get_in_or_equal($slots, SQL_PARAMS_NAMED
, 'slot');
632 $slotwhere = " AND qa.slot {$slottest}";
638 $rs = $this->db
->get_recordset_sql("
644 {$this->full_states_to_summary_state_sql()}
646 COUNT(1) AS numattempts
648 FROM {$qubaids->from_question_attempts('qa')}
649 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
650 AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
651 JOIN {question} q ON q.id = qa.questionid
663 {$this->full_states_to_summary_state_sql()}
671 ", $params +
$qubaids->from_where_params());
674 foreach ($rs as $row) {
675 $index = $row->slot
. ',' . $row->questionid
;
677 if (!array_key_exists($index, $results)) {
678 $res = new stdClass();
679 $res->slot
= $row->slot
;
680 $res->questionid
= $row->questionid
;
681 $res->name
= $row->name
;
682 $res->inprogress
= 0;
683 $res->needsgrading
= 0;
684 $res->autograded
= 0;
685 $res->manuallygraded
= 0;
687 $results[$index] = $res;
690 $results[$index]->{$row->summarystate
} = $row->numattempts
;
691 $results[$index]->all +
= $row->numattempts
;
699 * Get a list of usage ids where the question with slot $slot, and optionally
700 * also with question id $questionid, is in summary state $summarystate. Also
701 * return the total count of such states.
703 * Only a subset of the ids can be returned by using $orderby, $limitfrom and
704 * $limitnum. A special value 'random' can be passed as $orderby, in which case
705 * $limitfrom is ignored.
707 * This method may be called publicly.
709 * @param qubaid_condition $qubaids used to restrict which usages are included
710 * in the query. See {@link qubaid_condition}.
711 * @param int $slot The slot for the questions you want to know about.
712 * @param int $questionid (optional) Only return attempts that were of this specific question.
713 * @param string $summarystate the summary state of interest, or 'all'.
714 * @param string $orderby the column to order by.
715 * @param array $params any params required by any of the SQL fragments.
716 * @param int $limitfrom implements paging of the results.
717 * Ignored if $orderby = random or $limitnum is null.
718 * @param int $limitnum implements paging of the results. null = all.
719 * @param string $extraselect anything passed here will be added to the SELECT list, use this to return extra data.
720 * @return array with two elements, an array of usage ids, and a count of the total number.
722 public function load_questions_usages_where_question_in_state(
723 qubaid_condition
$qubaids, $summarystate, $slot, $questionid = null,
724 $orderby = 'random', $params = array(), $limitfrom = 0, $limitnum = null, $extraselect = '') {
728 $extrawhere .= ' AND qa.questionid = :questionid';
729 $params['questionid'] = $questionid;
731 if ($summarystate !== 'all') {
732 list($test, $sparams) = $this->in_summary_state_test($summarystate);
733 $extrawhere .= ' AND qas.state ' . $test;
737 if (!empty($extraselect)) {
738 $extraselect = ', ' . $extraselect;
741 if ($orderby === 'random') {
743 } else if ($orderby) {
744 $sqlorderby = 'ORDER BY ' . $orderby;
749 // We always want the total count, as well as the partcular list of ids
750 // based on the paging and sort order. Because the list of ids is never
751 // going to be too ridiculously long. My worst-case scenario is
752 // 10,000 students in the course, each doing 5 quiz attempts. That
753 // is a 50,000 element int => int array, which PHP seems to use 5MB
754 // memory to store on a 64 bit server.
755 $qubaidswhere = $qubaids->where(); // Must call this before params.
756 $params +
= $qubaids->from_where_params();
757 $params['slot'] = $slot;
758 $sql = "SELECT qa.questionusageid,
761 FROM {$qubaids->from_question_attempts('qa')}
762 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
763 AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
764 JOIN {question} q ON q.id = qa.questionid
765 WHERE {$qubaidswhere}
770 $qubaids = $this->db
->get_records_sql_menu($sql, $params);
772 $qubaids = array_keys($qubaids);
773 $count = count($qubaids);
775 if ($orderby === 'random') {
780 if (!is_null($limitnum)) {
781 $qubaids = array_slice($qubaids, $limitfrom, $limitnum);
784 return array($qubaids, $count);
788 * Load the average mark, and number of attempts, for each slot in a set of
791 * This method may be called publicly.
793 * @param qubaid_condition $qubaids used to restrict which usages are included
794 * in the query. See {@link qubaid_condition}.
795 * @param array|null $slots if null, load info for all quesitions, otherwise only
796 * load the averages for the specified questions.
797 * @return array of objects with fields ->slot, ->averagefraction and ->numaveraged.
799 public function load_average_marks(qubaid_condition
$qubaids, $slots = null) {
800 if (!empty($slots)) {
801 list($slottest, $slotsparams) = $this->db
->get_in_or_equal(
802 $slots, SQL_PARAMS_NAMED
, 'slot');
803 $slotwhere = " AND qa.slot {$slottest}";
806 $slotsparams = array();
809 list($statetest, $stateparams) = $this->db
->get_in_or_equal(array(
810 (string) question_state
::$gaveup,
811 (string) question_state
::$gradedwrong,
812 (string) question_state
::$gradedpartial,
813 (string) question_state
::$gradedright,
814 (string) question_state
::$mangaveup,
815 (string) question_state
::$mangrwrong,
816 (string) question_state
::$mangrpartial,
817 (string) question_state
::$mangrright), SQL_PARAMS_NAMED
, 'st');
819 return $this->db
->get_records_sql("
822 AVG(COALESCE(qas.fraction, 0)) AS averagefraction,
823 COUNT(1) AS numaveraged
825 FROM {$qubaids->from_question_attempts('qa')}
826 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
827 AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
832 AND qas.state $statetest
837 ", $slotsparams +
$stateparams +
$qubaids->from_where_params());
841 * Load all the attempts at a given queston from a set of question_usages.
844 * This method may be called publicly.
846 * @param int $questionid the question to load all the attempts fors.
847 * @param qubaid_condition $qubaids used to restrict which usages are included
848 * in the query. See {@link qubaid_condition}.
849 * @return question_attempt[] array of question_attempts that were loaded.
851 public function load_attempts_at_question($questionid, qubaid_condition
$qubaids) {
855 quba.preferredbehaviour,
856 qa.id AS questionattemptid,
870 qas.id AS attemptstepid,
879 FROM {$qubaids->from_question_attempts('qa')}
880 JOIN {question_usages} quba ON quba.id = qa.questionusageid
881 LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
882 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
885 {$qubaids->where()} AND
886 qa.questionid = :questionid
893 // For qubaid_list must call this after calling methods that generate sql.
894 $params = $qubaids->from_where_params();
895 $params['questionid'] = $questionid;
897 $records = $this->db
->get_recordset_sql($sql, $params);
899 $questionattempts = array();
900 while ($records->valid()) {
901 $record = $records->current();
902 $questionattempts[$record->questionattemptid
] =
903 question_attempt
::load_from_records($records,
904 $record->questionattemptid
, new question_usage_null_observer(),
905 $record->preferredbehaviour
);
909 return $questionattempts;
913 * Update a question_usages row to refect any changes in a usage (but not
914 * any of its question_attempts.
916 * You should not call this method directly. You should use
917 * @link question_engine::save_questions_usage_by_activity()}.
919 * @param question_usage_by_activity $quba the usage that has changed.
921 public function update_questions_usage_by_activity(question_usage_by_activity
$quba) {
922 $record = new stdClass();
923 $record->id
= $quba->get_id();
924 $record->contextid
= $quba->get_owning_context()->id
;
925 $record->component
= $quba->get_owning_component();
926 $record->preferredbehaviour
= $quba->get_preferred_behaviour();
928 $this->db
->update_record('question_usages', $record);
932 * Update a question_attempts row to refect any changes in a question_attempt
933 * (but not any of its steps).
935 * You should not call this method directly. You should use
936 * @link question_engine::save_questions_usage_by_activity()}.
938 * @param question_attempt $qa the question attempt that has changed.
940 public function update_question_attempt(question_attempt
$qa) {
941 $record = new stdClass();
942 $record->id
= $qa->get_database_id();
943 $record->slot
= $qa->get_slot();
944 $record->questionid
= $qa->get_question(false)->id
;
945 $record->variant
= $qa->get_variant();
946 $record->maxmark
= $qa->get_max_mark();
947 $record->minfraction
= $qa->get_min_fraction();
948 $record->maxfraction
= $qa->get_max_fraction();
949 $record->flagged
= $qa->is_flagged();
950 $record->questionsummary
= $qa->get_question_summary();
951 $record->rightanswer
= $qa->get_right_answer_summary();
952 $record->responsesummary
= $qa->get_response_summary();
953 $record->timemodified
= time();
955 $this->db
->update_record('question_attempts', $record);
959 * Delete a question_usage_by_activity and all its associated
961 * You should not call this method directly. You should use
962 * @link question_engine::delete_questions_usage_by_activities()}.
964 * {@link question_attempts} and {@link question_attempt_steps} from the
966 * @param qubaid_condition $qubaids identifies which question useages to delete.
968 public function delete_questions_usage_by_activities(qubaid_condition
$qubaids) {
969 $where = "qa.questionusageid {$qubaids->usage_id_in()}";
970 $params = $qubaids->usage_id_in_params();
972 $contextids = $this->db
->get_records_sql_menu("
973 SELECT DISTINCT contextid, 1
974 FROM {question_usages}
975 WHERE id {$qubaids->usage_id_in()}", $qubaids->usage_id_in_params());
976 foreach ($contextids as $contextid => $notused) {
977 $this->delete_response_files($contextid, "IN (
979 FROM {question_attempts} qa
980 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
981 WHERE $where)", $params);
984 if ($this->db
->get_dbfamily() == 'mysql') {
985 $this->delete_usage_records_for_mysql($qubaids);
989 $this->db
->delete_records_select('question_attempt_step_data', "attemptstepid IN (
991 FROM {question_attempts} qa
992 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
993 WHERE $where)", $params);
995 $this->db
->delete_records_select('question_attempt_steps', "questionattemptid IN (
997 FROM {question_attempts} qa
998 WHERE $where)", $params);
1000 $this->db
->delete_records_select('question_attempts',
1001 "{question_attempts}.questionusageid {$qubaids->usage_id_in()}",
1002 $qubaids->usage_id_in_params());
1004 $this->db
->delete_records_select('question_usages',
1005 "{question_usages}.id {$qubaids->usage_id_in()}", $qubaids->usage_id_in_params());
1009 * This function is a work-around for poor MySQL performance with
1010 * DELETE FROM x WHERE id IN (SELECT ...). We have to use a non-standard
1011 * syntax to get good performance. See MDL-29520.
1012 * @param qubaid_condition $qubaids identifies which question useages to delete.
1014 protected function delete_usage_records_for_mysql(qubaid_condition
$qubaids) {
1015 // Get the list of question attempts to delete and delete them in chunks.
1016 $allids = $this->db
->get_records_sql_menu("
1017 SELECT DISTINCT id, id AS id2
1018 FROM {question_usages}
1019 WHERE id " . $qubaids->usage_id_in(),
1020 $qubaids->usage_id_in_params());
1022 foreach (array_chunk($allids, 1000) as $todelete) {
1023 list($idsql, $idparams) = $this->db
->get_in_or_equal($todelete);
1024 $this->db
->execute('
1025 DELETE qu, qa, qas, qasd
1026 FROM {question_usages} qu
1027 JOIN {question_attempts} qa ON qa.questionusageid = qu.id
1028 LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
1029 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
1030 WHERE qu.id ' . $idsql,
1036 * Delete some steps of a question attempt.
1038 * Private method, only for use by other parts of the question engine.
1040 * @param array $stepids array of step ids to delete.
1041 * @param context $context the context that the $quba belongs to.
1043 public function delete_steps($stepids, $context) {
1044 if (empty($stepids)) {
1047 list($test, $params) = $this->db
->get_in_or_equal($stepids, SQL_PARAMS_NAMED
);
1049 $this->delete_response_files($context->id
, $test, $params);
1051 $this->db
->delete_records_select('question_attempt_step_data',
1052 "attemptstepid {$test}", $params);
1053 $this->db
->delete_records_select('question_attempt_steps',
1054 "id {$test}", $params);
1058 * Delete all the files belonging to the response variables in the gives
1059 * question attempt steps.
1060 * @param int $contextid the context these attempts belong to.
1061 * @param string $itemidstest a bit of SQL that can be used in a
1062 * WHERE itemid $itemidstest clause. Must use named params.
1063 * @param array $params any query parameters used in $itemidstest.
1065 protected function delete_response_files($contextid, $itemidstest, $params) {
1066 $fs = get_file_storage();
1067 foreach (question_engine
::get_all_response_file_areas() as $filearea) {
1068 $fs->delete_area_files_select($contextid, 'question', $filearea,
1069 $itemidstest, $params);
1074 * Delete all the previews for a given question.
1076 * Private method, only for use by other parts of the question engine.
1078 * @param int $questionid question id.
1080 public function delete_previews($questionid) {
1081 $previews = $this->db
->get_records_sql_menu("
1082 SELECT DISTINCT quba.id, 1
1083 FROM {question_usages} quba
1084 JOIN {question_attempts} qa ON qa.questionusageid = quba.id
1085 WHERE quba.component = 'core_question_preview' AND
1086 qa.questionid = ?", array($questionid));
1087 if (empty($previews)) {
1090 $this->delete_questions_usage_by_activities(new qubaid_list($previews));
1094 * Update the flagged state of a question in the database.
1096 * You should call {@link question_engine::update_flag()()}
1097 * rather than calling this method directly.
1099 * @param int $qubaid the question usage id.
1100 * @param int $questionid the question id.
1101 * @param int $qaid the question_attempt id.
1102 * @param int $slot the slot number of the question attempt to update.
1103 * @param bool $newstate the new state of the flag. true = flagged.
1105 public function update_question_attempt_flag($qubaid, $questionid, $qaid, $slot, $newstate) {
1106 if (!$this->db
->record_exists('question_attempts', array('id' => $qaid,
1107 'questionusageid' => $qubaid, 'questionid' => $questionid, 'slot' => $slot))) {
1108 throw new moodle_exception('errorsavingflags', 'question');
1111 $this->db
->set_field('question_attempts', 'flagged', $newstate, array('id' => $qaid));
1115 * Get all the WHEN 'x' THEN 'y' terms needed to convert the question_attempt_steps.state
1116 * column to a summary state. Use this like
1117 * CASE qas.state {$this->full_states_to_summary_state_sql()} END AS summarystate,
1119 * @return string SQL fragment.
1121 protected function full_states_to_summary_state_sql() {
1123 foreach (question_state
::get_all() as $state) {
1124 $sql .= "WHEN '{$state}' THEN '{$state->get_summary_state()}'\n";
1130 * Get the SQL needed to test that question_attempt_steps.state is in a
1131 * state corresponding to $summarystate.
1133 * This method may be called publicly.
1135 * @param string $summarystate one of
1136 * inprogress, needsgrading, manuallygraded or autograded
1137 * @param bool $equal if false, do a NOT IN test. Default true.
1138 * @param string $prefix used in the call to $DB->get_in_or_equal().
1139 * @return array as returned by $DB->get_in_or_equal().
1141 public function in_summary_state_test($summarystate, $equal = true, $prefix = 'summarystates') {
1142 $states = question_state
::get_all_for_summary_state($summarystate);
1143 return $this->db
->get_in_or_equal(array_map('strval', $states),
1144 SQL_PARAMS_NAMED
, $prefix, $equal);
1148 * Change the maxmark for the question_attempt with number in usage $slot
1149 * for all the specified question_attempts.
1151 * You should call {@link question_engine::set_max_mark_in_attempts()}
1152 * rather than calling this method directly.
1154 * @param qubaid_condition $qubaids Selects which usages are updated.
1155 * @param int $slot the number is usage to affect.
1156 * @param number $newmaxmark the new max mark to set.
1158 public function set_max_mark_in_attempts(qubaid_condition
$qubaids, $slot, $newmaxmark) {
1159 if ($this->db
->get_dbfamily() == 'mysql') {
1160 // MySQL's query optimiser completely fails to cope with the
1161 // set_field_select call below, so we have to give it a clue. See MDL-32616.
1162 // TODO MDL-29589 encapsulate this MySQL-specific code with a $DB method.
1163 $this->db
->execute("
1164 UPDATE " . $qubaids->from_question_attempts('qa') . "
1165 SET qa.maxmark = :newmaxmark
1166 WHERE " . $qubaids->where() . "
1168 ", $qubaids->from_where_params() +
array('newmaxmark' => $newmaxmark, 'slot' => $slot));
1172 // Normal databases.
1173 $this->db
->set_field_select('question_attempts', 'maxmark', $newmaxmark,
1174 "questionusageid {$qubaids->usage_id_in()} AND slot = :slot",
1175 $qubaids->usage_id_in_params() +
array('slot' => $slot));
1179 * Return a sub-query that computes the sum of the marks for all the questions
1180 * in a usage. Which usage to compute the sum for is controlled by the $qubaid
1183 * See {@see \mod_quiz\grade_calculator::recompute_all_attempt_sumgrades()} for an example of the usage of
1186 * This method may be called publicly.
1188 * @param string $qubaid SQL fragment that controls which usage is summed.
1189 * This will normally be the name of a column in the outer query. Not that this
1190 * SQL fragment must not contain any placeholders.
1191 * @return string SQL code for the subquery.
1193 public function sum_usage_marks_subquery($qubaid) {
1194 // To explain the COALESCE in the following SQL: SUM(lots of NULLs) gives
1195 // NULL, while SUM(one 0.0 and lots of NULLS) gives 0.0. We don't want that.
1196 // We always want to return a number, so the COALESCE is there to turn the
1197 // NULL total into a 0.
1198 return "SELECT COALESCE(SUM(qa.maxmark * qas.fraction), 0)
1199 FROM {question_attempts} qa
1200 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
1201 AND qas.sequencenumber = (
1202 SELECT MAX(summarks_qas.sequencenumber)
1203 FROM {question_attempt_steps} summarks_qas
1204 WHERE summarks_qas.questionattemptid = qa.id
1206 WHERE qa.questionusageid = $qubaid
1208 WHEN qas.state = 'needsgrading' AND qa.maxmark > 0 THEN 1
1214 * Get a subquery that returns the latest step of every qa in some qubas.
1215 * Currently, this is only used by the quiz reports. See
1216 * {@see \mod_quiz\local\reports\attempts_report_table::add_latest_state_join()}.
1218 * This method may be called publicly.
1220 * @param string $alias alias to use for this inline-view.
1221 * @param qubaid_condition $qubaids restriction on which question_usages we
1222 * are interested in. This is important for performance.
1223 * @return array with two elements, the SQL fragment and any params requried.
1225 public function question_attempt_latest_state_view($alias, qubaid_condition
$qubaids) {
1227 SELECT {$alias}qa.id AS questionattemptid,
1228 {$alias}qa.questionusageid,
1230 {$alias}qa.behaviour,
1231 {$alias}qa.questionid,
1234 {$alias}qa.minfraction,
1235 {$alias}qa.maxfraction,
1237 {$alias}qa.questionsummary,
1238 {$alias}qa.rightanswer,
1239 {$alias}qa.responsesummary,
1240 {$alias}qa.timemodified,
1241 {$alias}qas.id AS attemptstepid,
1242 {$alias}qas.sequencenumber,
1244 {$alias}qas.fraction,
1245 {$alias}qas.timecreated,
1248 FROM {$qubaids->from_question_attempts($alias . 'qa')}
1249 JOIN {question_attempt_steps} {$alias}qas ON {$alias}qas.questionattemptid = {$alias}qa.id
1250 AND {$alias}qas.sequencenumber = {$this->latest_step_for_qa_subquery($alias . 'qa.id')}
1251 WHERE {$qubaids->where()}
1252 ) {$alias}", $qubaids->from_where_params());
1255 protected function latest_step_for_qa_subquery($questionattemptid = 'qa.id') {
1257 SELECT MAX(sequencenumber)
1258 FROM {question_attempt_steps}
1259 WHERE questionattemptid = $questionattemptid
1264 * Are any of these questions are currently in use?
1266 * You should call {@link question_engine::questions_in_use()}
1267 * rather than calling this method directly.
1269 * @param array $questionids of question ids.
1270 * @param qubaid_condition $qubaids ids of the usages to consider.
1271 * @return bool whether any of these questions are being used by any of
1274 public function questions_in_use(array $questionids, qubaid_condition
$qubaids) {
1275 list($test, $params) = $this->db
->get_in_or_equal($questionids);
1276 return $this->db
->record_exists_select('question_attempts',
1277 'questionid ' . $test . ' AND questionusageid ' .
1278 $qubaids->usage_id_in(), $params +
$qubaids->usage_id_in_params());
1282 * Get the number of times each variant has been used for each question in a list
1283 * in a set of usages.
1284 * @param array $questionids of question ids.
1285 * @param qubaid_condition $qubaids ids of the usages to consider.
1286 * @return array questionid => variant number => num uses.
1288 public function load_used_variants(array $questionids, qubaid_condition
$qubaids) {
1289 list($test, $params) = $this->db
->get_in_or_equal($questionids, SQL_PARAMS_NAMED
, 'qid');
1290 $recordset = $this->db
->get_recordset_sql("
1291 SELECT qa.questionid, qa.variant, COUNT(1) AS usescount
1292 FROM " . $qubaids->from_question_attempts('qa') . "
1293 WHERE qa.questionid $test
1294 AND " . $qubaids->where() . "
1295 GROUP BY qa.questionid, qa.variant
1296 ORDER BY COUNT(1) ASC
1297 ", $params +
$qubaids->from_where_params());
1299 $usedvariants = array_combine($questionids, array_fill(0, count($questionids), array()));
1300 foreach ($recordset as $row) {
1301 $usedvariants[$row->questionid
][$row->variant
] = $row->usescount
;
1303 $recordset->close();
1304 return $usedvariants;
1310 * Implementation of the unit of work pattern for the question engine.
1312 * See http://martinfowler.com/eaaCatalog/unitOfWork.html. This tracks all the
1313 * changes to a {@link question_usage_by_activity}, and its constituent parts,
1314 * so that the changes can be saved to the database when {@link save()} is called.
1316 * @copyright 2009 The Open University
1317 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1319 class question_engine_unit_of_work
implements question_usage_observer
{
1320 /** @var question_usage_by_activity the usage being tracked. */
1323 /** @var boolean whether any of the fields of the usage have been changed. */
1324 protected $modified = false;
1327 * @var question_attempt[] list of slot => {@link question_attempt}s that
1328 * have been added to the usage.
1330 protected $attemptsadded = array();
1333 * @var question_attempt[] list of slot => {@link question_attempt}s that
1334 * were already in the usage, and which have been modified.
1336 protected $attemptsmodified = array();
1339 * @var question_attempt[] list of slot => {@link question_attempt}s that
1340 * have been added to the usage.
1342 protected $attemptsdeleted = array();
1345 * @var array of array(question_attempt_step, question_attempt id, seq number)
1346 * of steps that have been added to question attempts in this usage.
1348 protected $stepsadded = array();
1351 * @var array of array(question_attempt_step, question_attempt id, seq number)
1352 * of steps that have been modified in their attempt.
1354 protected $stepsmodified = array();
1357 * @var question_attempt_step[] list of question_attempt_step.id => question_attempt_step of steps
1358 * that were previously stored in the database, but which are no longer required.
1360 protected $stepsdeleted = array();
1363 * @var array int slot => string name => question_attempt.
1365 protected $metadataadded = array();
1368 * @var array int slot => string name => question_attempt.
1370 protected $metadatamodified = array();
1374 * @param question_usage_by_activity $quba the usage to track.
1376 public function __construct(question_usage_by_activity
$quba) {
1377 $this->quba
= $quba;
1380 public function notify_modified() {
1381 $this->modified
= true;
1384 public function notify_attempt_added(question_attempt
$qa) {
1385 $this->attemptsadded
[$qa->get_slot()] = $qa;
1388 public function notify_attempt_modified(question_attempt
$qa) {
1389 $slot = $qa->get_slot();
1390 if (!array_key_exists($slot, $this->attemptsadded
)) {
1391 $this->attemptsmodified
[$slot] = $qa;
1395 public function notify_attempt_moved(question_attempt
$qa, $oldslot) {
1396 $newslot = $qa->get_slot();
1398 if (array_key_exists($oldslot, $this->attemptsadded
)) {
1399 unset($this->attemptsadded
[$oldslot]);
1400 $this->attemptsadded
[$newslot] = $qa;
1404 if (array_key_exists($oldslot, $this->attemptsmodified
)) {
1405 unset($this->attemptsmodified
[$oldslot]);
1407 $this->attemptsmodified
[$newslot] = $qa;
1409 if (array_key_exists($oldslot, $this->metadataadded
)) {
1410 $this->metadataadded
[$newslot] = $this->metadataadded
[$oldslot];
1411 unset($this->metadataadded
[$oldslot]);
1413 if (array_key_exists($oldslot, $this->metadatamodified
)) {
1414 $this->metadatamodified
[$newslot] = $this->metadatamodified
[$oldslot];
1415 unset($this->metadatamodified
[$oldslot]);
1419 public function notify_step_added(question_attempt_step
$step, question_attempt
$qa, $seq) {
1420 if (array_key_exists($qa->get_slot(), $this->attemptsadded
)) {
1424 if (($key = $this->is_step_added($step)) !== false) {
1428 if (($key = $this->is_step_modified($step)) !== false) {
1429 throw new coding_exception('Cannot add a step that has already been modified.');
1432 if (($key = $this->is_step_deleted($step)) !== false) {
1433 unset($this->stepsdeleted
[$step->get_id()]);
1434 $this->stepsmodified
[] = array($step, $qa->get_database_id(), $seq);
1438 $stepid = $step->get_id();
1440 if (array_key_exists($stepid, $this->stepsdeleted
)) {
1441 unset($this->stepsdeleted
[$stepid]);
1443 $this->stepsmodified
[] = array($step, $qa->get_database_id(), $seq);
1446 $this->stepsadded
[] = array($step, $qa->get_database_id(), $seq);
1450 public function notify_step_modified(question_attempt_step
$step, question_attempt
$qa, $seq) {
1451 if (array_key_exists($qa->get_slot(), $this->attemptsadded
)) {
1455 if (($key = $this->is_step_added($step)) !== false) {
1459 if (($key = $this->is_step_deleted($step)) !== false) {
1460 throw new coding_exception('Cannot modify a step after it has been deleted.');
1463 $stepid = $step->get_id();
1464 if (empty($stepid)) {
1465 throw new coding_exception('Cannot modify a step that has never been stored in the database.');
1468 $this->stepsmodified
[] = array($step, $qa->get_database_id(), $seq);
1471 public function notify_step_deleted(question_attempt_step
$step, question_attempt
$qa) {
1472 if (array_key_exists($qa->get_slot(), $this->attemptsadded
)) {
1476 if (($key = $this->is_step_added($step)) !== false) {
1477 unset($this->stepsadded
[$key]);
1481 if (($key = $this->is_step_modified($step)) !== false) {
1482 unset($this->stepsmodified
[$key]);
1485 $stepid = $step->get_id();
1486 if (empty($stepid)) {
1487 return; // Was never in the database.
1490 $this->stepsdeleted
[$stepid] = $step;
1493 public function notify_metadata_added(question_attempt
$qa, $name) {
1494 if (array_key_exists($qa->get_slot(), $this->attemptsadded
)) {
1498 if ($this->is_step_added($qa->get_step(0)) !== false) {
1502 if (isset($this->metadataadded
[$qa->get_slot()][$name])) {
1506 $this->metadataadded
[$qa->get_slot()][$name] = $qa;
1509 public function notify_metadata_modified(question_attempt
$qa, $name) {
1510 if (array_key_exists($qa->get_slot(), $this->attemptsadded
)) {
1514 if ($this->is_step_added($qa->get_step(0)) !== false) {
1518 if (isset($this->metadataadded
[$qa->get_slot()][$name])) {
1522 if (isset($this->metadatamodified
[$qa->get_slot()][$name])) {
1526 $this->metadatamodified
[$qa->get_slot()][$name] = $qa;
1530 * Determine if a step is new. If so get its array key.
1532 * @param question_attempt_step $step a step
1533 * @return int|false if the step is in the list of steps to be added, return
1534 * the key, otherwise return false.
1536 protected function is_step_added(question_attempt_step
$step) {
1537 foreach ($this->stepsadded
as $key => $data) {
1538 list($addedstep) = $data;
1539 if ($addedstep === $step) {
1547 * Determine if a step is modified. If so get its array key.
1549 * @param question_attempt_step $step a step
1550 * @return int|false if the step is in the list of steps to be modified, return
1551 * the key, otherwise return false.
1553 protected function is_step_modified(question_attempt_step
$step) {
1554 foreach ($this->stepsmodified
as $key => $data) {
1555 list($modifiedstep) = $data;
1556 if ($modifiedstep === $step) {
1564 * @param question_attempt_step $step a step
1565 * @return bool whether the step is in the list of steps to be deleted.
1567 protected function is_step_deleted(question_attempt_step
$step) {
1568 foreach ($this->stepsdeleted
as $deletedstep) {
1569 if ($deletedstep === $step) {
1577 * Write all the changes we have recorded to the database.
1578 * @param question_engine_data_mapper $dm the mapper to use to update the database.
1580 public function save(question_engine_data_mapper
$dm) {
1581 $dm->delete_steps(array_keys($this->stepsdeleted
), $this->quba
->get_owning_context());
1583 // Initially an array of array of question_attempt_step_objects.
1584 // Built as a nested array for efficiency, then flattened.
1585 $stepdata = array();
1587 foreach ($this->stepsmodified
as $stepinfo) {
1588 list($step, $questionattemptid, $seq) = $stepinfo;
1589 $stepdata[] = $dm->update_question_attempt_step(
1590 $step, $questionattemptid, $seq, $this->quba
->get_owning_context());
1593 foreach ($this->stepsadded
as $stepinfo) {
1594 list($step, $questionattemptid, $seq) = $stepinfo;
1595 $stepdata[] = $dm->insert_question_attempt_step(
1596 $step, $questionattemptid, $seq, $this->quba
->get_owning_context());
1599 foreach ($this->attemptsmodified
as $qa) {
1600 $dm->update_question_attempt($qa);
1603 foreach ($this->attemptsadded
as $qa) {
1604 $stepdata[] = $dm->insert_question_attempt(
1605 $qa, $this->quba
->get_owning_context());
1608 foreach ($this->metadataadded
as $info) {
1610 $stepdata[] = $dm->insert_question_attempt_metadata($qa, array_keys($info));
1613 foreach ($this->metadatamodified
as $info) {
1615 $stepdata[] = $dm->update_question_attempt_metadata($qa, array_keys($info));
1618 if ($this->modified
) {
1619 $dm->update_questions_usage_by_activity($this->quba
);
1622 $dm->insert_all_step_data($dm->combine_step_data($stepdata));
1624 $this->stepsdeleted
= array();
1625 $this->stepsmodified
= array();
1626 $this->stepsadded
= array();
1627 $this->attemptsdeleted
= array();
1628 $this->attemptsadded
= array();
1629 $this->attemptsmodified
= array();
1630 $this->modified
= false;
1636 * The interface implemented by {@link question_file_saver} and {@link question_file_loader}.
1638 * @copyright 2012 The Open University
1639 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1641 interface question_response_files
{
1643 * Get the files that were submitted.
1644 * @return array of stored_files objects.
1646 public function get_files();
1651 * This class represents the promise to save some files from a particular draft
1652 * file area into a particular file area. It is used beause the necessary
1653 * information about what to save is to hand in the
1654 * {@link question_attempt::process_response_files()} method, but we don't know
1655 * if this question attempt will actually be saved in the database until later,
1656 * when the {@link question_engine_unit_of_work} is saved, if it is.
1658 * @copyright 2011 The Open University
1659 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1661 class question_file_saver
implements question_response_files
{
1662 /** @var int the id of the draft file area to save files from. */
1663 protected $draftitemid;
1664 /** @var string the owning component name. */
1665 protected $component;
1666 /** @var string the file area name. */
1667 protected $filearea;
1670 * @var string the value to store in the question_attempt_step_data to
1671 * represent these files.
1673 protected $value = null;
1678 * @param int $draftitemid the draft area to save the files from.
1679 * @param string $component the component for the file area to save into.
1680 * @param string $uncleanedfilearea the name of the file area to save into - but before it has been cleaned up.
1681 * @param string $text optional content containing file links.
1683 public function __construct($draftitemid, $component, $uncleanedfilearea, $text = null) {
1684 $this->draftitemid
= $draftitemid;
1685 $this->component
= $component;
1686 $this->filearea
= self
::clean_file_area_name($uncleanedfilearea);
1687 $this->value
= $this->compute_value($draftitemid, $text);
1691 * Compute the value that should be stored in the question_attempt_step_data table.
1693 * Contains a hash that (almost) uniquely encodes all the files.
1695 * @param int $draftitemid the draft file area itemid.
1696 * @param string $text optional content containing file links.
1697 * @return string the value.
1699 protected function compute_value($draftitemid, $text) {
1702 $fs = get_file_storage();
1703 $usercontext = context_user
::instance($USER->id
);
1705 $files = $fs->get_area_files($usercontext->id
, 'user', 'draft',
1706 $draftitemid, 'sortorder, filepath, filename', false);
1709 foreach ($files as $file) {
1710 $string .= $file->get_filepath() . $file->get_filename() . '|' .
1711 $file->get_contenthash() . '|';
1713 $hash = md5($string);
1715 if (is_null($text)) {
1723 // We add the file hash so a simple string comparison will say if the
1724 // files have been changed. First strip off any existing file hash.
1726 $text = preg_replace('/\s*<!-- File hash: \w+ -->\s*$/', '', $text);
1727 $text = file_rewrite_urls_to_pluginfile($text, $draftitemid);
1729 $text .= '<!-- File hash: ' . $hash . ' -->';
1735 public function __toString() {
1736 return $this->value
;
1740 * Actually save the files.
1742 * @param integer $itemid the item id for the file area to save into.
1743 * @param context $context the context where the files should be saved.
1745 public function save_files($itemid, $context) {
1746 file_save_draft_area_files($this->draftitemid
, $context->id
,
1747 $this->component
, $this->filearea
, $itemid);
1751 * Clean up a possible file area name to ensure that it matches the required rules.
1753 * @param string $uncleanedfilearea the proposed file area name (e.g. 'response_-attachments').
1754 * @return string a similar valid file area name. E.g: response_attachments.
1756 public static function clean_file_area_name(string $uncleanedfilearea): string {
1757 $filearea = $uncleanedfilearea;
1758 if ($filearea !== clean_param($filearea, PARAM_AREA
)) {
1759 // Only lowercase ascii letters, numbers and underscores are allowed.
1760 // Remove the invalid character in the filearea string.
1761 $filearea = preg_replace('~[^a-z0-9_]~', '', core_text
::strtolower($filearea));
1762 // Replace multiple underscore to a single underscore.
1763 $filearea = preg_replace('~_+~', '_', $filearea);
1764 // If, after attempted cleaning, the filearea is not valid, throw a clear error to avoid subtle bugs.
1765 if ($filearea !== clean_param($filearea, PARAM_AREA
)) {
1766 throw new coding_exception('Name ' . $filearea .
1767 ' cannot be used with question_file_saver because it does not match the rules for file area names');
1774 * Get the files that were submitted.
1775 * @return array of stored_files objects.
1777 public function get_files() {
1780 $fs = get_file_storage();
1781 $usercontext = context_user
::instance($USER->id
);
1783 return $fs->get_area_files($usercontext->id
, 'user', 'draft',
1784 $this->draftitemid
, 'sortorder, filepath, filename', false);
1790 * This class is the mirror image of {@link question_file_saver}. It allows
1791 * files to be accessed again later (e.g. when re-grading) using that same
1792 * API as when doing the original grading.
1794 * @copyright 2012 The Open University
1795 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1797 class question_file_loader
implements question_response_files
{
1798 /** @var question_attempt_step the step that these files belong to. */
1801 /** @var string the field name for these files - which is used to construct the file area name. */
1805 * @var string the value to stored in the question_attempt_step_data to
1806 * represent these files.
1810 /** @var int the context id that the files belong to. */
1811 protected $contextid;
1815 * @param question_attempt_step $step the step that these files belong to.
1816 * @param string $name string the field name for these files - which is used to construct the file area name.
1817 * @param string $value the value to stored in the question_attempt_step_data to
1818 * represent these files.
1819 * @param int $contextid the context id that the files belong to.
1821 public function __construct(question_attempt_step
$step, $name, $value, $contextid) {
1822 $this->step
= $step;
1823 $this->name
= $name;
1824 $this->value
= $value;
1825 $this->contextid
= $contextid;
1828 public function __toString() {
1829 return $this->value
;
1833 * Get the files that were submitted.
1834 * @return array of stored_files objects.
1836 public function get_files() {
1837 return $this->step
->get_qt_files($this->name
, $this->contextid
);
1841 * Copy these files into a draft area, and return the corresponding
1842 * {@link question_file_saver} that can save them again.
1844 * This is used by {@link question_attempt::start_based_on()}, which is used
1845 * (for example) by the quizzes 'Each attempt builds on last' feature.
1847 * @return question_file_saver that can re-save these files again.
1849 public function get_question_file_saver() {
1851 // There are three possibilities here for what $value will look like:
1852 // 1) some HTML content followed by an MD5 hash in a HTML comment;
1853 // 2) a plain MD5 hash;
1854 // 3) or some real content, without any hash.
1855 // The problem is that 3) is ambiguous in the case where a student writes
1856 // a response that looks exactly like an MD5 hash. For attempts made now,
1857 // we avoid case 3) by always going for case 1) or 2) (except when the
1858 // response is blank. However, there may be case 3) data in the database
1859 // so we need to handle it as best we can.
1860 if (preg_match('/\s*<!-- File hash: [0-9a-zA-Z]{32} -->\s*$/', $this->value
)) {
1861 $value = preg_replace('/\s*<!-- File hash: [0-9a-zA-Z]{32} -->\s*$/', '', $this->value
);
1863 } else if (preg_match('/^[0-9a-zA-Z]{32}$/', $this->value
)) {
1867 $value = $this->value
;
1870 list($draftid, $text) = $this->step
->prepare_response_files_draft_itemid_with_text(
1871 $this->name
, $this->contextid
, $value);
1872 return new question_file_saver($draftid, 'question', 'response_' . $this->name
, $text);
1878 * This class represents a restriction on the set of question_usage ids to include
1879 * in a larger database query. Depending of the how you are going to restrict the
1880 * list of usages, construct an appropriate subclass.
1882 * If $qubaids is an instance of this class, example usage might be
1884 * SELECT qa.id, qa.maxmark
1885 * FROM $qubaids->from_question_attempts('qa')
1886 * WHERE $qubaids->where() AND qa.slot = 1
1888 * @copyright 2010 The Open University
1889 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1891 abstract class qubaid_condition
{
1894 * Get the SQL fragment to go in a FROM clause.
1896 * The SQL that needs to go in the FROM clause when trying
1897 * to select records from the 'question_attempts' table based on this
1900 * @param string $alias
1901 * @return string SQL fragment.
1903 abstract public function from_question_attempts($alias);
1905 /** @return string the SQL that needs to go in the where clause. */
1906 abstract public function where();
1909 * @return array the params needed by a query that uses
1910 * {@link from_question_attempts()} and {@link where()}.
1912 abstract public function from_where_params();
1915 * @return string SQL that can use used in a WHERE qubaid IN (...) query.
1916 * This method returns the "IN (...)" part.
1918 abstract public function usage_id_in();
1921 * @return array the params needed by a query that uses {@link usage_id_in()}.
1923 abstract public function usage_id_in_params();
1926 * @return string 40-character hash code that uniquely identifies the combination of properties and class name of this qubaid
1929 public function get_hash_code() {
1930 return sha1(serialize($this));
1936 * This class represents a restriction on the set of question_usage ids to include
1937 * in a larger database query based on an explicit list of ids.
1939 * @copyright 2010 The Open University
1940 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1942 class qubaid_list
extends qubaid_condition
{
1943 /** @var array of ids. */
1945 protected $columntotest = null;
1950 * @param array $qubaids of question usage ids.
1952 public function __construct(array $qubaids) {
1953 $this->qubaids
= $qubaids;
1956 public function from_question_attempts($alias) {
1957 $this->columntotest
= $alias . '.questionusageid';
1958 return '{question_attempts} ' . $alias;
1961 public function where() {
1962 if (is_null($this->columntotest
)) {
1963 throw new coding_exception('Must call from_question_attempts before where().');
1965 if (empty($this->qubaids
)) {
1966 $this->params
= array();
1970 return $this->columntotest
. ' ' . $this->usage_id_in();
1973 public function from_where_params() {
1974 return $this->params
;
1977 public function usage_id_in() {
1980 if (empty($this->qubaids
)) {
1981 $this->params
= array();
1984 list($where, $this->params
) = $DB->get_in_or_equal(
1985 $this->qubaids
, SQL_PARAMS_NAMED
, 'qubaid');
1989 public function usage_id_in_params() {
1990 return $this->params
;
1996 * This class represents a restriction on the set of question_usage ids to include
1997 * in a larger database query based on JOINing to some other tables.
1999 * The general form of the query is something like
2001 * SELECT qa.id, qa.maxmark
2003 * JOIN {question_attempts} qa ON qa.questionusageid = $usageidcolumn
2004 * WHERE $where AND qa.slot = 1
2006 * where $from, $usageidcolumn and $where are the arguments to the constructor.
2008 * @copyright 2010 The Open University
2009 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
2011 class qubaid_join
extends qubaid_condition
{
2013 public $usageidcolumn;
2018 * Constructor. The meaning of the arguments is explained in the class comment.
2019 * @param string $from SQL fragemnt to go in the FROM clause.
2020 * @param string $usageidcolumn the column in $from that should be
2021 * made equal to the usageid column in the JOIN clause.
2022 * @param string $where SQL fragment to go in the where clause.
2023 * @param array $params required by the SQL. You must use named parameters.
2025 public function __construct($from, $usageidcolumn, $where = '', $params = array()) {
2026 $this->from
= $from;
2027 $this->usageidcolumn
= $usageidcolumn;
2028 $this->params
= $params;
2029 if (empty($where)) {
2032 $this->where
= $where;
2035 public function from_question_attempts($alias) {
2036 return "{$this->from}
2037 JOIN {question_attempts} {$alias} ON " .
2038 "{$alias}.questionusageid = $this->usageidcolumn";
2041 public function where() {
2042 return $this->where
;
2045 public function from_where_params() {
2046 return $this->params
;
2049 public function usage_id_in() {
2050 return "IN (SELECT {$this->usageidcolumn} FROM {$this->from} WHERE {$this->where})";
2053 public function usage_id_in_params() {
2054 return $this->params
;