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