weekly release 2.7dev
[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')}
84b37108
TH
403JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
404 AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
d1b7e03d
TH
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')}
84b37108
TH
441JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
442 AND qas.sequencenumber = {$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')}
84b37108
TH
550JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
551 AND qas.sequencenumber = {$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')}
84b37108
TH
612JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
613 AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
d1b7e03d
TH
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
84b37108
TH
929 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
930 AND qas.sequencenumber = (
931 SELECT MAX(summarks_qas.sequencenumber)
932 FROM {question_attempt_steps} summarks_qas
933 WHERE summarks_qas.questionattemptid = qa.id
d2c69d93 934 )
d1b7e03d 935 WHERE qa.questionusageid = $qubaid
9c197f44
TH
936 HAVING COUNT(CASE
937 WHEN qas.state = 'needsgrading' AND qa.maxmark > 0 THEN 1
938 ELSE NULL
939 END) = 0";
d1b7e03d
TH
940 }
941
94c0ec21
TH
942 /**
943 * Get a subquery that returns the latest step of every qa in some qubas.
944 * Currently, this is only used by the quiz reports. See
ac4d9157 945 * {@link quiz_attempts_report_table::add_latest_state_join()}.
94c0ec21
TH
946 * @param string $alias alias to use for this inline-view.
947 * @param qubaid_condition $qubaids restriction on which question_usages we
948 * are interested in. This is important for performance.
949 * @return array with two elements, the SQL fragment and any params requried.
950 */
951 public function question_attempt_latest_state_view($alias, qubaid_condition $qubaids) {
952 return array("(
953 SELECT {$alias}qa.id AS questionattemptid,
954 {$alias}qa.questionusageid,
955 {$alias}qa.slot,
956 {$alias}qa.behaviour,
957 {$alias}qa.questionid,
958 {$alias}qa.variant,
959 {$alias}qa.maxmark,
960 {$alias}qa.minfraction,
4e3d8293 961 {$alias}qa.maxfraction,
94c0ec21
TH
962 {$alias}qa.flagged,
963 {$alias}qa.questionsummary,
964 {$alias}qa.rightanswer,
965 {$alias}qa.responsesummary,
966 {$alias}qa.timemodified,
967 {$alias}qas.id AS attemptstepid,
968 {$alias}qas.sequencenumber,
969 {$alias}qas.state,
970 {$alias}qas.fraction,
971 {$alias}qas.timecreated,
972 {$alias}qas.userid
973
974 FROM {$qubaids->from_question_attempts($alias . 'qa')}
84b37108
TH
975 JOIN {question_attempt_steps} {$alias}qas ON {$alias}qas.questionattemptid = {$alias}qa.id
976 AND {$alias}qas.sequencenumber = {$this->latest_step_for_qa_subquery($alias . 'qa.id')}
94c0ec21
TH
977 WHERE {$qubaids->where()}
978 ) $alias", $qubaids->from_where_params());
d1b7e03d
TH
979 }
980
981 protected function latest_step_for_qa_subquery($questionattemptid = 'qa.id') {
d1b7e03d 982 return "(
84b37108 983 SELECT MAX(sequencenumber)
c76145d3 984 FROM {question_attempt_steps}
d1b7e03d
TH
985 WHERE questionattemptid = $questionattemptid
986 )";
987 }
988
989 /**
990 * @param array $questionids of question ids.
07f88584
TH
991 * @param qubaid_condition $qubaids ids of the usages to consider.
992 * @return boolean whether any of these questions are being used by any of
993 * those usages.
d1b7e03d 994 */
07f88584 995 public function questions_in_use(array $questionids, qubaid_condition $qubaids) {
c76145d3
TH
996 list($test, $params) = $this->db->get_in_or_equal($questionids);
997 return $this->db->record_exists_select('question_attempts',
07f88584
TH
998 'questionid ' . $test . ' AND questionusageid ' .
999 $qubaids->usage_id_in(), $params + $qubaids->usage_id_in_params());
d1b7e03d
TH
1000 }
1001}
1002
6b5f24d3 1003
d1b7e03d
TH
1004/**
1005 * Implementation of the unit of work pattern for the question engine.
1006 *
1007 * See http://martinfowler.com/eaaCatalog/unitOfWork.html. This tracks all the
1008 * changes to a {@link question_usage_by_activity}, and its constituent parts,
1009 * so that the changes can be saved to the database when {@link save()} is called.
1010 *
017bc1d9
TH
1011 * @copyright 2009 The Open University
1012 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
1013 */
1014class question_engine_unit_of_work implements question_usage_observer {
1015 /** @var question_usage_by_activity the usage being tracked. */
1016 protected $quba;
1017
1018 /** @var boolean whether any of the fields of the usage have been changed. */
1019 protected $modified = false;
1020
1021 /**
94815ccf 1022 * @var array list of slot => {@link question_attempt}s that
d1b7e03d
TH
1023 * were already in the usage, and which have been modified.
1024 */
1025 protected $attemptsmodified = array();
1026
1027 /**
94815ccf 1028 * @var array list of slot => {@link question_attempt}s that
d1b7e03d
TH
1029 * have been added to the usage.
1030 */
1031 protected $attemptsadded = array();
1032
1033 /**
94815ccf
TH
1034 * @var array of array(question_attempt_step, question_attempt id, seq number)
1035 * of steps that have been added to question attempts in this usage.
d1b7e03d 1036 */
94815ccf 1037 protected $stepsadded = array();
d1b7e03d
TH
1038
1039 /**
94815ccf
TH
1040 * @var array of array(question_attempt_step, question_attempt id, seq number)
1041 * of steps that have been modified in their attempt.
d1b7e03d 1042 */
94815ccf
TH
1043 protected $stepsmodified = array();
1044
1045 /**
1046 * @var array list of question_attempt_step.id => question_attempt_step of steps
1047 * that were previously stored in the database, but which are no longer required.
1048 */
1049 protected $stepsdeleted = array();
d1b7e03d
TH
1050
1051 /**
1052 * Constructor.
1053 * @param question_usage_by_activity $quba the usage to track.
1054 */
1055 public function __construct(question_usage_by_activity $quba) {
1056 $this->quba = $quba;
1057 }
1058
1059 public function notify_modified() {
1060 $this->modified = true;
1061 }
1062
1063 public function notify_attempt_modified(question_attempt $qa) {
94815ccf
TH
1064 $slot = $qa->get_slot();
1065 if (!array_key_exists($slot, $this->attemptsadded)) {
1066 $this->attemptsmodified[$slot] = $qa;
d1b7e03d
TH
1067 }
1068 }
1069
1070 public function notify_attempt_added(question_attempt $qa) {
1071 $this->attemptsadded[$qa->get_slot()] = $qa;
1072 }
1073
94815ccf 1074 public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq) {
d1b7e03d
TH
1075 if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
1076 return;
1077 }
1078
94815ccf
TH
1079 if (($key = $this->is_step_added($step)) !== false) {
1080 return;
1081 }
1082
1083 if (($key = $this->is_step_modified($step)) !== false) {
1084 throw new coding_exception('Cannot add a step that has already been modified.');
1085 }
1086
1087 if (($key = $this->is_step_deleted($step)) !== false) {
1088 unset($this->stepsdeleted[$step->get_id()]);
1089 $this->stepsmodified[] = array($step, $qa->get_database_id(), $seq);
1090 return;
1091 }
1092
1093 $stepid = $step->get_id();
1094 if ($stepid) {
1095 if (array_key_exists($stepid, $this->stepsdeleted)) {
1096 unset($this->stepsdeleted[$stepid]);
d1b7e03d 1097 }
94815ccf
TH
1098 $this->stepsmodified[] = array($step, $qa->get_database_id(), $seq);
1099
1100 } else {
1101 $this->stepsadded[] = array($step, $qa->get_database_id(), $seq);
1102 }
1103 }
1104
1105 public function notify_step_modified(question_attempt_step $step, question_attempt $qa, $seq) {
1106 if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
1107 return;
d1b7e03d
TH
1108 }
1109
94815ccf
TH
1110 if (($key = $this->is_step_added($step)) !== false) {
1111 return;
1112 }
1113
1114 if (($key = $this->is_step_deleted($step)) !== false) {
1115 throw new coding_exception('Cannot modify a step after it has been deleted.');
1116 }
1117
1118 $stepid = $step->get_id();
1119 if (empty($stepid)) {
1120 throw new coding_exception('Cannot modify a step that has never been stored in the database.');
1121 }
1122
1123 $this->stepsmodified[] = array($step, $qa->get_database_id(), $seq);
d1b7e03d
TH
1124 }
1125
94815ccf 1126 public function notify_step_deleted(question_attempt_step $step, question_attempt $qa) {
d1b7e03d
TH
1127 if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
1128 return;
1129 }
94815ccf
TH
1130
1131 if (($key = $this->is_step_added($step)) !== false) {
1132 unset($this->stepsadded[$key]);
1133 return;
1134 }
1135
1136 if (($key = $this->is_step_modified($step)) !== false) {
1137 unset($this->stepsmodified[$key]);
1138 }
1139
1140 $stepid = $step->get_id();
1141 if (empty($stepid)) {
1142 return; // Was never in the database.
1143 }
1144
1145 $this->stepsdeleted[$stepid] = $step;
1146 }
1147
1148 /**
1149 * @param question_attempt_step $step a step
1150 * @return int|false if the step is in the list of steps to be added, return
1151 * the key, otherwise return false.
1152 */
1153 protected function is_step_added(question_attempt_step $step) {
1154 foreach ($this->stepsadded as $key => $data) {
1155 list($addedstep, $qaid, $seq) = $data;
1156 if ($addedstep === $step) {
1157 return $key;
1158 }
1159 }
1160 return false;
1161 }
1162
1163 /**
1164 * @param question_attempt_step $step a step
1165 * @return int|false if the step is in the list of steps to be modified, return
1166 * the key, otherwise return false.
1167 */
1168 protected function is_step_modified(question_attempt_step $step) {
1169 foreach ($this->stepsmodified as $key => $data) {
1170 list($modifiedstep, $qaid, $seq) = $data;
1171 if ($modifiedstep === $step) {
1172 return $key;
1173 }
1174 }
1175 return false;
1176 }
1177
1178 /**
1179 * @param question_attempt_step $step a step
1180 * @return bool whether the step is in the list of steps to be deleted.
1181 */
1182 protected function is_step_deleted(question_attempt_step $step) {
1183 foreach ($this->stepsdeleted as $deletedstep) {
1184 if ($deletedstep === $step) {
1185 return true;
1186 }
1187 }
1188 return false;
d1b7e03d
TH
1189 }
1190
1191 /**
1192 * Write all the changes we have recorded to the database.
1193 * @param question_engine_data_mapper $dm the mapper to use to update the database.
1194 */
1195 public function save(question_engine_data_mapper $dm) {
94815ccf
TH
1196 $dm->delete_steps(array_keys($this->stepsdeleted), $this->quba->get_owning_context());
1197
1198 foreach ($this->stepsmodified as $stepinfo) {
1199 list($step, $questionattemptid, $seq) = $stepinfo;
1200 $dm->update_question_attempt_step($step, $questionattemptid, $seq,
1201 $this->quba->get_owning_context());
1202 }
6b5f24d3 1203
d1b7e03d
TH
1204 foreach ($this->stepsadded as $stepinfo) {
1205 list($step, $questionattemptid, $seq) = $stepinfo;
217f9a61
TH
1206 $dm->insert_question_attempt_step($step, $questionattemptid, $seq,
1207 $this->quba->get_owning_context());
d1b7e03d 1208 }
6b5f24d3 1209
d1b7e03d 1210 foreach ($this->attemptsadded as $qa) {
217f9a61 1211 $dm->insert_question_attempt($qa, $this->quba->get_owning_context());
d1b7e03d 1212 }
6b5f24d3 1213
d1b7e03d
TH
1214 foreach ($this->attemptsmodified as $qa) {
1215 $dm->update_question_attempt($qa);
1216 }
6b5f24d3 1217
d1b7e03d
TH
1218 if ($this->modified) {
1219 $dm->update_questions_usage_by_activity($this->quba);
1220 }
1221 }
1222}
1223
1224
8a1e7b77
TH
1225/**
1226 * The interface implemented by {@link question_file_saver} and {@link question_file_loader}.
1227 *
1228 * @copyright 2012 The Open University
1229 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1230 */
1231interface question_response_files {
1232 /**
1233 * Get the files that were submitted.
1234 * @return array of stored_files objects.
1235 */
1236 public function get_files();
1237}
1238
1239
217f9a61
TH
1240/**
1241 * This class represents the promise to save some files from a particular draft
1242 * file area into a particular file area. It is used beause the necessary
1243 * information about what to save is to hand in the
1244 * {@link question_attempt::process_response_files()} method, but we don't know
1245 * if this question attempt will actually be saved in the database until later,
1246 * when the {@link question_engine_unit_of_work} is saved, if it is.
1247 *
1248 * @copyright 2011 The Open University
1249 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1250 */
8a1e7b77 1251class question_file_saver implements question_response_files {
217f9a61
TH
1252 /** @var int the id of the draft file area to save files from. */
1253 protected $draftitemid;
1254 /** @var string the owning component name. */
1255 protected $component;
1256 /** @var string the file area name. */
1257 protected $filearea;
1258
cd3557e6
TH
1259 /**
1260 * @var string the value to store in the question_attempt_step_data to
1261 * represent these files.
1262 */
1263 protected $value = null;
1264
217f9a61
TH
1265 /**
1266 * Constuctor.
1267 * @param int $draftitemid the draft area to save the files from.
1268 * @param string $component the component for the file area to save into.
1269 * @param string $filearea the name of the file area to save into.
1270 */
48d9c17d 1271 public function __construct($draftitemid, $component, $filearea, $text = null) {
217f9a61
TH
1272 $this->draftitemid = $draftitemid;
1273 $this->component = $component;
1274 $this->filearea = $filearea;
48d9c17d 1275 $this->value = $this->compute_value($draftitemid, $text);
217f9a61
TH
1276 }
1277
48d9c17d
TH
1278 /**
1279 * Compute the value that should be stored in the question_attempt_step_data
1280 * table. Contains a hash that (almost) uniquely encodes all the files.
1281 * @param int $draftitemid the draft file area itemid.
1282 * @param string $text optional content containing file links.
1283 */
1284 protected function compute_value($draftitemid, $text) {
cd3557e6
TH
1285 global $USER;
1286
cd3557e6 1287 $fs = get_file_storage();
21c08c63 1288 $usercontext = context_user::instance($USER->id);
cd3557e6
TH
1289
1290 $files = $fs->get_area_files($usercontext->id, 'user', 'draft',
48d9c17d 1291 $draftitemid, 'sortorder, filepath, filename', false);
cd3557e6
TH
1292
1293 $string = '';
1294 foreach ($files as $file) {
1295 $string .= $file->get_filepath() . $file->get_filename() . '|' .
1296 $file->get_contenthash() . '|';
1297 }
d629327a 1298 $hash = md5($string);
cd3557e6 1299
48d9c17d 1300 if (is_null($text)) {
d629327a
TH
1301 if ($string) {
1302 return $hash;
1303 } else {
1304 return '';
1305 }
48d9c17d
TH
1306 }
1307
1308 // We add the file hash so a simple string comparison will say if the
1309 // files have been changed. First strip off any existing file hash.
d629327a
TH
1310 if ($text !== '') {
1311 $text = preg_replace('/\s*<!-- File hash: \w+ -->\s*$/', '', $text);
1312 $text = file_rewrite_urls_to_pluginfile($text, $draftitemid);
3fc9410f
TH
1313 if ($string) {
1314 $text .= '<!-- File hash: ' . $hash . ' -->';
1315 }
48d9c17d
TH
1316 }
1317 return $text;
cd3557e6
TH
1318 }
1319
217f9a61 1320 public function __toString() {
48d9c17d 1321 return $this->value;
217f9a61
TH
1322 }
1323
1324 /**
1325 * Actually save the files.
1326 * @param integer $itemid the item id for the file area to save into.
1327 */
1328 public function save_files($itemid, $context) {
1329 file_save_draft_area_files($this->draftitemid, $context->id,
1330 $this->component, $this->filearea, $itemid);
1331 }
8a1e7b77
TH
1332
1333 /**
1334 * Get the files that were submitted.
1335 * @return array of stored_files objects.
1336 */
1337 public function get_files() {
1338 global $USER;
1339
1340 $fs = get_file_storage();
1341 $usercontext = context_user::instance($USER->id);
1342
1343 return $fs->get_area_files($usercontext->id, 'user', 'draft',
1344 $this->draftitemid, 'sortorder, filepath, filename', false);
1345 }
1346}
1347
1348
1349/**
1350 * This class is the mirror image of {@link question_file_saver}. It allows
1351 * files to be accessed again later (e.g. when re-grading) using that same
1352 * API as when doing the original grading.
1353 *
1354 * @copyright 2012 The Open University
1355 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1356 */
1357class question_file_loader implements question_response_files {
1358 /** @var question_attempt_step the step that these files belong to. */
1359 protected $step;
1360
1361 /** @var string the field name for these files - which is used to construct the file area name. */
1362 protected $name;
1363
1364 /**
1365 * @var string the value to stored in the question_attempt_step_data to
1366 * represent these files.
1367 */
1368 protected $value;
1369
1370 /** @var int the context id that the files belong to. */
1371 protected $contextid;
1372
1373 /**
1374 * Constuctor.
1375 * @param question_attempt_step $step the step that these files belong to.
1376 * @param string $name string the field name for these files - which is used to construct the file area name.
1377 * @param string $value the value to stored in the question_attempt_step_data to
1378 * represent these files.
1379 * @param int $contextid the context id that the files belong to.
1380 */
1381 public function __construct(question_attempt_step $step, $name, $value, $contextid) {
d0782585
MPC
1382 $this->step = $step;
1383 $this->name = $name;
1384 $this->value = $value;
1385 $this->contextid = $contextid;
8a1e7b77
TH
1386 }
1387
1388 public function __toString() {
1389 return $this->value;
1390 }
1391
1392 /**
1393 * Get the files that were submitted.
1394 * @return array of stored_files objects.
1395 */
1396 public function get_files() {
1397 return $this->step->get_qt_files($this->name, $this->contextid);
1398 }
afb1b3d0
TH
1399
1400 /**
1401 * Copy these files into a draft area, and return the corresponding
1402 * {@link question_file_saver} that can save them again.
1403 *
1404 * This is used by {@link question_attempt::start_based_on()}, which is used
1405 * (for example) by the quizzes 'Each attempt builds on last' feature.
1406 *
1407 * @return question_file_saver that can re-save these files again.
1408 */
1409 public function get_question_file_saver() {
1410
d629327a
TH
1411 // There are three possibilities here for what $value will look like:
1412 // 1) some HTML content followed by an MD5 hash in a HTML comment;
1413 // 2) a plain MD5 hash;
1414 // 3) or some real content, without any hash.
1415 // The problem is that 3) is ambiguous in the case where a student writes
1416 // a response that looks exactly like an MD5 hash. For attempts made now,
1417 // we avoid case 3) by always going for case 1) or 2) (except when the
1418 // response is blank. However, there may be case 3) data in the database
1419 // so we need to handle it as best we can.
afb1b3d0
TH
1420 if (preg_match('/\s*<!-- File hash: [0-9a-zA-Z]{32} -->\s*$/', $this->value)) {
1421 $value = preg_replace('/\s*<!-- File hash: [0-9a-zA-Z]{32} -->\s*$/', '', $this->value);
1422
1423 } else if (preg_match('/^[0-9a-zA-Z]{32}$/', $this->value)) {
1424 $value = null;
1425
1426 } else {
d629327a 1427 $value = $this->value;
afb1b3d0
TH
1428 }
1429
1430 list($draftid, $text) = $this->step->prepare_response_files_draft_itemid_with_text(
1431 $this->name, $this->contextid, $value);
1432 return new question_file_saver($draftid, 'question', 'response_' . $this->name, $text);
1433 }
217f9a61
TH
1434}
1435
1436
d1b7e03d
TH
1437/**
1438 * This class represents a restriction on the set of question_usage ids to include
1439 * in a larger database query. Depending of the how you are going to restrict the
1440 * list of usages, construct an appropriate subclass.
1441 *
1442 * If $qubaids is an instance of this class, example usage might be
1443 *
1444 * SELECT qa.id, qa.maxmark
1445 * FROM $qubaids->from_question_attempts('qa')
1446 * WHERE $qubaids->where() AND qa.slot = 1
1447 *
f7970e3c 1448 * @copyright 2010 The Open University
017bc1d9 1449 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
1450 */
1451abstract class qubaid_condition {
1452
1453 /**
1454 * @return string the SQL that needs to go in the FROM clause when trying
1455 * to select records from the 'question_attempts' table based on the
1456 * qubaid_condition.
1457 */
1458 public abstract function from_question_attempts($alias);
1459
1460 /** @return string the SQL that needs to go in the where clause. */
1461 public abstract function where();
1462
1463 /**
1464 * @return the params needed by a query that uses
1465 * {@link from_question_attempts()} and {@link where()}.
1466 */
1467 public abstract function from_where_params();
1468
1469 /**
1470 * @return string SQL that can use used in a WHERE qubaid IN (...) query.
1471 * This method returns the "IN (...)" part.
1472 */
1473 public abstract function usage_id_in();
1474
1475 /**
1476 * @return the params needed by a query that uses {@link usage_id_in()}.
1477 */
1478 public abstract function usage_id_in_params();
e68e4ccf
JP
1479
1480 /**
1481 * @return string 40-character hash code that uniquely identifies the combination of properties and class name of this qubaid
1482 * condition.
1483 */
1484 public function get_hash_code() {
1485 return sha1(serialize($this));
1486 }
d1b7e03d
TH
1487}
1488
1489
1490/**
1491 * This class represents a restriction on the set of question_usage ids to include
1492 * in a larger database query based on an explicit list of ids.
1493 *
f7970e3c 1494 * @copyright 2010 The Open University
017bc1d9 1495 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
1496 */
1497class qubaid_list extends qubaid_condition {
1498 /** @var array of ids. */
1499 protected $qubaids;
1500 protected $columntotest = null;
1501 protected $params;
1502
1503 /**
1504 * Constructor.
1505 * @param array $qubaids of question usage ids.
1506 */
1507 public function __construct(array $qubaids) {
1508 $this->qubaids = $qubaids;
1509 }
1510
1511 public function from_question_attempts($alias) {
d1b7e03d 1512 $this->columntotest = $alias . '.questionusageid';
9b40c540 1513 return '{question_attempts} ' . $alias;
d1b7e03d
TH
1514 }
1515
1516 public function where() {
1517 global $DB;
1518
1519 if (is_null($this->columntotest)) {
88f0eb15 1520 throw new coding_exception('Must call from_question_attempts before where().');
d1b7e03d
TH
1521 }
1522 if (empty($this->qubaids)) {
9b40c540 1523 $this->params = array();
d1b7e03d
TH
1524 return '1 = 0';
1525 }
d1b7e03d 1526
9b40c540 1527 return $this->columntotest . ' ' . $this->usage_id_in();
d1b7e03d
TH
1528 }
1529
1530 public function from_where_params() {
1531 return $this->params;
1532 }
1533
1534 public function usage_id_in() {
1535 global $DB;
1536
1537 if (empty($this->qubaids)) {
5f79a9bc 1538 $this->params = array();
d1b7e03d
TH
1539 return '= 0';
1540 }
9c197f44
TH
1541 list($where, $this->params) = $DB->get_in_or_equal(
1542 $this->qubaids, SQL_PARAMS_NAMED, 'qubaid');
d1b7e03d
TH
1543 return $where;
1544 }
1545
1546 public function usage_id_in_params() {
1547 return $this->params;
1548 }
1549}
1550
1551
1552/**
1553 * This class represents a restriction on the set of question_usage ids to include
1554 * in a larger database query based on JOINing to some other tables.
1555 *
1556 * The general form of the query is something like
1557 *
1558 * SELECT qa.id, qa.maxmark
1559 * FROM $from
2a3bdbf9 1560 * JOIN {question_attempts} qa ON qa.questionusageid = $usageidcolumn
d1b7e03d
TH
1561 * WHERE $where AND qa.slot = 1
1562 *
1563 * where $from, $usageidcolumn and $where are the arguments to the constructor.
1564 *
f7970e3c 1565 * @copyright 2010 The Open University
017bc1d9 1566 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
1567 */
1568class qubaid_join extends qubaid_condition {
1569 public $from;
1570 public $usageidcolumn;
1571 public $where;
1572 public $params;
1573
1574 /**
1575 * Constructor. The meaning of the arguments is explained in the class comment.
1576 * @param string $from SQL fragemnt to go in the FROM clause.
1577 * @param string $usageidcolumn the column in $from that should be
1578 * made equal to the usageid column in the JOIN clause.
1579 * @param string $where SQL fragment to go in the where clause.
e24ee794 1580 * @param array $params required by the SQL. You must use named parameters.
d1b7e03d
TH
1581 */
1582 public function __construct($from, $usageidcolumn, $where = '', $params = array()) {
1583 $this->from = $from;
1584 $this->usageidcolumn = $usageidcolumn;
1585 $this->params = $params;
1586 if (empty($where)) {
1587 $where = '1 = 1';
1588 }
1589 $this->where = $where;
1590 }
1591
1592 public function from_question_attempts($alias) {
d1b7e03d 1593 return "$this->from
0f33deaf 1594 JOIN {question_attempts} {$alias} ON " .
d1b7e03d
TH
1595 "{$alias}.questionusageid = $this->usageidcolumn";
1596 }
1597
1598 public function where() {
1599 return $this->where;
1600 }
1601
1602 public function from_where_params() {
1603 return $this->params;
1604 }
1605
1606 public function usage_id_in() {
1607 return "IN (SELECT $this->usageidcolumn FROM $this->from WHERE $this->where)";
1608 }
1609
1610 public function usage_id_in_params() {
1611 return $this->params;
1612 }
1613}