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