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