weekly release 2.8dev
[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
99caa248
JP
361 /**
362 * Load all {@link question_usage_by_activity} from the database for one qubaid_condition
363 * Include all its {@link question_attempt}s and all their steps.
364 * @param qubaid_condition $qubaids the condition that tells us which usages to load.
365 * @return question_usage_by_activity[] the usages that were loaded.
366 */
367 public function load_questions_usages_by_activity($qubaids) {
368 $records = $this->db->get_recordset_sql("
369SELECT
370 quba.id AS qubaid,
371 quba.contextid,
372 quba.component,
373 quba.preferredbehaviour,
374 qa.id AS questionattemptid,
375 qa.questionusageid,
376 qa.slot,
377 qa.behaviour,
378 qa.questionid,
379 qa.variant,
380 qa.maxmark,
381 qa.minfraction,
382 qa.maxfraction,
383 qa.flagged,
384 qa.questionsummary,
385 qa.rightanswer,
386 qa.responsesummary,
387 qa.timemodified,
388 qas.id AS attemptstepid,
389 qas.sequencenumber,
390 qas.state,
391 qas.fraction,
392 qas.timecreated,
393 qas.userid,
394 qasd.name,
395 qasd.value
396
397FROM {question_usages} quba
398LEFT JOIN {question_attempts} qa ON qa.questionusageid = quba.id
399LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
400LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
401
402WHERE
403 quba.id {$qubaids->usage_id_in()}
404
405ORDER BY
406 quba.id,
407 qa.slot,
408 qas.sequencenumber
409 ", $qubaids->usage_id_in_params());
410
411 if (!$records->valid()) {
412 throw new coding_exception('Failed to load questions_usages_by_activity for qubaid_condition :' . $qubaids);
413 }
414
415 $qubas = array();
416 do {
417 $record = $records->current();
418 $qubas[$record->qubaid] = question_usage_by_activity::load_from_records($records, $record->qubaid);
419 } while ($records->valid());
420
421 $records->close();
422
423 return $qubas;
424 }
425
d1b7e03d
TH
426 /**
427 * Load information about the latest state of each question from the database.
428 *
429 * @param qubaid_condition $qubaids used to restrict which usages are included
e68e4ccf 430 * in the query. See {@link qubaid_condition}.
5b0a31bf 431 * @param array $slots A list of slots for the questions you want to know about.
e68e4ccf 432 * @param string|null $fields
d1b7e03d
TH
433 * @return array of records. See the SQL in this function to see the fields available.
434 */
e68e4ccf 435 public function load_questions_usages_latest_steps(qubaid_condition $qubaids, $slots, $fields = null) {
a2ac2349 436 list($slottest, $params) = $this->db->get_in_or_equal($slots, SQL_PARAMS_NAMED, 'slot');
d1b7e03d 437
e68e4ccf 438 if ($fields === null) {
99caa248 439 $fields = "qas.id,
d1b7e03d
TH
440 qa.id AS questionattemptid,
441 qa.questionusageid,
442 qa.slot,
443 qa.behaviour,
444 qa.questionid,
1da821bb 445 qa.variant,
d1b7e03d
TH
446 qa.maxmark,
447 qa.minfraction,
4e3d8293 448 qa.maxfraction,
d1b7e03d
TH
449 qa.flagged,
450 qa.questionsummary,
451 qa.rightanswer,
452 qa.responsesummary,
453 qa.timemodified,
454 qas.id AS attemptstepid,
455 qas.sequencenumber,
456 qas.state,
457 qas.fraction,
458 qas.timecreated,
e68e4ccf
JP
459 qas.userid";
460
461 }
462
463 $records = $this->db->get_records_sql("
464SELECT
465 {$fields}
d1b7e03d
TH
466
467FROM {$qubaids->from_question_attempts('qa')}
84b37108
TH
468JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
469 AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
d1b7e03d
TH
470
471WHERE
472 {$qubaids->where()} AND
473 qa.slot $slottest
06f8ed54 474 ", $params + $qubaids->from_where_params());
d1b7e03d
TH
475
476 return $records;
477 }
478
479 /**
5e8a85aa
TH
480 * Load summary information about the state of each question in a group of
481 * attempts. This is used, for example, by the quiz manual grading report,
482 * to show how many attempts at each question need to be graded.
d1b7e03d
TH
483 *
484 * @param qubaid_condition $qubaids used to restrict which usages are included
485 * in the query. See {@link qubaid_condition}.
486 * @param array $slots A list of slots for the questions you want to konw about.
487 * @return array The array keys are slot,qestionid. The values are objects with
488 * fields $slot, $questionid, $inprogress, $name, $needsgrading, $autograded,
489 * $manuallygraded and $all.
490 */
9c197f44
TH
491 public function load_questions_usages_question_state_summary(
492 qubaid_condition $qubaids, $slots) {
a2ac2349 493 list($slottest, $params) = $this->db->get_in_or_equal($slots, SQL_PARAMS_NAMED, 'slot');
d1b7e03d 494
06f8ed54 495 $rs = $this->db->get_recordset_sql("
d1b7e03d
TH
496SELECT
497 qa.slot,
498 qa.questionid,
499 q.name,
500 CASE qas.state
501 {$this->full_states_to_summary_state_sql()}
502 END AS summarystate,
503 COUNT(1) AS numattempts
504
505FROM {$qubaids->from_question_attempts('qa')}
84b37108
TH
506JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
507 AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
06f8ed54 508JOIN {question} q ON q.id = qa.questionid
d1b7e03d
TH
509
510WHERE
511 {$qubaids->where()} AND
512 qa.slot $slottest
513
514GROUP BY
515 qa.slot,
516 qa.questionid,
517 q.name,
518 q.id,
304f0d85
TH
519 CASE qas.state
520 {$this->full_states_to_summary_state_sql()}
521 END
d1b7e03d 522
f9b0500f 523ORDER BY
d1b7e03d
TH
524 qa.slot,
525 qa.questionid,
526 q.name,
527 q.id
06f8ed54 528 ", $params + $qubaids->from_where_params());
d1b7e03d 529
d1b7e03d 530 $results = array();
cf3b6568 531 foreach ($rs as $row) {
d1b7e03d
TH
532 $index = $row->slot . ',' . $row->questionid;
533
534 if (!array_key_exists($index, $results)) {
0ff4bd08 535 $res = new stdClass();
d1b7e03d
TH
536 $res->slot = $row->slot;
537 $res->questionid = $row->questionid;
538 $res->name = $row->name;
539 $res->inprogress = 0;
540 $res->needsgrading = 0;
541 $res->autograded = 0;
542 $res->manuallygraded = 0;
543 $res->all = 0;
544 $results[$index] = $res;
545 }
546
547 $results[$index]->{$row->summarystate} = $row->numattempts;
548 $results[$index]->all += $row->numattempts;
549 }
cf3b6568 550 $rs->close();
d1b7e03d
TH
551
552 return $results;
553 }
554
555 /**
556 * Get a list of usage ids where the question with slot $slot, and optionally
557 * also with question id $questionid, is in summary state $summarystate. Also
558 * return the total count of such states.
559 *
560 * Only a subset of the ids can be returned by using $orderby, $limitfrom and
561 * $limitnum. A special value 'random' can be passed as $orderby, in which case
562 * $limitfrom is ignored.
563 *
564 * @param qubaid_condition $qubaids used to restrict which usages are included
565 * in the query. See {@link qubaid_condition}.
f7970e3c
TH
566 * @param int $slot The slot for the questions you want to konw about.
567 * @param int $questionid (optional) Only return attempts that were of this specific question.
d1b7e03d
TH
568 * @param string $summarystate the summary state of interest, or 'all'.
569 * @param string $orderby the column to order by.
cf3b6568 570 * @param array $params any params required by any of the SQL fragments.
f7970e3c 571 * @param int $limitfrom implements paging of the results.
d1b7e03d 572 * Ignored if $orderby = random or $limitnum is null.
f7970e3c 573 * @param int $limitnum implements paging of the results. null = all.
d1b7e03d
TH
574 * @return array with two elements, an array of usage ids, and a count of the total number.
575 */
576 public function load_questions_usages_where_question_in_state(
577 qubaid_condition $qubaids, $summarystate, $slot, $questionid = null,
5b0a31bf 578 $orderby = 'random', $params = array(), $limitfrom = 0, $limitnum = null) {
d1b7e03d
TH
579
580 $extrawhere = '';
581 if ($questionid) {
cf3b6568
TH
582 $extrawhere .= ' AND qa.questionid = :questionid';
583 $params['questionid'] = $questionid;
d1b7e03d
TH
584 }
585 if ($summarystate != 'all') {
cf3b6568 586 list($test, $sparams) = $this->in_summary_state_test($summarystate);
d1b7e03d 587 $extrawhere .= ' AND qas.state ' . $test;
cf3b6568 588 $params += $sparams;
d1b7e03d
TH
589 }
590
591 if ($orderby == 'random') {
592 $sqlorderby = '';
593 } else if ($orderby) {
594 $sqlorderby = 'ORDER BY ' . $orderby;
595 } else {
596 $sqlorderby = '';
597 }
598
5b0a31bf
TH
599 // We always want the total count, as well as the partcular list of ids
600 // based on the paging and sort order. Because the list of ids is never
601 // going to be too ridiculously long. My worst-case scenario is
602 // 10,000 students in the course, each doing 5 quiz attempts. That
d1b7e03d 603 // is a 50,000 element int => int array, which PHP seems to use 5MB
5b0a31bf
TH
604 // memory to store on a 64 bit server.
605 $qubaidswhere = $qubaids->where(); // Must call this before params.
cf3b6568
TH
606 $params += $qubaids->from_where_params();
607 $params['slot'] = $slot;
5b0a31bf 608
cf3b6568 609 $qubaids = $this->db->get_records_sql_menu("
d1b7e03d
TH
610SELECT
611 qa.questionusageid,
612 1
613
614FROM {$qubaids->from_question_attempts('qa')}
84b37108
TH
615JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
616 AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
cf3b6568 617JOIN {question} q ON q.id = qa.questionid
d1b7e03d
TH
618
619WHERE
5b0a31bf 620 {$qubaidswhere} AND
cf3b6568 621 qa.slot = :slot
d1b7e03d
TH
622 $extrawhere
623
624$sqlorderby
cf3b6568 625 ", $params);
d1b7e03d
TH
626
627 $qubaids = array_keys($qubaids);
628 $count = count($qubaids);
629
630 if ($orderby == 'random') {
631 shuffle($qubaids);
632 $limitfrom = 0;
633 }
634
635 if (!is_null($limitnum)) {
636 $qubaids = array_slice($qubaids, $limitfrom, $limitnum);
637 }
638
639 return array($qubaids, $count);
640 }
641
642 /**
643 * Load a {@link question_usage_by_activity} from the database, including
644 * all its {@link question_attempt}s and all their steps.
645 * @param qubaid_condition $qubaids used to restrict which usages are included
646 * in the query. See {@link qubaid_condition}.
647 * @param array $slots if null, load info for all quesitions, otherwise only
648 * load the averages for the specified questions.
649 */
650 public function load_average_marks(qubaid_condition $qubaids, $slots = null) {
d1b7e03d 651 if (!empty($slots)) {
9c197f44
TH
652 list($slottest, $slotsparams) = $this->db->get_in_or_equal(
653 $slots, SQL_PARAMS_NAMED, 'slot');
f4fe3968 654 $slotwhere = " AND qa.slot {$slottest}";
d1b7e03d
TH
655 } else {
656 $slotwhere = '';
5b0a31bf 657 $slotsparams = array();
d1b7e03d
TH
658 }
659
e24ee794 660 list($statetest, $stateparams) = $this->db->get_in_or_equal(array(
deef04a4
TH
661 (string) question_state::$gaveup,
662 (string) question_state::$gradedwrong,
663 (string) question_state::$gradedpartial,
664 (string) question_state::$gradedright,
665 (string) question_state::$mangaveup,
666 (string) question_state::$mangrwrong,
667 (string) question_state::$mangrpartial,
668 (string) question_state::$mangrright), SQL_PARAMS_NAMED, 'st');
d1b7e03d 669
e24ee794 670 return $this->db->get_records_sql("
d1b7e03d
TH
671SELECT
672 qa.slot,
673 AVG(COALESCE(qas.fraction, 0)) AS averagefraction,
674 COUNT(1) AS numaveraged
675
676FROM {$qubaids->from_question_attempts('qa')}
84b37108
TH
677JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
678 AND qas.sequencenumber = {$this->latest_step_for_qa_subquery()}
d1b7e03d
TH
679
680WHERE
681 {$qubaids->where()}
682 $slotwhere
683 AND qas.state $statetest
684
685GROUP BY qa.slot
686
687ORDER BY qa.slot
e24ee794 688 ", $slotsparams + $stateparams + $qubaids->from_where_params());
d1b7e03d
TH
689 }
690
691 /**
692 * Load a {@link question_attempt} from the database, including all its
693 * steps.
038014c4 694 *
f7970e3c 695 * @param int $questionid the question to load all the attempts fors.
d1b7e03d
TH
696 * @param qubaid_condition $qubaids used to restrict which usages are included
697 * in the query. See {@link qubaid_condition}.
038014c4 698 * @return question_attempt[] array of question_attempts that were loaded.
d1b7e03d
TH
699 */
700 public function load_attempts_at_question($questionid, qubaid_condition $qubaids) {
99caa248 701 $sql = "
d1b7e03d 702SELECT
04853f27 703 quba.contextid,
d1b7e03d
TH
704 quba.preferredbehaviour,
705 qa.id AS questionattemptid,
706 qa.questionusageid,
707 qa.slot,
708 qa.behaviour,
709 qa.questionid,
1da821bb 710 qa.variant,
d1b7e03d
TH
711 qa.maxmark,
712 qa.minfraction,
4e3d8293 713 qa.maxfraction,
d1b7e03d
TH
714 qa.flagged,
715 qa.questionsummary,
716 qa.rightanswer,
717 qa.responsesummary,
718 qa.timemodified,
719 qas.id AS attemptstepid,
720 qas.sequencenumber,
721 qas.state,
722 qas.fraction,
723 qas.timecreated,
724 qas.userid,
725 qasd.name,
726 qasd.value
727
728FROM {$qubaids->from_question_attempts('qa')}
04853f27
TH
729JOIN {question_usages} quba ON quba.id = qa.questionusageid
730LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
731LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
d1b7e03d
TH
732
733WHERE
734 {$qubaids->where()} AND
04853f27 735 qa.questionid = :questionid
d1b7e03d
TH
736
737ORDER BY
738 quba.id,
739 qa.id,
99caa248
JP
740 qas.sequencenumber";
741
742 // For qubaid_list must call this after calling methods that generate sql.
743 $params = $qubaids->from_where_params();
744 $params['questionid'] = $questionid;
745
746 $records = $this->db->get_recordset_sql($sql, $params);
d1b7e03d 747
d1b7e03d 748 $questionattempts = array();
35d5f1c2
TH
749 while ($records->valid()) {
750 $record = $records->current();
d1b7e03d
TH
751 $questionattempts[$record->questionattemptid] =
752 question_attempt::load_from_records($records,
753 $record->questionattemptid, new question_usage_null_observer(),
754 $record->preferredbehaviour);
d1b7e03d 755 }
35d5f1c2
TH
756 $records->close();
757
d1b7e03d
TH
758 return $questionattempts;
759 }
760
761 /**
762 * Update a question_usages row to refect any changes in a usage (but not
763 * any of its question_attempts.
764 * @param question_usage_by_activity $quba the usage that has changed.
765 */
766 public function update_questions_usage_by_activity(question_usage_by_activity $quba) {
0ff4bd08 767 $record = new stdClass();
d1b7e03d
TH
768 $record->id = $quba->get_id();
769 $record->contextid = $quba->get_owning_context()->id;
56b0df7e
TH
770 $record->component = $quba->get_owning_component();
771 $record->preferredbehaviour = $quba->get_preferred_behaviour();
d1b7e03d 772
88f0eb15 773 $this->db->update_record('question_usages', $record);
d1b7e03d
TH
774 }
775
776 /**
777 * Update a question_attempts row to refect any changes in a question_attempt
778 * (but not any of its steps).
779 * @param question_attempt $qa the question attempt that has changed.
780 */
781 public function update_question_attempt(question_attempt $qa) {
0ff4bd08 782 $record = new stdClass();
d1b7e03d
TH
783 $record->id = $qa->get_database_id();
784 $record->maxmark = $qa->get_max_mark();
785 $record->minfraction = $qa->get_min_fraction();
4e3d8293 786 $record->maxfraction = $qa->get_max_fraction();
d1b7e03d 787 $record->flagged = $qa->is_flagged();
56b0df7e
TH
788 $record->questionsummary = $qa->get_question_summary();
789 $record->rightanswer = $qa->get_right_answer_summary();
790 $record->responsesummary = $qa->get_response_summary();
d1b7e03d
TH
791 $record->timemodified = time();
792
88f0eb15 793 $this->db->update_record('question_attempts', $record);
d1b7e03d
TH
794 }
795
796 /**
797 * Delete a question_usage_by_activity and all its associated
798 * {@link question_attempts} and {@link question_attempt_steps} from the
799 * database.
6b5f24d3 800 * @param qubaid_condition $qubaids identifies which question useages to delete.
d1b7e03d 801 */
6b5f24d3
TH
802 public function delete_questions_usage_by_activities(qubaid_condition $qubaids) {
803 $where = "qa.questionusageid {$qubaids->usage_id_in()}";
804 $params = $qubaids->usage_id_in_params();
805
806 $contextids = $this->db->get_records_sql_menu("
807 SELECT DISTINCT contextid, 1
808 FROM {question_usages}
a2ac2349 809 WHERE id {$qubaids->usage_id_in()}", $qubaids->usage_id_in_params());
6b5f24d3
TH
810 foreach ($contextids as $contextid => $notused) {
811 $this->delete_response_files($contextid, "IN (
812 SELECT qas.id
813 FROM {question_attempts} qa
814 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
815 WHERE $where)", $params);
816 }
817
f0bfd964
TH
818 if ($this->db->get_dbfamily() == 'mysql') {
819 $this->delete_usage_records_for_mysql($qubaids);
820 return;
821 }
822
c76145d3 823 $this->db->delete_records_select('question_attempt_step_data', "attemptstepid IN (
d1b7e03d 824 SELECT qas.id
c76145d3
TH
825 FROM {question_attempts} qa
826 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
c76145d3 827 WHERE $where)", $params);
6b5f24d3 828
c76145d3 829 $this->db->delete_records_select('question_attempt_steps', "questionattemptid IN (
d1b7e03d 830 SELECT qa.id
c76145d3 831 FROM {question_attempts} qa
c76145d3 832 WHERE $where)", $params);
6b5f24d3
TH
833
834 $this->db->delete_records_select('question_attempts',
a2ac2349
TH
835 "{question_attempts}.questionusageid {$qubaids->usage_id_in()}",
836 $qubaids->usage_id_in_params());
6b5f24d3
TH
837
838 $this->db->delete_records_select('question_usages',
a2ac2349 839 "{question_usages}.id {$qubaids->usage_id_in()}", $qubaids->usage_id_in_params());
d1b7e03d
TH
840 }
841
f0bfd964
TH
842 /**
843 * This function is a work-around for poor MySQL performance with
844 * DELETE FROM x WHERE id IN (SELECT ...). We have to use a non-standard
845 * syntax to get good performance. See MDL-29520.
846 * @param qubaid_condition $qubaids identifies which question useages to delete.
847 */
848 protected function delete_usage_records_for_mysql(qubaid_condition $qubaids) {
2ec325c2
TH
849 $qubaidtest = $qubaids->usage_id_in();
850 if (strpos($qubaidtest, 'question_usages') !== false &&
851 strpos($qubaidtest, 'IN (SELECT') === 0) {
852 // This horrible hack is required by MDL-29847. It comes from
853 // http://www.xaprb.com/blog/2006/06/23/how-to-select-from-an-update-target-in-mysql/
854 $qubaidtest = 'IN (SELECT * FROM ' . substr($qubaidtest, 3) . ' AS hack_subquery_alias)';
855 }
856
f0bfd964
TH
857 // TODO once MDL-29589 is fixed, eliminate this method, and instead use the new $DB API.
858 $this->db->execute('
859 DELETE qu, qa, qas, qasd
860 FROM {question_usages} qu
861 JOIN {question_attempts} qa ON qa.questionusageid = qu.id
033d6566
EL
862 LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
863 LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
2ec325c2 864 WHERE qu.id ' . $qubaidtest,
f0bfd964
TH
865 $qubaids->usage_id_in_params());
866 }
867
d1b7e03d
TH
868 /**
869 * Delete all the steps for a question attempt.
f7970e3c 870 * @param int $qaids question_attempt id.
94815ccf 871 * @param context $context the context that the $quba belongs to.
d1b7e03d 872 */
94815ccf
TH
873 public function delete_steps($stepids, $context) {
874 if (empty($stepids)) {
d1b7e03d
TH
875 return;
876 }
94815ccf 877 list($test, $params) = $this->db->get_in_or_equal($stepids, SQL_PARAMS_NAMED);
6b5f24d3 878
296e1e97 879 $this->delete_response_files($context->id, $test, $params);
f0bfd964 880
94815ccf 881 $this->db->delete_records_select('question_attempt_step_data',
f4fe3968 882 "attemptstepid {$test}", $params);
9c197f44 883 $this->db->delete_records_select('question_attempt_steps',
f4fe3968 884 "id {$test}", $params);
d1b7e03d
TH
885 }
886
6b5f24d3
TH
887 /**
888 * Delete all the files belonging to the response variables in the gives
889 * question attempt steps.
890 * @param int $contextid the context these attempts belong to.
891 * @param string $itemidstest a bit of SQL that can be used in a
892 * WHERE itemid $itemidstest clause. Must use named params.
893 * @param array $params any query parameters used in $itemidstest.
894 */
895 protected function delete_response_files($contextid, $itemidstest, $params) {
896 $fs = get_file_storage();
c749527b 897 foreach (question_engine::get_all_response_file_areas() as $filearea) {
6b5f24d3
TH
898 $fs->delete_area_files_select($contextid, 'question', $filearea,
899 $itemidstest, $params);
900 }
901 }
902
d1b7e03d
TH
903 /**
904 * Delete all the previews for a given question.
f7970e3c 905 * @param int $questionid question id.
d1b7e03d
TH
906 */
907 public function delete_previews($questionid) {
c76145d3 908 $previews = $this->db->get_records_sql_menu("
d1b7e03d 909 SELECT DISTINCT quba.id, 1
c76145d3
TH
910 FROM {question_usages} quba
911 JOIN {question_attempts} qa ON qa.questionusageid = quba.id
d1b7e03d 912 WHERE quba.component = 'core_question_preview' AND
c76145d3 913 qa.questionid = ?", array($questionid));
d1b7e03d
TH
914 if (empty($previews)) {
915 return;
916 }
6b5f24d3 917 $this->delete_questions_usage_by_activities(new qubaid_list($previews));
d1b7e03d
TH
918 }
919
920 /**
921 * Update the flagged state of a question in the database.
f7970e3c
TH
922 * @param int $qubaid the question usage id.
923 * @param int $questionid the question id.
924 * @param int $sessionid the question_attempt id.
925 * @param bool $newstate the new state of the flag. true = flagged.
d1b7e03d 926 */
06f8ed54 927 public function update_question_attempt_flag($qubaid, $questionid, $qaid, $slot, $newstate) {
f9b0500f 928 if (!$this->db->record_exists('question_attempts', array('id' => $qaid,
06f8ed54 929 'questionusageid' => $qubaid, 'questionid' => $questionid, 'slot' => $slot))) {
88f0eb15 930 throw new moodle_exception('errorsavingflags', 'question');
d1b7e03d
TH
931 }
932
06f8ed54 933 $this->db->set_field('question_attempts', 'flagged', $newstate, array('id' => $qaid));
d1b7e03d
TH
934 }
935
936 /**
937 * Get all the WHEN 'x' THEN 'y' terms needed to convert the question_attempt_steps.state
938 * column to a summary state. Use this like
939 * CASE qas.state {$this->full_states_to_summary_state_sql()} END AS summarystate,
940 * @param string SQL fragment.
941 */
942 protected function full_states_to_summary_state_sql() {
943 $sql = '';
944 foreach (question_state::get_all() as $state) {
f4fe3968 945 $sql .= "WHEN '{$state}' THEN '{$state->get_summary_state()}'\n";
d1b7e03d
TH
946 }
947 return $sql;
948 }
949
950 /**
951 * Get the SQL needed to test that question_attempt_steps.state is in a
952 * state corresponding to $summarystate.
953 * @param string $summarystate one of
954 * inprogress, needsgrading, manuallygraded or autograded
f7970e3c 955 * @param bool $equal if false, do a NOT IN test. Default true.
d1b7e03d
TH
956 * @return string SQL fragment.
957 */
cf3b6568 958 public function in_summary_state_test($summarystate, $equal = true, $prefix = 'summarystates') {
d1b7e03d 959 $states = question_state::get_all_for_summary_state($summarystate);
deef04a4
TH
960 return $this->db->get_in_or_equal(array_map('strval', $states),
961 SQL_PARAMS_NAMED, $prefix, $equal);
d1b7e03d
TH
962 }
963
964 /**
965 * Change the maxmark for the question_attempt with number in usage $slot
966 * for all the specified question_attempts.
967 * @param qubaid_condition $qubaids Selects which usages are updated.
f7970e3c 968 * @param int $slot the number is usage to affect.
d1b7e03d
TH
969 * @param number $newmaxmark the new max mark to set.
970 */
971 public function set_max_mark_in_attempts(qubaid_condition $qubaids, $slot, $newmaxmark) {
c76145d3
TH
972 $this->db->set_field_select('question_attempts', 'maxmark', $newmaxmark,
973 "questionusageid {$qubaids->usage_id_in()} AND slot = :slot",
974 $qubaids->usage_id_in_params() + array('slot' => $slot));
d1b7e03d
TH
975 }
976
977 /**
978 * Return a subquery that computes the sum of the marks for all the questions
979 * in a usage. Which useage to compute the sum for is controlled bu the $qubaid
980 * parameter.
981 *
982 * See {@link quiz_update_all_attempt_sumgrades()} for an example of the usage of
983 * this method.
984 *
985 * @param string $qubaid SQL fragment that controls which usage is summed.
2daffca5
TH
986 * This will normally be the name of a column in the outer query. Not that this
987 * SQL fragment must not contain any placeholders.
d1b7e03d
TH
988 * @return string SQL code for the subquery.
989 */
990 public function sum_usage_marks_subquery($qubaid) {
ad73a2cb
TH
991 // To explain the COALESCE in the following SQL: SUM(lots of NULLs) gives
992 // NULL, while SUM(one 0.0 and lots of NULLS) gives 0.0. We don't want that.
993 // We always want to return a number, so the COALESCE is there to turn the
994 // NULL total into a 0.
995 return "SELECT COALESCE(SUM(qa.maxmark * qas.fraction), 0)
c76145d3 996 FROM {question_attempts} qa
84b37108
TH
997 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
998 AND qas.sequencenumber = (
999 SELECT MAX(summarks_qas.sequencenumber)
1000 FROM {question_attempt_steps} summarks_qas
1001 WHERE summarks_qas.questionattemptid = qa.id
d2c69d93 1002 )
d1b7e03d 1003 WHERE qa.questionusageid = $qubaid
9c197f44
TH
1004 HAVING COUNT(CASE
1005 WHEN qas.state = 'needsgrading' AND qa.maxmark > 0 THEN 1
1006 ELSE NULL
1007 END) = 0";
d1b7e03d
TH
1008 }
1009
94c0ec21
TH
1010 /**
1011 * Get a subquery that returns the latest step of every qa in some qubas.
1012 * Currently, this is only used by the quiz reports. See
ac4d9157 1013 * {@link quiz_attempts_report_table::add_latest_state_join()}.
94c0ec21
TH
1014 * @param string $alias alias to use for this inline-view.
1015 * @param qubaid_condition $qubaids restriction on which question_usages we
1016 * are interested in. This is important for performance.
1017 * @return array with two elements, the SQL fragment and any params requried.
1018 */
1019 public function question_attempt_latest_state_view($alias, qubaid_condition $qubaids) {
1020 return array("(
1021 SELECT {$alias}qa.id AS questionattemptid,
1022 {$alias}qa.questionusageid,
1023 {$alias}qa.slot,
1024 {$alias}qa.behaviour,
1025 {$alias}qa.questionid,
1026 {$alias}qa.variant,
1027 {$alias}qa.maxmark,
1028 {$alias}qa.minfraction,
4e3d8293 1029 {$alias}qa.maxfraction,
94c0ec21
TH
1030 {$alias}qa.flagged,
1031 {$alias}qa.questionsummary,
1032 {$alias}qa.rightanswer,
1033 {$alias}qa.responsesummary,
1034 {$alias}qa.timemodified,
1035 {$alias}qas.id AS attemptstepid,
1036 {$alias}qas.sequencenumber,
1037 {$alias}qas.state,
1038 {$alias}qas.fraction,
1039 {$alias}qas.timecreated,
1040 {$alias}qas.userid
1041
1042 FROM {$qubaids->from_question_attempts($alias . 'qa')}
84b37108
TH
1043 JOIN {question_attempt_steps} {$alias}qas ON {$alias}qas.questionattemptid = {$alias}qa.id
1044 AND {$alias}qas.sequencenumber = {$this->latest_step_for_qa_subquery($alias . 'qa.id')}
94c0ec21 1045 WHERE {$qubaids->where()}
f4fe3968 1046 ) {$alias}", $qubaids->from_where_params());
d1b7e03d
TH
1047 }
1048
1049 protected function latest_step_for_qa_subquery($questionattemptid = 'qa.id') {
d1b7e03d 1050 return "(
84b37108 1051 SELECT MAX(sequencenumber)
c76145d3 1052 FROM {question_attempt_steps}
d1b7e03d
TH
1053 WHERE questionattemptid = $questionattemptid
1054 )";
1055 }
1056
1057 /**
1058 * @param array $questionids of question ids.
07f88584
TH
1059 * @param qubaid_condition $qubaids ids of the usages to consider.
1060 * @return boolean whether any of these questions are being used by any of
1061 * those usages.
d1b7e03d 1062 */
07f88584 1063 public function questions_in_use(array $questionids, qubaid_condition $qubaids) {
c76145d3
TH
1064 list($test, $params) = $this->db->get_in_or_equal($questionids);
1065 return $this->db->record_exists_select('question_attempts',
07f88584
TH
1066 'questionid ' . $test . ' AND questionusageid ' .
1067 $qubaids->usage_id_in(), $params + $qubaids->usage_id_in_params());
d1b7e03d
TH
1068 }
1069}
1070
6b5f24d3 1071
d1b7e03d
TH
1072/**
1073 * Implementation of the unit of work pattern for the question engine.
1074 *
1075 * See http://martinfowler.com/eaaCatalog/unitOfWork.html. This tracks all the
1076 * changes to a {@link question_usage_by_activity}, and its constituent parts,
1077 * so that the changes can be saved to the database when {@link save()} is called.
1078 *
017bc1d9
TH
1079 * @copyright 2009 The Open University
1080 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
1081 */
1082class question_engine_unit_of_work implements question_usage_observer {
1083 /** @var question_usage_by_activity the usage being tracked. */
1084 protected $quba;
1085
1086 /** @var boolean whether any of the fields of the usage have been changed. */
1087 protected $modified = false;
1088
1089 /**
94815ccf 1090 * @var array list of slot => {@link question_attempt}s that
d1b7e03d
TH
1091 * were already in the usage, and which have been modified.
1092 */
1093 protected $attemptsmodified = array();
1094
1095 /**
94815ccf 1096 * @var array list of slot => {@link question_attempt}s that
d1b7e03d
TH
1097 * have been added to the usage.
1098 */
1099 protected $attemptsadded = array();
1100
1101 /**
94815ccf
TH
1102 * @var array of array(question_attempt_step, question_attempt id, seq number)
1103 * of steps that have been added to question attempts in this usage.
d1b7e03d 1104 */
94815ccf 1105 protected $stepsadded = array();
d1b7e03d
TH
1106
1107 /**
94815ccf
TH
1108 * @var array of array(question_attempt_step, question_attempt id, seq number)
1109 * of steps that have been modified in their attempt.
d1b7e03d 1110 */
94815ccf
TH
1111 protected $stepsmodified = array();
1112
1113 /**
1114 * @var array list of question_attempt_step.id => question_attempt_step of steps
1115 * that were previously stored in the database, but which are no longer required.
1116 */
1117 protected $stepsdeleted = array();
d1b7e03d
TH
1118
1119 /**
1120 * Constructor.
1121 * @param question_usage_by_activity $quba the usage to track.
1122 */
1123 public function __construct(question_usage_by_activity $quba) {
1124 $this->quba = $quba;
1125 }
1126
1127 public function notify_modified() {
1128 $this->modified = true;
1129 }
1130
1131 public function notify_attempt_modified(question_attempt $qa) {
94815ccf
TH
1132 $slot = $qa->get_slot();
1133 if (!array_key_exists($slot, $this->attemptsadded)) {
1134 $this->attemptsmodified[$slot] = $qa;
d1b7e03d
TH
1135 }
1136 }
1137
1138 public function notify_attempt_added(question_attempt $qa) {
1139 $this->attemptsadded[$qa->get_slot()] = $qa;
1140 }
1141
94815ccf 1142 public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq) {
d1b7e03d
TH
1143 if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
1144 return;
1145 }
1146
94815ccf
TH
1147 if (($key = $this->is_step_added($step)) !== false) {
1148 return;
1149 }
1150
1151 if (($key = $this->is_step_modified($step)) !== false) {
1152 throw new coding_exception('Cannot add a step that has already been modified.');
1153 }
1154
1155 if (($key = $this->is_step_deleted($step)) !== false) {
1156 unset($this->stepsdeleted[$step->get_id()]);
1157 $this->stepsmodified[] = array($step, $qa->get_database_id(), $seq);
1158 return;
1159 }
1160
1161 $stepid = $step->get_id();
1162 if ($stepid) {
1163 if (array_key_exists($stepid, $this->stepsdeleted)) {
1164 unset($this->stepsdeleted[$stepid]);
d1b7e03d 1165 }
94815ccf
TH
1166 $this->stepsmodified[] = array($step, $qa->get_database_id(), $seq);
1167
1168 } else {
1169 $this->stepsadded[] = array($step, $qa->get_database_id(), $seq);
1170 }
1171 }
1172
1173 public function notify_step_modified(question_attempt_step $step, question_attempt $qa, $seq) {
1174 if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
1175 return;
d1b7e03d
TH
1176 }
1177
94815ccf
TH
1178 if (($key = $this->is_step_added($step)) !== false) {
1179 return;
1180 }
1181
1182 if (($key = $this->is_step_deleted($step)) !== false) {
1183 throw new coding_exception('Cannot modify a step after it has been deleted.');
1184 }
1185
1186 $stepid = $step->get_id();
1187 if (empty($stepid)) {
1188 throw new coding_exception('Cannot modify a step that has never been stored in the database.');
1189 }
1190
1191 $this->stepsmodified[] = array($step, $qa->get_database_id(), $seq);
d1b7e03d
TH
1192 }
1193
94815ccf 1194 public function notify_step_deleted(question_attempt_step $step, question_attempt $qa) {
d1b7e03d
TH
1195 if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
1196 return;
1197 }
94815ccf
TH
1198
1199 if (($key = $this->is_step_added($step)) !== false) {
1200 unset($this->stepsadded[$key]);
1201 return;
1202 }
1203
1204 if (($key = $this->is_step_modified($step)) !== false) {
1205 unset($this->stepsmodified[$key]);
1206 }
1207
1208 $stepid = $step->get_id();
1209 if (empty($stepid)) {
1210 return; // Was never in the database.
1211 }
1212
1213 $this->stepsdeleted[$stepid] = $step;
1214 }
1215
1216 /**
1217 * @param question_attempt_step $step a step
1218 * @return int|false if the step is in the list of steps to be added, return
1219 * the key, otherwise return false.
1220 */
1221 protected function is_step_added(question_attempt_step $step) {
1222 foreach ($this->stepsadded as $key => $data) {
1223 list($addedstep, $qaid, $seq) = $data;
1224 if ($addedstep === $step) {
1225 return $key;
1226 }
1227 }
1228 return false;
1229 }
1230
1231 /**
1232 * @param question_attempt_step $step a step
1233 * @return int|false if the step is in the list of steps to be modified, return
1234 * the key, otherwise return false.
1235 */
1236 protected function is_step_modified(question_attempt_step $step) {
1237 foreach ($this->stepsmodified as $key => $data) {
1238 list($modifiedstep, $qaid, $seq) = $data;
1239 if ($modifiedstep === $step) {
1240 return $key;
1241 }
1242 }
1243 return false;
1244 }
1245
1246 /**
1247 * @param question_attempt_step $step a step
1248 * @return bool whether the step is in the list of steps to be deleted.
1249 */
1250 protected function is_step_deleted(question_attempt_step $step) {
1251 foreach ($this->stepsdeleted as $deletedstep) {
1252 if ($deletedstep === $step) {
1253 return true;
1254 }
1255 }
1256 return false;
d1b7e03d
TH
1257 }
1258
1259 /**
1260 * Write all the changes we have recorded to the database.
1261 * @param question_engine_data_mapper $dm the mapper to use to update the database.
1262 */
1263 public function save(question_engine_data_mapper $dm) {
94815ccf
TH
1264 $dm->delete_steps(array_keys($this->stepsdeleted), $this->quba->get_owning_context());
1265
1266 foreach ($this->stepsmodified as $stepinfo) {
1267 list($step, $questionattemptid, $seq) = $stepinfo;
1268 $dm->update_question_attempt_step($step, $questionattemptid, $seq,
1269 $this->quba->get_owning_context());
1270 }
6b5f24d3 1271
d1b7e03d
TH
1272 foreach ($this->stepsadded as $stepinfo) {
1273 list($step, $questionattemptid, $seq) = $stepinfo;
217f9a61
TH
1274 $dm->insert_question_attempt_step($step, $questionattemptid, $seq,
1275 $this->quba->get_owning_context());
d1b7e03d 1276 }
6b5f24d3 1277
d1b7e03d 1278 foreach ($this->attemptsadded as $qa) {
217f9a61 1279 $dm->insert_question_attempt($qa, $this->quba->get_owning_context());
d1b7e03d 1280 }
6b5f24d3 1281
d1b7e03d
TH
1282 foreach ($this->attemptsmodified as $qa) {
1283 $dm->update_question_attempt($qa);
1284 }
6b5f24d3 1285
d1b7e03d
TH
1286 if ($this->modified) {
1287 $dm->update_questions_usage_by_activity($this->quba);
1288 }
1289 }
1290}
1291
1292
8a1e7b77
TH
1293/**
1294 * The interface implemented by {@link question_file_saver} and {@link question_file_loader}.
1295 *
1296 * @copyright 2012 The Open University
1297 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1298 */
1299interface question_response_files {
1300 /**
1301 * Get the files that were submitted.
1302 * @return array of stored_files objects.
1303 */
1304 public function get_files();
1305}
1306
1307
217f9a61
TH
1308/**
1309 * This class represents the promise to save some files from a particular draft
1310 * file area into a particular file area. It is used beause the necessary
1311 * information about what to save is to hand in the
1312 * {@link question_attempt::process_response_files()} method, but we don't know
1313 * if this question attempt will actually be saved in the database until later,
1314 * when the {@link question_engine_unit_of_work} is saved, if it is.
1315 *
1316 * @copyright 2011 The Open University
1317 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1318 */
8a1e7b77 1319class question_file_saver implements question_response_files {
217f9a61
TH
1320 /** @var int the id of the draft file area to save files from. */
1321 protected $draftitemid;
1322 /** @var string the owning component name. */
1323 protected $component;
1324 /** @var string the file area name. */
1325 protected $filearea;
1326
cd3557e6
TH
1327 /**
1328 * @var string the value to store in the question_attempt_step_data to
1329 * represent these files.
1330 */
1331 protected $value = null;
1332
217f9a61
TH
1333 /**
1334 * Constuctor.
1335 * @param int $draftitemid the draft area to save the files from.
1336 * @param string $component the component for the file area to save into.
1337 * @param string $filearea the name of the file area to save into.
1338 */
48d9c17d 1339 public function __construct($draftitemid, $component, $filearea, $text = null) {
217f9a61
TH
1340 $this->draftitemid = $draftitemid;
1341 $this->component = $component;
1342 $this->filearea = $filearea;
48d9c17d 1343 $this->value = $this->compute_value($draftitemid, $text);
217f9a61
TH
1344 }
1345
48d9c17d
TH
1346 /**
1347 * Compute the value that should be stored in the question_attempt_step_data
1348 * table. Contains a hash that (almost) uniquely encodes all the files.
1349 * @param int $draftitemid the draft file area itemid.
1350 * @param string $text optional content containing file links.
1351 */
1352 protected function compute_value($draftitemid, $text) {
cd3557e6
TH
1353 global $USER;
1354
cd3557e6 1355 $fs = get_file_storage();
21c08c63 1356 $usercontext = context_user::instance($USER->id);
cd3557e6
TH
1357
1358 $files = $fs->get_area_files($usercontext->id, 'user', 'draft',
48d9c17d 1359 $draftitemid, 'sortorder, filepath, filename', false);
cd3557e6
TH
1360
1361 $string = '';
1362 foreach ($files as $file) {
1363 $string .= $file->get_filepath() . $file->get_filename() . '|' .
1364 $file->get_contenthash() . '|';
1365 }
d629327a 1366 $hash = md5($string);
cd3557e6 1367
48d9c17d 1368 if (is_null($text)) {
d629327a
TH
1369 if ($string) {
1370 return $hash;
1371 } else {
1372 return '';
1373 }
48d9c17d
TH
1374 }
1375
1376 // We add the file hash so a simple string comparison will say if the
1377 // files have been changed. First strip off any existing file hash.
d629327a
TH
1378 if ($text !== '') {
1379 $text = preg_replace('/\s*<!-- File hash: \w+ -->\s*$/', '', $text);
1380 $text = file_rewrite_urls_to_pluginfile($text, $draftitemid);
3fc9410f
TH
1381 if ($string) {
1382 $text .= '<!-- File hash: ' . $hash . ' -->';
1383 }
48d9c17d
TH
1384 }
1385 return $text;
cd3557e6
TH
1386 }
1387
217f9a61 1388 public function __toString() {
48d9c17d 1389 return $this->value;
217f9a61
TH
1390 }
1391
1392 /**
1393 * Actually save the files.
1394 * @param integer $itemid the item id for the file area to save into.
1395 */
1396 public function save_files($itemid, $context) {
1397 file_save_draft_area_files($this->draftitemid, $context->id,
1398 $this->component, $this->filearea, $itemid);
1399 }
8a1e7b77
TH
1400
1401 /**
1402 * Get the files that were submitted.
1403 * @return array of stored_files objects.
1404 */
1405 public function get_files() {
1406 global $USER;
1407
1408 $fs = get_file_storage();
1409 $usercontext = context_user::instance($USER->id);
1410
1411 return $fs->get_area_files($usercontext->id, 'user', 'draft',
1412 $this->draftitemid, 'sortorder, filepath, filename', false);
1413 }
1414}
1415
1416
1417/**
1418 * This class is the mirror image of {@link question_file_saver}. It allows
1419 * files to be accessed again later (e.g. when re-grading) using that same
1420 * API as when doing the original grading.
1421 *
1422 * @copyright 2012 The Open University
1423 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
1424 */
1425class question_file_loader implements question_response_files {
1426 /** @var question_attempt_step the step that these files belong to. */
1427 protected $step;
1428
1429 /** @var string the field name for these files - which is used to construct the file area name. */
1430 protected $name;
1431
1432 /**
1433 * @var string the value to stored in the question_attempt_step_data to
1434 * represent these files.
1435 */
1436 protected $value;
1437
1438 /** @var int the context id that the files belong to. */
1439 protected $contextid;
1440
1441 /**
1442 * Constuctor.
1443 * @param question_attempt_step $step the step that these files belong to.
1444 * @param string $name string the field name for these files - which is used to construct the file area name.
1445 * @param string $value the value to stored in the question_attempt_step_data to
1446 * represent these files.
1447 * @param int $contextid the context id that the files belong to.
1448 */
1449 public function __construct(question_attempt_step $step, $name, $value, $contextid) {
d0782585
MPC
1450 $this->step = $step;
1451 $this->name = $name;
1452 $this->value = $value;
1453 $this->contextid = $contextid;
8a1e7b77
TH
1454 }
1455
1456 public function __toString() {
1457 return $this->value;
1458 }
1459
1460 /**
1461 * Get the files that were submitted.
1462 * @return array of stored_files objects.
1463 */
1464 public function get_files() {
1465 return $this->step->get_qt_files($this->name, $this->contextid);
1466 }
afb1b3d0
TH
1467
1468 /**
1469 * Copy these files into a draft area, and return the corresponding
1470 * {@link question_file_saver} that can save them again.
1471 *
1472 * This is used by {@link question_attempt::start_based_on()}, which is used
1473 * (for example) by the quizzes 'Each attempt builds on last' feature.
1474 *
1475 * @return question_file_saver that can re-save these files again.
1476 */
1477 public function get_question_file_saver() {
1478
d629327a
TH
1479 // There are three possibilities here for what $value will look like:
1480 // 1) some HTML content followed by an MD5 hash in a HTML comment;
1481 // 2) a plain MD5 hash;
1482 // 3) or some real content, without any hash.
1483 // The problem is that 3) is ambiguous in the case where a student writes
1484 // a response that looks exactly like an MD5 hash. For attempts made now,
1485 // we avoid case 3) by always going for case 1) or 2) (except when the
1486 // response is blank. However, there may be case 3) data in the database
1487 // so we need to handle it as best we can.
afb1b3d0
TH
1488 if (preg_match('/\s*<!-- File hash: [0-9a-zA-Z]{32} -->\s*$/', $this->value)) {
1489 $value = preg_replace('/\s*<!-- File hash: [0-9a-zA-Z]{32} -->\s*$/', '', $this->value);
1490
1491 } else if (preg_match('/^[0-9a-zA-Z]{32}$/', $this->value)) {
1492 $value = null;
1493
1494 } else {
d629327a 1495 $value = $this->value;
afb1b3d0
TH
1496 }
1497
1498 list($draftid, $text) = $this->step->prepare_response_files_draft_itemid_with_text(
1499 $this->name, $this->contextid, $value);
1500 return new question_file_saver($draftid, 'question', 'response_' . $this->name, $text);
1501 }
217f9a61
TH
1502}
1503
1504
d1b7e03d
TH
1505/**
1506 * This class represents a restriction on the set of question_usage ids to include
1507 * in a larger database query. Depending of the how you are going to restrict the
1508 * list of usages, construct an appropriate subclass.
1509 *
1510 * If $qubaids is an instance of this class, example usage might be
1511 *
1512 * SELECT qa.id, qa.maxmark
1513 * FROM $qubaids->from_question_attempts('qa')
1514 * WHERE $qubaids->where() AND qa.slot = 1
1515 *
f7970e3c 1516 * @copyright 2010 The Open University
017bc1d9 1517 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
1518 */
1519abstract class qubaid_condition {
1520
1521 /**
1522 * @return string the SQL that needs to go in the FROM clause when trying
1523 * to select records from the 'question_attempts' table based on the
1524 * qubaid_condition.
1525 */
1526 public abstract function from_question_attempts($alias);
1527
1528 /** @return string the SQL that needs to go in the where clause. */
1529 public abstract function where();
1530
1531 /**
1532 * @return the params needed by a query that uses
1533 * {@link from_question_attempts()} and {@link where()}.
1534 */
1535 public abstract function from_where_params();
1536
1537 /**
1538 * @return string SQL that can use used in a WHERE qubaid IN (...) query.
1539 * This method returns the "IN (...)" part.
1540 */
1541 public abstract function usage_id_in();
1542
1543 /**
1544 * @return the params needed by a query that uses {@link usage_id_in()}.
1545 */
1546 public abstract function usage_id_in_params();
e68e4ccf
JP
1547
1548 /**
1549 * @return string 40-character hash code that uniquely identifies the combination of properties and class name of this qubaid
1550 * condition.
1551 */
1552 public function get_hash_code() {
1553 return sha1(serialize($this));
1554 }
d1b7e03d
TH
1555}
1556
1557
1558/**
1559 * This class represents a restriction on the set of question_usage ids to include
1560 * in a larger database query based on an explicit list of ids.
1561 *
f7970e3c 1562 * @copyright 2010 The Open University
017bc1d9 1563 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
1564 */
1565class qubaid_list extends qubaid_condition {
1566 /** @var array of ids. */
1567 protected $qubaids;
1568 protected $columntotest = null;
1569 protected $params;
1570
1571 /**
1572 * Constructor.
1573 * @param array $qubaids of question usage ids.
1574 */
1575 public function __construct(array $qubaids) {
1576 $this->qubaids = $qubaids;
1577 }
1578
1579 public function from_question_attempts($alias) {
d1b7e03d 1580 $this->columntotest = $alias . '.questionusageid';
9b40c540 1581 return '{question_attempts} ' . $alias;
d1b7e03d
TH
1582 }
1583
1584 public function where() {
1585 global $DB;
1586
1587 if (is_null($this->columntotest)) {
88f0eb15 1588 throw new coding_exception('Must call from_question_attempts before where().');
d1b7e03d
TH
1589 }
1590 if (empty($this->qubaids)) {
9b40c540 1591 $this->params = array();
d1b7e03d
TH
1592 return '1 = 0';
1593 }
d1b7e03d 1594
9b40c540 1595 return $this->columntotest . ' ' . $this->usage_id_in();
d1b7e03d
TH
1596 }
1597
1598 public function from_where_params() {
1599 return $this->params;
1600 }
1601
1602 public function usage_id_in() {
1603 global $DB;
1604
1605 if (empty($this->qubaids)) {
5f79a9bc 1606 $this->params = array();
d1b7e03d
TH
1607 return '= 0';
1608 }
9c197f44
TH
1609 list($where, $this->params) = $DB->get_in_or_equal(
1610 $this->qubaids, SQL_PARAMS_NAMED, 'qubaid');
d1b7e03d
TH
1611 return $where;
1612 }
1613
1614 public function usage_id_in_params() {
1615 return $this->params;
1616 }
1617}
1618
1619
1620/**
1621 * This class represents a restriction on the set of question_usage ids to include
1622 * in a larger database query based on JOINing to some other tables.
1623 *
1624 * The general form of the query is something like
1625 *
1626 * SELECT qa.id, qa.maxmark
1627 * FROM $from
2a3bdbf9 1628 * JOIN {question_attempts} qa ON qa.questionusageid = $usageidcolumn
d1b7e03d
TH
1629 * WHERE $where AND qa.slot = 1
1630 *
1631 * where $from, $usageidcolumn and $where are the arguments to the constructor.
1632 *
f7970e3c 1633 * @copyright 2010 The Open University
017bc1d9 1634 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
1635 */
1636class qubaid_join extends qubaid_condition {
1637 public $from;
1638 public $usageidcolumn;
1639 public $where;
1640 public $params;
1641
1642 /**
1643 * Constructor. The meaning of the arguments is explained in the class comment.
1644 * @param string $from SQL fragemnt to go in the FROM clause.
1645 * @param string $usageidcolumn the column in $from that should be
1646 * made equal to the usageid column in the JOIN clause.
1647 * @param string $where SQL fragment to go in the where clause.
e24ee794 1648 * @param array $params required by the SQL. You must use named parameters.
d1b7e03d
TH
1649 */
1650 public function __construct($from, $usageidcolumn, $where = '', $params = array()) {
1651 $this->from = $from;
1652 $this->usageidcolumn = $usageidcolumn;
1653 $this->params = $params;
1654 if (empty($where)) {
1655 $where = '1 = 1';
1656 }
1657 $this->where = $where;
1658 }
1659
1660 public function from_question_attempts($alias) {
f4fe3968 1661 return "{$this->from}
0f33deaf 1662 JOIN {question_attempts} {$alias} ON " .
d1b7e03d
TH
1663 "{$alias}.questionusageid = $this->usageidcolumn";
1664 }
1665
1666 public function where() {
1667 return $this->where;
1668 }
1669
1670 public function from_where_params() {
1671 return $this->params;
1672 }
1673
1674 public function usage_id_in() {
f4fe3968 1675 return "IN (SELECT {$this->usageidcolumn} FROM {$this->from} WHERE {$this->where})";
d1b7e03d
TH
1676 }
1677
1678 public function usage_id_in_params() {
1679 return $this->params;
1680 }
1681}