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