Merge branch 'MDL-52813-master' of git://github.com/jleyva/moodle

This commit is contained in:
Dan Poltawski
2016-03-21 16:37:57 +08:00
5 changed files with 526 additions and 68 deletions
+11
View File
@@ -1232,6 +1232,17 @@ class quiz_attempt {
return $this->quba->get_question_action_time($slot);
}
/**
* Return the question type name for a given slot within the current attempt.
*
* @param int $slot the number used to identify this question within this attempt.
* @return string the question type name
* @since Moodle 3.1
*/
public function get_question_type_name($slot) {
return $this->quba->get_question($slot)->get_type_name();
}
/**
* Get the time remaining for an in-progress attempt, if the time is short
* enought that it would be worth showing a timer.
+228
View File
@@ -774,4 +774,232 @@ class mod_quiz_external extends external_api {
);
}
/**
* Utility function for validating a given attempt
*
* @param array $params Array of parameters including the attemptid and preflight data
* @return array containing the attempt object and access messages
* @throws moodle_quiz_exception
* @since Moodle 3.1
*/
protected static function validate_attempt($params) {
global $USER;
$attemptobj = quiz_attempt::create($params['attemptid']);
$context = context_module::instance($attemptobj->get_cm()->id);
self::validate_context($context);
// Check that this attempt belongs to this user.
if ($attemptobj->get_userid() != $USER->id) {
throw new moodle_quiz_exception($attemptobj->get_quizobj(), 'notyourattempt');
}
// General capabilities check.
$ispreviewuser = $attemptobj->is_preview_user();
if (!$ispreviewuser) {
$attemptobj->require_capability('mod/quiz:attempt');
}
// Attempt closed?.
if ($attemptobj->is_finished()) {
throw new moodle_quiz_exception($attemptobj->get_quizobj(), 'attemptalreadyclosed');
} else if ($attemptobj->get_state() == quiz_attempt::OVERDUE) {
throw new moodle_quiz_exception($attemptobj->get_quizobj(), 'stateoverdue');
}
// Check the access rules.
$accessmanager = $attemptobj->get_access_manager(time());
$messages = $accessmanager->prevent_access();
if (!$ispreviewuser && $messages) {
throw new moodle_quiz_exception($attemptobj->get_quizobj(), 'attempterror');
}
// User submitted data (like the quiz password).
if ($accessmanager->is_preflight_check_required($attemptobj->get_attemptid())) {
$provideddata = array();
foreach ($params['preflightdata'] as $data) {
$provideddata[$data['name']] = $data['value'];
}
$errors = $accessmanager->validate_preflight_check($provideddata, [], $params['attemptid']);
if (!empty($errors)) {
throw new moodle_quiz_exception($attemptobj->get_quizobj(), array_shift($errors));
}
// Pre-flight check passed.
$accessmanager->notify_preflight_check_passed($params['attemptid']);
}
// Check if the page is out of range.
if ($params['page'] != $attemptobj->force_page_number_into_range($params['page'])) {
throw new moodle_quiz_exception($attemptobj->get_quizobj(), 'Invalid page number');
}
// Prevent out of sequence access.
if ($attemptobj->get_currentpage() != $params['page']) {
if ($attemptobj->get_navigation_method() == QUIZ_NAVMETHOD_SEQ && $attemptobj->get_currentpage() > $params['page']) {
throw new moodle_quiz_exception($attemptobj->get_quizobj(), 'Out of sequence access');
}
}
// Check slots.
$slots = $attemptobj->get_slots($params['page']);
if (empty($slots)) {
throw new moodle_quiz_exception($attemptobj->get_quizobj(), 'noquestionsfound');
}
return array($attemptobj, $messages);
}
/**
* Describes a single question structure.
*
* @return external_single_structure the question structure
* @since Moodle 3.1
*/
private static function question_structure() {
return new external_single_structure(
array(
'slot' => new external_value(PARAM_INT, 'slot number'),
'type' => new external_value(PARAM_ALPHANUMEXT, 'question type, i.e: multichoice'),
'page' => new external_value(PARAM_INT, 'page of the quiz this question appears on'),
'html' => new external_value(PARAM_RAW, 'the question rendered'),
'flagged' => new external_value(PARAM_BOOL, 'whether the question is flagged or not'),
'number' => new external_value(PARAM_INT, 'question ordering number in the quiz', VALUE_OPTIONAL),
'state' => new external_value(PARAM_ALPHA, 'the state where the question is in', VALUE_OPTIONAL),
'status' => new external_value(PARAM_RAW, 'current formatted state of the question', VALUE_OPTIONAL),
'mark' => new external_value(PARAM_RAW, 'the mark awarded', VALUE_OPTIONAL),
'maxmark' => new external_value(PARAM_FLOAT, 'the maximum mark possible for this question attempt', VALUE_OPTIONAL),
)
);
}
/**
* Return questions information for a given attempt.
*
* @param quiz_attempt $attemptobj the quiz attempt object
* @param bool $review whether if we are in review mode or not
* @param mixed $page string 'all' or integer page number
* @return array array of questions including data
*/
private static function get_attempt_questions_data(quiz_attempt $attemptobj, $review, $page = 'all') {
global $PAGE;
$questions = array();
$contextid = $attemptobj->get_quizobj()->get_context()->id;
$displayoptions = $attemptobj->get_display_options($review);
$renderer = $PAGE->get_renderer('mod_quiz');
foreach ($attemptobj->get_slots($page) as $slot) {
$question = array(
'slot' => $slot,
'type' => $attemptobj->get_question_type_name($slot),
'page' => $attemptobj->get_question_page($slot),
'flagged' => $attemptobj->is_question_flagged($slot),
'html' => $attemptobj->render_question($slot, $review, $renderer) . $PAGE->requires->get_end_code()
);
if ($attemptobj->is_real_question($slot)) {
$question['number'] = $attemptobj->get_question_number($slot);
$question['state'] = (string) $attemptobj->get_question_state($slot);
$question['status'] = $attemptobj->get_question_status($slot, $displayoptions->correctness);
}
if ($displayoptions->marks >= question_display_options::MAX_ONLY) {
$question['maxmark'] = $attemptobj->get_question_attempt($slot)->get_max_mark();
}
if ($displayoptions->marks >= question_display_options::MARK_AND_MAX) {
$question['mark'] = $attemptobj->get_question_mark($slot);
}
$questions[] = $question;
}
return $questions;
}
/**
* Describes the parameters for get_attempt_data.
*
* @return external_external_function_parameters
* @since Moodle 3.1
*/
public static function get_attempt_data_parameters() {
return new external_function_parameters (
array(
'attemptid' => new external_value(PARAM_INT, 'attempt id'),
'page' => new external_value(PARAM_INT, 'page number'),
'preflightdata' => new external_multiple_structure(
new external_single_structure(
array(
'name' => new external_value(PARAM_ALPHANUMEXT, 'data name'),
'value' => new external_value(PARAM_RAW, 'data value'),
)
), 'Preflight required data (like passwords)', VALUE_DEFAULT, array()
)
)
);
}
/**
* Returns information for the given attempt page for a quiz attempt in progress.
*
* @param int $attemptid attempt id
* @param int $page page number
* @param array $preflightdata preflight required data (like passwords)
* @return array of warnings and the attempt data, next page, message and questions
* @since Moodle 3.1
* @throws moodle_quiz_exceptions
*/
public static function get_attempt_data($attemptid, $page, $preflightdata = array()) {
$warnings = array();
$params = array(
'attemptid' => $attemptid,
'page' => $page,
'preflightdata' => $preflightdata,
);
$params = self::validate_parameters(self::get_attempt_data_parameters(), $params);
list($attemptobj, $messages) = self::validate_attempt($params);
if ($attemptobj->is_last_page($params['page'])) {
$nextpage = -1;
} else {
$nextpage = $params['page'] + 1;
}
$result = array();
$result['attempt'] = $attemptobj->get_attempt();
$result['messages'] = $messages;
$result['nextpage'] = $nextpage;
$result['warnings'] = $warnings;
$result['questions'] = self::get_attempt_questions_data($attemptobj, false, $params['page']);
return $result;
}
/**
* Describes the get_attempt_data return value.
*
* @return external_single_structure
* @since Moodle 3.1
*/
public static function get_attempt_data_returns() {
return new external_single_structure(
array(
'attempt' => self::attempt_structure(),
'messages' => new external_multiple_structure(
new external_value(PARAM_TEXT, 'access message'),
'access messages, will only be returned for users with mod/quiz:preview capability,
for other users this method will throw an exception if there are messages'),
'nextpage' => new external_value(PARAM_INT, 'next page number'),
'questions' => new external_multiple_structure(self::question_structure()),
'warnings' => new external_warnings(),
)
);
}
}
+9
View File
@@ -82,4 +82,13 @@ $functions = array(
'capabilities' => 'mod/quiz:attempt',
'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE)
),
'mod_quiz_get_attempt_data' => array(
'classname' => 'mod_quiz_external',
'methodname' => 'get_attempt_data',
'description' => 'Returns information for the given attempt page for a quiz attempt in progress.',
'type' => 'read',
'capabilities' => 'mod/quiz:attempt',
'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE)
),
);
+277 -67
View File
@@ -30,6 +30,27 @@ global $CFG;
require_once($CFG->dirroot . '/webservice/tests/helpers.php');
/**
* Silly class to access mod_quiz_external internal methods.
*
* @package mod_quiz
* @copyright 2016 Juan Leyva <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since Moodle 3.1
*/
class testable_mod_quiz_external extends mod_quiz_external {
/**
* Public accessor.
*
* @param array $params Array of parameters including the attemptid and preflight data
* @return array containing the attempt object and access messages
*/
public static function validate_attempt($params) {
return parent::validate_attempt($params);
}
}
/**
* Quiz module external functions tests
*
@@ -66,6 +87,65 @@ class mod_quiz_external_testcase extends externallib_advanced_testcase {
$this->getDataGenerator()->enrol_user($this->teacher->id, $this->course->id, $this->teacherrole->id, 'manual');
}
/**
* Create a quiz with questions including a started or finished attempt optionally
*
* @param boolean $startattempt whether to start a new attempt
* @param boolean $finishattempt whether to finish the new attempt
* @return array array containing the quiz, context and the attempt
*/
private function create_quiz_with_questions($startattempt = false, $finishattempt = false) {
// Create a new quiz with attempts.
$quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
$data = array('course' => $this->course->id,
'sumgrades' => 1);
$quiz = $quizgenerator->create_instance($data);
$context = context_module::instance($quiz->cmid);
// Create a couple of questions.
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $questiongenerator->create_question_category();
$question = $questiongenerator->create_question('numerical', null, array('category' => $cat->id));
quiz_add_quiz_question($question->id, $quiz);
$question = $questiongenerator->create_question('numerical', null, array('category' => $cat->id));
quiz_add_quiz_question($question->id, $quiz);
$quizobj = quiz::create($quiz->id, $this->student->id);
// Set grade to pass.
$item = grade_item::fetch(array('courseid' => $this->course->id, 'itemtype' => 'mod',
'itemmodule' => 'quiz', 'iteminstance' => $quiz->id, 'outcomeid' => null));
$item->gradepass = 80;
$item->update();
if ($startattempt or $finishattempt) {
// Now, do one attempt.
$quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
$quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
$timenow = time();
$attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $this->student->id);
quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow);
quiz_attempt_save_started($quizobj, $quba, $attempt);
$attemptobj = quiz_attempt::create($attempt->id);
if ($finishattempt) {
// Process some responses from the student.
$tosubmit = array(1 => array('answer' => '3.14'));
$attemptobj->process_submitted_actions(time(), false, $tosubmit);
// Finish the attempt.
$attemptobj->process_finish(time(), false);
}
return array($quiz, $context, $quizobj, $attempt, $attemptobj);
} else {
return array($quiz, $context, $quizobj);
}
}
/*
* Test get quizzes by courses
*/
@@ -273,45 +353,8 @@ class mod_quiz_external_testcase extends externallib_advanced_testcase {
*/
public function test_get_user_attempts() {
// Create a new quiz with attempts.
$quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
$data = array('course' => $this->course->id,
'sumgrades' => 1);
$quiz = $quizgenerator->create_instance($data);
// Create a couple of questions.
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $questiongenerator->create_question_category();
$question = $questiongenerator->create_question('numerical', null, array('category' => $cat->id));
quiz_add_quiz_question($question->id, $quiz);
$quizobj = quiz::create($quiz->id, $this->student->id);
// Set grade to pass.
$item = grade_item::fetch(array('courseid' => $this->course->id, 'itemtype' => 'mod',
'itemmodule' => 'quiz', 'iteminstance' => $quiz->id, 'outcomeid' => null));
$item->gradepass = 80;
$item->update();
// Start the passing attempt.
$quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
$quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
$timenow = time();
$attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $this->student->id);
quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow);
quiz_attempt_save_started($quizobj, $quba, $attempt);
// Process some responses from the student.
$attemptobj = quiz_attempt::create($attempt->id);
$tosubmit = array(1 => array('answer' => '3.14'));
$attemptobj->process_submitted_actions($timenow, false, $tosubmit);
// Finish the attempt.
$attemptobj = quiz_attempt::create($attempt->id);
$this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question());
$attemptobj->process_finish($timenow, false);
// Create a quiz with one attempt finished.
list($quiz, $context, $quizobj, $attempt, $attemptobj) = $this->create_quiz_with_questions(true, true);
$this->setUser($this->student);
$result = mod_quiz_external::get_user_attempts($quiz->id);
@@ -591,34 +634,8 @@ class mod_quiz_external_testcase extends externallib_advanced_testcase {
public function test_start_attempt() {
global $DB;
// Create a new quiz with attempts.
$quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
$data = array('course' => $this->course->id,
'sumgrades' => 1);
$quiz = $quizgenerator->create_instance($data);
$context = context_module::instance($quiz->cmid);
try {
mod_quiz_external::start_attempt($quiz->id);
$this->fail('Exception expected due to missing questions.');
} catch (moodle_quiz_exception $e) {
$this->assertEquals('noquestionsfound', $e->errorcode);
}
// Create a question.
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$cat = $questiongenerator->create_question_category();
$question = $questiongenerator->create_question('numerical', null, array('category' => $cat->id));
quiz_add_quiz_question($question->id, $quiz);
$quizobj = quiz::create($quiz->id, $this->student->id);
// Set grade to pass.
$item = grade_item::fetch(array('courseid' => $this->course->id, 'itemtype' => 'mod',
'itemmodule' => 'quiz', 'iteminstance' => $quiz->id, 'outcomeid' => null));
$item->gradepass = 80;
$item->update();
// Create a new quiz with questions.
list($quiz, $context, $quizobj) = $this->create_quiz_with_questions();
$this->setUser($this->student);
@@ -702,4 +719,197 @@ class mod_quiz_external_testcase extends externallib_advanced_testcase {
}
/**
* Test validate_attempt
*/
public function test_validate_attempt() {
global $DB;
// Create a new quiz with one attempt started.
list($quiz, $context, $quizobj, $attempt, $attemptobj) = $this->create_quiz_with_questions(true);
$this->setUser($this->student);
// Invalid attempt.
try {
$params = array('attemptid' => -1, 'page' => 0);
testable_mod_quiz_external::validate_attempt($params);
$this->fail('Exception expected due to invalid attempt id.');
} catch (dml_missing_record_exception $e) {
$this->assertEquals('invalidrecord', $e->errorcode);
}
// Test OK case.
$params = array('attemptid' => $attempt->id, 'page' => 0);
$result = testable_mod_quiz_external::validate_attempt($params);
$this->assertEquals($attempt->id, $result[0]->get_attempt()->id);
$this->assertEquals([], $result[1]);
// Test with preflight data.
$quiz->password = 'abc';
$DB->update_record('quiz', $quiz);
try {
$params = array('attemptid' => $attempt->id, 'page' => 0,
'preflightdata' => array(array("name" => "quizpassword", "value" => 'bad')));
testable_mod_quiz_external::validate_attempt($params);
$this->fail('Exception expected due to invalid passwod.');
} catch (moodle_exception $e) {
$this->assertEquals(get_string('passworderror', 'quizaccess_password'), $e->errorcode);
}
// Now, try everything correct.
$params['preflightdata'][0]['value'] = 'abc';
$result = testable_mod_quiz_external::validate_attempt($params);
$this->assertEquals($attempt->id, $result[0]->get_attempt()->id);
$this->assertEquals([], $result[1]);
// Try to open attempt in closed quiz.
$quiz->timeopen = time() - WEEKSECS;
$quiz->timeclose = time() - DAYSECS;
$DB->update_record('quiz', $quiz);
try {
testable_mod_quiz_external::validate_attempt($params);
$this->fail('Exception expected due to passed dates.');
} catch (moodle_quiz_exception $e) {
$this->assertEquals('attempterror', $e->errorcode);
}
// Page out of range.
$quiz->timeopen = 0;
$quiz->timeclose = 0;
$DB->update_record('quiz', $quiz);
$params['page'] = 4;
try {
testable_mod_quiz_external::validate_attempt($params);
$this->fail('Exception expected due to page out of range.');
} catch (moodle_quiz_exception $e) {
$this->assertEquals('Invalid page number', $e->errorcode);
}
// Finish the attempt.
$attemptobj = quiz_attempt::create($attempt->id);
$attemptobj->process_finish(time(), false);
try {
testable_mod_quiz_external::validate_attempt($params);
$this->fail('Exception expected due to attempt finished.');
} catch (moodle_quiz_exception $e) {
$this->assertEquals('attemptalreadyclosed', $e->errorcode);
}
// Test user with no capabilities.
// We need a explicit prohibit since this capability is only defined in authenticated user and guest roles.
assign_capability('mod/quiz:attempt', CAP_PROHIBIT, $this->studentrole->id, $context->id);
// Empty all the caches that may be affected by this change.
accesslib_clear_all_caches_for_unit_testing();
course_modinfo::clear_instance_cache();
try {
testable_mod_quiz_external::validate_attempt($params);
$this->fail('Exception expected due to missing permissions.');
} catch (required_capability_exception $e) {
$this->assertEquals('nopermissions', $e->errorcode);
}
// Now try with a different user.
$this->setUser($this->teacher);
$params['page'] = 0;
try {
testable_mod_quiz_external::validate_attempt($params);
$this->fail('Exception expected due to not your attempt.');
} catch (moodle_quiz_exception $e) {
$this->assertEquals('notyourattempt', $e->errorcode);
}
}
/**
* Test get_attempt_data
*/
public function test_get_attempt_data() {
global $DB;
// Create a new quiz with one attempt started.
list($quiz, $context, $quizobj, $attempt, $attemptobj) = $this->create_quiz_with_questions(true);
$quizobj = $attemptobj->get_quizobj();
$quizobj->preload_questions();
$quizobj->load_questions();
$questions = $quizobj->get_questions();
$this->setUser($this->student);
// We receive one question per page.
$result = mod_quiz_external::get_attempt_data($attempt->id, 0);
$result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_data_returns(), $result);
$this->assertEquals($attempt, (object) $result['attempt']);
$this->assertEquals(1, $result['nextpage']);
$this->assertCount(0, $result['messages']);
$this->assertCount(1, $result['questions']);
$this->assertEquals(1, $result['questions'][0]['slot']);
$this->assertEquals(1, $result['questions'][0]['number']);
$this->assertEquals('numerical', $result['questions'][0]['type']);
$this->assertEquals('todo', $result['questions'][0]['state']);
$this->assertEquals(get_string('notyetanswered', 'question'), $result['questions'][0]['status']);
$this->assertFalse($result['questions'][0]['flagged']);
$this->assertEquals(0, $result['questions'][0]['page']);
$this->assertEmpty($result['questions'][0]['mark']);
$this->assertEquals(1, $result['questions'][0]['maxmark']);
// Now try the last page.
$result = mod_quiz_external::get_attempt_data($attempt->id, 1);
$result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_data_returns(), $result);
$this->assertEquals($attempt, (object) $result['attempt']);
$this->assertEquals(-1, $result['nextpage']);
$this->assertCount(0, $result['messages']);
$this->assertCount(1, $result['questions']);
$this->assertEquals(2, $result['questions'][0]['slot']);
$this->assertEquals(2, $result['questions'][0]['number']);
$this->assertEquals('numerical', $result['questions'][0]['type']);
$this->assertEquals('todo', $result['questions'][0]['state']);
$this->assertEquals(get_string('notyetanswered', 'question'), $result['questions'][0]['status']);
$this->assertFalse($result['questions'][0]['flagged']);
$this->assertEquals(1, $result['questions'][0]['page']);
// Finish previous attempt.
$attemptobj->process_finish(time(), false);
// Change setting and expect two pages.
$quiz->questionsperpage = 4;
$DB->update_record('quiz', $quiz);
quiz_repaginate_questions($quiz->id, $quiz->questionsperpage);
// Start with new attempt with the new layout.
$quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
$quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
$timenow = time();
$attempt = quiz_create_attempt($quizobj, 2, false, $timenow, false, $this->student->id);
quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow);
quiz_attempt_save_started($quizobj, $quba, $attempt);
// We receive two questions per page.
$result = mod_quiz_external::get_attempt_data($attempt->id, 0);
$result = external_api::clean_returnvalue(mod_quiz_external::get_attempt_data_returns(), $result);
$this->assertCount(2, $result['questions']);
$this->assertEquals(-1, $result['nextpage']);
// Check questions looks good.
$found = 0;
foreach ($questions as $question) {
foreach ($result['questions'] as $rquestion) {
if ($rquestion['slot'] == $question->slot) {
$this->assertTrue(strpos($rquestion['html'], "qid=$question->id") !== false);
$found++;
}
}
}
$this->assertEquals(2, $found);
}
}
+1 -1
View File
@@ -24,7 +24,7 @@
defined('MOODLE_INTERNAL') || die();
$plugin->version = 2015111607;
$plugin->version = 2015111608;
$plugin->requires = 2015111000;
$plugin->component = 'mod_quiz';
$plugin->cron = 60;