MDL-43246 some question data mapper unit tests
[moodle.git] / question / engine / datalib.php
CommitLineData
d1b7e03d 1<?php
d1b7e03d
TH
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.
13//
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/>.
16
d1b7e03d 17/**
5e8a85aa 18 * Code for loading and saving question attempts to and from the database.
d1b7e03d 19 *
94815ccf
TH
20 * A note for future reference. This code is pretty efficient but there are two
21 * potential optimisations that could be contemplated, at the cost of making the
22 * code more complex:
23 *
24 * 1. (This is the easier one, but probably not worth doing.) In the unit-of-work
25 * save method, we could get all the ids for steps due to be deleted or modified,
26 * and delete all the question_attempt_step_data for all of those steps in one
27 * query. That would save one DB query for each ->stepsupdated. However that number
28 * is 0 except when re-grading, and when regrading, there are many more inserts
29 * into question_attempt_step_data than deletes, so it is really hardly worth it.
30 *
31 * 2. A more significant optimisation would be to write an efficient
32 * $DB->insert_records($arrayofrecords) method (for example using functions
33 * like pg_copy_from) and then whenever we save stuff (unit_of_work->save and
34 * insert_questions_usage_by_activity) collect together all the records that
35 * need to be inserted into question_attempt_step_data, and insert them with
36 * a single call to $DB->insert_records. This is likely to be the biggest win.
37 * We do a lot of separate inserts into question_attempt_step_data.
38 *
017bc1d9 39 * @package moodlecore
d1b7e03d 40 * @subpackage questionengine
017bc1d9
TH
41 * @copyright 2009 The Open University
42 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
43 */
44
45
a17b297d
TH
46defined('MOODLE_INTERNAL') || die();
47
48
d1b7e03d
TH
49/**
50 * This class controls the loading and saving of question engine data to and from
51 * the database.
52 *
017bc1d9
TH
53 * @copyright 2009 The Open University
54 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
55 */
56class question_engine_data_mapper {
06f8ed54
TH
57 /**
58 * @var moodle_database normally points to global $DB, but I prefer not to
59 * use globals if I can help it.
60 */
61 protected $db;
62
63 /**
64 * @param moodle_database $db a database connectoin. Defaults to global $DB.
65 */
0a606a2b 66 public function __construct(moodle_database $db = null) {
06f8ed54
TH
67 if (is_null($db)) {
68 global $DB;
06f8ed54
TH
69 $this->db = $DB;
70 } else {
71 $this->db = $db;
72 }
73 }
74
d1b7e03d
TH
75 /**
76 * Store an entire {@link question_usage_by_activity} in the database,
77 * including all the question_attempts that comprise it.
78 * @param question_usage_by_activity $quba the usage to store.
79 */
80 public function insert_questions_usage_by_activity(question_usage_by_activity $quba) {
0ff4bd08 81 $record = new stdClass();
d1b7e03d 82 $record->contextid = $quba->get_owning_context()->id;
56b0df7e
TH
83 $record->component = $quba->get_owning_component();
84 $record->preferredbehaviour = $quba->get_preferred_behaviour();
d1b7e03d 85
06f8ed54 86 $newid = $this->db->insert_record('question_usages', $record);
d1b7e03d
TH
87 $quba->set_id_from_database($newid);
88
89 foreach ($quba->get_attempt_iterator() as $qa) {
217f9a61 90 $this->insert_question_attempt($qa, $quba->get_owning_context());
d1b7e03d
TH
91 }
92 }
93
94 /**
95 * Store an entire {@link question_attempt} in the database,
96 * including all the question_attempt_steps that comprise it.
97 * @param question_attempt $qa the question attempt to store.
94815ccf 98 * @param context $context the context of the owning question_usage_by_activity.
d1b7e03d 99 */
217f9a61 100 public function insert_question_attempt(question_attempt $qa, $context) {
0ff4bd08 101 $record = new stdClass();
d1b7e03d
TH
102 $record->questionusageid = $qa->get_usage_id();
103 $record->slot = $qa->get_slot();
56b0df7e 104 $record->behaviour = $qa->get_behaviour_name();
d1b7e03d 105 $record->questionid = $qa->get_question()->id;
1da821bb 106 $record->variant = $qa->get_variant();
d1b7e03d
TH
107 $record->maxmark = $qa->get_max_mark();
108 $record->minfraction = $qa->get_min_fraction();
4e3d8293 109 $record->maxfraction = $qa->get_max_fraction();
d1b7e03d 110 $record->flagged = $qa->is_flagged();
56b0df7e 111 $record->questionsummary = $qa->get_question_summary();
2f1e464a 112 if (core_text::strlen($record->questionsummary) > question_bank::MAX_SUMMARY_LENGTH) {
c83ed025 113 // It seems some people write very long quesions! MDL-30760
2f1e464a 114 $record->questionsummary = core_text::substr($record->questionsummary,
c83ed025
TH
115 0, question_bank::MAX_SUMMARY_LENGTH - 3) . '...';
116 }
56b0df7e
TH
117 $record->rightanswer = $qa->get_right_answer_summary();
118 $record->responsesummary = $qa->get_response_summary();
d1b7e03d 119 $record->timemodified = time();
06f8ed54 120 $record->id = $this->db->insert_record('question_attempts', $record);
1c80e79a 121 $qa->set_database_id($record->id);
d1b7e03d
TH
122
123 foreach ($qa->get_step_iterator() as $seq => $step) {
217f9a61 124 $this->insert_question_attempt_step($step, $record->id, $seq, $context);
d1b7e03d
TH
125 }
126 }
127
128 /**
94815ccf
TH
129 * Helper method used by insert_question_attempt_step and update_question_attempt_step
130 * @param question_attempt_step $step the step to store.
217f9a61
TH
131 * @param int $questionattemptid the question attept id this step belongs to.
132 * @param int $seq the sequence number of this stop.
94815ccf 133 * @return stdClass data to insert into the database.
d1b7e03d 134 */
94815ccf 135 protected function make_step_record(question_attempt_step $step, $questionattemptid, $seq) {
0ff4bd08 136 $record = new stdClass();
d1b7e03d
TH
137 $record->questionattemptid = $questionattemptid;
138 $record->sequencenumber = $seq;
deef04a4 139 $record->state = (string) $step->get_state();
d1b7e03d
TH
140 $record->fraction = $step->get_fraction();
141 $record->timecreated = $step->get_timecreated();
142 $record->userid = $step->get_user_id();
94815ccf
TH
143 return $record;
144 }
d1b7e03d 145
94815ccf
TH
146 /**
147 * Helper method used by insert_question_attempt_step and update_question_attempt_step
148 * @param question_attempt_step $step the step to store.
149 * @param int $stepid the id of the step.
150 * @param context $context the context of the owning question_usage_by_activity.
151 */
152 protected function insert_step_data(question_attempt_step $step, $stepid, $context) {
d1b7e03d 153 foreach ($step->get_all_data() as $name => $value) {
48d9c17d 154 if ($value instanceof question_file_saver) {
94815ccf 155 $value->save_files($stepid, $context);
ea07b2d6
TH
156 }
157 if ($value instanceof question_response_files) {
7a26403f 158 $value = (string) $value;
48d9c17d
TH
159 }
160
0ff4bd08 161 $data = new stdClass();
94815ccf 162 $data->attemptstepid = $stepid;
56b0df7e
TH
163 $data->name = $name;
164 $data->value = $value;
06f8ed54 165 $this->db->insert_record('question_attempt_step_data', $data, false);
d1b7e03d
TH
166 }
167 }
168
94815ccf
TH
169 /**
170 * Store a {@link question_attempt_step} in the database.
171 * @param question_attempt_step $step the step to store.
172 * @param int $questionattemptid the question attept id this step belongs to.
173 * @param int $seq the sequence number of this stop.
174 * @param context $context the context of the owning question_usage_by_activity.
175 */
176 public function insert_question_attempt_step(question_attempt_step $step,
177 $questionattemptid, $seq, $context) {
178
179 $record = $this->make_step_record($step, $questionattemptid, $seq);
180 $record->id = $this->db->insert_record('question_attempt_steps', $record);
181
182 $this->insert_step_data($step, $record->id, $context);
183 }
184
185 /**
186 * Update a {@link question_attempt_step} in the database.
187 * @param question_attempt_step $qa the step to store.
188 * @param int $questionattemptid the question attept id this step belongs to.
189 * @param int $seq the sequence number of this stop.
190 * @param context $context the context of the owning question_usage_by_activity.
191 */
192 public function update_question_attempt_step(question_attempt_step $step,
193 $questionattemptid, $seq, $context) {
194
195 $record = $this->make_step_record($step, $questionattemptid, $seq);
196 $record->id = $step->get_id();
197 $this->db->update_record('question_attempt_steps', $record);
198
199 $this->db->delete_records('question_attempt_step_data',
200 array('attemptstepid' => $record->id));
201 $this->insert_step_data($step, $record->id, $context);
202 }
203
d1b7e03d
TH
204 /**
205 * Load a {@link question_attempt_step} from the database.
f7970e3c 206 * @param int $stepid the id of the step to load.
d1b7e03d
TH
207 * @param question_attempt_step the step that was loaded.
208 */
209 public function load_question_attempt_step($stepid) {
35d5f1c2 210 $records = $this->db->get_recordset_sql("
d1b7e03d 211SELECT
dc1ee5cb
TH
212 quba.contextid,
213 COALLESCE(q.qtype, 'missingtype') AS qtype,
d1b7e03d
TH
214 qas.id AS attemptstepid,
215 qas.questionattemptid,
216 qas.sequencenumber,
217 qas.state,
218 qas.fraction,
219 qas.timecreated,
220 qas.userid,
221 qasd.name,
222 qasd.value
223
dc1ee5cb
TH
224FROM {question_attempt_steps} qas
225JOIN {question_attempts} qa ON qa.id = qas.questionattemptid
226JOIN {question_usages} quba ON quba.id = qa.questionusageid
227LEFT JOIN {question} q ON q.id = qa.questionid
06f8ed54 228LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
d1b7e03d
TH
229
230WHERE
06f8ed54
TH
231 qas.id = :stepid
232 ", array('stepid' => $stepid));
d1b7e03d 233
35d5f1c2 234 if (!$records->valid()) {
88f0eb15 235 throw new coding_exception('Failed to load question_attempt_step ' . $stepid);
d1b7e03d
TH
236 }
237
35d5f1c2
TH
238 $step = question_attempt_step::load_from_records($records, $stepid);
239 $records->close();
240
241 return $step;
d1b7e03d
TH
242 }
243
244 /**
245 * Load a {@link question_attempt} from the database, including all its
246 * steps.
f7970e3c 247 * @param int $questionattemptid the id of the question attempt to load.
d1b7e03d
TH
248 * @param question_attempt the question attempt that was loaded.
249 */
250 public function load_question_attempt($questionattemptid) {
35d5f1c2 251 $records = $this->db->get_recordset_sql("
d1b7e03d 252SELECT
7a719748 253 quba.contextid,
d1b7e03d
TH
254 quba.preferredbehaviour,
255 qa.id AS questionattemptid,
256 qa.questionusageid,
257 qa.slot,
258 qa.behaviour,
259 qa.questionid,
1da821bb 260 qa.variant,
d1b7e03d
TH
261 qa.maxmark,
262 qa.minfraction,
4e3d8293 263 qa.maxfraction,
d1b7e03d
TH
264 qa.flagged,
265 qa.questionsummary,
266 qa.rightanswer,
267 qa.responsesummary,
268 qa.timemodified,
269 qas.id AS attemptstepid,
270 qas.sequencenumber,
271 qas.state,
272 qas.fraction,
273 qas.timecreated,
274 qas.userid,
275 qasd.name,
276 qasd.value
277
d50e8a1b 278FROM {question_attempts} qa
06f8ed54
TH
279JOIN {question_usages} quba ON quba.id = qa.questionusageid
280LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
281LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
d1b7e03d
TH
282
283WHERE
06f8ed54 284 qa.id = :questionattemptid
d1b7e03d
TH
285
286ORDER BY
287 qas.sequencenumber
06f8ed54 288 ", array('questionattemptid' => $questionattemptid));
d1b7e03d 289
35d5f1c2 290 if (!$records->valid()) {
88f0eb15 291 throw new coding_exception('Failed to load question_attempt ' . $questionattemptid);
d1b7e03d
TH
292 }
293
d50e8a1b 294 $record = $records->current();
35d5f1c2 295 $qa = question_attempt::load_from_records($records, $questionattemptid,
d1b7e03d 296 new question_usage_null_observer(), $record->preferredbehaviour);
35d5f1c2
TH
297 $records->close();
298
299 return $qa;
d1b7e03d
TH
300 }
301
302 /**
303 * Load a {@link question_usage_by_activity} from the database, including
304 * all its {@link question_attempt}s and all their steps.
f7970e3c 305 * @param int $qubaid the id of the usage to load.
d1b7e03d
TH
306 * @param question_usage_by_activity the usage that was loaded.
307 */
308 public function load_questions_usage_by_activity($qubaid) {
35d5f1c2 309 $records = $this->db->get_recordset_sql("
d1b7e03d 310SELECT
d1b7e03d
TH
311 quba.id AS qubaid,
312 quba.contextid,
313 quba.component,
314 quba.preferredbehaviour,
315 qa.id AS questionattemptid,
316 qa.questionusageid,
317 qa.slot,
318 qa.behaviour,
319 qa.questionid,
1da821bb 320 qa.variant,
d1b7e03d
TH
321 qa.maxmark,
322 qa.minfraction,
4e3d8293 323 qa.maxfraction,
d1b7e03d
TH
324 qa.flagged,
325 qa.questionsummary,
326 qa.rightanswer,
327 qa.responsesummary,
328 qa.timemodified,
329 qas.id AS attemptstepid,
330 qas.sequencenumber,
331 qas.state,
332 qas.fraction,
333 qas.timecreated,
334 qas.userid,
335 qasd.name,
336 qasd.value
337
06f8ed54
TH
338FROM {question_usages} quba
339LEFT JOIN {question_attempts} qa ON qa.questionusageid = quba.id
340LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
341LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
d1b7e03d
TH
342
343WHERE
06f8ed54 344 quba.id = :qubaid
d1b7e03d
TH
345
346ORDER BY
347 qa.slot,
348 qas.sequencenumber
c76145d3 349 ", array('qubaid' => $qubaid));
d1b7e03d 350
35d5f1c2 351 if (!$records->valid()) {
88f0eb15 352 throw new coding_exception('Failed to load questions_usage_by_activity ' . $qubaid);
d1b7e03d
TH
353 }
354
35d5f1c2
TH
355 $quba = question_usage_by_activity::load_from_records($records, $qubaid);
356 $records->close();
357
358 return $quba;
d1b7e03d
TH
359 }
360
361 /**
362 * Load information about the latest state of each question from the database.
363 *
364 * @param qubaid_condition $qubaids used to restrict which usages are included
e68e4ccf 365 * in the query. See {@link qubaid_condition}.
5b0a31bf 366 * @param array $slots A list of slots for the questions you want to know about.
e68e4ccf 367 * @param string|null $fields
d1b7e03d
TH
368 * @return array of records. See the SQL in this function to see the fields available.
369 */
e68e4ccf 370 public function load_questions_usages_latest_steps(qubaid_condition $qubaids, $slots, $fields = null) {
a2ac2349 371 list($slottest, $params) = $this->db->get_in_or_equal($slots, SQL_PARAMS_NAMED, 'slot');
d1b7e03d 372
e68e4ccf
JP
373 if ($fields === null) {
374 $fields = "qas.id,
d1b7e03d
TH
375 qa.id AS questionattemptid,
376 qa.questionusageid,
377 qa.slot,
378 qa.behaviour,
379 qa.questionid,
1da821bb 380 qa.variant,
d1b7e03d
TH
381 qa.maxmark,
382 qa.minfraction,
4e3d8293 383 qa.maxfraction,
d1b7e03d
TH
384 qa.flagged,
385 qa.questionsummary,
386 qa.rightanswer,
387 qa.responsesummary,
388 qa.timemodified,
389 qas.id AS attemptstepid,
390 qas.sequencenumber,
391 qas.state,
392 qas.fraction,
393 qas.timecreated,
e68e4ccf
JP
394 qas.userid";
395
396 }
397
398 $records = $this->db->get_records_sql("
399SELECT
400 {$fields}
d1b7e03d
TH
401
402FROM {$qubaids->from_question_attempts('qa')}
06f8ed54 403JOIN {question_attempt_steps} qas ON
d1b7e03d
TH
404 qas.id = {$this->latest_step_for_qa_subquery()}
405
406WHERE
407 {$qubaids->where()} AND
408 qa.slot $slottest
06f8ed54 409 ", $params + $qubaids->from_where_params());
d1b7e03d
TH
410
411 return $records;
412 }
413
414 /**
5e8a85aa
TH
415 * Load summary information about the state of each question in a group of
416 * attempts. This is used, for example, by the quiz manual grading report,
417 * to show how many attempts at each question need to be graded.
d1b7e03d
TH
418 *
419 * @param qubaid_condition $qubaids used to restrict which usages are included
420 * in the query. See {@link qubaid_condition}.
421 * @param array $slots A list of slots for the questions you want to konw about.
422 * @return array The array keys are slot,qestionid. The values are objects with
423 * fields $slot, $questionid, $inprogress, $name, $needsgrading, $autograded,
424 * $manuallygraded and $all.
425 */
9c197f44
TH
426 public function load_questions_usages_question_state_summary(
427 qubaid_condition $qubaids, $slots) {
a2ac2349 428 list($slottest, $params) = $this->db->get_in_or_equal($slots, SQL_PARAMS_NAMED, 'slot');
d1b7e03d 429
06f8ed54 430 $rs = $this->db->get_recordset_sql("
d1b7e03d
TH
431SELECT
432 qa.slot,
433 qa.questionid,
434 q.name,
435 CASE qas.state
436 {$this->full_states_to_summary_state_sql()}
437 END AS summarystate,
438 COUNT(1) AS numattempts
439
440FROM {$qubaids->from_question_attempts('qa')}
06f8ed54 441JOIN {question_attempt_steps} qas ON
d1b7e03d 442 qas.id = {$this->latest_step_for_qa_subquery()}
06f8ed54 443JOIN {question} q ON q.id = qa.questionid
d1b7e03d
TH
444
445WHERE
446 {$qubaids->where()} AND
447 qa.slot $slottest
448
449GROUP BY
450 qa.slot,
451 qa.questionid,
452 q.name,
453 q.id,
304f0d85
TH
454 CASE qas.state
455 {$this->full_states_to_summary_state_sql()}
456 END
d1b7e03d 457
f9b0500f 458ORDER BY
d1b7e03d
TH
459 qa.slot,
460 qa.questionid,
461 q.name,
462 q.id
06f8ed54 463 ", $params + $qubaids->from_where_params());
d1b7e03d 464
d1b7e03d 465 $results = array();
cf3b6568 466 foreach ($rs as $row) {
d1b7e03d
TH
467 $index = $row->slot . ',' . $row->questionid;
468
469 if (!array_key_exists($index, $results)) {
0ff4bd08 470 $res = new stdClass();
d1b7e03d
TH
471 $res->slot = $row->slot;
472 $res->questionid = $row->questionid;
473 $res->name = $row->name;
474 $res->inprogress = 0;
475 $res->needsgrading = 0;
476 $res->autograded = 0;
477 $res->manuallygraded = 0;
478 $res->all = 0;
479 $results[$index] = $res;
480 }
481
482 $results[$index]->{$row->summarystate} = $row->numattempts;
483 $results[$index]->all += $row->numattempts;
484 }
cf3b6568 485 $rs->close();
d1b7e03d
TH
486
487 return $results;
488 }
489
490 /**
491 * Get a list of usage ids where the question with slot $slot, and optionally
492 * also with question id $questionid, is in summary state $summarystate. Also
493 * return the total count of such states.
494 *
495 * Only a subset of the ids can be returned by using $orderby, $limitfrom and
496 * $limitnum. A special value 'random' can be passed as $orderby, in which case
497 * $limitfrom is ignored.
498 *
499 * @param qubaid_condition $qubaids used to restrict which usages are included
500 * in the query. See {@link qubaid_condition}.
f7970e3c
TH
501 * @param int $slot The slot for the questions you want to konw about.
502 * @param int $questionid (optional) Only return attempts that were of this specific question.
d1b7e03d
TH
503 * @param string $summarystate the summary state of interest, or 'all'.
504 * @param string $orderby the column to order by.
cf3b6568 505 * @param array $params any params required by any of the SQL fragments.
f7970e3c 506 * @param int $limitfrom implements paging of the results.
d1b7e03d 507 * Ignored if $orderby = random or $limitnum is null.
f7970e3c 508 * @param int $limitnum implements paging of the results. null = all.
d1b7e03d
TH
509 * @return array with two elements, an array of usage ids, and a count of the total number.
510 */
511 public function load_questions_usages_where_question_in_state(
512 qubaid_condition $qubaids, $summarystate, $slot, $questionid = null,
5b0a31bf 513 $orderby = 'random', $params = array(), $limitfrom = 0, $limitnum = null) {
d1b7e03d
TH
514
515 $extrawhere = '';
516 if ($questionid) {
cf3b6568
TH
517 $extrawhere .= ' AND qa.questionid = :questionid';
518 $params['questionid'] = $questionid;
d1b7e03d
TH
519 }
520 if ($summarystate != 'all') {
cf3b6568 521 list($test, $sparams) = $this->in_summary_state_test($summarystate);
d1b7e03d 522 $extrawhere .= ' AND qas.state ' . $test;
cf3b6568 523 $params += $sparams;
d1b7e03d
TH
524 }
525
526 if ($orderby == 'random') {
527 $sqlorderby = '';
528 } else if ($orderby) {
529 $sqlorderby = 'ORDER BY ' . $orderby;
530 } else {
531 $sqlorderby = '';
532 }
533
5b0a31bf
TH
534 // We always want the total count, as well as the partcular list of ids
535 // based on the paging and sort order. Because the list of ids is never
536 // going to be too ridiculously long. My worst-case scenario is
537 // 10,000 students in the course, each doing 5 quiz attempts. That
d1b7e03d 538 // is a 50,000 element int => int array, which PHP seems to use 5MB
5b0a31bf
TH
539 // memory to store on a 64 bit server.
540 $qubaidswhere = $qubaids->where(); // Must call this before params.
cf3b6568
TH
541 $params += $qubaids->from_where_params();
542 $params['slot'] = $slot;
5b0a31bf 543
cf3b6568 544 $qubaids = $this->db->get_records_sql_menu("
d1b7e03d
TH
545SELECT
546 qa.questionusageid,
547 1
548
549FROM {$qubaids->from_question_attempts('qa')}
cf3b6568 550JOIN {question_attempt_steps} qas ON
d1b7e03d 551 qas.id = {$this->latest_step_for_qa_subquery()}
cf3b6568 552JOIN {question} q ON q.id = qa.questionid
d1b7e03d
TH
553
554WHERE
5b0a31bf 555 {$qubaidswhere} AND
cf3b6568 556 qa.slot = :slot
d1b7e03d
TH
557 $extrawhere
558
559$sqlorderby
cf3b6568 560 ", $params);
d1b7e03d
TH
561
562 $qubaids = array_keys($qubaids);
563 $count = count($qubaids);
564
565 if ($orderby == 'random') {
566 shuffle($qubaids);
567 $limitfrom = 0;
568 }
569
570 if (!is_null($limitnum)) {
571 $qubaids = array_slice($qubaids, $limitfrom, $limitnum);
572 }
573
574 return array($qubaids, $count);
575 }
576
577 /**
578 * Load a {@link question_usage_by_activity} from the database, including
579 * all its {@link question_attempt}s and all their steps.
580 * @param qubaid_condition $qubaids used to restrict which usages are included
581 * in the query. See {@link qubaid_condition}.
582 * @param array $slots if null, load info for all quesitions, otherwise only
583 * load the averages for the specified questions.
584 */
585 public function load_average_marks(qubaid_condition $qubaids, $slots = null) {
d1b7e03d 586 if (!empty($slots)) {
9c197f44
TH
587 list($slottest, $slotsparams) = $this->db->get_in_or_equal(
588 $slots, SQL_PARAMS_NAMED, 'slot');
d1b7e03d
TH
589 $slotwhere = " AND qa.slot $slottest";
590 } else {
591 $slotwhere = '';
5b0a31bf 592 $slotsparams = array();
d1b7e03d
TH
593 }
594
e24ee794 595 list($statetest, $stateparams) = $this->db->get_in_or_equal(array(
deef04a4
TH
596 (string) question_state::$gaveup,
597 (string) question_state::$gradedwrong,
598 (string) question_state::$gradedpartial,
599 (string) question_state::$gradedright,
600 (string) question_state::$mangaveup,
601 (string) question_state::$mangrwrong,
602 (string) question_state::$mangrpartial,
603 (string) question_state::$mangrright), SQL_PARAMS_NAMED, 'st');
d1b7e03d 604
e24ee794 605 return $this->db->get_records_sql("
d1b7e03d
TH
606SELECT
607 qa.slot,
608 AVG(COALESCE(qas.fraction, 0)) AS averagefraction,
609 COUNT(1) AS numaveraged
610
611FROM {$qubaids->from_question_attempts('qa')}
2a3bdbf9 612JOIN {question_attempt_steps} qas ON
d1b7e03d
TH
613 qas.id = {$this->latest_step_for_qa_subquery()}
614
615WHERE
616 {$qubaids->where()}
617 $slotwhere
618 AND qas.state $statetest
619
620GROUP BY qa.slot
621
622ORDER BY qa.slot
e24ee794 623 ", $slotsparams + $stateparams + $qubaids->from_where_params());
d1b7e03d
TH
624 }
625
626 /**
627 * Load a {@link question_attempt} from the database, including all its
628 * steps.
f7970e3c 629 * @param int $questionid the question to load all the attempts fors.
d1b7e03d
TH
630 * @param qubaid_condition $qubaids used to restrict which usages are included
631 * in the query. See {@link qubaid_condition}.
632 * @return array of question_attempts.
633 */
634 public function load_attempts_at_question($questionid, qubaid_condition $qubaids) {
04853f27
TH
635 $params = $qubaids->from_where_params();
636 $params['questionid'] = $questionid;
637
0a606a2b 638 $records = $this->db->get_recordset_sql("
d1b7e03d 639SELECT
04853f27 640 quba.contextid,
d1b7e03d
TH
641 quba.preferredbehaviour,
642 qa.id AS questionattemptid,
643 qa.questionusageid,
644 qa.slot,
645 qa.behaviour,
646 qa.questionid,
1da821bb 647 qa.variant,
d1b7e03d
TH
648 qa.maxmark,
649 qa.minfraction,
4e3d8293 650 qa.maxfraction,
d1b7e03d
TH
651 qa.flagged,
652 qa.questionsummary,
653 qa.rightanswer,
654 qa.responsesummary,
655 qa.timemodified,
656 qas.id AS attemptstepid,
657 qas.sequencenumber,
658 qas.state,
659 qas.fraction,
660 qas.timecreated,
661 qas.userid,
662 qasd.name,
663 qasd.value
664
665FROM {$qubaids->from_question_attempts('qa')}
04853f27
TH
666JOIN {question_usages} quba ON quba.id = qa.questionusageid
667LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
668LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
d1b7e03d
TH
669
670WHERE
671 {$qubaids->where()} AND
04853f27 672 qa.questionid = :questionid
d1b7e03d
TH
673
674ORDER BY
675 quba.id,
676 qa.id,
677 qas.sequencenumber
04853f27 678 ", $params);
d1b7e03d 679
d1b7e03d 680 $questionattempts = array();
35d5f1c2
TH
681 while ($records->valid()) {
682 $record = $records->current();
d1b7e03d
TH
683 $questionattempts[$record->questionattemptid] =
684 question_attempt::load_from_records($records,
685 $record->questionattemptid, new question_usage_null_observer(),
686 $record->preferredbehaviour);
d1b7e03d 687 }
35d5f1c2
TH
688 $records->close();
689
d1b7e03d
TH
690 return $questionattempts;
691 }
692
693 /**
694 * Update a question_usages row to refect any changes in a usage (but not
695 * any of its question_attempts.
696 * @param question_usage_by_activity $quba the usage that has changed.
697 */
698 public function update_questions_usage_by_activity(question_usage_by_activity $quba) {
0ff4bd08 699 $record = new stdClass();
d1b7e03d
TH
700 $record->id = $quba->get_id();
701 $record->contextid = $quba->get_owning_context()->id;
56b0df7e
TH
702 $record->component = $quba->get_owning_component();
703 $record->preferredbehaviour = $quba->get_preferred_behaviour();
d1b7e03d 704
88f0eb15 705 $this->db->update_record('question_usages', $record);
d1b7e03d
TH
706 }
707
708 /**
709 * Update a question_attempts row to refect any changes in a question_attempt
710 * (but not any of its steps).
711 * @param question_attempt $qa the question attempt that has changed.
712 */
713 public function update_question_attempt(question_attempt $qa) {
0ff4bd08 714 $record = new stdClass();
d1b7e03d
TH
715 $record->id = $qa->get_database_id();
716 $record->maxmark = $qa->get_max_mark();
717 $record->minfraction = $qa->get_min_fraction();
4e3d8293 718 $record->maxfraction = $qa->get_max_fraction();
d1b7e03d 719 $record->flagged = $qa->is_flagged();
56b0df7e
TH
720 $record->questionsummary = $qa->get_question_summary();
721 $record->rightanswer = $qa->get_right_answer_summary();
722 $record->responsesummary = $qa->get_response_summary();
d1b7e03d
TH
723 $record->timemodified = time();
724
88f0eb15 725 $this->db->update_record('question_attempts', $record);
d1b7e03d
TH
726 }
727
728 /**
729 * Delete a question_usage_by_activity and all its associated
730 * {@link question_attempts} and {@link question_attempt_steps} from the
731 * database.
6b5f24d3 732 * @param qubaid_condition $qubaids identifies which question useages to delete.
d1b7e03d 733 */
6b5f24d3
TH
734 public function delete_questions_usage_by_activities(qubaid_condition $qubaids) {
735 $where = "qa.questionusageid {$qubaids->usage_id_in()}";
736 $params = $qubaids->usage_id_in_params();
737
738 $contextids = $this->db->get_records_sql_menu("
739 SELECT DISTINCT contextid, 1
740 FROM {question_usages}
a2ac2349 741 WHERE id {$qubaids->usage_id_in()}", $qubaids->usage_id_in_params());
6b5f24d3
TH
742 foreach ($contextids as $contextid => $notused) {
743 $this->delete_response_files($contextid, "IN (
744 SELECT qas.id
745 FROM {question_attempts} qa
746 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
747 WHERE $where)", $params);
748 }
749
f0bfd964
TH
750 if ($this->db->get_dbfamily() == 'mysql') {
751 $this->delete_usage_records_for_mysql($qubaids);
752 return;
753 }
754
c76145d3 755 $this->db->delete_records_select('question_attempt_step_data', "attemptstepid IN (
d1b7e03d 756 SELECT qas.id
c76145d3
TH
757 FROM {question_attempts} qa
758 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
c76145d3 759 WHERE $where)", $params);
6b5f24d3 760
c76145d3 761 $this->db->delete_records_select('question_attempt_steps', "questionattemptid IN (
d1b7e03d 762 SELECT qa.id
c76145d3 763 FROM {question_attempts} qa
c76145d3 764 WHERE $where)", $params);
6b5f24d3
TH
765
766 $this->db->delete_records_select('question_attempts',
a2ac2349
TH
767 "{question_attempts}.questionusageid {$qubaids->usage_id_in()}",
768 $qubaids->usage_id_in_params());
6b5f24d3
TH
769
770 $this->db->delete_records_select('question_usages',
a2ac2349 771 "{question_usages}.id {$qubaids->usage_id_in()}", $qubaids->usage_id_in_params());
d1b7e03d
TH
772 }
773
f0bfd964
TH
774 /**
775 * This function is a work-around for poor MySQL performance with
776 * DELETE FROM x WHERE id IN (SELECT ...). We have to use a non-standard
777 * syntax to get good performance. See MDL-29520.
778 * @param qubaid_condition $qubaids identifies which question useages to delete.
779 */
780 protected function delete_usage_records_for_mysql(qubaid_condition $qubaids) {
2ec325c2
TH
781 $qubaidtest = $qubaids->usage_id_in();
782 if (strpos($qubaidtest, 'question_usages') !== false &&
783 strpos($qubaidtest, 'IN (SELECT') === 0) {
784 // This horrible hack is required by MDL-29847. It comes from
785 // http://www.xaprb.com/blog/2006/06/23/how-to-select-from-an-update-target-in-mysql/
786 $qubaidtest = 'IN (SELECT * FROM ' . substr($qubaidtest, 3) . ' AS hack_subquery_alias)';
787 }
788
f0bfd964
TH
789 // TODO once MDL-29589 is fixed, eliminate this method, and instead use the new $DB API.
790 $this->db->execute('
791 DELETE qu, qa, qas, qasd
792 FROM {question_usages} qu
793 JOIN {question_attempts} qa ON qa.questionusageid = qu.id
033d6566
EL
794 LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
795 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
2ec325c2 796 WHERE qu.id ' . $qubaidtest,
f0bfd964
TH
797 $qubaids->usage_id_in_params());
798 }
799
d1b7e03d
TH
800 /**
801 * Delete all the steps for a question attempt.
f7970e3c 802 * @param int $qaids question_attempt id.
94815ccf 803 * @param context $context the context that the $quba belongs to.
d1b7e03d 804 */
94815ccf
TH
805 public function delete_steps($stepids, $context) {
806 if (empty($stepids)) {
d1b7e03d
TH
807 return;
808 }
94815ccf 809 list($test, $params) = $this->db->get_in_or_equal($stepids, SQL_PARAMS_NAMED);
6b5f24d3 810
296e1e97 811 $this->delete_response_files($context->id, $test, $params);
f0bfd964 812
94815ccf
TH
813 $this->db->delete_records_select('question_attempt_step_data',
814 "attemptstepid $test", $params);
9c197f44 815 $this->db->delete_records_select('question_attempt_steps',
296e1e97 816 "id $test", $params);
d1b7e03d
TH
817 }
818
6b5f24d3
TH
819 /**
820 * Delete all the files belonging to the response variables in the gives
821 * question attempt steps.
822 * @param int $contextid the context these attempts belong to.
823 * @param string $itemidstest a bit of SQL that can be used in a
824 * WHERE itemid $itemidstest clause. Must use named params.
825 * @param array $params any query parameters used in $itemidstest.
826 */
827 protected function delete_response_files($contextid, $itemidstest, $params) {
828 $fs = get_file_storage();
c749527b 829 foreach (question_engine::get_all_response_file_areas() as $filearea) {
6b5f24d3
TH
830 $fs->delete_area_files_select($contextid, 'question', $filearea,
831 $itemidstest, $params);
832 }
833 }
834
d1b7e03d
TH
835 /**
836 * Delete all the previews for a given question.
f7970e3c 837 * @param int $questionid question id.
d1b7e03d
TH
838 */
839 public function delete_previews($questionid) {
c76145d3 840 $previews = $this->db->get_records_sql_menu("
d1b7e03d 841 SELECT DISTINCT quba.id, 1
c76145d3
TH
842 FROM {question_usages} quba
843 JOIN {question_attempts} qa ON qa.questionusageid = quba.id
d1b7e03d 844 WHERE quba.component = 'core_question_preview' AND
c76145d3 845 qa.questionid = ?", array($questionid));
d1b7e03d
TH
846 if (empty($previews)) {
847 return;
848 }
6b5f24d3 849 $this->delete_questions_usage_by_activities(new qubaid_list($previews));
d1b7e03d
TH
850 }
851
852 /**
853 * Update the flagged state of a question in the database.
f7970e3c
TH
854 * @param int $qubaid the question usage id.
855 * @param int $questionid the question id.
856 * @param int $sessionid the question_attempt id.
857 * @param bool $newstate the new state of the flag. true = flagged.
d1b7e03d 858 */
06f8ed54 859 public function update_question_attempt_flag($qubaid, $questionid, $qaid, $slot, $newstate) {
f9b0500f 860 if (!$this->db->record_exists('question_attempts', array('id' => $qaid,
06f8ed54 861 'questionusageid' => $qubaid, 'questionid' => $questionid, 'slot' => $slot))) {
88f0eb15 862 throw new moodle_exception('errorsavingflags', 'question');
d1b7e03d
TH
863 }
864
06f8ed54 865 $this->db->set_field('question_attempts', 'flagged', $newstate, array('id' => $qaid));
d1b7e03d
TH
866 }
867
868 /**
869 * Get all the WHEN 'x' THEN 'y' terms needed to convert the question_attempt_steps.state
870 * column to a summary state. Use this like
871 * CASE qas.state {$this->full_states_to_summary_state_sql()} END AS summarystate,
872 * @param string SQL fragment.
873 */
874 protected function full_states_to_summary_state_sql() {
875 $sql = '';
876 foreach (question_state::get_all() as $state) {
877 $sql .= "WHEN '$state' THEN '{$state->get_summary_state()}'\n";
878 }
879 return $sql;
880 }
881
882 /**
883 * Get the SQL needed to test that question_attempt_steps.state is in a
884 * state corresponding to $summarystate.
885 * @param string $summarystate one of
886 * inprogress, needsgrading, manuallygraded or autograded
f7970e3c 887 * @param bool $equal if false, do a NOT IN test. Default true.
d1b7e03d
TH
888 * @return string SQL fragment.
889 */
cf3b6568 890 public function in_summary_state_test($summarystate, $equal = true, $prefix = 'summarystates') {
d1b7e03d 891 $states = question_state::get_all_for_summary_state($summarystate);
deef04a4
TH
892 return $this->db->get_in_or_equal(array_map('strval', $states),
893 SQL_PARAMS_NAMED, $prefix, $equal);
d1b7e03d
TH
894 }
895
896 /**
897 * Change the maxmark for the question_attempt with number in usage $slot
898 * for all the specified question_attempts.
899 * @param qubaid_condition $qubaids Selects which usages are updated.
f7970e3c 900 * @param int $slot the number is usage to affect.
d1b7e03d
TH
901 * @param number $newmaxmark the new max mark to set.
902 */
903 public function set_max_mark_in_attempts(qubaid_condition $qubaids, $slot, $newmaxmark) {
c76145d3
TH
904 $this->db->set_field_select('question_attempts', 'maxmark', $newmaxmark,
905 "questionusageid {$qubaids->usage_id_in()} AND slot = :slot",
906 $qubaids->usage_id_in_params() + array('slot' => $slot));
d1b7e03d
TH
907 }
908
909 /**
910 * Return a subquery that computes the sum of the marks for all the questions
911 * in a usage. Which useage to compute the sum for is controlled bu the $qubaid
912 * parameter.
913 *
914 * See {@link quiz_update_all_attempt_sumgrades()} for an example of the usage of
915 * this method.
916 *
917 * @param string $qubaid SQL fragment that controls which usage is summed.
2daffca5
TH
918 * This will normally be the name of a column in the outer query. Not that this
919 * SQL fragment must not contain any placeholders.
d1b7e03d
TH
920 * @return string SQL code for the subquery.
921 */
922 public function sum_usage_marks_subquery($qubaid) {
ad73a2cb
TH
923 // To explain the COALESCE in the following SQL: SUM(lots of NULLs) gives
924 // NULL, while SUM(one 0.0 and lots of NULLS) gives 0.0. We don't want that.
925 // We always want to return a number, so the COALESCE is there to turn the
926 // NULL total into a 0.
927 return "SELECT COALESCE(SUM(qa.maxmark * qas.fraction), 0)
c76145d3 928 FROM {question_attempts} qa
d2c69d93
TH
929 JOIN {question_attempt_steps} qas ON qas.id = (
930 SELECT MAX(summarks_qas.id)
931 FROM {question_attempt_steps} summarks_qas
932 WHERE summarks_qas.questionattemptid = qa.id
933 )
d1b7e03d 934 WHERE qa.questionusageid = $qubaid
9c197f44
TH
935 HAVING COUNT(CASE
936 WHEN qas.state = 'needsgrading' AND qa.maxmark > 0 THEN 1
937 ELSE NULL
938 END) = 0";
d1b7e03d
TH
939 }
940
94c0ec21
TH
941 /**
942 * Get a subquery that returns the latest step of every qa in some qubas.
943 * Currently, this is only used by the quiz reports. See
ac4d9157 944 * {@link quiz_attempts_report_table::add_latest_state_join()}.
94c0ec21
TH
945 * @param string $alias alias to use for this inline-view.
946 * @param qubaid_condition $qubaids restriction on which question_usages we
947 * are interested in. This is important for performance.
948 * @return array with two elements, the SQL fragment and any params requried.
949 */
950 public function question_attempt_latest_state_view($alias, qubaid_condition $qubaids) {
951 return array("(
952 SELECT {$alias}qa.id AS questionattemptid,
953 {$alias}qa.questionusageid,
954 {$alias}qa.slot,
955 {$alias}qa.behaviour,
956 {$alias}qa.questionid,
957 {$alias}qa.variant,
958 {$alias}qa.maxmark,
959 {$alias}qa.minfraction,
4e3d8293 960 {$alias}qa.maxfraction,
94c0ec21
TH
961 {$alias}qa.flagged,
962 {$alias}qa.questionsummary,
963 {$alias}qa.rightanswer,
964 {$alias}qa.responsesummary,
965 {$alias}qa.timemodified,
966 {$alias}qas.id AS attemptstepid,
967 {$alias}qas.sequencenumber,
968 {$alias}qas.state,
969 {$alias}qas.fraction,
970 {$alias}qas.timecreated,
971 {$alias}qas.userid
972
973 FROM {$qubaids->from_question_attempts($alias . 'qa')}
974 JOIN {question_attempt_steps} {$alias}qas ON
975 {$alias}qas.id = {$this->latest_step_for_qa_subquery($alias . 'qa.id')}
976 WHERE {$qubaids->where()}
977 ) $alias", $qubaids->from_where_params());
d1b7e03d
TH
978 }
979
980 protected function latest_step_for_qa_subquery($questionattemptid = 'qa.id') {
d1b7e03d
TH
981 return "(
982 SELECT MAX(id)
c76145d3 983 FROM {question_attempt_steps}
d1b7e03d
TH
984 WHERE questionattemptid = $questionattemptid
985 )";
986 }
987
988 /**
989 * @param array $questionids of question ids.
07f88584
TH
990 * @param qubaid_condition $qubaids ids of the usages to consider.
991 * @return boolean whether any of these questions are being used by any of
992 * those usages.
d1b7e03d 993 */
07f88584 994 public function questions_in_use(array $questionids, qubaid_condition $qubaids) {
c76145d3
TH
995 list($test, $params) = $this->db->get_in_or_equal($questionids);
996 return $this->db->record_exists_select('question_attempts',
07f88584
TH
997 'questionid ' . $test . ' AND questionusageid ' .
998 $qubaids->usage_id_in(), $params + $qubaids->usage_id_in_params());
d1b7e03d
TH
999 }
1000}
1001
6b5f24d3 1002
d1b7e03d
TH
1003/**
1004 * Implementation of the unit of work pattern for the question engine.
1005 *
1006 * See http://martinfowler.com/eaaCatalog/unitOfWork.html. This tracks all the
1007 * changes to a {@link question_usage_by_activity}, and its constituent parts,
1008 * so that the changes can be saved to the database when {@link save()} is called.
1009 *
017bc1d9
TH
1010 * @copyright 2009 The Open University
1011 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
1012 */
1013class question_engine_unit_of_work implements question_usage_observer {
1014 /** @var question_usage_by_activity the usage being tracked. */
1015 protected $quba;
1016
1017 /** @var boolean whether any of the fields of the usage have been changed. */
1018 protected $modified = false;
1019
1020 /**
94815ccf 1021 * @var array list of slot => {@link question_attempt}s that
d1b7e03d
TH
1022 * were already in the usage, and which have been modified.
1023 */
1024 protected $attemptsmodified = array();
1025
1026 /**
94815ccf 1027 * @var array list of slot => {@link question_attempt}s that
d1b7e03d
TH
1028 * have been added to the usage.
1029 */
1030 protected $attemptsadded = array();
1031
1032 /**
94815ccf
TH
1033 * @var array of array(question_attempt_step, question_attempt id, seq number)
1034 * of steps that have been added to question attempts in this usage.
d1b7e03d 1035 */
94815ccf 1036 protected $stepsadded = array();
d1b7e03d
TH
1037
1038 /**
94815ccf
TH
1039 * @var array of array(question_attempt_step, question_attempt id, seq number)
1040 * of steps that have been modified in their attempt.
d1b7e03d 1041 */
94815ccf
TH
1042 protected $stepsmodified = array();
1043
1044 /**
1045 * @var array list of question_attempt_step.id => question_attempt_step of steps
1046 * that were previously stored in the database, but which are no longer required.
1047 */
1048 protected $stepsdeleted = array();
d1b7e03d
TH
1049
1050 /**
1051 * Constructor.
1052 * @param question_usage_by_activity $quba the usage to track.
1053 */
1054 public function __construct(question_usage_by_activity $quba) {
1055 $this->quba = $quba;
1056 }
1057
1058 public function notify_modified() {
1059 $this->modified = true;
1060 }
1061
1062 public function notify_attempt_modified(question_attempt $qa) {
94815ccf
TH
1063 $slot = $qa->get_slot();
1064 if (!array_key_exists($slot, $this->attemptsadded)) {
1065 $this->attemptsmodified[$slot] = $qa;
d1b7e03d
TH
1066 }
1067 }
1068
1069 public function notify_attempt_added(question_attempt $qa) {
1070 $this->attemptsadded[$qa->get_slot()] = $qa;
1071 }
1072
94815ccf 1073 public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq) {
d1b7e03d
TH
1074 if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
1075 return;
1076 }
1077
94815ccf
TH
1078 if (($key = $this->is_step_added($step)) !== false) {
1079 return;
1080 }
1081
1082 if (($key = $this->is_step_modified($step)) !== false) {
1083 throw new coding_exception('Cannot add a step that has already been modified.');
1084 }
1085
1086 if (($key = $this->is_step_deleted($step)) !== false) {
1087 unset($this->stepsdeleted[$step->get_id()]);
1088 $this->stepsmodified[] = array($step, $qa->get_database_id(), $seq);
1089 return;
1090 }
1091
1092 $stepid = $step->get_id();
1093 if ($stepid) {
1094 if (array_key_exists($stepid, $this->stepsdeleted)) {
1095 unset($this->stepsdeleted[$stepid]);
d1b7e03d 1096 }
94815ccf
TH
1097 $this->stepsmodified[] = array($step, $qa->get_database_id(), $seq);
1098
1099 } else {
1100 $this->stepsadded[] = array($step, $qa->get_database_id(), $seq);
1101 }
1102 }
1103
1104 public function notify_step_modified(question_attempt_step $step, question_attempt $qa, $seq) {
1105 if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
1106 return;
d1b7e03d
TH
1107 }
1108
94815ccf
TH
1109 if (($key = $this->is_step_added($step)) !== false) {
1110 return;
1111 }
1112
1113 if (($key = $this->is_step_deleted($step)) !== false) {
1114 throw new coding_exception('Cannot modify a step after it has been deleted.');
1115 }
1116
1117 $stepid = $step->get_id();
1118 if (empty($stepid)) {
1119 throw new coding_exception('Cannot modify a step that has never been stored in the database.');
1120 }
1121
1122 $this->stepsmodified[] = array($step, $qa->get_database_id(), $seq);
d1b7e03d
TH
1123 }
1124
94815ccf 1125 public function notify_step_deleted(question_attempt_step $step, question_attempt $qa) {
d1b7e03d
TH
1126 if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
1127 return;
1128 }
94815ccf
TH
1129
1130 if (($key = $this->is_step_added($step)) !== false) {
1131 unset($this->stepsadded[$key]);
1132 return;
1133 }
1134
1135 if (($key = $this->is_step_modified($step)) !== false) {
1136 unset($this->stepsmodified[$key]);
1137 }
1138
1139 $stepid = $step->get_id();
1140 if (empty($stepid)) {
1141 return; // Was never in the database.
1142 }
1143
1144 $this->stepsdeleted[$stepid] = $step;
1145 }
1146
1147 /**
1148 * @param question_attempt_step $step a step
1149 * @return int|false if the step is in the list of steps to be added, return
1150 * the key, otherwise return false.
1151 */
1152 protected function is_step_added(question_attempt_step $step) {
1153 foreach ($this->stepsadded as $key => $data) {
1154 list($addedstep, $qaid, $seq) = $data;
1155 if ($addedstep === $step) {
1156 return $key;
1157 }
1158 }
1159 return false;
1160 }
1161
1162 /**
1163 * @param question_attempt_step $step a step
1164 * @return int|false if the step is in the list of steps to be modified, return
1165 * the key, otherwise return false.
1166 */
1167 protected function is_step_modified(question_attempt_step $step) {
1168 foreach ($this->stepsmodified as $key => $data) {
1169 list($modifiedstep, $qaid, $seq) = $data;
1170 if ($modifiedstep === $step) {
1171 return $key;
1172 }
1173 }
1174 return false;
1175 }
1176
1177 /**
1178 * @param question_attempt_step $step a step
1179 * @return bool whether the step is in the list of steps to be deleted.
1180 */
1181 protected function is_step_deleted(question_attempt_step $step) {
1182 foreach ($this->stepsdeleted as $deletedstep) {
1183 if ($deletedstep === $step) {
1184 return true;
1185 }
1186 }
1187 return false;
d1b7e03d
TH
1188 }
1189
1190 /**
1191 * Write all the changes we have recorded to the database.
1192 * @param question_engine_data_mapper $dm the mapper to use to update the database.
1193 */
1194 public function save(question_engine_data_mapper $dm) {
94815ccf
TH
1195 $dm->delete_steps(array_keys($this->stepsdeleted), $this->quba->get_owning_context());
1196
1197 foreach ($this->stepsmodified as $stepinfo) {
1198 list($step, $questionattemptid, $seq) = $stepinfo;
1199 $dm->update_question_attempt_step($step, $questionattemptid, $seq,
1200 $this->quba->get_owning_context());
1201 }
6b5f24d3 1202
d1b7e03d
TH
1203 foreach ($this->stepsadded as $stepinfo) {
1204 list($step, $questionattemptid, $seq) = $stepinfo;
217f9a61
TH
1205 $dm->insert_question_attempt_step($step, $questionattemptid, $seq,
1206 $this->quba->get_owning_context());
d1b7e03d 1207 }
6b5f24d3 1208
d1b7e03d 1209 foreach ($this->attemptsadded as $qa) {
217f9a61 1210 $dm->insert_question_attempt($qa, $this->quba->get_owning_context());
d1b7e03d 1211 }
6b5f24d3 1212
d1b7e03d
TH
1213 foreach ($this->attemptsmodified as $qa) {
1214 $dm->update_question_attempt($qa);
1215 }
6b5f24d3 1216
d1b7e03d
TH
1217 if ($this->modified) {
1218 $dm->update_questions_usage_by_activity($this->quba);
1219 }
1220 }
1221}
1222
1223
8a1e7b77
TH
1224/**
1225 * The interface implemented by {@link question_file_saver} and {@link question_file_loader}.
1226 *
1227 * @copyright 2012 The Open University
1228 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1229 */
1230interface question_response_files {
1231 /**
1232 * Get the files that were submitted.
1233 * @return array of stored_files objects.
1234 */
1235 public function get_files();
1236}
1237
1238
217f9a61
TH
1239/**
1240 * This class represents the promise to save some files from a particular draft
1241 * file area into a particular file area. It is used beause the necessary
1242 * information about what to save is to hand in the
1243 * {@link question_attempt::process_response_files()} method, but we don't know
1244 * if this question attempt will actually be saved in the database until later,
1245 * when the {@link question_engine_unit_of_work} is saved, if it is.
1246 *
1247 * @copyright 2011 The Open University
1248 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1249 */
8a1e7b77 1250class question_file_saver implements question_response_files {
217f9a61
TH
1251 /** @var int the id of the draft file area to save files from. */
1252 protected $draftitemid;
1253 /** @var string the owning component name. */
1254 protected $component;
1255 /** @var string the file area name. */
1256 protected $filearea;
1257
cd3557e6
TH
1258 /**
1259 * @var string the value to store in the question_attempt_step_data to
1260 * represent these files.
1261 */
1262 protected $value = null;
1263
217f9a61
TH
1264 /**
1265 * Constuctor.
1266 * @param int $draftitemid the draft area to save the files from.
1267 * @param string $component the component for the file area to save into.
1268 * @param string $filearea the name of the file area to save into.
1269 */
48d9c17d 1270 public function __construct($draftitemid, $component, $filearea, $text = null) {
217f9a61
TH
1271 $this->draftitemid = $draftitemid;
1272 $this->component = $component;
1273 $this->filearea = $filearea;
48d9c17d 1274 $this->value = $this->compute_value($draftitemid, $text);
217f9a61
TH
1275 }
1276
48d9c17d
TH
1277 /**
1278 * Compute the value that should be stored in the question_attempt_step_data
1279 * table. Contains a hash that (almost) uniquely encodes all the files.
1280 * @param int $draftitemid the draft file area itemid.
1281 * @param string $text optional content containing file links.
1282 */
1283 protected function compute_value($draftitemid, $text) {
cd3557e6
TH
1284 global $USER;
1285
cd3557e6 1286 $fs = get_file_storage();
21c08c63 1287 $usercontext = context_user::instance($USER->id);
cd3557e6
TH
1288
1289 $files = $fs->get_area_files($usercontext->id, 'user', 'draft',
48d9c17d 1290 $draftitemid, 'sortorder, filepath, filename', false);
cd3557e6
TH
1291
1292 $string = '';
1293 foreach ($files as $file) {
1294 $string .= $file->get_filepath() . $file->get_filename() . '|' .
1295 $file->get_contenthash() . '|';
1296 }
d629327a 1297 $hash = md5($string);
cd3557e6 1298
48d9c17d 1299 if (is_null($text)) {
d629327a
TH
1300 if ($string) {
1301 return $hash;
1302 } else {
1303 return '';
1304 }
48d9c17d
TH
1305 }
1306
1307 // We add the file hash so a simple string comparison will say if the
1308 // files have been changed. First strip off any existing file hash.
d629327a
TH
1309 if ($text !== '') {
1310 $text = preg_replace('/\s*<!-- File hash: \w+ -->\s*$/', '', $text);
1311 $text = file_rewrite_urls_to_pluginfile($text, $draftitemid);
3fc9410f
TH
1312 if ($string) {
1313 $text .= '<!-- File hash: ' . $hash . ' -->';
1314 }
48d9c17d
TH
1315 }
1316 return $text;
cd3557e6
TH
1317 }
1318
217f9a61 1319 public function __toString() {
48d9c17d 1320 return $this->value;
217f9a61
TH
1321 }
1322
1323 /**
1324 * Actually save the files.
1325 * @param integer $itemid the item id for the file area to save into.
1326 */
1327 public function save_files($itemid, $context) {
1328 file_save_draft_area_files($this->draftitemid, $context->id,
1329 $this->component, $this->filearea, $itemid);
1330 }
8a1e7b77
TH
1331
1332 /**
1333 * Get the files that were submitted.
1334 * @return array of stored_files objects.
1335 */
1336 public function get_files() {
1337 global $USER;
1338
1339 $fs = get_file_storage();
1340 $usercontext = context_user::instance($USER->id);
1341
1342 return $fs->get_area_files($usercontext->id, 'user', 'draft',
1343 $this->draftitemid, 'sortorder, filepath, filename', false);
1344 }
1345}
1346
1347
1348/**
1349 * This class is the mirror image of {@link question_file_saver}. It allows
1350 * files to be accessed again later (e.g. when re-grading) using that same
1351 * API as when doing the original grading.
1352 *
1353 * @copyright 2012 The Open University
1354 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1355 */
1356class question_file_loader implements question_response_files {
1357 /** @var question_attempt_step the step that these files belong to. */
1358 protected $step;
1359
1360 /** @var string the field name for these files - which is used to construct the file area name. */
1361 protected $name;
1362
1363 /**
1364 * @var string the value to stored in the question_attempt_step_data to
1365 * represent these files.
1366 */
1367 protected $value;
1368
1369 /** @var int the context id that the files belong to. */
1370 protected $contextid;
1371
1372 /**
1373 * Constuctor.
1374 * @param question_attempt_step $step the step that these files belong to.
1375 * @param string $name string the field name for these files - which is used to construct the file area name.
1376 * @param string $value the value to stored in the question_attempt_step_data to
1377 * represent these files.
1378 * @param int $contextid the context id that the files belong to.
1379 */
1380 public function __construct(question_attempt_step $step, $name, $value, $contextid) {
d0782585
MPC
1381 $this->step = $step;
1382 $this->name = $name;
1383 $this->value = $value;
1384 $this->contextid = $contextid;
8a1e7b77
TH
1385 }
1386
1387 public function __toString() {
1388 return $this->value;
1389 }
1390
1391 /**
1392 * Get the files that were submitted.
1393 * @return array of stored_files objects.
1394 */
1395 public function get_files() {
1396 return $this->step->get_qt_files($this->name, $this->contextid);
1397 }
afb1b3d0
TH
1398
1399 /**
1400 * Copy these files into a draft area, and return the corresponding
1401 * {@link question_file_saver} that can save them again.
1402 *
1403 * This is used by {@link question_attempt::start_based_on()}, which is used
1404 * (for example) by the quizzes 'Each attempt builds on last' feature.
1405 *
1406 * @return question_file_saver that can re-save these files again.
1407 */
1408 public function get_question_file_saver() {
1409
d629327a
TH
1410 // There are three possibilities here for what $value will look like:
1411 // 1) some HTML content followed by an MD5 hash in a HTML comment;
1412 // 2) a plain MD5 hash;
1413 // 3) or some real content, without any hash.
1414 // The problem is that 3) is ambiguous in the case where a student writes
1415 // a response that looks exactly like an MD5 hash. For attempts made now,
1416 // we avoid case 3) by always going for case 1) or 2) (except when the
1417 // response is blank. However, there may be case 3) data in the database
1418 // so we need to handle it as best we can.
afb1b3d0
TH
1419 if (preg_match('/\s*<!-- File hash: [0-9a-zA-Z]{32} -->\s*$/', $this->value)) {
1420 $value = preg_replace('/\s*<!-- File hash: [0-9a-zA-Z]{32} -->\s*$/', '', $this->value);
1421
1422 } else if (preg_match('/^[0-9a-zA-Z]{32}$/', $this->value)) {
1423 $value = null;
1424
1425 } else {
d629327a 1426 $value = $this->value;
afb1b3d0
TH
1427 }
1428
1429 list($draftid, $text) = $this->step->prepare_response_files_draft_itemid_with_text(
1430 $this->name, $this->contextid, $value);
1431 return new question_file_saver($draftid, 'question', 'response_' . $this->name, $text);
1432 }
217f9a61
TH
1433}
1434
1435
d1b7e03d
TH
1436/**
1437 * This class represents a restriction on the set of question_usage ids to include
1438 * in a larger database query. Depending of the how you are going to restrict the
1439 * list of usages, construct an appropriate subclass.
1440 *
1441 * If $qubaids is an instance of this class, example usage might be
1442 *
1443 * SELECT qa.id, qa.maxmark
1444 * FROM $qubaids->from_question_attempts('qa')
1445 * WHERE $qubaids->where() AND qa.slot = 1
1446 *
f7970e3c 1447 * @copyright 2010 The Open University
017bc1d9 1448 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
1449 */
1450abstract class qubaid_condition {
1451
1452 /**
1453 * @return string the SQL that needs to go in the FROM clause when trying
1454 * to select records from the 'question_attempts' table based on the
1455 * qubaid_condition.
1456 */
1457 public abstract function from_question_attempts($alias);
1458
1459 /** @return string the SQL that needs to go in the where clause. */
1460 public abstract function where();
1461
1462 /**
1463 * @return the params needed by a query that uses
1464 * {@link from_question_attempts()} and {@link where()}.
1465 */
1466 public abstract function from_where_params();
1467
1468 /**
1469 * @return string SQL that can use used in a WHERE qubaid IN (...) query.
1470 * This method returns the "IN (...)" part.
1471 */
1472 public abstract function usage_id_in();
1473
1474 /**
1475 * @return the params needed by a query that uses {@link usage_id_in()}.
1476 */
1477 public abstract function usage_id_in_params();
e68e4ccf
JP
1478
1479 /**
1480 * @return string 40-character hash code that uniquely identifies the combination of properties and class name of this qubaid
1481 * condition.
1482 */
1483 public function get_hash_code() {
1484 return sha1(serialize($this));
1485 }
d1b7e03d
TH
1486}
1487
1488
1489/**
1490 * This class represents a restriction on the set of question_usage ids to include
1491 * in a larger database query based on an explicit list of ids.
1492 *
f7970e3c 1493 * @copyright 2010 The Open University
017bc1d9 1494 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
1495 */
1496class qubaid_list extends qubaid_condition {
1497 /** @var array of ids. */
1498 protected $qubaids;
1499 protected $columntotest = null;
1500 protected $params;
1501
1502 /**
1503 * Constructor.
1504 * @param array $qubaids of question usage ids.
1505 */
1506 public function __construct(array $qubaids) {
1507 $this->qubaids = $qubaids;
1508 }
1509
1510 public function from_question_attempts($alias) {
d1b7e03d 1511 $this->columntotest = $alias . '.questionusageid';
9b40c540 1512 return '{question_attempts} ' . $alias;
d1b7e03d
TH
1513 }
1514
1515 public function where() {
1516 global $DB;
1517
1518 if (is_null($this->columntotest)) {
88f0eb15 1519 throw new coding_exception('Must call from_question_attempts before where().');
d1b7e03d
TH
1520 }
1521 if (empty($this->qubaids)) {
9b40c540 1522 $this->params = array();
d1b7e03d
TH
1523 return '1 = 0';
1524 }
d1b7e03d 1525
9b40c540 1526 return $this->columntotest . ' ' . $this->usage_id_in();
d1b7e03d
TH
1527 }
1528
1529 public function from_where_params() {
1530 return $this->params;
1531 }
1532
1533 public function usage_id_in() {
1534 global $DB;
1535
1536 if (empty($this->qubaids)) {
5f79a9bc 1537 $this->params = array();
d1b7e03d
TH
1538 return '= 0';
1539 }
9c197f44
TH
1540 list($where, $this->params) = $DB->get_in_or_equal(
1541 $this->qubaids, SQL_PARAMS_NAMED, 'qubaid');
d1b7e03d
TH
1542 return $where;
1543 }
1544
1545 public function usage_id_in_params() {
1546 return $this->params;
1547 }
1548}
1549
1550
1551/**
1552 * This class represents a restriction on the set of question_usage ids to include
1553 * in a larger database query based on JOINing to some other tables.
1554 *
1555 * The general form of the query is something like
1556 *
1557 * SELECT qa.id, qa.maxmark
1558 * FROM $from
2a3bdbf9 1559 * JOIN {question_attempts} qa ON qa.questionusageid = $usageidcolumn
d1b7e03d
TH
1560 * WHERE $where AND qa.slot = 1
1561 *
1562 * where $from, $usageidcolumn and $where are the arguments to the constructor.
1563 *
f7970e3c 1564 * @copyright 2010 The Open University
017bc1d9 1565 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
1566 */
1567class qubaid_join extends qubaid_condition {
1568 public $from;
1569 public $usageidcolumn;
1570 public $where;
1571 public $params;
1572
1573 /**
1574 * Constructor. The meaning of the arguments is explained in the class comment.
1575 * @param string $from SQL fragemnt to go in the FROM clause.
1576 * @param string $usageidcolumn the column in $from that should be
1577 * made equal to the usageid column in the JOIN clause.
1578 * @param string $where SQL fragment to go in the where clause.
e24ee794 1579 * @param array $params required by the SQL. You must use named parameters.
d1b7e03d
TH
1580 */
1581 public function __construct($from, $usageidcolumn, $where = '', $params = array()) {
1582 $this->from = $from;
1583 $this->usageidcolumn = $usageidcolumn;
1584 $this->params = $params;
1585 if (empty($where)) {
1586 $where = '1 = 1';
1587 }
1588 $this->where = $where;
1589 }
1590
1591 public function from_question_attempts($alias) {
d1b7e03d 1592 return "$this->from
0f33deaf 1593 JOIN {question_attempts} {$alias} ON " .
d1b7e03d
TH
1594 "{$alias}.questionusageid = $this->usageidcolumn";
1595 }
1596
1597 public function where() {
1598 return $this->where;
1599 }
1600
1601 public function from_where_params() {
1602 return $this->params;
1603 }
1604
1605 public function usage_id_in() {
1606 return "IN (SELECT $this->usageidcolumn FROM $this->from WHERE $this->where)";
1607 }
1608
1609 public function usage_id_in_params() {
1610 return $this->params;
1611 }
1612}