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.
21 * @subpackage questionengine
22 * @copyright 2009 The Open University
23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
27 defined('MOODLE_INTERNAL') || die();
31 * This class controls the loading and saving of question engine data to and from
34 * @copyright 2009 The Open University
35 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
37 class question_engine_data_mapper {
39 * @var moodle_database normally points to global $DB, but I prefer not to
40 * use globals if I can help it.
45 * @param moodle_database $db a database connectoin. Defaults to global $DB.
47 public function __construct($db = null) {
57 * Store an entire {@link question_usage_by_activity} in the database,
58 * including all the question_attempts that comprise it.
59 * @param question_usage_by_activity $quba the usage to store.
61 public function insert_questions_usage_by_activity(question_usage_by_activity $quba) {
62 $record = new stdClass();
63 $record->contextid = $quba->get_owning_context()->id;
64 $record->component = $quba->get_owning_component();
65 $record->preferredbehaviour = $quba->get_preferred_behaviour();
67 $newid = $this->db->insert_record('question_usages', $record);
68 $quba->set_id_from_database($newid);
70 foreach ($quba->get_attempt_iterator() as $qa) {
71 $this->insert_question_attempt($qa, $quba->get_owning_context());
76 * Store an entire {@link question_attempt} in the database,
77 * including all the question_attempt_steps that comprise it.
78 * @param question_attempt $qa the question attempt to store.
79 * @param object $context the context of the owning question_usage_by_activity.
81 public function insert_question_attempt(question_attempt $qa, $context) {
82 $record = new stdClass();
83 $record->questionusageid = $qa->get_usage_id();
84 $record->slot = $qa->get_slot();
85 $record->behaviour = $qa->get_behaviour_name();
86 $record->questionid = $qa->get_question()->id;
87 $record->variant = $qa->get_variant();
88 $record->maxmark = $qa->get_max_mark();
89 $record->minfraction = $qa->get_min_fraction();
90 $record->flagged = $qa->is_flagged();
91 $record->questionsummary = $qa->get_question_summary();
92 $record->rightanswer = $qa->get_right_answer_summary();
93 $record->responsesummary = $qa->get_response_summary();
94 $record->timemodified = time();
95 $record->id = $this->db->insert_record('question_attempts', $record);
97 foreach ($qa->get_step_iterator() as $seq => $step) {
98 $this->insert_question_attempt_step($step, $record->id, $seq, $context);
103 * Store a {@link question_attempt_step} in the database.
104 * @param question_attempt_step $qa the step to store.
105 * @param int $questionattemptid the question attept id this step belongs to.
106 * @param int $seq the sequence number of this stop.
107 * @param object $context the context of the owning question_usage_by_activity.
109 public function insert_question_attempt_step(question_attempt_step $step,
110 $questionattemptid, $seq, $context) {
111 $record = new stdClass();
112 $record->questionattemptid = $questionattemptid;
113 $record->sequencenumber = $seq;
114 $record->state = (string) $step->get_state();
115 $record->fraction = $step->get_fraction();
116 $record->timecreated = $step->get_timecreated();
117 $record->userid = $step->get_user_id();
119 $record->id = $this->db->insert_record('question_attempt_steps', $record);
121 foreach ($step->get_all_data() as $name => $value) {
122 if ($value instanceof question_file_saver) {
123 $value->save_files($record->id, $context);
126 $data = new stdClass();
127 $data->attemptstepid = $record->id;
129 $data->value = $value;
130 $this->db->insert_record('question_attempt_step_data', $data, false);
135 * Load a {@link question_attempt_step} from the database.
136 * @param int $stepid the id of the step to load.
137 * @param question_attempt_step the step that was loaded.
139 public function load_question_attempt_step($stepid) {
140 $records = $this->db->get_recordset_sql("
142 qas.id AS attemptstepid,
143 qas.questionattemptid,
152 FROM {question_attempt_steps} qas
153 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
157 ", array('stepid' => $stepid));
159 if (!$records->valid()) {
160 throw new coding_exception('Failed to load question_attempt_step ' . $stepid);
163 $step = question_attempt_step::load_from_records($records, $stepid);
170 * Load a {@link question_attempt} from the database, including all its
172 * @param int $questionattemptid the id of the question attempt to load.
173 * @param question_attempt the question attempt that was loaded.
175 public function load_question_attempt($questionattemptid) {
176 $records = $this->db->get_recordset_sql("
179 quba.preferredbehaviour,
180 qa.id AS questionattemptid,
193 qas.id AS attemptstepid,
202 FROM {question_attempts qa
203 JOIN {question_usages} quba ON quba.id = qa.questionusageid
204 LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
205 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
208 qa.id = :questionattemptid
212 ", array('questionattemptid' => $questionattemptid));
214 if (!$records->valid()) {
215 throw new coding_exception('Failed to load question_attempt ' . $questionattemptid);
218 $record = current($records);
219 $qa = question_attempt::load_from_records($records, $questionattemptid,
220 new question_usage_null_observer(), $record->preferredbehaviour);
227 * Load a {@link question_usage_by_activity} from the database, including
228 * all its {@link question_attempt}s and all their steps.
229 * @param int $qubaid the id of the usage to load.
230 * @param question_usage_by_activity the usage that was loaded.
232 public function load_questions_usage_by_activity($qubaid) {
233 $records = $this->db->get_recordset_sql("
238 quba.preferredbehaviour,
239 qa.id AS questionattemptid,
252 qas.id AS attemptstepid,
261 FROM {question_usages} quba
262 LEFT JOIN {question_attempts} qa ON qa.questionusageid = quba.id
263 LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
264 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
272 ", array('qubaid' => $qubaid));
274 if (!$records->valid()) {
275 throw new coding_exception('Failed to load questions_usage_by_activity ' . $qubaid);
278 $quba = question_usage_by_activity::load_from_records($records, $qubaid);
285 * Load information about the latest state of each question from the database.
287 * @param qubaid_condition $qubaids used to restrict which usages are included
288 * in the query. See {@link qubaid_condition}.
289 * @param array $slots A list of slots for the questions you want to konw about.
290 * @return array of records. See the SQL in this function to see the fields available.
292 public function load_questions_usages_latest_steps(qubaid_condition $qubaids, $slots) {
293 list($slottest, $params) = $this->db->get_in_or_equal($slots, SQL_PARAMS_NAMED, 'slot');
295 $records = $this->db->get_records_sql("
298 qa.id AS questionattemptid,
311 qas.id AS attemptstepid,
318 FROM {$qubaids->from_question_attempts('qa')}
319 JOIN {question_attempt_steps} qas ON
320 qas.id = {$this->latest_step_for_qa_subquery()}
323 {$qubaids->where()} AND
325 ", $params + $qubaids->from_where_params());
331 * Load summary information about the state of each question in a group of
332 * attempts. This is used, for example, by the quiz manual grading report,
333 * to show how many attempts at each question need to be graded.
335 * @param qubaid_condition $qubaids used to restrict which usages are included
336 * in the query. See {@link qubaid_condition}.
337 * @param array $slots A list of slots for the questions you want to konw about.
338 * @return array The array keys are slot,qestionid. The values are objects with
339 * fields $slot, $questionid, $inprogress, $name, $needsgrading, $autograded,
340 * $manuallygraded and $all.
342 public function load_questions_usages_question_state_summary(
343 qubaid_condition $qubaids, $slots) {
344 list($slottest, $params) = $this->db->get_in_or_equal($slots, SQL_PARAMS_NAMED, 'slot');
346 $rs = $this->db->get_recordset_sql("
352 {$this->full_states_to_summary_state_sql()}
354 COUNT(1) AS numattempts
356 FROM {$qubaids->from_question_attempts('qa')}
357 JOIN {question_attempt_steps} qas ON
358 qas.id = {$this->latest_step_for_qa_subquery()}
359 JOIN {question} q ON q.id = qa.questionid
362 {$qubaids->where()} AND
371 {$this->full_states_to_summary_state_sql()}
379 ", $params + $qubaids->from_where_params());
382 foreach ($rs as $row) {
383 $index = $row->slot . ',' . $row->questionid;
385 if (!array_key_exists($index, $results)) {
386 $res = new stdClass();
387 $res->slot = $row->slot;
388 $res->questionid = $row->questionid;
389 $res->name = $row->name;
390 $res->inprogress = 0;
391 $res->needsgrading = 0;
392 $res->autograded = 0;
393 $res->manuallygraded = 0;
395 $results[$index] = $res;
398 $results[$index]->{$row->summarystate} = $row->numattempts;
399 $results[$index]->all += $row->numattempts;
407 * Get a list of usage ids where the question with slot $slot, and optionally
408 * also with question id $questionid, is in summary state $summarystate. Also
409 * return the total count of such states.
411 * Only a subset of the ids can be returned by using $orderby, $limitfrom and
412 * $limitnum. A special value 'random' can be passed as $orderby, in which case
413 * $limitfrom is ignored.
415 * @param qubaid_condition $qubaids used to restrict which usages are included
416 * in the query. See {@link qubaid_condition}.
417 * @param int $slot The slot for the questions you want to konw about.
418 * @param int $questionid (optional) Only return attempts that were of this specific question.
419 * @param string $summarystate the summary state of interest, or 'all'.
420 * @param string $orderby the column to order by.
421 * @param array $params any params required by any of the SQL fragments.
422 * @param int $limitfrom implements paging of the results.
423 * Ignored if $orderby = random or $limitnum is null.
424 * @param int $limitnum implements paging of the results. null = all.
425 * @return array with two elements, an array of usage ids, and a count of the total number.
427 public function load_questions_usages_where_question_in_state(
428 qubaid_condition $qubaids, $summarystate, $slot, $questionid = null,
429 $orderby = 'random', $params, $limitfrom = 0, $limitnum = null) {
433 $extrawhere .= ' AND qa.questionid = :questionid';
434 $params['questionid'] = $questionid;
436 if ($summarystate != 'all') {
437 list($test, $sparams) = $this->in_summary_state_test($summarystate);
438 $extrawhere .= ' AND qas.state ' . $test;
442 if ($orderby == 'random') {
444 } else if ($orderby) {
445 $sqlorderby = 'ORDER BY ' . $orderby;
450 // We always want the total count, as well as the partcular list of ids,
451 // based on the paging and sort order. Becuase the list of ids is never
452 // going to be too rediculously long. My worst-case scenario is
453 // 10,000 students in the coures, each doing 5 quiz attempts. That
454 // is a 50,000 element int => int array, which PHP seems to use 5MB
455 // memeory to store on a 64 bit server.
456 $params += $qubaids->from_where_params();
457 $params['slot'] = $slot;
458 $qubaids = $this->db->get_records_sql_menu("
463 FROM {$qubaids->from_question_attempts('qa')}
464 JOIN {question_attempt_steps} qas ON
465 qas.id = {$this->latest_step_for_qa_subquery()}
466 JOIN {question} q ON q.id = qa.questionid
469 {$qubaids->where()} AND
476 $qubaids = array_keys($qubaids);
477 $count = count($qubaids);
479 if ($orderby == 'random') {
484 if (!is_null($limitnum)) {
485 $qubaids = array_slice($qubaids, $limitfrom, $limitnum);
488 return array($qubaids, $count);
492 * Load a {@link question_usage_by_activity} from the database, including
493 * all its {@link question_attempt}s and all their steps.
494 * @param qubaid_condition $qubaids used to restrict which usages are included
495 * in the query. See {@link qubaid_condition}.
496 * @param array $slots if null, load info for all quesitions, otherwise only
497 * load the averages for the specified questions.
499 public function load_average_marks(qubaid_condition $qubaids, $slots = null) {
500 if (!empty($slots)) {
501 list($slottest, $slotsparams) = $this->db->get_in_or_equal(
502 $slots, SQL_PARAMS_NAMED, 'slot');
503 $slotwhere = " AND qa.slot $slottest";
509 list($statetest, $stateparams) = $this->db->get_in_or_equal(array(
510 (string) question_state::$gaveup,
511 (string) question_state::$gradedwrong,
512 (string) question_state::$gradedpartial,
513 (string) question_state::$gradedright,
514 (string) question_state::$mangaveup,
515 (string) question_state::$mangrwrong,
516 (string) question_state::$mangrpartial,
517 (string) question_state::$mangrright), SQL_PARAMS_NAMED, 'st');
519 return $this->db->get_records_sql("
522 AVG(COALESCE(qas.fraction, 0)) AS averagefraction,
523 COUNT(1) AS numaveraged
525 FROM {$qubaids->from_question_attempts('qa')}
526 JOIN {question_attempt_steps} qas ON
527 qas.id = {$this->latest_step_for_qa_subquery()}
532 AND qas.state $statetest
537 ", $slotsparams + $stateparams + $qubaids->from_where_params());
541 * Load a {@link question_attempt} from the database, including all its
543 * @param int $questionid the question to load all the attempts fors.
544 * @param qubaid_condition $qubaids used to restrict which usages are included
545 * in the query. See {@link qubaid_condition}.
546 * @return array of question_attempts.
548 public function load_attempts_at_question($questionid, qubaid_condition $qubaids) {
551 $params = $qubaids->from_where_params();
552 $params['questionid'] = $questionid;
554 $records = $DB->get_recordset_sql("
557 quba.preferredbehaviour,
558 qa.id AS questionattemptid,
571 qas.id AS attemptstepid,
580 FROM {$qubaids->from_question_attempts('qa')}
581 JOIN {question_usages} quba ON quba.id = qa.questionusageid
582 LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
583 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
586 {$qubaids->where()} AND
587 qa.questionid = :questionid
595 $questionattempts = array();
596 while ($records->valid()) {
597 $record = $records->current();
598 $questionattempts[$record->questionattemptid] =
599 question_attempt::load_from_records($records,
600 $record->questionattemptid, new question_usage_null_observer(),
601 $record->preferredbehaviour);
605 return $questionattempts;
609 * Update a question_usages row to refect any changes in a usage (but not
610 * any of its question_attempts.
611 * @param question_usage_by_activity $quba the usage that has changed.
613 public function update_questions_usage_by_activity(question_usage_by_activity $quba) {
614 $record = new stdClass();
615 $record->id = $quba->get_id();
616 $record->contextid = $quba->get_owning_context()->id;
617 $record->component = $quba->get_owning_component();
618 $record->preferredbehaviour = $quba->get_preferred_behaviour();
620 $this->db->update_record('question_usages', $record);
624 * Update a question_attempts row to refect any changes in a question_attempt
625 * (but not any of its steps).
626 * @param question_attempt $qa the question attempt that has changed.
628 public function update_question_attempt(question_attempt $qa) {
629 $record = new stdClass();
630 $record->id = $qa->get_database_id();
631 $record->maxmark = $qa->get_max_mark();
632 $record->minfraction = $qa->get_min_fraction();
633 $record->flagged = $qa->is_flagged();
634 $record->questionsummary = $qa->get_question_summary();
635 $record->rightanswer = $qa->get_right_answer_summary();
636 $record->responsesummary = $qa->get_response_summary();
637 $record->timemodified = time();
639 $this->db->update_record('question_attempts', $record);
643 * Delete a question_usage_by_activity and all its associated
644 * {@link question_attempts} and {@link question_attempt_steps} from the
646 * @param qubaid_condition $qubaids identifies which question useages to delete.
648 public function delete_questions_usage_by_activities(qubaid_condition $qubaids) {
649 $where = "qa.questionusageid {$qubaids->usage_id_in()}";
650 $params = $qubaids->usage_id_in_params();
652 $contextids = $this->db->get_records_sql_menu("
653 SELECT DISTINCT contextid, 1
654 FROM {question_usages}
655 WHERE id {$qubaids->usage_id_in()}", $qubaids->usage_id_in_params());
656 foreach ($contextids as $contextid => $notused) {
657 $this->delete_response_files($contextid, "IN (
659 FROM {question_attempts} qa
660 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
661 WHERE $where)", $params);
664 if ($this->db->get_dbfamily() == 'mysql') {
665 $this->delete_usage_records_for_mysql($qubaids);
669 $this->db->delete_records_select('question_attempt_step_data', "attemptstepid IN (
671 FROM {question_attempts} qa
672 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
673 WHERE $where)", $params);
675 $this->db->delete_records_select('question_attempt_steps', "questionattemptid IN (
677 FROM {question_attempts} qa
678 WHERE $where)", $params);
680 $this->db->delete_records_select('question_attempts',
681 "{question_attempts}.questionusageid {$qubaids->usage_id_in()}",
682 $qubaids->usage_id_in_params());
684 $this->db->delete_records_select('question_usages',
685 "{question_usages}.id {$qubaids->usage_id_in()}", $qubaids->usage_id_in_params());
689 * This function is a work-around for poor MySQL performance with
690 * DELETE FROM x WHERE id IN (SELECT ...). We have to use a non-standard
691 * syntax to get good performance. See MDL-29520.
692 * @param qubaid_condition $qubaids identifies which question useages to delete.
694 protected function delete_usage_records_for_mysql(qubaid_condition $qubaids) {
695 // TODO once MDL-29589 is fixed, eliminate this method, and instead use the new $DB API.
697 DELETE qu, qa, qas, qasd
698 FROM {question_usages} qu
699 JOIN {question_attempts} qa ON qa.questionusageid = qu.id
700 LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
701 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
702 WHERE qu.id ' . $qubaids->usage_id_in(),
703 $qubaids->usage_id_in_params());
707 * This function is a work-around for poor MySQL performance with
708 * DELETE FROM x WHERE id IN (SELECT ...). We have to use a non-standard
709 * syntax to get good performance. See MDL-29520.
710 * @param string $test sql fragment.
711 * @param array $params used by $test.
713 protected function delete_attempt_steps_for_mysql($test, $params) {
714 // TODO once MDL-29589 is fixed, eliminate this method, and instead use the new $DB API.
717 FROM {question_attempt_steps} qas
718 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
719 WHERE qas.questionattemptid ' . $test, $params);
723 * Delete all the steps for a question attempt.
724 * @param int $qaids question_attempt id.
726 public function delete_steps_for_question_attempts($qaids, $context) {
730 list($test, $params) = $this->db->get_in_or_equal($qaids, SQL_PARAMS_NAMED);
732 $this->delete_response_files($context->id, "IN (
734 FROM {question_attempt_steps}
735 WHERE questionattemptid $test)", $params);
737 if ($this->db->get_dbfamily() == 'mysql') {
738 $this->delete_attempt_steps_for_mysql($test, $params);
742 $this->db->delete_records_select('question_attempt_step_data', "attemptstepid IN (
744 FROM {question_attempt_steps} qas
745 WHERE questionattemptid $test)", $params);
746 $this->db->delete_records_select('question_attempt_steps',
747 'questionattemptid ' . $test, $params);
751 * Delete all the files belonging to the response variables in the gives
752 * question attempt steps.
753 * @param int $contextid the context these attempts belong to.
754 * @param string $itemidstest a bit of SQL that can be used in a
755 * WHERE itemid $itemidstest clause. Must use named params.
756 * @param array $params any query parameters used in $itemidstest.
758 protected function delete_response_files($contextid, $itemidstest, $params) {
759 $fs = get_file_storage();
760 foreach (question_engine::get_all_response_file_areas() as $filearea) {
761 $fs->delete_area_files_select($contextid, 'question', $filearea,
762 $itemidstest, $params);
767 * Delete all the previews for a given question.
768 * @param int $questionid question id.
770 public function delete_previews($questionid) {
771 $previews = $this->db->get_records_sql_menu("
772 SELECT DISTINCT quba.id, 1
773 FROM {question_usages} quba
774 JOIN {question_attempts} qa ON qa.questionusageid = quba.id
775 WHERE quba.component = 'core_question_preview' AND
776 qa.questionid = ?", array($questionid));
777 if (empty($previews)) {
780 $this->delete_questions_usage_by_activities(new qubaid_list($previews));
784 * Update the flagged state of a question in the database.
785 * @param int $qubaid the question usage id.
786 * @param int $questionid the question id.
787 * @param int $sessionid the question_attempt id.
788 * @param bool $newstate the new state of the flag. true = flagged.
790 public function update_question_attempt_flag($qubaid, $questionid, $qaid, $slot, $newstate) {
791 if (!$this->db->record_exists('question_attempts', array('id' => $qaid,
792 'questionusageid' => $qubaid, 'questionid' => $questionid, 'slot' => $slot))) {
793 throw new moodle_exception('errorsavingflags', 'question');
796 $this->db->set_field('question_attempts', 'flagged', $newstate, array('id' => $qaid));
800 * Get all the WHEN 'x' THEN 'y' terms needed to convert the question_attempt_steps.state
801 * column to a summary state. Use this like
802 * CASE qas.state {$this->full_states_to_summary_state_sql()} END AS summarystate,
803 * @param string SQL fragment.
805 protected function full_states_to_summary_state_sql() {
807 foreach (question_state::get_all() as $state) {
808 $sql .= "WHEN '$state' THEN '{$state->get_summary_state()}'\n";
814 * Get the SQL needed to test that question_attempt_steps.state is in a
815 * state corresponding to $summarystate.
816 * @param string $summarystate one of
817 * inprogress, needsgrading, manuallygraded or autograded
818 * @param bool $equal if false, do a NOT IN test. Default true.
819 * @return string SQL fragment.
821 public function in_summary_state_test($summarystate, $equal = true, $prefix = 'summarystates') {
822 $states = question_state::get_all_for_summary_state($summarystate);
823 return $this->db->get_in_or_equal(array_map('strval', $states),
824 SQL_PARAMS_NAMED, $prefix, $equal);
828 * Change the maxmark for the question_attempt with number in usage $slot
829 * for all the specified question_attempts.
830 * @param qubaid_condition $qubaids Selects which usages are updated.
831 * @param int $slot the number is usage to affect.
832 * @param number $newmaxmark the new max mark to set.
834 public function set_max_mark_in_attempts(qubaid_condition $qubaids, $slot, $newmaxmark) {
835 $this->db->set_field_select('question_attempts', 'maxmark', $newmaxmark,
836 "questionusageid {$qubaids->usage_id_in()} AND slot = :slot",
837 $qubaids->usage_id_in_params() + array('slot' => $slot));
841 * Return a subquery that computes the sum of the marks for all the questions
842 * in a usage. Which useage to compute the sum for is controlled bu the $qubaid
845 * See {@link quiz_update_all_attempt_sumgrades()} for an example of the usage of
848 * @param string $qubaid SQL fragment that controls which usage is summed.
849 * This will normally be the name of a column in the outer query. Not that this
850 * SQL fragment must not contain any placeholders.
851 * @return string SQL code for the subquery.
853 public function sum_usage_marks_subquery($qubaid) {
854 return "SELECT SUM(qa.maxmark * qas.fraction)
855 FROM {question_attempts} qa
856 JOIN {question_attempt_steps} qas ON qas.id = (
857 SELECT MAX(summarks_qas.id)
858 FROM {question_attempt_steps} summarks_qas
859 WHERE summarks_qas.questionattemptid = qa.id
861 WHERE qa.questionusageid = $qubaid
863 WHEN qas.state = 'needsgrading' AND qa.maxmark > 0 THEN 1
868 public function question_attempt_latest_state_view($alias) {
871 {$alias}qa.id AS questionattemptid,
872 {$alias}qa.questionusageid,
874 {$alias}qa.behaviour,
875 {$alias}qa.questionid,
878 {$alias}qa.minfraction,
880 {$alias}qa.questionsummary,
881 {$alias}qa.rightanswer,
882 {$alias}qa.responsesummary,
883 {$alias}qa.timemodified,
884 {$alias}qas.id AS attemptstepid,
885 {$alias}qas.sequencenumber,
887 {$alias}qas.fraction,
888 {$alias}qas.timecreated,
891 FROM {question_attempts} {$alias}qa
892 JOIN {question_attempt_steps} {$alias}qas ON
893 {$alias}qas.id = {$this->latest_step_for_qa_subquery($alias . 'qa.id')}
897 protected function latest_step_for_qa_subquery($questionattemptid = 'qa.id') {
900 FROM {question_attempt_steps}
901 WHERE questionattemptid = $questionattemptid
906 * @param array $questionids of question ids.
907 * @param qubaid_condition $qubaids ids of the usages to consider.
908 * @return boolean whether any of these questions are being used by any of
911 public function questions_in_use(array $questionids, qubaid_condition $qubaids) {
912 list($test, $params) = $this->db->get_in_or_equal($questionids);
913 return $this->db->record_exists_select('question_attempts',
914 'questionid ' . $test . ' AND questionusageid ' .
915 $qubaids->usage_id_in(), $params + $qubaids->usage_id_in_params());
921 * Implementation of the unit of work pattern for the question engine.
923 * See http://martinfowler.com/eaaCatalog/unitOfWork.html. This tracks all the
924 * changes to a {@link question_usage_by_activity}, and its constituent parts,
925 * so that the changes can be saved to the database when {@link save()} is called.
927 * @copyright 2009 The Open University
928 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
930 class question_engine_unit_of_work implements question_usage_observer {
931 /** @var question_usage_by_activity the usage being tracked. */
934 /** @var boolean whether any of the fields of the usage have been changed. */
935 protected $modified = false;
938 * @var array list of number in usage => {@link question_attempt}s that
939 * were already in the usage, and which have been modified.
941 protected $attemptsmodified = array();
944 * @var array list of number in usage => {@link question_attempt}s that
945 * have been added to the usage.
947 protected $attemptsadded = array();
950 * @var array list of question attempt ids to delete the steps for, before
951 * inserting new steps.
953 protected $attemptstodeletestepsfor = array();
956 * @var array list of array(question_attempt_step, question_attempt id, seq number)
957 * of steps that have been added to question attempts in this usage.
959 protected $stepsadded = array();
963 * @param question_usage_by_activity $quba the usage to track.
965 public function __construct(question_usage_by_activity $quba) {
969 public function notify_modified() {
970 $this->modified = true;
973 public function notify_attempt_modified(question_attempt $qa) {
974 $no = $qa->get_slot();
975 if (!array_key_exists($no, $this->attemptsadded)) {
976 $this->attemptsmodified[$no] = $qa;
980 public function notify_attempt_added(question_attempt $qa) {
981 $this->attemptsadded[$qa->get_slot()] = $qa;
984 public function notify_delete_attempt_steps(question_attempt $qa) {
986 if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
990 $qaid = $qa->get_database_id();
991 foreach ($this->stepsadded as $key => $stepinfo) {
992 if ($stepinfo[1] == $qaid) {
993 unset($this->stepsadded[$key]);
997 $this->attemptstodeletestepsfor[$qaid] = 1;
1000 public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq) {
1001 if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
1004 $this->stepsadded[] = array($step, $qa->get_database_id(), $seq);
1008 * Write all the changes we have recorded to the database.
1009 * @param question_engine_data_mapper $dm the mapper to use to update the database.
1011 public function save(question_engine_data_mapper $dm) {
1012 $dm->delete_steps_for_question_attempts(array_keys($this->attemptstodeletestepsfor),
1013 $this->quba->get_owning_context());
1015 foreach ($this->stepsadded as $stepinfo) {
1016 list($step, $questionattemptid, $seq) = $stepinfo;
1017 $dm->insert_question_attempt_step($step, $questionattemptid, $seq,
1018 $this->quba->get_owning_context());
1021 foreach ($this->attemptsadded as $qa) {
1022 $dm->insert_question_attempt($qa, $this->quba->get_owning_context());
1025 foreach ($this->attemptsmodified as $qa) {
1026 $dm->update_question_attempt($qa);
1029 if ($this->modified) {
1030 $dm->update_questions_usage_by_activity($this->quba);
1037 * This class represents the promise to save some files from a particular draft
1038 * file area into a particular file area. It is used beause the necessary
1039 * information about what to save is to hand in the
1040 * {@link question_attempt::process_response_files()} method, but we don't know
1041 * if this question attempt will actually be saved in the database until later,
1042 * when the {@link question_engine_unit_of_work} is saved, if it is.
1044 * @copyright 2011 The Open University
1045 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1047 class question_file_saver {
1048 /** @var int the id of the draft file area to save files from. */
1049 protected $draftitemid;
1050 /** @var string the owning component name. */
1051 protected $component;
1052 /** @var string the file area name. */
1053 protected $filearea;
1056 * @var string the value to store in the question_attempt_step_data to
1057 * represent these files.
1059 protected $value = null;
1063 * @param int $draftitemid the draft area to save the files from.
1064 * @param string $component the component for the file area to save into.
1065 * @param string $filearea the name of the file area to save into.
1067 public function __construct($draftitemid, $component, $filearea, $text = null) {
1068 $this->draftitemid = $draftitemid;
1069 $this->component = $component;
1070 $this->filearea = $filearea;
1071 $this->value = $this->compute_value($draftitemid, $text);
1075 * Compute the value that should be stored in the question_attempt_step_data
1076 * table. Contains a hash that (almost) uniquely encodes all the files.
1077 * @param int $draftitemid the draft file area itemid.
1078 * @param string $text optional content containing file links.
1080 protected function compute_value($draftitemid, $text) {
1083 $fs = get_file_storage();
1084 $usercontext = get_context_instance(CONTEXT_USER, $USER->id);
1086 $files = $fs->get_area_files($usercontext->id, 'user', 'draft',
1087 $draftitemid, 'sortorder, filepath, filename', false);
1090 foreach ($files as $file) {
1091 $string .= $file->get_filepath() . $file->get_filename() . '|' .
1092 $file->get_contenthash() . '|';
1096 $hash = md5($string);
1101 if (is_null($text)) {
1105 // We add the file hash so a simple string comparison will say if the
1106 // files have been changed. First strip off any existing file hash.
1107 $text = preg_replace('/\s*<!-- File hash: \w+ -->\s*$/', '', $text);
1108 $text = file_rewrite_urls_to_pluginfile($text, $draftitemid);
1110 $text .= '<!-- File hash: ' . $hash . ' -->';
1115 public function __toString() {
1116 return $this->value;
1120 * Actually save the files.
1121 * @param integer $itemid the item id for the file area to save into.
1123 public function save_files($itemid, $context) {
1124 file_save_draft_area_files($this->draftitemid, $context->id,
1125 $this->component, $this->filearea, $itemid);
1131 * This class represents a restriction on the set of question_usage ids to include
1132 * in a larger database query. Depending of the how you are going to restrict the
1133 * list of usages, construct an appropriate subclass.
1135 * If $qubaids is an instance of this class, example usage might be
1137 * SELECT qa.id, qa.maxmark
1138 * FROM $qubaids->from_question_attempts('qa')
1139 * WHERE $qubaids->where() AND qa.slot = 1
1141 * @copyright 2010 The Open University
1142 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1144 abstract class qubaid_condition {
1147 * @return string the SQL that needs to go in the FROM clause when trying
1148 * to select records from the 'question_attempts' table based on the
1151 public abstract function from_question_attempts($alias);
1153 /** @return string the SQL that needs to go in the where clause. */
1154 public abstract function where();
1157 * @return the params needed by a query that uses
1158 * {@link from_question_attempts()} and {@link where()}.
1160 public abstract function from_where_params();
1163 * @return string SQL that can use used in a WHERE qubaid IN (...) query.
1164 * This method returns the "IN (...)" part.
1166 public abstract function usage_id_in();
1169 * @return the params needed by a query that uses {@link usage_id_in()}.
1171 public abstract function usage_id_in_params();
1176 * This class represents a restriction on the set of question_usage ids to include
1177 * in a larger database query based on an explicit list of ids.
1179 * @copyright 2010 The Open University
1180 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1182 class qubaid_list extends qubaid_condition {
1183 /** @var array of ids. */
1185 protected $columntotest = null;
1190 * @param array $qubaids of question usage ids.
1192 public function __construct(array $qubaids) {
1193 $this->qubaids = $qubaids;
1196 public function from_question_attempts($alias) {
1197 $this->columntotest = $alias . '.questionusageid';
1198 return '{question_attempts} ' . $alias;
1201 public function where() {
1204 if (is_null($this->columntotest)) {
1205 throw new coding_exception('Must call from_question_attempts before where().');
1207 if (empty($this->qubaids)) {
1208 $this->params = array();
1212 return $this->columntotest . ' ' . $this->usage_id_in();
1215 public function from_where_params() {
1216 return $this->params;
1219 public function usage_id_in() {
1222 if (empty($this->qubaids)) {
1223 $this->params = array();
1226 list($where, $this->params) = $DB->get_in_or_equal(
1227 $this->qubaids, SQL_PARAMS_NAMED, 'qubaid');
1231 public function usage_id_in_params() {
1232 return $this->params;
1238 * This class represents a restriction on the set of question_usage ids to include
1239 * in a larger database query based on JOINing to some other tables.
1241 * The general form of the query is something like
1243 * SELECT qa.id, qa.maxmark
1245 * JOIN {question_attempts} qa ON qa.questionusageid = $usageidcolumn
1246 * WHERE $where AND qa.slot = 1
1248 * where $from, $usageidcolumn and $where are the arguments to the constructor.
1250 * @copyright 2010 The Open University
1251 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1253 class qubaid_join extends qubaid_condition {
1255 public $usageidcolumn;
1260 * Constructor. The meaning of the arguments is explained in the class comment.
1261 * @param string $from SQL fragemnt to go in the FROM clause.
1262 * @param string $usageidcolumn the column in $from that should be
1263 * made equal to the usageid column in the JOIN clause.
1264 * @param string $where SQL fragment to go in the where clause.
1265 * @param array $params required by the SQL. You must use named parameters.
1267 public function __construct($from, $usageidcolumn, $where = '', $params = array()) {
1268 $this->from = $from;
1269 $this->usageidcolumn = $usageidcolumn;
1270 $this->params = $params;
1271 if (empty($where)) {
1274 $this->where = $where;
1277 public function from_question_attempts($alias) {
1279 JOIN {question_attempts} {$alias} ON " .
1280 "{$alias}.questionusageid = $this->usageidcolumn";
1283 public function where() {
1284 return $this->where;
1287 public function from_where_params() {
1288 return $this->params;
1291 public function usage_id_in() {
1292 return "IN (SELECT $this->usageidcolumn FROM $this->from WHERE $this->where)";
1295 public function usage_id_in_params() {
1296 return $this->params;