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