MDL-26847 new method file_storage::delete_area_files_select for efficient bulk file...
[moodle.git] / question / engine / datalib.php
CommitLineData
d1b7e03d
TH
1<?php
2
3// This file is part of Moodle - http://moodle.org/
4//
5// Moodle is free software: you can redistribute it and/or modify
6// it under the terms of the GNU General Public License as published by
7// the Free Software Foundation, either version 3 of the License, or
8// (at your option) any later version.
9//
10// Moodle is distributed in the hope that it will be useful,
11// but WITHOUT ANY WARRANTY; without even the implied warranty of
12// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13// GNU General Public License for more details.
14//
15// You should have received a copy of the GNU General Public License
16// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
17
d1b7e03d 18/**
5e8a85aa 19 * Code for loading and saving question attempts to and from the database.
d1b7e03d 20 *
017bc1d9 21 * @package moodlecore
d1b7e03d 22 * @subpackage questionengine
017bc1d9
TH
23 * @copyright 2009 The Open University
24 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
25 */
26
27
a17b297d
TH
28defined('MOODLE_INTERNAL') || die();
29
30
d1b7e03d
TH
31/**
32 * This class controls the loading and saving of question engine data to and from
33 * the database.
34 *
017bc1d9
TH
35 * @copyright 2009 The Open University
36 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
37 */
38class question_engine_data_mapper {
06f8ed54
TH
39 /**
40 * @var moodle_database normally points to global $DB, but I prefer not to
41 * use globals if I can help it.
42 */
43 protected $db;
44
45 /**
46 * @param moodle_database $db a database connectoin. Defaults to global $DB.
47 */
48 public function __construct($db = null) {
49 if (is_null($db)) {
50 global $DB;
06f8ed54
TH
51 $this->db = $DB;
52 } else {
53 $this->db = $db;
54 }
55 }
56
d1b7e03d
TH
57 /**
58 * Store an entire {@link question_usage_by_activity} in the database,
59 * including all the question_attempts that comprise it.
60 * @param question_usage_by_activity $quba the usage to store.
61 */
62 public function insert_questions_usage_by_activity(question_usage_by_activity $quba) {
0ff4bd08 63 $record = new stdClass();
d1b7e03d 64 $record->contextid = $quba->get_owning_context()->id;
56b0df7e
TH
65 $record->component = $quba->get_owning_component();
66 $record->preferredbehaviour = $quba->get_preferred_behaviour();
d1b7e03d 67
06f8ed54 68 $newid = $this->db->insert_record('question_usages', $record);
d1b7e03d
TH
69 $quba->set_id_from_database($newid);
70
71 foreach ($quba->get_attempt_iterator() as $qa) {
217f9a61 72 $this->insert_question_attempt($qa, $quba->get_owning_context());
d1b7e03d
TH
73 }
74 }
75
76 /**
77 * Store an entire {@link question_attempt} in the database,
78 * including all the question_attempt_steps that comprise it.
79 * @param question_attempt $qa the question attempt to store.
217f9a61 80 * @param object $context the context of the owning question_usage_by_activity.
d1b7e03d 81 */
217f9a61 82 public function insert_question_attempt(question_attempt $qa, $context) {
0ff4bd08 83 $record = new stdClass();
d1b7e03d
TH
84 $record->questionusageid = $qa->get_usage_id();
85 $record->slot = $qa->get_slot();
56b0df7e 86 $record->behaviour = $qa->get_behaviour_name();
d1b7e03d
TH
87 $record->questionid = $qa->get_question()->id;
88 $record->maxmark = $qa->get_max_mark();
89 $record->minfraction = $qa->get_min_fraction();
90 $record->flagged = $qa->is_flagged();
56b0df7e
TH
91 $record->questionsummary = $qa->get_question_summary();
92 $record->rightanswer = $qa->get_right_answer_summary();
93 $record->responsesummary = $qa->get_response_summary();
d1b7e03d 94 $record->timemodified = time();
06f8ed54 95 $record->id = $this->db->insert_record('question_attempts', $record);
d1b7e03d
TH
96
97 foreach ($qa->get_step_iterator() as $seq => $step) {
217f9a61 98 $this->insert_question_attempt_step($step, $record->id, $seq, $context);
d1b7e03d
TH
99 }
100 }
101
102 /**
103 * Store a {@link question_attempt_step} in the database.
104 * @param question_attempt_step $qa the step to store.
217f9a61
TH
105 * @param int $questionattemptid the question attept id this step belongs to.
106 * @param int $seq the sequence number of this stop.
107 * @param object $context the context of the owning question_usage_by_activity.
d1b7e03d
TH
108 */
109 public function insert_question_attempt_step(question_attempt_step $step,
217f9a61 110 $questionattemptid, $seq, $context) {
0ff4bd08 111 $record = new stdClass();
d1b7e03d
TH
112 $record->questionattemptid = $questionattemptid;
113 $record->sequencenumber = $seq;
56b0df7e 114 $record->state = '' . $step->get_state();
d1b7e03d
TH
115 $record->fraction = $step->get_fraction();
116 $record->timecreated = $step->get_timecreated();
117 $record->userid = $step->get_user_id();
118
06f8ed54 119 $record->id = $this->db->insert_record('question_attempt_steps', $record);
d1b7e03d
TH
120
121 foreach ($step->get_all_data() as $name => $value) {
0ff4bd08 122 $data = new stdClass();
d1b7e03d 123 $data->attemptstepid = $record->id;
56b0df7e
TH
124 $data->name = $name;
125 $data->value = $value;
06f8ed54 126 $this->db->insert_record('question_attempt_step_data', $data, false);
217f9a61
TH
127
128 if ($value instanceof question_file_saver) {
129 $value->save_files($record->id, $context);
130 }
d1b7e03d
TH
131 }
132 }
133
134 /**
135 * Load a {@link question_attempt_step} from the database.
f7970e3c 136 * @param int $stepid the id of the step to load.
d1b7e03d
TH
137 * @param question_attempt_step the step that was loaded.
138 */
139 public function load_question_attempt_step($stepid) {
06f8ed54 140 $records = $this->db->get_records_sql("
d1b7e03d
TH
141SELECT
142 COALESCE(qasd.id, -1 * qas.id) AS id,
143 qas.id AS attemptstepid,
144 qas.questionattemptid,
145 qas.sequencenumber,
146 qas.state,
147 qas.fraction,
148 qas.timecreated,
149 qas.userid,
150 qasd.name,
151 qasd.value
152
06f8ed54
TH
153FROM {question_attempt_steps} qas
154LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
d1b7e03d
TH
155
156WHERE
06f8ed54
TH
157 qas.id = :stepid
158 ", array('stepid' => $stepid));
d1b7e03d
TH
159
160 if (!$records) {
88f0eb15 161 throw new coding_exception('Failed to load question_attempt_step ' . $stepid);
d1b7e03d
TH
162 }
163
164 return question_attempt_step::load_from_records($records, $stepid);
165 }
166
167 /**
168 * Load a {@link question_attempt} from the database, including all its
169 * steps.
f7970e3c 170 * @param int $questionattemptid the id of the question attempt to load.
d1b7e03d
TH
171 * @param question_attempt the question attempt that was loaded.
172 */
173 public function load_question_attempt($questionattemptid) {
06f8ed54 174 $records = $this->db->get_records_sql("
d1b7e03d
TH
175SELECT
176 COALESCE(qasd.id, -1 * qas.id) AS id,
7a719748 177 quba.contextid,
d1b7e03d
TH
178 quba.preferredbehaviour,
179 qa.id AS questionattemptid,
180 qa.questionusageid,
181 qa.slot,
182 qa.behaviour,
183 qa.questionid,
184 qa.maxmark,
185 qa.minfraction,
186 qa.flagged,
187 qa.questionsummary,
188 qa.rightanswer,
189 qa.responsesummary,
190 qa.timemodified,
191 qas.id AS attemptstepid,
192 qas.sequencenumber,
193 qas.state,
194 qas.fraction,
195 qas.timecreated,
196 qas.userid,
197 qasd.name,
198 qasd.value
199
06f8ed54
TH
200FROM {question_attempts qa
201JOIN {question_usages} quba ON quba.id = qa.questionusageid
202LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
203LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
d1b7e03d
TH
204
205WHERE
06f8ed54 206 qa.id = :questionattemptid
d1b7e03d
TH
207
208ORDER BY
209 qas.sequencenumber
06f8ed54 210 ", array('questionattemptid' => $questionattemptid));
d1b7e03d
TH
211
212 if (!$records) {
88f0eb15 213 throw new coding_exception('Failed to load question_attempt ' . $questionattemptid);
d1b7e03d
TH
214 }
215
216 $record = current($records);
217 return question_attempt::load_from_records($records, $questionattemptid,
218 new question_usage_null_observer(), $record->preferredbehaviour);
219 }
220
221 /**
222 * Load a {@link question_usage_by_activity} from the database, including
223 * all its {@link question_attempt}s and all their steps.
f7970e3c 224 * @param int $qubaid the id of the usage to load.
d1b7e03d
TH
225 * @param question_usage_by_activity the usage that was loaded.
226 */
227 public function load_questions_usage_by_activity($qubaid) {
06f8ed54 228 $records = $this->db->get_records_sql("
d1b7e03d
TH
229SELECT
230 COALESCE(qasd.id, -1 * qas.id) AS id,
231 quba.id AS qubaid,
232 quba.contextid,
233 quba.component,
234 quba.preferredbehaviour,
235 qa.id AS questionattemptid,
236 qa.questionusageid,
237 qa.slot,
238 qa.behaviour,
239 qa.questionid,
240 qa.maxmark,
241 qa.minfraction,
242 qa.flagged,
243 qa.questionsummary,
244 qa.rightanswer,
245 qa.responsesummary,
246 qa.timemodified,
247 qas.id AS attemptstepid,
248 qas.sequencenumber,
249 qas.state,
250 qas.fraction,
251 qas.timecreated,
252 qas.userid,
253 qasd.name,
254 qasd.value
255
06f8ed54
TH
256FROM {question_usages} quba
257LEFT JOIN {question_attempts} qa ON qa.questionusageid = quba.id
258LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
259LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
d1b7e03d
TH
260
261WHERE
06f8ed54 262 quba.id = :qubaid
d1b7e03d
TH
263
264ORDER BY
265 qa.slot,
266 qas.sequencenumber
c76145d3 267 ", array('qubaid' => $qubaid));
d1b7e03d
TH
268
269 if (!$records) {
88f0eb15 270 throw new coding_exception('Failed to load questions_usage_by_activity ' . $qubaid);
d1b7e03d
TH
271 }
272
273 return question_usage_by_activity::load_from_records($records, $qubaid);
274 }
275
276 /**
277 * Load information about the latest state of each question from the database.
278 *
279 * @param qubaid_condition $qubaids used to restrict which usages are included
280 * in the query. See {@link qubaid_condition}.
281 * @param array $slots A list of slots for the questions you want to konw about.
282 * @return array of records. See the SQL in this function to see the fields available.
283 */
284 public function load_questions_usages_latest_steps(qubaid_condition $qubaids, $slots) {
c76145d3 285 list($slottest, $params) = $this->db->get_in_or_equal($slots, SQL_PARAMS_NAMED, 'slot0000');
d1b7e03d 286
9b40c540 287 $records = $this->db->get_records_sql("
d1b7e03d
TH
288SELECT
289 qas.id,
290 qa.id AS questionattemptid,
291 qa.questionusageid,
292 qa.slot,
293 qa.behaviour,
294 qa.questionid,
295 qa.maxmark,
296 qa.minfraction,
297 qa.flagged,
298 qa.questionsummary,
299 qa.rightanswer,
300 qa.responsesummary,
301 qa.timemodified,
302 qas.id AS attemptstepid,
303 qas.sequencenumber,
304 qas.state,
305 qas.fraction,
306 qas.timecreated,
307 qas.userid
308
309FROM {$qubaids->from_question_attempts('qa')}
06f8ed54 310JOIN {question_attempt_steps} qas ON
d1b7e03d
TH
311 qas.id = {$this->latest_step_for_qa_subquery()}
312
313WHERE
314 {$qubaids->where()} AND
315 qa.slot $slottest
06f8ed54 316 ", $params + $qubaids->from_where_params());
d1b7e03d
TH
317
318 return $records;
319 }
320
321 /**
5e8a85aa
TH
322 * Load summary information about the state of each question in a group of
323 * attempts. This is used, for example, by the quiz manual grading report,
324 * to show how many attempts at each question need to be graded.
d1b7e03d
TH
325 *
326 * @param qubaid_condition $qubaids used to restrict which usages are included
327 * in the query. See {@link qubaid_condition}.
328 * @param array $slots A list of slots for the questions you want to konw about.
329 * @return array The array keys are slot,qestionid. The values are objects with
330 * fields $slot, $questionid, $inprogress, $name, $needsgrading, $autograded,
331 * $manuallygraded and $all.
332 */
333 public function load_questions_usages_question_state_summary(qubaid_condition $qubaids, $slots) {
cf3b6568 334 list($slottest, $params) = $this->db->get_in_or_equal($slots, SQL_PARAMS_NAMED, 'slot0000');
d1b7e03d 335
06f8ed54 336 $rs = $this->db->get_recordset_sql("
d1b7e03d
TH
337SELECT
338 qa.slot,
339 qa.questionid,
340 q.name,
341 CASE qas.state
342 {$this->full_states_to_summary_state_sql()}
343 END AS summarystate,
344 COUNT(1) AS numattempts
345
346FROM {$qubaids->from_question_attempts('qa')}
06f8ed54 347JOIN {question_attempt_steps} qas ON
d1b7e03d 348 qas.id = {$this->latest_step_for_qa_subquery()}
06f8ed54 349JOIN {question} q ON q.id = qa.questionid
d1b7e03d
TH
350
351WHERE
352 {$qubaids->where()} AND
353 qa.slot $slottest
354
355GROUP BY
356 qa.slot,
357 qa.questionid,
358 q.name,
359 q.id,
360 summarystate
361
f9b0500f 362ORDER BY
d1b7e03d
TH
363 qa.slot,
364 qa.questionid,
365 q.name,
366 q.id
06f8ed54 367 ", $params + $qubaids->from_where_params());
d1b7e03d 368
d1b7e03d 369 $results = array();
cf3b6568 370 foreach ($rs as $row) {
d1b7e03d
TH
371 $index = $row->slot . ',' . $row->questionid;
372
373 if (!array_key_exists($index, $results)) {
0ff4bd08 374 $res = new stdClass();
d1b7e03d
TH
375 $res->slot = $row->slot;
376 $res->questionid = $row->questionid;
377 $res->name = $row->name;
378 $res->inprogress = 0;
379 $res->needsgrading = 0;
380 $res->autograded = 0;
381 $res->manuallygraded = 0;
382 $res->all = 0;
383 $results[$index] = $res;
384 }
385
386 $results[$index]->{$row->summarystate} = $row->numattempts;
387 $results[$index]->all += $row->numattempts;
388 }
cf3b6568 389 $rs->close();
d1b7e03d
TH
390
391 return $results;
392 }
393
394 /**
395 * Get a list of usage ids where the question with slot $slot, and optionally
396 * also with question id $questionid, is in summary state $summarystate. Also
397 * return the total count of such states.
398 *
399 * Only a subset of the ids can be returned by using $orderby, $limitfrom and
400 * $limitnum. A special value 'random' can be passed as $orderby, in which case
401 * $limitfrom is ignored.
402 *
403 * @param qubaid_condition $qubaids used to restrict which usages are included
404 * in the query. See {@link qubaid_condition}.
f7970e3c
TH
405 * @param int $slot The slot for the questions you want to konw about.
406 * @param int $questionid (optional) Only return attempts that were of this specific question.
d1b7e03d
TH
407 * @param string $summarystate the summary state of interest, or 'all'.
408 * @param string $orderby the column to order by.
cf3b6568 409 * @param array $params any params required by any of the SQL fragments.
f7970e3c 410 * @param int $limitfrom implements paging of the results.
d1b7e03d 411 * Ignored if $orderby = random or $limitnum is null.
f7970e3c 412 * @param int $limitnum implements paging of the results. null = all.
d1b7e03d
TH
413 * @return array with two elements, an array of usage ids, and a count of the total number.
414 */
415 public function load_questions_usages_where_question_in_state(
416 qubaid_condition $qubaids, $summarystate, $slot, $questionid = null,
cf3b6568 417 $orderby = 'random', $params, $limitfrom = 0, $limitnum = null) {
d1b7e03d
TH
418
419 $extrawhere = '';
420 if ($questionid) {
cf3b6568
TH
421 $extrawhere .= ' AND qa.questionid = :questionid';
422 $params['questionid'] = $questionid;
d1b7e03d
TH
423 }
424 if ($summarystate != 'all') {
cf3b6568 425 list($test, $sparams) = $this->in_summary_state_test($summarystate);
d1b7e03d 426 $extrawhere .= ' AND qas.state ' . $test;
cf3b6568 427 $params += $sparams;
d1b7e03d
TH
428 }
429
430 if ($orderby == 'random') {
431 $sqlorderby = '';
432 } else if ($orderby) {
433 $sqlorderby = 'ORDER BY ' . $orderby;
434 } else {
435 $sqlorderby = '';
436 }
437
438 // We always want the total count, as well as the partcular list of ids,
439 // based on the paging and sort order. Becuase the list of ids is never
440 // going to be too rediculously long. My worst-case scenario is
441 // 10,000 students in the coures, each doing 5 quiz attempts. That
442 // is a 50,000 element int => int array, which PHP seems to use 5MB
443 // memeory to store on a 64 bit server.
cf3b6568
TH
444 $params += $qubaids->from_where_params();
445 $params['slot'] = $slot;
446 $qubaids = $this->db->get_records_sql_menu("
d1b7e03d
TH
447SELECT
448 qa.questionusageid,
449 1
450
451FROM {$qubaids->from_question_attempts('qa')}
cf3b6568 452JOIN {question_attempt_steps} qas ON
d1b7e03d 453 qas.id = {$this->latest_step_for_qa_subquery()}
cf3b6568 454JOIN {question} q ON q.id = qa.questionid
d1b7e03d
TH
455
456WHERE
457 {$qubaids->where()} AND
cf3b6568 458 qa.slot = :slot
d1b7e03d
TH
459 $extrawhere
460
461$sqlorderby
cf3b6568 462 ", $params);
d1b7e03d
TH
463
464 $qubaids = array_keys($qubaids);
465 $count = count($qubaids);
466
467 if ($orderby == 'random') {
468 shuffle($qubaids);
469 $limitfrom = 0;
470 }
471
472 if (!is_null($limitnum)) {
473 $qubaids = array_slice($qubaids, $limitfrom, $limitnum);
474 }
475
476 return array($qubaids, $count);
477 }
478
479 /**
480 * Load a {@link question_usage_by_activity} from the database, including
481 * all its {@link question_attempt}s and all their steps.
482 * @param qubaid_condition $qubaids used to restrict which usages are included
483 * in the query. See {@link qubaid_condition}.
484 * @param array $slots if null, load info for all quesitions, otherwise only
485 * load the averages for the specified questions.
486 */
487 public function load_average_marks(qubaid_condition $qubaids, $slots = null) {
d1b7e03d 488 if (!empty($slots)) {
e24ee794 489 list($slottest, $slotsparams) = $this->db->get_in_or_equal($slots, SQL_PARAMS_NAMED, 'slot0000');
d1b7e03d
TH
490 $slotwhere = " AND qa.slot $slottest";
491 } else {
492 $slotwhere = '';
e24ee794 493 $params = array();
d1b7e03d
TH
494 }
495
e24ee794 496 list($statetest, $stateparams) = $this->db->get_in_or_equal(array(
d1b7e03d
TH
497 question_state::$gaveup,
498 question_state::$gradedwrong,
499 question_state::$gradedpartial,
500 question_state::$gradedright,
501 question_state::$mangaveup,
502 question_state::$mangrwrong,
503 question_state::$mangrpartial,
e24ee794 504 question_state::$mangrright), SQL_PARAMS_NAMED, 'st00');
d1b7e03d 505
e24ee794 506 return $this->db->get_records_sql("
d1b7e03d
TH
507SELECT
508 qa.slot,
509 AVG(COALESCE(qas.fraction, 0)) AS averagefraction,
510 COUNT(1) AS numaveraged
511
512FROM {$qubaids->from_question_attempts('qa')}
2a3bdbf9 513JOIN {question_attempt_steps} qas ON
d1b7e03d
TH
514 qas.id = {$this->latest_step_for_qa_subquery()}
515
516WHERE
517 {$qubaids->where()}
518 $slotwhere
519 AND qas.state $statetest
520
521GROUP BY qa.slot
522
523ORDER BY qa.slot
e24ee794 524 ", $slotsparams + $stateparams + $qubaids->from_where_params());
d1b7e03d
TH
525 }
526
527 /**
528 * Load a {@link question_attempt} from the database, including all its
529 * steps.
f7970e3c 530 * @param int $questionid the question to load all the attempts fors.
d1b7e03d
TH
531 * @param qubaid_condition $qubaids used to restrict which usages are included
532 * in the query. See {@link qubaid_condition}.
533 * @return array of question_attempts.
534 */
535 public function load_attempts_at_question($questionid, qubaid_condition $qubaids) {
9b40c540 536 global $DB;
04853f27
TH
537
538 $params = $qubaids->from_where_params();
539 $params['questionid'] = $questionid;
540
9b40c540 541 $records = $DB->get_records_sql("
d1b7e03d
TH
542SELECT
543 COALESCE(qasd.id, -1 * qas.id) AS id,
04853f27 544 quba.contextid,
d1b7e03d
TH
545 quba.preferredbehaviour,
546 qa.id AS questionattemptid,
547 qa.questionusageid,
548 qa.slot,
549 qa.behaviour,
550 qa.questionid,
551 qa.maxmark,
552 qa.minfraction,
553 qa.flagged,
554 qa.questionsummary,
555 qa.rightanswer,
556 qa.responsesummary,
557 qa.timemodified,
558 qas.id AS attemptstepid,
559 qas.sequencenumber,
560 qas.state,
561 qas.fraction,
562 qas.timecreated,
563 qas.userid,
564 qasd.name,
565 qasd.value
566
567FROM {$qubaids->from_question_attempts('qa')}
04853f27
TH
568JOIN {question_usages} quba ON quba.id = qa.questionusageid
569LEFT JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
570LEFT JOIN {question_attempt_step_data} qasd ON qasd.attemptstepid = qas.id
d1b7e03d
TH
571
572WHERE
573 {$qubaids->where()} AND
04853f27 574 qa.questionid = :questionid
d1b7e03d
TH
575
576ORDER BY
577 quba.id,
578 qa.id,
579 qas.sequencenumber
04853f27 580 ", $params);
d1b7e03d
TH
581
582 if (!$records) {
583 return array();
584 }
585
586 $questionattempts = array();
587 $record = current($records);
588 while ($record) {
589 $questionattempts[$record->questionattemptid] =
590 question_attempt::load_from_records($records,
591 $record->questionattemptid, new question_usage_null_observer(),
592 $record->preferredbehaviour);
593 $record = current($records);
594 }
595 return $questionattempts;
596 }
597
598 /**
599 * Update a question_usages row to refect any changes in a usage (but not
600 * any of its question_attempts.
601 * @param question_usage_by_activity $quba the usage that has changed.
602 */
603 public function update_questions_usage_by_activity(question_usage_by_activity $quba) {
0ff4bd08 604 $record = new stdClass();
d1b7e03d
TH
605 $record->id = $quba->get_id();
606 $record->contextid = $quba->get_owning_context()->id;
56b0df7e
TH
607 $record->component = $quba->get_owning_component();
608 $record->preferredbehaviour = $quba->get_preferred_behaviour();
d1b7e03d 609
88f0eb15 610 $this->db->update_record('question_usages', $record);
d1b7e03d
TH
611 }
612
613 /**
614 * Update a question_attempts row to refect any changes in a question_attempt
615 * (but not any of its steps).
616 * @param question_attempt $qa the question attempt that has changed.
617 */
618 public function update_question_attempt(question_attempt $qa) {
0ff4bd08 619 $record = new stdClass();
d1b7e03d
TH
620 $record->id = $qa->get_database_id();
621 $record->maxmark = $qa->get_max_mark();
622 $record->minfraction = $qa->get_min_fraction();
623 $record->flagged = $qa->is_flagged();
56b0df7e
TH
624 $record->questionsummary = $qa->get_question_summary();
625 $record->rightanswer = $qa->get_right_answer_summary();
626 $record->responsesummary = $qa->get_response_summary();
d1b7e03d
TH
627 $record->timemodified = time();
628
88f0eb15 629 $this->db->update_record('question_attempts', $record);
d1b7e03d
TH
630 }
631
632 /**
633 * Delete a question_usage_by_activity and all its associated
634 * {@link question_attempts} and {@link question_attempt_steps} from the
635 * database.
636 * @param string $where a where clause. Becuase of MySQL limitations, you
2a3bdbf9 637 * must refer to {question_usages}.id in full like that.
c76145d3 638 * @param array $params values to substitute for placeholders in $where.
d1b7e03d 639 */
c76145d3 640 public function delete_questions_usage_by_activities($where, $params) {
c76145d3 641 $this->db->delete_records_select('question_attempt_step_data', "attemptstepid IN (
d1b7e03d 642 SELECT qas.id
c76145d3
TH
643 FROM {question_attempts} qa
644 JOIN {question_attempt_steps} qas ON qas.questionattemptid = qa.id
645 JOIN {question_usages} ON qa.questionusageid = {question_usages}.id
646 WHERE $where)", $params);
647 $this->db->delete_records_select('question_attempt_steps', "questionattemptid IN (
d1b7e03d 648 SELECT qa.id
c76145d3
TH
649 FROM {question_attempts} qa
650 JOIN {question_usages} ON qa.questionusageid = {question_usages}.id
651 WHERE $where)", $params);
652 $this->db->delete_records_select('question_attempts', "questionusageid IN (
d1b7e03d 653 SELECT id
c76145d3
TH
654 FROM {question_usages}
655 WHERE $where)", $params);
656 $this->db->delete_records_select('question_usages', $where, $params);
d1b7e03d
TH
657 }
658
659 /**
660 * Delete all the steps for a question attempt.
f7970e3c 661 * @param int $qaids question_attempt id.
d1b7e03d
TH
662 */
663 public function delete_steps_for_question_attempts($qaids) {
d1b7e03d
TH
664 if (empty($qaids)) {
665 return;
666 }
e24ee794 667 list($test, $params) = $this->db->get_in_or_equal($qaids);
c76145d3 668 $this->db->delete_records_select('question_attempt_step_data', "attemptstepid IN (
d1b7e03d 669 SELECT qas.id
c76145d3
TH
670 FROM {question_attempt_steps} qas
671 WHERE questionattemptid $test)", $params);
672 $this->db->delete_records_select('question_attempt_steps', 'questionattemptid ' . $test, $params);
d1b7e03d
TH
673 }
674
675 /**
676 * Delete all the previews for a given question.
f7970e3c 677 * @param int $questionid question id.
d1b7e03d
TH
678 */
679 public function delete_previews($questionid) {
c76145d3 680 $previews = $this->db->get_records_sql_menu("
d1b7e03d 681 SELECT DISTINCT quba.id, 1
c76145d3
TH
682 FROM {question_usages} quba
683 JOIN {question_attempts} qa ON qa.questionusageid = quba.id
d1b7e03d 684 WHERE quba.component = 'core_question_preview' AND
c76145d3 685 qa.questionid = ?", array($questionid));
d1b7e03d
TH
686 if (empty($previews)) {
687 return;
688 }
c76145d3
TH
689 list($test, $params) = $this->db->get_in_or_equal(array_keys($previews));
690 $this->delete_questions_usage_by_activities('question_usages.id ' . $test, $params);
d1b7e03d
TH
691 }
692
693 /**
694 * Update the flagged state of a question in the database.
f7970e3c
TH
695 * @param int $qubaid the question usage id.
696 * @param int $questionid the question id.
697 * @param int $sessionid the question_attempt id.
698 * @param bool $newstate the new state of the flag. true = flagged.
d1b7e03d 699 */
06f8ed54 700 public function update_question_attempt_flag($qubaid, $questionid, $qaid, $slot, $newstate) {
f9b0500f 701 if (!$this->db->record_exists('question_attempts', array('id' => $qaid,
06f8ed54 702 'questionusageid' => $qubaid, 'questionid' => $questionid, 'slot' => $slot))) {
88f0eb15 703 throw new moodle_exception('errorsavingflags', 'question');
d1b7e03d
TH
704 }
705
06f8ed54 706 $this->db->set_field('question_attempts', 'flagged', $newstate, array('id' => $qaid));
d1b7e03d
TH
707 }
708
709 /**
710 * Get all the WHEN 'x' THEN 'y' terms needed to convert the question_attempt_steps.state
711 * column to a summary state. Use this like
712 * CASE qas.state {$this->full_states_to_summary_state_sql()} END AS summarystate,
713 * @param string SQL fragment.
714 */
715 protected function full_states_to_summary_state_sql() {
716 $sql = '';
717 foreach (question_state::get_all() as $state) {
718 $sql .= "WHEN '$state' THEN '{$state->get_summary_state()}'\n";
719 }
720 return $sql;
721 }
722
723 /**
724 * Get the SQL needed to test that question_attempt_steps.state is in a
725 * state corresponding to $summarystate.
726 * @param string $summarystate one of
727 * inprogress, needsgrading, manuallygraded or autograded
f7970e3c 728 * @param bool $equal if false, do a NOT IN test. Default true.
d1b7e03d
TH
729 * @return string SQL fragment.
730 */
cf3b6568 731 public function in_summary_state_test($summarystate, $equal = true, $prefix = 'summarystates') {
d1b7e03d 732 $states = question_state::get_all_for_summary_state($summarystate);
cf3b6568 733 return $this->db->get_in_or_equal($states, SQL_PARAMS_NAMED, $prefix . '00', $equal);
d1b7e03d
TH
734 }
735
736 /**
737 * Change the maxmark for the question_attempt with number in usage $slot
738 * for all the specified question_attempts.
739 * @param qubaid_condition $qubaids Selects which usages are updated.
f7970e3c 740 * @param int $slot the number is usage to affect.
d1b7e03d
TH
741 * @param number $newmaxmark the new max mark to set.
742 */
743 public function set_max_mark_in_attempts(qubaid_condition $qubaids, $slot, $newmaxmark) {
c76145d3
TH
744 $this->db->set_field_select('question_attempts', 'maxmark', $newmaxmark,
745 "questionusageid {$qubaids->usage_id_in()} AND slot = :slot",
746 $qubaids->usage_id_in_params() + array('slot' => $slot));
d1b7e03d
TH
747 }
748
749 /**
750 * Return a subquery that computes the sum of the marks for all the questions
751 * in a usage. Which useage to compute the sum for is controlled bu the $qubaid
752 * parameter.
753 *
754 * See {@link quiz_update_all_attempt_sumgrades()} for an example of the usage of
755 * this method.
756 *
757 * @param string $qubaid SQL fragment that controls which usage is summed.
2daffca5
TH
758 * This will normally be the name of a column in the outer query. Not that this
759 * SQL fragment must not contain any placeholders.
d1b7e03d
TH
760 * @return string SQL code for the subquery.
761 */
762 public function sum_usage_marks_subquery($qubaid) {
d1b7e03d 763 return "SELECT SUM(qa.maxmark * qas.fraction)
c76145d3 764 FROM {question_attempts} qa
d1b7e03d
TH
765 JOIN (
766 SELECT summarks_qa.id AS questionattemptid, MAX(summarks_qas.id) AS latestid
c76145d3
TH
767 FROM {question_attempt_steps} summarks_qas
768 JOIN {question_attempts} summarks_qa ON summarks_qa.id = summarks_qas.questionattemptid
d1b7e03d
TH
769 WHERE summarks_qa.questionusageid = $qubaid
770 GROUP BY summarks_qa.id
771 ) lateststepid ON lateststepid.questionattemptid = qa.id
c76145d3 772 JOIN {question_attempt_steps} qas ON qas.id = lateststepid.latestid
d1b7e03d 773 WHERE qa.questionusageid = $qubaid
f86390dc 774 HAVING COUNT(CASE WHEN qas.state = 'needsgrading' AND qa.maxmark > 0 THEN 1 ELSE NULL END) = 0";
d1b7e03d
TH
775 }
776
777 public function question_attempt_latest_state_view($alias) {
d1b7e03d
TH
778 return "(
779 SELECT
780 {$alias}qa.id AS questionattemptid,
781 {$alias}qa.questionusageid,
782 {$alias}qa.slot,
783 {$alias}qa.behaviour,
784 {$alias}qa.questionid,
785 {$alias}qa.maxmark,
786 {$alias}qa.minfraction,
787 {$alias}qa.flagged,
788 {$alias}qa.questionsummary,
789 {$alias}qa.rightanswer,
790 {$alias}qa.responsesummary,
791 {$alias}qa.timemodified,
792 {$alias}qas.id AS attemptstepid,
793 {$alias}qas.sequencenumber,
794 {$alias}qas.state,
795 {$alias}qas.fraction,
796 {$alias}qas.timecreated,
797 {$alias}qas.userid
798
2a3bdbf9
TH
799 FROM {question_attempts} {$alias}qa
800 JOIN {question_attempt_steps} {$alias}qas ON
d1b7e03d
TH
801 {$alias}qas.id = {$this->latest_step_for_qa_subquery($alias . 'qa.id')}
802 ) $alias";
803 }
804
805 protected function latest_step_for_qa_subquery($questionattemptid = 'qa.id') {
d1b7e03d
TH
806 return "(
807 SELECT MAX(id)
c76145d3 808 FROM {question_attempt_steps}
d1b7e03d
TH
809 WHERE questionattemptid = $questionattemptid
810 )";
811 }
812
813 /**
814 * @param array $questionids of question ids.
07f88584
TH
815 * @param qubaid_condition $qubaids ids of the usages to consider.
816 * @return boolean whether any of these questions are being used by any of
817 * those usages.
d1b7e03d 818 */
07f88584 819 public function questions_in_use(array $questionids, qubaid_condition $qubaids) {
c76145d3
TH
820 list($test, $params) = $this->db->get_in_or_equal($questionids);
821 return $this->db->record_exists_select('question_attempts',
07f88584
TH
822 'questionid ' . $test . ' AND questionusageid ' .
823 $qubaids->usage_id_in(), $params + $qubaids->usage_id_in_params());
d1b7e03d
TH
824 }
825}
826
827/**
828 * Implementation of the unit of work pattern for the question engine.
829 *
830 * See http://martinfowler.com/eaaCatalog/unitOfWork.html. This tracks all the
831 * changes to a {@link question_usage_by_activity}, and its constituent parts,
832 * so that the changes can be saved to the database when {@link save()} is called.
833 *
017bc1d9
TH
834 * @copyright 2009 The Open University
835 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
836 */
837class question_engine_unit_of_work implements question_usage_observer {
838 /** @var question_usage_by_activity the usage being tracked. */
839 protected $quba;
840
841 /** @var boolean whether any of the fields of the usage have been changed. */
842 protected $modified = false;
843
844 /**
845 * @var array list of number in usage => {@link question_attempt}s that
846 * were already in the usage, and which have been modified.
847 */
848 protected $attemptsmodified = array();
849
850 /**
851 * @var array list of number in usage => {@link question_attempt}s that
852 * have been added to the usage.
853 */
854 protected $attemptsadded = array();
855
856 /**
857 * @var array list of question attempt ids to delete the steps for, before
858 * inserting new steps.
859 */
860 protected $attemptstodeletestepsfor = array();
861
862 /**
863 * @var array list of array(question_attempt_step, question_attempt id, seq number)
864 * of steps that have been added to question attempts in this usage.
865 */
866 protected $stepsadded = array();
867
868 /**
869 * Constructor.
870 * @param question_usage_by_activity $quba the usage to track.
871 */
872 public function __construct(question_usage_by_activity $quba) {
873 $this->quba = $quba;
874 }
875
876 public function notify_modified() {
877 $this->modified = true;
878 }
879
880 public function notify_attempt_modified(question_attempt $qa) {
881 $no = $qa->get_slot();
882 if (!array_key_exists($no, $this->attemptsadded)) {
883 $this->attemptsmodified[$no] = $qa;
884 }
885 }
886
887 public function notify_attempt_added(question_attempt $qa) {
888 $this->attemptsadded[$qa->get_slot()] = $qa;
889 }
890
891 public function notify_delete_attempt_steps(question_attempt $qa) {
892
893 if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
894 return;
895 }
896
897 $qaid = $qa->get_database_id();
898 foreach ($this->stepsadded as $key => $stepinfo) {
899 if ($stepinfo[1] == $qaid) {
900 unset($this->stepsadded[$key]);
901 }
902 }
903
904 $this->attemptstodeletestepsfor[$qaid] = 1;
905 }
906
907 public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq) {
908 if (array_key_exists($qa->get_slot(), $this->attemptsadded)) {
909 return;
910 }
911 $this->stepsadded[] = array($step, $qa->get_database_id(), $seq);
912 }
913
914 /**
915 * Write all the changes we have recorded to the database.
916 * @param question_engine_data_mapper $dm the mapper to use to update the database.
917 */
918 public function save(question_engine_data_mapper $dm) {
919 $dm->delete_steps_for_question_attempts(array_keys($this->attemptstodeletestepsfor));
920 foreach ($this->stepsadded as $stepinfo) {
921 list($step, $questionattemptid, $seq) = $stepinfo;
217f9a61
TH
922 $dm->insert_question_attempt_step($step, $questionattemptid, $seq,
923 $this->quba->get_owning_context());
d1b7e03d
TH
924 }
925 foreach ($this->attemptsadded as $qa) {
217f9a61 926 $dm->insert_question_attempt($qa, $this->quba->get_owning_context());
d1b7e03d
TH
927 }
928 foreach ($this->attemptsmodified as $qa) {
929 $dm->update_question_attempt($qa);
930 }
931 if ($this->modified) {
932 $dm->update_questions_usage_by_activity($this->quba);
933 }
934 }
935}
936
937
217f9a61
TH
938/**
939 * This class represents the promise to save some files from a particular draft
940 * file area into a particular file area. It is used beause the necessary
941 * information about what to save is to hand in the
942 * {@link question_attempt::process_response_files()} method, but we don't know
943 * if this question attempt will actually be saved in the database until later,
944 * when the {@link question_engine_unit_of_work} is saved, if it is.
945 *
946 * @copyright 2011 The Open University
947 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
948 */
949class question_file_saver {
950 /** @var int the id of the draft file area to save files from. */
951 protected $draftitemid;
952 /** @var string the owning component name. */
953 protected $component;
954 /** @var string the file area name. */
955 protected $filearea;
956
cd3557e6
TH
957 /**
958 * @var string the value to store in the question_attempt_step_data to
959 * represent these files.
960 */
961 protected $value = null;
962
217f9a61
TH
963 /**
964 * Constuctor.
965 * @param int $draftitemid the draft area to save the files from.
966 * @param string $component the component for the file area to save into.
967 * @param string $filearea the name of the file area to save into.
968 */
969 public function __construct($draftitemid, $component, $filearea) {
970 $this->draftitemid = $draftitemid;
971 $this->component = $component;
972 $this->filearea = $filearea;
973 }
974
cd3557e6
TH
975 protected function get_value() {
976 global $USER;
977
978 if (!is_null($this->value)) {
979 return $this->value;
980 }
981
982 $fs = get_file_storage();
983 $usercontext = get_context_instance(CONTEXT_USER, $USER->id);
984
985 $files = $fs->get_area_files($usercontext->id, 'user', 'draft',
986 $this->draftitemid, 'sortorder, filepath, filename', false);
987
988 $string = '';
989 foreach ($files as $file) {
990 $string .= $file->get_filepath() . $file->get_filename() . '|' .
991 $file->get_contenthash() . '|';
992 }
993
994 if ($string) {
995 $this->value = md5($string);
996 } else {
997 $this->value = '';
998 }
999
1000 return $this->value;
1001 }
1002
217f9a61 1003 public function __toString() {
cd3557e6 1004 return $this->get_value();
217f9a61
TH
1005 }
1006
1007 /**
1008 * Actually save the files.
1009 * @param integer $itemid the item id for the file area to save into.
1010 */
1011 public function save_files($itemid, $context) {
1012 file_save_draft_area_files($this->draftitemid, $context->id,
1013 $this->component, $this->filearea, $itemid);
1014 }
1015}
1016
1017
d1b7e03d
TH
1018/**
1019 * This class represents a restriction on the set of question_usage ids to include
1020 * in a larger database query. Depending of the how you are going to restrict the
1021 * list of usages, construct an appropriate subclass.
1022 *
1023 * If $qubaids is an instance of this class, example usage might be
1024 *
1025 * SELECT qa.id, qa.maxmark
1026 * FROM $qubaids->from_question_attempts('qa')
1027 * WHERE $qubaids->where() AND qa.slot = 1
1028 *
f7970e3c 1029 * @copyright 2010 The Open University
017bc1d9 1030 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
1031 */
1032abstract class qubaid_condition {
1033
1034 /**
1035 * @return string the SQL that needs to go in the FROM clause when trying
1036 * to select records from the 'question_attempts' table based on the
1037 * qubaid_condition.
1038 */
1039 public abstract function from_question_attempts($alias);
1040
1041 /** @return string the SQL that needs to go in the where clause. */
1042 public abstract function where();
1043
1044 /**
1045 * @return the params needed by a query that uses
1046 * {@link from_question_attempts()} and {@link where()}.
1047 */
1048 public abstract function from_where_params();
1049
1050 /**
1051 * @return string SQL that can use used in a WHERE qubaid IN (...) query.
1052 * This method returns the "IN (...)" part.
1053 */
1054 public abstract function usage_id_in();
1055
1056 /**
1057 * @return the params needed by a query that uses {@link usage_id_in()}.
1058 */
1059 public abstract function usage_id_in_params();
1060}
1061
1062
1063/**
1064 * This class represents a restriction on the set of question_usage ids to include
1065 * in a larger database query based on an explicit list of ids.
1066 *
f7970e3c 1067 * @copyright 2010 The Open University
017bc1d9 1068 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
1069 */
1070class qubaid_list extends qubaid_condition {
1071 /** @var array of ids. */
1072 protected $qubaids;
1073 protected $columntotest = null;
1074 protected $params;
1075
1076 /**
1077 * Constructor.
1078 * @param array $qubaids of question usage ids.
1079 */
1080 public function __construct(array $qubaids) {
1081 $this->qubaids = $qubaids;
1082 }
1083
1084 public function from_question_attempts($alias) {
d1b7e03d 1085 $this->columntotest = $alias . '.questionusageid';
9b40c540 1086 return '{question_attempts} ' . $alias;
d1b7e03d
TH
1087 }
1088
1089 public function where() {
1090 global $DB;
1091
1092 if (is_null($this->columntotest)) {
88f0eb15 1093 throw new coding_exception('Must call from_question_attempts before where().');
d1b7e03d
TH
1094 }
1095 if (empty($this->qubaids)) {
9b40c540 1096 $this->params = array();
d1b7e03d
TH
1097 return '1 = 0';
1098 }
1099 list($where, $this->params) = $DB->get_in_or_equal($this->qubaids, SQL_PARAMS_NAMED, 'qubaid0000');
1100
9b40c540 1101 return $this->columntotest . ' ' . $this->usage_id_in();
d1b7e03d
TH
1102 }
1103
1104 public function from_where_params() {
1105 return $this->params;
1106 }
1107
1108 public function usage_id_in() {
1109 global $DB;
1110
1111 if (empty($this->qubaids)) {
1112 return '= 0';
1113 }
1114 list($where, $this->params) = $DB->get_in_or_equal($this->qubaids, SQL_PARAMS_NAMED, 'qubaid0000');
1115 return $where;
1116 }
1117
1118 public function usage_id_in_params() {
1119 return $this->params;
1120 }
1121}
1122
1123
1124/**
1125 * This class represents a restriction on the set of question_usage ids to include
1126 * in a larger database query based on JOINing to some other tables.
1127 *
1128 * The general form of the query is something like
1129 *
1130 * SELECT qa.id, qa.maxmark
1131 * FROM $from
2a3bdbf9 1132 * JOIN {question_attempts} qa ON qa.questionusageid = $usageidcolumn
d1b7e03d
TH
1133 * WHERE $where AND qa.slot = 1
1134 *
1135 * where $from, $usageidcolumn and $where are the arguments to the constructor.
1136 *
f7970e3c 1137 * @copyright 2010 The Open University
017bc1d9 1138 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
d1b7e03d
TH
1139 */
1140class qubaid_join extends qubaid_condition {
1141 public $from;
1142 public $usageidcolumn;
1143 public $where;
1144 public $params;
1145
1146 /**
1147 * Constructor. The meaning of the arguments is explained in the class comment.
1148 * @param string $from SQL fragemnt to go in the FROM clause.
1149 * @param string $usageidcolumn the column in $from that should be
1150 * made equal to the usageid column in the JOIN clause.
1151 * @param string $where SQL fragment to go in the where clause.
e24ee794 1152 * @param array $params required by the SQL. You must use named parameters.
d1b7e03d
TH
1153 */
1154 public function __construct($from, $usageidcolumn, $where = '', $params = array()) {
1155 $this->from = $from;
1156 $this->usageidcolumn = $usageidcolumn;
1157 $this->params = $params;
1158 if (empty($where)) {
1159 $where = '1 = 1';
1160 }
1161 $this->where = $where;
1162 }
1163
1164 public function from_question_attempts($alias) {
d1b7e03d 1165 return "$this->from
0f33deaf 1166 JOIN {question_attempts} {$alias} ON " .
d1b7e03d
TH
1167 "{$alias}.questionusageid = $this->usageidcolumn";
1168 }
1169
1170 public function where() {
1171 return $this->where;
1172 }
1173
1174 public function from_where_params() {
1175 return $this->params;
1176 }
1177
1178 public function usage_id_in() {
1179 return "IN (SELECT $this->usageidcolumn FROM $this->from WHERE $this->where)";
1180 }
1181
1182 public function usage_id_in_params() {
1183 return $this->params;
1184 }
1185}