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