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