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