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