From f29aeb5afdaee4e585a63863495bd9e3477f1532 Mon Sep 17 00:00:00 2001 From: Tim Hunt Date: Thu, 23 Dec 2010 08:41:01 +0000 Subject: [PATCH] MDL-20636 converstion of questionlib.php and base questiontype.php, plus other cheanges required to get the question editing page to display. --- admin/qtypes.php | 34 +- lib/questionlib.php | 1808 ++--------------- question/engine/bank.php | 17 +- question/engine/lib.php | 39 +- question/engine/renderer.php | 2 +- question/todo/diffstat.txt | 20 +- question/todo/questionlib_2.0.diff.txt | 807 ++++++++ question/todo/questionlib_qe.diff.txt | 1720 ++++++++++++++++ question/type/description/questiontype.php | 4 + question/type/old_questiontype.php | 638 +----- question/type/questiontype.php | 625 +++--- question/type/rendererbase.php | 4 +- question/type/simpletest/testquestiontype.php | 86 +- 13 files changed, 3165 insertions(+), 2639 deletions(-) create mode 100644 question/todo/questionlib_2.0.diff.txt create mode 100644 question/todo/questionlib_qe.diff.txt diff --git a/admin/qtypes.php b/admin/qtypes.php index 553654022da..cb9595a3331 100644 --- a/admin/qtypes.php +++ b/admin/qtypes.php @@ -14,12 +14,14 @@ admin_externalpage_setup('manageqtypes'); + $qtypes = question_bank::get_all_qtypes(); + /// Get some data we will need - question counts and which types are needed. $counts = $DB->get_records_sql(" SELECT qtype, COUNT(1) as numquestions, SUM(hidden) as numhidden FROM {question} GROUP BY qtype", array()); $needed = array(); - foreach ($QTYPES as $qtypename => $qtype) { + foreach ($qtypes as $qtypename => $qtype) { if (!isset($counts[$qtypename])) { $counts[$qtypename] = new stdClass; $counts[$qtypename]->numquestions = 0; @@ -29,13 +31,13 @@ $counts[$qtypename]->numquestions -= $counts[$qtypename]->numhidden; } $needed['missingtype'] = true; // The system needs the missing question type. - foreach ($QTYPES as $qtypename => $qtype) { + foreach ($qtypes as $qtypename => $qtype) { foreach ($qtype->requires_qtypes() as $reqtype) { $needed[$reqtype] = true; } } foreach ($counts as $qtypename => $count) { - if (!isset($QTYPES[$qtypename])) { + if (!isset($qtypes[$qtypename])) { $counts['missingtype']->numquestions += $count->numquestions - $count->numhidden; $counts['missingtype']->numhidden += $count->numhidden; } @@ -44,7 +46,7 @@ /// Work of the correct sort order. $config = get_config('question'); $sortedqtypes = array(); - foreach ($QTYPES as $qtypename => $qtype) { + foreach ($qtypes as $qtypename => $qtype) { $sortedqtypes[$qtypename] = $qtype->local_name(); } $sortedqtypes = question_sort_qtype_array($sortedqtypes, $config); @@ -53,7 +55,7 @@ // Disable. if (($disable = optional_param('disable', '', PARAM_SAFEDIR)) && confirm_sesskey()) { - if (!isset($QTYPES[$disable])) { + if (!isset($qtypes[$disable])) { print_error('unknownquestiontype', 'question', admin_url('qtypes.php'), $disable); } @@ -63,11 +65,11 @@ // Enable. if (($enable = optional_param('enable', '', PARAM_SAFEDIR)) && confirm_sesskey()) { - if (!isset($QTYPES[$enable])) { + if (!isset($qtypes[$enable])) { print_error('unknownquestiontype', 'question', admin_url('qtypes.php'), $enable); } - if (!$QTYPES[$enable]->menu_name()) { + if (!$qtypes[$enable]->menu_name()) { print_error('cannotenable', 'question', admin_url('qtypes.php'), $enable); } @@ -77,7 +79,7 @@ // Move up in order. if (($up = optional_param('up', '', PARAM_SAFEDIR)) && confirm_sesskey()) { - if (!isset($QTYPES[$up])) { + if (!isset($qtypes[$up])) { print_error('unknownquestiontype', 'question', admin_url('qtypes.php'), $up); } @@ -88,7 +90,7 @@ // Move down in order. if (($down = optional_param('down', '', PARAM_SAFEDIR)) && confirm_sesskey()) { - if (!isset($QTYPES[$down])) { + if (!isset($qtypes[$down])) { print_error('unknownquestiontype', 'question', admin_url('qtypes.php'), $down); } @@ -104,11 +106,11 @@ print_error('cannotdeletemissingqtype', 'admin', admin_url('qtypes.php')); } - if (!isset($QTYPES[$delete])) { + if (!isset($qtypes[$delete])) { print_error('unknownquestiontype', 'question', admin_url('qtypes.php'), $delete); } - $qtypename = $QTYPES[$delete]->local_name(); + $qtypename = $qtypes[$delete]->local_name(); if ($counts[$delete]->numquestions + $counts[$delete]->numhidden > 0) { print_error('cannotdeleteqtypeinuse', 'admin', admin_url('qtypes.php'), $qtypename); } @@ -119,7 +121,7 @@ // If not yet confirmed, display a confirmation message. if (!optional_param('confirm', '', PARAM_BOOL)) { - $qtypename = $QTYPES[$delete]->local_name(); + $qtypename = $qtypes[$delete]->local_name(); echo $OUTPUT->header(); echo $OUTPUT->heading(get_string('deleteqtypeareyousure', 'admin', $qtypename)); echo $OUTPUT->confirm(get_string('deleteqtypeareyousuremessage', 'admin', $qtypename), @@ -141,13 +143,13 @@ unset_config($delete . '_sortorder', 'question'); // Then the tables themselves - drop_plugin_tables($delete, $QTYPES[$delete]->plugin_dir() . '/db/install.xml', false); + drop_plugin_tables($delete, $qtypes[$delete]->plugin_dir() . '/db/install.xml', false); // Remove event handlers and dequeue pending events events_uninstall('qtype/' . $delete); $a->qtype = $qtypename; - $a->directory = $QTYPES[$delete]->plugin_dir(); + $a->directory = $qtypes[$delete]->plugin_dir(); echo $OUTPUT->box(get_string('qtypedeletefiles', 'admin', $a), 'generalbox', 'notice'); echo $OUTPUT->continue_button(admin_url('qtypes.php')); echo $OUTPUT->footer(); @@ -174,7 +176,7 @@ /// Add a row for each question type. $createabletypes = question_type_menu(); foreach ($sortedqtypes as $qtypename => $localname) { - $qtype = $QTYPES[$qtypename]; + $qtype = $qtypes[$qtypename]; $row = array(); // Question icon and name. @@ -213,7 +215,7 @@ $strtypes = array(); if (!empty($requiredtypes)) { foreach ($requiredtypes as $required) { - $strtypes[] = $QTYPES[$required]->local_name(); + $strtypes[] = $qtypes[$required]->local_name(); } $row[] = implode(', ', $strtypes); } else { diff --git a/lib/questionlib.php b/lib/questionlib.php index 6ae69e4b78a..ddeb74fb79a 100644 --- a/lib/questionlib.php +++ b/lib/questionlib.php @@ -26,47 +26,21 @@ * TODO: separate those functions which form part of the API * from the helper functions. * - * Major Contributors - * - Alex Smith, Julian Sedding and Gustav Delius {@link http://maths.york.ac.uk/serving_maths} - * - * @package core - * @subpackage question - * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @package moodlecore + * @subpackage questionbank + * @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ + +require_once($CFG->dirroot . '/question/engine/lib.php'); +require_once($CFG->dirroot . '/question/type/questiontype.php'); + + defined('MOODLE_INTERNAL') || die(); /// CONSTANTS /////////////////////////////////// -/**#@+ - * The different types of events that can create question states - */ -define('QUESTION_EVENTOPEN', '0'); // The state was created by Moodle -define('QUESTION_EVENTNAVIGATE', '1'); // The responses were saved because the student navigated to another page (this is not currently used) -define('QUESTION_EVENTSAVE', '2'); // The student has requested that the responses should be saved but not submitted or validated -define('QUESTION_EVENTGRADE', '3'); // Moodle has graded the responses. A SUBMIT event can be changed to a GRADE event by Moodle. -define('QUESTION_EVENTDUPLICATE', '4'); // The responses submitted were the same as previously -define('QUESTION_EVENTVALIDATE', '5'); // The student has requested a validation. This causes the responses to be saved as well, but not graded. -define('QUESTION_EVENTCLOSEANDGRADE', '6'); // Moodle has graded the responses. A CLOSE event can be changed to a CLOSEANDGRADE event by Moodle. -define('QUESTION_EVENTSUBMIT', '7'); // The student response has been submitted but it has not yet been marked -define('QUESTION_EVENTCLOSE', '8'); // The response has been submitted and the session has been closed, either because the student requested it or because Moodle did it (e.g. because of a timelimit). The responses have not been graded. -define('QUESTION_EVENTMANUALGRADE', '9'); // Grade was entered by teacher - -define('QUESTION_EVENTS_GRADED', QUESTION_EVENTGRADE.','. - QUESTION_EVENTCLOSEANDGRADE.','. - QUESTION_EVENTMANUALGRADE); - - -define('QUESTION_EVENTS_CLOSED', QUESTION_EVENTCLOSE.','. - QUESTION_EVENTCLOSEANDGRADE.','. - QUESTION_EVENTMANUALGRADE); - -define('QUESTION_EVENTS_CLOSED_OR_GRADED', QUESTION_EVENTGRADE.','. - QUESTION_EVENTS_CLOSED); - -/**#@-*/ - /**#@+ * The core question types. */ @@ -87,7 +61,7 @@ define("ESSAY", "essay"); * Constant determines the number of answer boxes supplied in the editing * form for multiple choice and similar question types. */ -define("QUESTION_NUMANS", "10"); +define("QUESTION_NUMANS", 10); /** * Constant determines the number of answer boxes supplied in the editing @@ -106,66 +80,21 @@ define("QUESTION_NUMANS_ADD", 3); /** * The options used when popping up a question preview window in Javascript. */ -define('QUESTION_PREVIEW_POPUP_OPTIONS', 'scrollbars=yes&resizable=yes&width=700&height=540'); - -/**#@+ - * Option flags for ->optionflags - * The options are read out via bitwise operation using these constants - */ -/** - * Whether the questions is to be run in adaptive mode. If this is not set then - * a question closes immediately after the first submission of responses. This - * is how question is Moodle always worked before version 1.5 - */ -define('QUESTION_ADAPTIVE', 1); -/**#@-*/ - -/**#@+ - * Options for whether flags are shown/editable when rendering questions. - */ -define('QUESTION_FLAGSHIDDEN', 0); -define('QUESTION_FLAGSSHOWN', 1); -define('QUESTION_FLAGSEDITABLE', 2); -/**#@-*/ +define('QUESTION_PREVIEW_POPUP_OPTIONS', 'scrollbars=yes,resizable=yes,width=800,height=600'); /** - * GLOBAL VARAIBLES - * @global array $QTYPES - * @name $QTYPES + * @global array holding question type objects + * @deprecated */ global $QTYPES; -/** - * Array holding question type objects. Initialised via calls to - * question_register_questiontype as the question type classes are included. - */ -$QTYPES = array(); - -/** - * Add a new question type to the various global arrays above. - * - * @global object - * @param object $qtype An instance of the new question type class. - */ -function question_register_questiontype($qtype) { - global $QTYPES; - - $name = $qtype->name(); - $QTYPES[$name] = $qtype; +$QTYPES = question_bank::get_all_qtypes(); +function question_register_questiontype() { + // TODO kill this. } - -require_once("$CFG->dirroot/question/type/questiontype.php"); - -// Load the questiontype.php file for each question type -// These files in turn call question_register_questiontype() -// with a new instance of each qtype class. -$qtypenames = get_plugin_list('qtype'); -foreach($qtypenames as $qtypename => $qdir) { - // Instanciates all plug-in question types - $qtypefilepath= "$qdir/questiontype.php"; - - // echo "Loading $qtypename
"; // Uncomment for debugging - if (is_readable($qtypefilepath)) { - require_once($qtypefilepath); +// TODO kill this. +class default_questiontype { + function plugin_dir() { + return ''; } } @@ -177,17 +106,14 @@ foreach($qtypenames as $qtypename => $qdir) { * The array returned will only hold the names of all the question types that the user should * be able to create directly. Some internal question types like random questions are excluded. * - * @global object * @return array an array of question type names translated to the user's language. */ function question_type_menu() { - global $QTYPES; static $menuoptions = null; if (is_null($menuoptions)) { $config = get_config('question'); $menuoptions = array(); - foreach ($QTYPES as $name => $qtype) { - // Get the name if this qtype is enabled. + foreach (question_bank::get_all_qtypes() as $name => $qtype) { $menuname = $qtype->menu_name(); $enabledvar = $name . '_disabled'; if ($menuname && !isset($config->$enabledvar)) { @@ -282,93 +208,58 @@ function question_save_qtype_order($neworder, $config = null) { } } -/// OTHER CLASSES ///////////////////////////////////////////////////////// - -/** - * This holds the options that are set by the course module - * - * @package moodlecore - * @subpackage question - * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ -class cmoptions { - /** - * Whether a new attempt should be based on the previous one. If true - * then a new attempt will start in a state where all responses are set - * to the last responses from the previous attempt. - */ - var $attemptonlast = false; - - /** - * Various option flags. The flags are accessed via bitwise operations - * using the constants defined in the CONSTANTS section above. - */ - var $optionflags = QUESTION_ADAPTIVE; - - /** - * Determines whether in the calculation of the score for a question - * penalties for earlier wrong responses within the same attempt will - * be subtracted. - */ - var $penaltyscheme = true; - - /** - * The maximum time the user is allowed to answer the questions withing - * an attempt. This is measured in minutes so needs to be multiplied by - * 60 before compared to timestamps. If set to 0 no timelimit will be applied - */ - var $timelimit = 0; - - /** - * Timestamp for the closing time. Responses submitted after this time will - * be saved but no credit will be given for them. - */ - var $timeclose = 9999999999; - - /** - * The id of the course from withing which the question is currently being used - */ - var $course = SITEID; - - /** - * Whether the answers in a multiple choice question should be randomly - * shuffled when a new attempt is started. - */ - var $shuffleanswers = true; - - /** - * The number of decimals to be shown when scores are printed - */ - var $decimalpoints = 2; -} - - /// FUNCTIONS ////////////////////////////////////////////////////// /** * Returns an array of names of activity modules that use this question * - * @global object - * @global object + * @deprecated since Moodle 2.1. Use {@link questions_in_use} instead. + * @param object $questionid * @return array of strings */ function question_list_instances($questionid) { - global $CFG, $DB; - $instances = array(); - $modules = $DB->get_records('modules'); - foreach ($modules as $module) { - $fullmod = $CFG->dirroot . '/mod/' . $module->name; - if (file_exists($fullmod . '/lib.php')) { - include_once($fullmod . '/lib.php'); - $fn = $module->name.'_question_list_instances'; + throw new coding_exception('question_list_instances has been deprectated. Please use questions_in_use instead.'); +} + +/** + * @param array $questionids of question ids. + * @return boolean whether any of these questions are being used by any part of Moodle. + */ +function questions_in_use($questionids) { + global $CFG; + + if (question_engine::questions_in_use($questionids)) { + return true; + } + + foreach (get_plugin_list('mod') as $module => $path) { + $lib = $path . '/lib.php'; + if (is_readable($lib)) { + include_once($lib); + + $fn = $module . '_questions_in_use'; if (function_exists($fn)) { - $instances = $instances + $fn($questionid); + if ($fn($questionids)) { + return true; + } + } else { + + // Fallback for legacy modules. + $fn = $module . '_question_list_instances'; + if (function_exists($fn)) { + foreach ($questionids as $questionid) { + $instances = $fn($questionid); + if (!empty($instances)) { + return true; + } + } + } } } } - return $instances; + + return false; } /** @@ -376,7 +267,6 @@ function question_list_instances($questionid) { * question categories contain any questions. This will return true even if all the questions are * hidden. * - * @global object * @param mixed $context either a context object, or a context id. * @return boolean whether any of the question categories beloning to this context have * any questions in them. @@ -435,12 +325,11 @@ function get_grade_options() { $gradeoptions = array(); foreach ($grades as $grade) { $percentage = 100 * $grade; - $neggrade = -$grade; - $gradeoptions["$grade"] = "$percentage %"; - $gradeoptionsfull["$grade"] = "$percentage %"; - $gradeoptionsfull["$neggrade"] = -$percentage." %"; + $gradeoptions["$grade"] = $percentage . '%'; + $gradeoptionsfull["$grade"] = $percentage . '%'; + $gradeoptionsfull['' . (-$grade)] = (-$percentage) . '%'; } - $gradeoptionsfull["0"] = $gradeoptions["0"] = get_string("none"); + $gradeoptionsfull['0'] = $gradeoptions['0'] = get_string('none'); // sort lists arsort($gradeoptions, SORT_NUMERIC); @@ -494,32 +383,40 @@ function match_grade_options($gradeoptionsfull, $grade, $matchgrades='error') { } /** - * Tests whether a category is in use by any activity module - * - * @global object - * @return boolean - * @param integer $categoryid - * @param boolean $recursive Whether to examine category children recursively + * @deprecated Since Moodle 2.1. Use {@link question_category_in_use} instead. + * @param integer $categoryid a question category id. + * @param boolean $recursive whether to check child categories too. + * @return boolean whether any question in this category is in use. */ function question_category_isused($categoryid, $recursive = false) { + throw new coding_exception('question_category_isused has been deprectated. Please use question_category_in_use instead.'); +} + +/** + * Tests whether any question in a category is used by any part of Moodle. + * + * @param integer $categoryid a question category id. + * @param boolean $recursive whether to check child categories too. + * @return boolean whether any question in this category is in use. + */ +function question_category_in_use($categoryid, $recursive = false) { global $DB; //Look at each question in the category - if ($questions = $DB->get_records('question', array('category'=>$categoryid), '', 'id,qtype')) { - foreach ($questions as $question) { - if (count(question_list_instances($question->id))) { - return true; - } + if ($questions = $DB->get_records_menu('question', array('category' => $categoryid), '', 'id,1')) { + if (questions_in_use(array_keys($questions))) { + return true; } } + if (!$recursive) { + return false; + } //Look under child categories recursively - if ($recursive) { - if ($children = $DB->get_records('question_categories', array('parent'=>$categoryid))) { - foreach ($children as $child) { - if (question_category_isused($child->id, $recursive)) { - return true; - } + if ($children = $DB->get_records('question_categories', array('parent' => $categoryid), '', 'id,1')) { + foreach ($children as $child) { + if (question_category_in_use($child->id, $recursive)) { + return true; } } } @@ -527,43 +424,13 @@ function question_category_isused($categoryid, $recursive = false) { return false; } -/** - * Deletes all data associated to an attempt from the database - * - * @global object - * @global object - * @param integer $attemptid The id of the attempt being deleted - */ -function delete_attempt($attemptid) { - global $QTYPES, $DB; - - $states = $DB->get_records('question_states', array('attempt'=>$attemptid)); - if ($states) { - $stateslist = implode(',', array_keys($states)); - - // delete question-type specific data - foreach ($QTYPES as $qtype) { - $qtype->delete_states($stateslist); - } - } - - // delete entries from all other question tables - // It is important that this is done only after calling the questiontype functions - $DB->delete_records("question_states", array("attempt"=>$attemptid)); - $DB->delete_records("question_sessions", array("attemptid"=>$attemptid)); - $DB->delete_records("question_attempts", array("id"=>$attemptid)); -} - /** * Deletes question and all associated data from the database * * It will not delete a question if it is used by an activity module - * - * @global object - * @global object * @param object $question The question being deleted */ -function delete_question($questionid) { +function question_delete_question($questionid) { global $QTYPES, $DB; $question = $DB->get_record_sql(' @@ -579,30 +446,19 @@ function delete_question($questionid) { } // Do not delete a question if it is used by an activity module - if (count(question_list_instances($questionid))) { + if (questions_in_use(array($questionid))) { return; } - // delete questiontype-specific data + // Check permissions. question_require_capability_on($question, 'edit'); - if (isset($QTYPES[$question->qtype])) { - $QTYPES[$question->qtype]->delete_question($questionid, $question->contextid); - } - if ($states = $DB->get_records('question_states', array('question'=>$questionid))) { - $stateslist = implode(',', array_keys($states)); + $dm = new question_engine_data_mapper(); + $dm->delete_previews($questionid); - // delete questiontype-specific data - foreach ($QTYPES as $qtype) { - $qtype->delete_states($stateslist); - } - } - - // Delete entries from all other question tables - // It is important that this is done only after calling the questiontype functions - $DB->delete_records('question_answers', array('question' => $questionid)); - $DB->delete_records('question_states', array('question' => $questionid)); - $DB->delete_records('question_sessions', array('questionid' => $questionid)); + // delete questiontype-specific data + question_bank::get_qtype($question->qtype, false)->delete_question( + $questionid, $question->contextid); // Now recursively delete all child questions if ($children = $DB->get_records('question', array('parent' => $questionid), '', 'id,qtype')) { @@ -614,13 +470,12 @@ function delete_question($questionid) { } // Finally delete the question record itself - $DB->delete_records('question', array('id'=>$questionid)); + $DB->delete_records('question', array('id' => $questionid)); } /** * All question categories and their questions are deleted for this course. * - * @global object * @param object $mod an object representing the activity * @param boolean $feedback to specify if the process must output a summary of its work * @return boolean @@ -674,7 +529,6 @@ function question_delete_course($course, $feedback=true) { * 1/ All question categories and their questions are deleted for this course category. * 2/ All questions are moved to new category * - * @global object * @param object $category course category object * @param object $newcategory empty means everything deleted, otherwise id of category where content moved * @param boolean $feedback to specify if the process must output a summary of its work @@ -752,7 +606,6 @@ function question_delete_course_category($category, $newcategory, $feedback=true /** * Enter description here... * - * @global object * @param string $questionids list of questionids * @param object $newcontext the context to create the saved category in. * @param string $oldplace a textual description of the think being deleted, e.g. from get_context_name @@ -784,7 +637,6 @@ function question_save_from_deletion($questionids, $newcontextid, $oldplace, $ne /** * All question categories and their questions are deleted for this activity. * - * @global object * @param object $cm the course module object representing the activity * @param boolean $feedback to specify if the process must output a summary of its work * @return boolean @@ -836,7 +688,6 @@ function question_delete_activity($cm, $feedback=true) { * acutally moving questions and associated data. However, callers of this function also have to * do other work, which is why you should not call this method directly from outside the questionbank. * - * @global object * @param string $questionids a comma-separated list of question ids. * @param integer $newcategoryid the id of the category to move to. */ @@ -894,6 +745,28 @@ function question_move_category_to_context($categoryid, $oldcontextid, $newconte } } +/** + * Generate the URL for starting a new preview of a given question with the given options. + * @param integer $questionid the question to preview. + * @param string $preferredbehaviour the behaviour to use for the preview. + * @param float $maxmark the maximum to mark the question out of. + * @param question_display_options $displayoptions the display options to use. + * @return string the URL. + */ +function question_preview_url($questionid, $preferredbehaviour, $maxmark, $displayoptions) { + return new moodle_url('/question/preview.php', array( + 'id' => $questionid, + 'behaviour' => $preferredbehaviour, + 'maxmark' => $maxmark, + 'correctness' => $displayoptions->correctness, + 'marks' => $displayoptions->marks, + 'markdp' => $displayoptions->markdp, + 'feedback' => (bool) $displayoptions->feedback, + 'generalfeedback' => (bool) $displayoptions->generalfeedback, + 'rightanswer' => (bool) $displayoptions->rightanswer, + 'history' => (bool) $displayoptions->history)); +} + /** * Given a list of ids, load the basic information about a set of questions from the questions table. * The $join and $extrafields arguments can be used together to pull in extra data. @@ -912,7 +785,7 @@ function question_move_category_to_context($categoryid, $oldcontextid, $newconte * on them before they can be properly used. */ function question_preload_questions($questionids, $extrafields = '', $join = '', $extraparams = array()) { - global $CFG, $DB; + global $DB; if (empty($questionids)) { return array(); } @@ -929,7 +802,7 @@ function question_preload_questions($questionids, $extrafields = '', $join = '', // Load the questions if (!$questions = $DB->get_records_sql($sql, $extraparams + $params)) { - return 'Could not load questions.'; + return array(); } foreach ($questions as $question) { @@ -972,8 +845,6 @@ function question_load_questions($questionids, $extrafields = '', $join = '') { /** * Private function to factor common code out of get_question_options(). * - * @global object - * @global object * @param object $question the question to tidy. * @param boolean $loadtags load the question tags from the tags table. Optional, default false. * @return boolean true if successful, else false. @@ -984,7 +855,6 @@ function _tidy_question(&$question, $loadtags = false) { $question->qtype = 'missingtype'; $question->questiontext = '

' . get_string('warningmissingtype', 'quiz') . '

' . $question->questiontext; } - $question->name_prefix = question_make_name_prefix($question->id); if ($success = $QTYPES[$question->qtype]->get_question_options($question)) { if (isset($question->_partiallyloaded)) { unset($question->_partiallyloaded); @@ -1022,1022 +892,24 @@ function get_question_options(&$questions, $loadtags = false) { } } -/** - * Load the basic state information for - * - * @global object - * @param integer $attemptid the attempt id to load the states for. - * @return array an array of state data from the database, you will subsequently - * need to call question_load_states to get fully loaded states that can be - * used by the question types. The states here should be sufficient for - * basic tasks like rendering navigation. - */ -function question_preload_states($attemptid) { - global $DB; - // Note, changes here probably also need to be reflected in - // regrade_question_in_attempt and question_load_specific_state. - - // The questionid field must be listed first so that it is used as the - // array index in the array returned by $DB->get_records_sql - $statefields = 'n.questionid as question, s.id, s.attempt, ' . - 's.seq_number, s.answer, s.timestamp, s.event, s.grade, s.raw_grade, ' . - 's.penalty, n.sumpenalty, n.manualcomment, n.manualcommentformat, ' . - 'n.flagged, n.id as questionsessionid'; - - // Load the newest states for the questions - $sql = "SELECT $statefields - FROM {question_states} s, {question_sessions} n - WHERE s.id = n.newest AND n.attemptid = ?"; - $states = $DB->get_records_sql($sql, array($attemptid)); - if (!$states) { - return false; - } - - // Load the newest graded states for the questions - $sql = "SELECT $statefields - FROM {question_states} s, {question_sessions} n - WHERE s.id = n.newgraded AND n.attemptid = ?"; - $gradedstates = $DB->get_records_sql($sql, array($attemptid)); - - // Hook the two together. - foreach ($states as $questionid => $state) { - $states[$questionid]->_partiallyloaded = true; - if ($gradedstates[$questionid]) { - $states[$questionid]->last_graded = $gradedstates[$questionid]; - $states[$questionid]->last_graded->_partiallyloaded = true; - } - } - - return $states; -} - -/** - * Finish loading the question states that were extracted from the database with - * question_preload_states, creating new states for any question where there - * is not a state in the database. - * - * @global object - * @global object - * @param array $questions the questions to load state for. - * @param array $states the partially loaded states this array is updated. - * @param object $cmoptions options from the module we are loading the states for. E.g. $quiz. - * @param object $attempt The attempt for which the question sessions are - * to be restored or created. - * @param mixed either the id of a previous attempt, if this attmpt is - * building on a previous one, or false for a clean attempt. - * @return true or false for success or failure. - */ -function question_load_states(&$questions, &$states, $cmoptions, $attempt, $lastattemptid = false) { - global $QTYPES, $DB; - - // loop through all questions and set the last_graded states - foreach (array_keys($questions) as $qid) { - if (isset($states[$qid])) { - restore_question_state($questions[$qid], $states[$qid]); - if (isset($states[$qid]->_partiallyloaded)) { - unset($states[$qid]->_partiallyloaded); - } - if (isset($states[$qid]->last_graded)) { - restore_question_state($questions[$qid], $states[$qid]->last_graded); - if (isset($states[$qid]->last_graded->_partiallyloaded)) { - unset($states[$qid]->last_graded->_partiallyloaded); - } - } else { - $states[$qid]->last_graded = clone($states[$qid]); - } - } else { - - if ($lastattemptid) { - // If the new attempt is to be based on this previous attempt. - // Find the responses from the previous attempt and save them to the new session - - // Load the last graded state for the question. Note, $statefields is - // the same as above, except that we don't want n.manualcomment. - $statefields = 'n.questionid as question, s.id, s.attempt, ' . - 's.seq_number, s.answer, s.timestamp, s.event, s.grade, s.raw_grade, ' . - 's.penalty, n.sumpenalty'; - $sql = "SELECT $statefields - FROM {question_states} s, {question_sessions} n - WHERE s.id = n.newest - AND n.attemptid = ? - AND n.questionid = ?"; - if (!$laststate = $DB->get_record_sql($sql, array($lastattemptid, $qid))) { - // Only restore previous responses that have been graded - continue; - } - // Restore the state so that the responses will be restored - restore_question_state($questions[$qid], $laststate); - $states[$qid] = clone($laststate); - unset($states[$qid]->id); - } else { - // create a new empty state - $states[$qid] = new stdClass(); - $states[$qid]->question = $qid; - $states[$qid]->responses = array('' => ''); - $states[$qid]->raw_grade = 0; - } - - // now fill/overide initial values - $states[$qid]->attempt = $attempt->uniqueid; - $states[$qid]->seq_number = 0; - $states[$qid]->timestamp = $attempt->timestart; - $states[$qid]->event = ($attempt->timefinish) ? QUESTION_EVENTCLOSE : QUESTION_EVENTOPEN; - $states[$qid]->grade = 0; - $states[$qid]->penalty = 0; - $states[$qid]->sumpenalty = 0; - $states[$qid]->manualcomment = ''; - $states[$qid]->manualcommentformat = FORMAT_HTML; - $states[$qid]->flagged = 0; - - // Prevent further changes to the session from incrementing the - // sequence number - $states[$qid]->changed = true; - - if ($lastattemptid) { - // prepare the previous responses for new processing - $action = new stdClass; - $action->responses = $laststate->responses; - $action->timestamp = $laststate->timestamp; - $action->event = QUESTION_EVENTSAVE; //emulate save of questions from all pages MDL-7631 - - // Process these responses ... - question_process_responses($questions[$qid], $states[$qid], $action, $cmoptions, $attempt); - - // Fix for Bug #5506: When each attempt is built on the last one, - // preserve the options from any previous attempt. - if ( isset($laststate->options) ) { - $states[$qid]->options = $laststate->options; - } - } else { - // Create the empty question type specific information - if (!$QTYPES[$questions[$qid]->qtype]->create_session_and_responses( - $questions[$qid], $states[$qid], $cmoptions, $attempt)) { - return false; - } - } - $states[$qid]->last_graded = clone($states[$qid]); - } - } - return true; -} - -/** -* Loads the most recent state of each question session from the database -* or create new one. -* -* For each question the most recent session state for the current attempt -* is loaded from the question_states table and the question type specific data and -* responses are added by calling {@link restore_question_state()} which in turn -* calls {@link restore_session_and_responses()} for each question. -* If no states exist for the question instance an empty state object is -* created representing the start of a session and empty question -* type specific information and responses are created by calling -* {@link create_session_and_responses()}. -* -* @return array An array of state objects representing the most recent -* states of the question sessions. -* @param array $questions The questions for which sessions are to be restored or -* created. -* @param object $cmoptions -* @param object $attempt The attempt for which the question sessions are -* to be restored or created. -* @param mixed either the id of a previous attempt, if this attmpt is -* building on a previous one, or false for a clean attempt. -*/ -function get_question_states(&$questions, $cmoptions, $attempt, $lastattemptid = false) { - // Preload the states. - $states = question_preload_states($attempt->uniqueid); - if (!$states) { - $states = array(); - } - - // Then finish the job. - if (!question_load_states($questions, $states, $cmoptions, $attempt, $lastattemptid)) { - return false; - } - - return $states; -} - -/** - * Load a particular previous state of a question. - * - * @global object - * @param array $question The question to load the state for. - * @param object $cmoptions Options from the specifica activity module, e.g. $quiz. - * @param object $attempt The attempt for which the question sessions are to be loaded. - * @param integer $stateid The id of a specific state of this question. - * @return object the requested state. False on error. - */ -function question_load_specific_state($question, $cmoptions, $attempt, $stateid) { - global $DB; - // Load specified states for the question. - // sess.sumpenalty is probably wrong here shoul really be a sum of penalties from before the one we are asking for. - $sql = 'SELECT st.*, sess.sumpenalty, sess.manualcomment, sess.manualcommentformat, - sess.flagged, sess.id as questionsessionid - FROM {question_states} st, {question_sessions} sess - WHERE st.id = ? - AND st.attempt = ? - AND sess.attemptid = st.attempt - AND st.question = ? - AND sess.questionid = st.question'; - $state = $DB->get_record_sql($sql, array($stateid, $attempt->id, $question->id)); - if (!$state) { - return false; - } - restore_question_state($question, $state); - - // Load the most recent graded states for the questions before the specified one. - $sql = 'SELECT st.*, sess.sumpenalty, sess.manualcomment, sess.manualcommentformat, - sess.flagged, sess.id as questionsessionid - FROM {question_states} st, {question_sessions} sess - WHERE st.seq_number <= ? - AND st.attempt = ? - AND sess.attemptid = st.attempt - AND st.question = ? - AND sess.questionid = st.question - AND st.event IN ('.QUESTION_EVENTS_GRADED.') '. - 'ORDER BY st.seq_number DESC'; - $gradedstates = $DB->get_records_sql($sql, array($state->seq_number, $attempt->id, $question->id), 0, 1); - if (empty($gradedstates)) { - $state->last_graded = clone($state); - } else { - $gradedstate = reset($gradedstates); - restore_question_state($question, $gradedstate); - $state->last_graded = $gradedstate; - } - return $state; -} - -/** -* Creates the run-time fields for the states -* -* Extends the state objects for a question by calling -* {@link restore_session_and_responses()} - * - * @global object -* @param object $question The question for which the state is needed -* @param object $state The state as loaded from the database -* @return boolean Represents success or failure -*/ -function restore_question_state(&$question, &$state) { - global $QTYPES; - - // initialise response to the value in the answer field - $state->responses = array('' => $state->answer); - - // Set the changed field to false; any code which changes the - // question session must set this to true and must increment - // ->seq_number. The save_question_session - // function will save the new state object to the database if the field is - // set to true. - $state->changed = false; - - // Load the question type specific data - return $QTYPES[$question->qtype]->restore_session_and_responses($question, $state); - -} - -/** -* Saves the current state of the question session to the database -* -* The state object representing the current state of the session for the -* question is saved to the question_states table with ->responses[''] saved -* to the answer field of the database table. The information in the -* question_sessions table is updated. -* The question type specific data is then saved. - * - * @global array - * @global object -* @return mixed The id of the saved or updated state or false -* @param object $question The question for which session is to be saved. -* @param object $state The state information to be saved. In particular the -* most recent responses are in ->responses. The object -* is updated to hold the new ->id. -*/ -function save_question_session($question, $state) { - global $QTYPES, $DB; - - // Check if the state has changed - if (!$state->changed && isset($state->id)) { - if (isset($state->newflaggedstate) && $state->flagged != $state->newflaggedstate) { - // If this fails, don't worry too much, it is not critical data. - question_update_flag($state->questionsessionid, $state->newflaggedstate); - } - return $state->id; - } - // Set the legacy answer field - $state->answer = isset($state->responses['']) ? $state->responses[''] : ''; - - // Save the state - if (!empty($state->update)) { // this forces the old state record to be overwritten - $DB->update_record('question_states', $state); - } else { - $state->id = $DB->insert_record('question_states', $state); - } - - // create or update the session - if (!$session = $DB->get_record('question_sessions', array('attemptid' => $state->attempt, 'questionid' => $question->id))) { - $session = new stdClass; - $session->attemptid = $state->attempt; - $session->questionid = $question->id; - $session->newest = $state->id; - // The following may seem weird, but the newgraded field needs to be set - // already even if there is no graded state yet. - $session->newgraded = $state->id; - $session->sumpenalty = $state->sumpenalty; - $session->manualcomment = $state->manualcomment; - $session->manualcommentformat = $state->manualcommentformat; - $session->flagged = !empty($state->newflaggedstate); - $DB->insert_record('question_sessions', $session); - } else { - $session->newest = $state->id; - if (question_state_is_graded($state) or $state->event == QUESTION_EVENTOPEN) { - // this state is graded or newly opened, so it goes into the lastgraded field as well - $session->newgraded = $state->id; - $session->sumpenalty = $state->sumpenalty; - $session->manualcomment = $state->manualcomment; - $session->manualcommentformat = $state->manualcommentformat; - } - $session->flagged = !empty($state->newflaggedstate); - $DB->update_record('question_sessions', $session); - } - - unset($state->answer); - - // Save the question type specific state information and responses - if (!$QTYPES[$question->qtype]->save_session_and_responses($question, $state)) { - return false; - } - - // Reset the changed flag - $state->changed = false; - return $state->id; -} - -/** -* Determines whether a state has been graded by looking at the event field -* -* @return boolean true if the state has been graded -* @param object $state -*/ -function question_state_is_graded($state) { - static $question_events_graded = array(); - if (!$question_events_graded){ - $question_events_graded = explode(',', QUESTION_EVENTS_GRADED); - } - return (in_array($state->event, $question_events_graded)); -} - -/** -* Determines whether a state has been closed by looking at the event field -* -* @return boolean true if the state has been closed -* @param object $state -*/ -function question_state_is_closed($state) { - static $question_events_closed = array(); - if (!$question_events_closed){ - $question_events_closed = explode(',', QUESTION_EVENTS_CLOSED); - } - return (in_array($state->event, $question_events_closed)); -} - - -/** - * Extracts responses from submitted form - * - * This can extract the responses given to one or several questions present on a page - * It returns an array with one entry for each question, indexed by question id - * Each entry is an object with the properties - * ->event The event that has triggered the submission. This is determined by which button - * the user has pressed. - * ->responses An array holding the responses to an individual question, indexed by the - * name of the corresponding form element. - * ->timestamp A unix timestamp - * @return array array of action objects, indexed by question ids. - * @param array $questions an array containing at least all questions that are used on the form - * @param array $formdata the data submitted by the form on the question page - * @param integer $defaultevent the event type used if no 'mark' or 'validate' is submitted - */ -function question_extract_responses($questions, $formdata, $defaultevent=QUESTION_EVENTSAVE) { - - $time = time(); - $actions = array(); - foreach ($formdata as $key => $response) { - // Get the question id from the response name - if (false !== ($quid = question_get_id_from_name_prefix($key))) { - // check if this is a valid id - if (!isset($questions[$quid])) { - print_error('formquestionnotinids', 'question'); - } - - // Remove the name prefix from the name - //decrypt trying - $key = substr($key, strlen($questions[$quid]->name_prefix)); - if (false === $key) { - $key = ''; - } - // Check for question validate and mark buttons & set events - if ($key === 'validate') { - $actions[$quid]->event = QUESTION_EVENTVALIDATE; - } else if ($key === 'submit') { - $actions[$quid]->event = QUESTION_EVENTSUBMIT; - } else { - $actions[$quid]->event = $defaultevent; - } - // Update the state with the new response - $actions[$quid]->responses[$key] = $response; - - // Set the timestamp - $actions[$quid]->timestamp = $time; - } - } - foreach ($actions as $quid => $notused) { - ksort($actions[$quid]->responses); - } - return $actions; -} - - -/** - * Returns the html for question feedback image. - * - * @global object - * @param float $fraction value representing the correctness of the user's - * response to a question. - * @param boolean $selected whether or not the answer is the one that the - * user picked. - * @return string - */ -function question_get_feedback_image($fraction, $selected=true) { - global $CFG, $OUTPUT; - static $icons = array('correct' => 'tick_green', 'partiallycorrect' => 'tick_amber', - 'incorrect' => 'cross_red'); - - if ($selected) { - $size = 'big'; - } else { - $size = 'small'; - } - $class = question_get_feedback_class($fraction); - return '' . get_string($class, 'quiz') . ''; -} - -/** - * Returns the class name for question feedback. - * @param float $fraction value representing the correctness of the user's - * response to a question. - * @return string - */ -function question_get_feedback_class($fraction) { - if ($fraction >= 1/1.01) { - return 'correct'; - } else if ($fraction > 0.0) { - return 'partiallycorrect'; - } else { - return 'incorrect'; - } -} - - -/** -* For a given question in an attempt we walk the complete history of states -* and recalculate the grades as we go along. -* -* This is used when a question is changed and old student -* responses need to be marked with the new version of a question. -* -* @todo Make sure this is not quiz-specific -* - * @global object -* @return boolean Indicates whether the grade has changed -* @param object $question A question object -* @param object $attempt The attempt, in which the question needs to be regraded. -* @param object $cmoptions -* @param boolean $verbose Optional. Whether to print progress information or not. -* @param boolean $dryrun Optional. Whether to make changes to grades records -* or record that changes need to be made for a later regrade. -*/ -function regrade_question_in_attempt($question, $attempt, $cmoptions, $verbose=false, $dryrun=false) { - global $DB, $OUTPUT; - - // load all states for this question in this attempt, ordered in sequence - if ($states = $DB->get_records('question_states', - array('attempt'=>$attempt->uniqueid, 'question'=>$question->id), - 'seq_number ASC')) { - $states = array_values($states); - - // Subtract the grade for the latest state from $attempt->sumgrades to get the - // sumgrades for the attempt without this question. - $attempt->sumgrades -= $states[count($states)-1]->grade; - - // Initialise the replaystate - $replaystate = question_load_specific_state($question, $cmoptions, $attempt, $states[0]->id); - $replaystate->sumpenalty = 0; - $replaystate->last_graded->sumpenalty = 0; - - $changed = false; - for($j = 1; $j < count($states); $j++) { - restore_question_state($question, $states[$j]); - $action = new stdClass; - $action->responses = $states[$j]->responses; - $action->timestamp = $states[$j]->timestamp; - - // Change event to submit so that it will be reprocessed - if (in_array($states[$j]->event, array(QUESTION_EVENTCLOSE, - QUESTION_EVENTGRADE, QUESTION_EVENTCLOSEANDGRADE))) { - $action->event = QUESTION_EVENTSUBMIT; - - // By default take the event that was saved in the database - } else { - $action->event = $states[$j]->event; - } - - if ($action->event == QUESTION_EVENTMANUALGRADE) { - // Ensure that the grade is in range - in the past this was not checked, - // but now it is (MDL-14835) - so we need to ensure the data is valid before - // proceeding. - if ($states[$j]->grade < 0) { - $states[$j]->grade = 0; - $changed = true; - } else if ($states[$j]->grade > $question->maxgrade) { - $states[$j]->grade = $question->maxgrade; - $changed = true; - - } - if (!$dryrun){ - $error = question_process_comment($question, $replaystate, $attempt, - $replaystate->manualcomment, $replaystate->manualcommentformat, $states[$j]->grade); - if (is_string($error)) { - echo $OUTPUT->notification($error); - } - } else { - $replaystate->grade = $states[$j]->grade; - } - } else { - // Reprocess (regrade) responses - if (!question_process_responses($question, $replaystate, - $action, $cmoptions, $attempt) && $verbose) { - $a = new stdClass; - $a->qid = $question->id; - $a->stateid = $states[$j]->id; - echo $OUTPUT->notification(get_string('errorduringregrade', 'question', $a)); - } - // We need rounding here because grades in the DB get truncated - // e.g. 0.33333 != 0.3333333, but we want them to be equal here - if ((round((float)$replaystate->raw_grade, 5) != round((float)$states[$j]->raw_grade, 5)) - or (round((float)$replaystate->penalty, 5) != round((float)$states[$j]->penalty, 5)) - or (round((float)$replaystate->grade, 5) != round((float)$states[$j]->grade, 5))) { - $changed = true; - } - // If this was previously a closed state, and it has been knoced back to - // graded, then fix up the state again. - if ($replaystate->event == QUESTION_EVENTGRADE && - ($states[$j]->event == QUESTION_EVENTCLOSE || - $states[$j]->event == QUESTION_EVENTCLOSEANDGRADE)) { - $replaystate->event = $states[$j]->event; - } - } - - $replaystate->id = $states[$j]->id; - $replaystate->changed = true; - $replaystate->update = true; // This will ensure that the existing database entry is updated rather than a new one created - if (!$dryrun){ - save_question_session($question, $replaystate); - } - } - if ($changed) { - if (!$dryrun){ - // TODO, call a method in quiz to do this, where 'quiz' comes from - // the question_attempts table. - $DB->update_record('quiz_attempts', $attempt); - } - } - if ($changed){ - $toinsert = new stdClass(); - $toinsert->oldgrade = round((float)$states[count($states)-1]->grade, 5); - $toinsert->newgrade = round((float)$replaystate->grade, 5); - $toinsert->attemptid = $attempt->uniqueid; - $toinsert->questionid = $question->id; - //the grade saved is the old grade if the new grade is saved - //it is the new grade if this is a dry run. - $toinsert->regraded = $dryrun?0:1; - $toinsert->timemodified = time(); - $DB->insert_record('quiz_question_regrade', $toinsert); - return true; - } else { - return false; - } - } - return false; -} - -/** -* Processes an array of student responses, grading and saving them as appropriate -* - * @global array -* @param object $question Full question object, passed by reference -* @param object $state Full state object, passed by reference -* @param object $action object with the fields ->responses which -* is an array holding the student responses, -* ->action which specifies the action, e.g., QUESTION_EVENTGRADE, -* and ->timestamp which is a timestamp from when the responses -* were submitted by the student. -* @param object $cmoptions -* @param object $attempt The attempt is passed by reference so that -* during grading its ->sumgrades field can be updated -* @return boolean Indicates success/failure -*/ -function question_process_responses($question, &$state, $action, $cmoptions, &$attempt) { - global $QTYPES; - - // if no responses are set initialise to empty response - if (!isset($action->responses)) { - $action->responses = array('' => ''); - } - - $state->newflaggedstate = !empty($action->responses['_flagged']); - - // make sure these are gone! - unset($action->responses['submit'], $action->responses['validate'], $action->responses['_flagged']); - - // Check the question session is still open - if (question_state_is_closed($state)) { - return true; - } - - // If $action->event is not set that implies saving - if (! isset($action->event)) { - debugging('Ambiguous action in question_process_responses.' , DEBUG_DEVELOPER); - $action->event = QUESTION_EVENTSAVE; - } - // If submitted then compare against last graded - // responses, not last given responses in this case - if (question_isgradingevent($action->event)) { - $state->responses = $state->last_graded->responses; - } - - // Check for unchanged responses (exactly unchanged, not equivalent). - // We also have to catch questions that the student has not yet attempted - $sameresponses = $QTYPES[$question->qtype]->compare_responses($question, $action, $state); - if (!empty($state->last_graded) && $state->last_graded->event == QUESTION_EVENTOPEN && - question_isgradingevent($action->event)) { - $sameresponses = false; - } - - // If the response has not been changed then we do not have to process it again - // unless the attempt is closing or validation is requested - if ($sameresponses and QUESTION_EVENTCLOSE != $action->event - and QUESTION_EVENTVALIDATE != $action->event) { - return true; - } - - // Roll back grading information to last graded state and set the new - // responses - $newstate = clone($state->last_graded); - $newstate->responses = $action->responses; - $newstate->seq_number = $state->seq_number + 1; - $newstate->changed = true; // will assure that it gets saved to the database - $newstate->last_graded = clone($state->last_graded); - $newstate->timestamp = $action->timestamp; - $newstate->newflaggedstate = $state->newflaggedstate; - $newstate->flagged = $state->flagged; - $newstate->questionsessionid = $state->questionsessionid; - $state = $newstate; - - // Set the event to the action we will perform. The question type specific - // grading code may override this by setting it to QUESTION_EVENTCLOSE if the - // attempt at the question causes the session to close - $state->event = $action->event; - - if (!question_isgradingevent($action->event)) { - // Grade the response but don't update the overall grade - if (!$QTYPES[$question->qtype]->grade_responses($question, $state, $cmoptions)) { - return false; - } - - // Temporary hack because question types are not given enough control over what is going - // on. Used by Opaque questions. - // TODO fix this code properly. - if (!empty($state->believeevent)) { - // If the state was graded we need to ... - if (question_state_is_graded($state)) { - question_apply_penalty_and_timelimit($question, $state, $attempt, $cmoptions); - - // update the attempt grade - $attempt->sumgrades -= (float)$state->last_graded->grade; - $attempt->sumgrades += (float)$state->grade; - - // and update the last_graded field. - unset($state->last_graded); - $state->last_graded = clone($state); - unset($state->last_graded->changed); - } - } else { - // Don't allow the processing to change the event type - $state->event = $action->event; - } - - } else { // grading event - - // Unless the attempt is closing, we want to work out if the current responses - // (or equivalent responses) were already given in the last graded attempt. - if(QUESTION_EVENTCLOSE != $action->event && QUESTION_EVENTOPEN != $state->last_graded->event && - $QTYPES[$question->qtype]->compare_responses($question, $state, $state->last_graded)) { - $state->event = QUESTION_EVENTDUPLICATE; - } - - // If we did not find a duplicate or if the attempt is closing, perform grading - if ((!$sameresponses and QUESTION_EVENTDUPLICATE != $state->event) or - QUESTION_EVENTCLOSE == $action->event) { - if (!$QTYPES[$question->qtype]->grade_responses($question, $state, $cmoptions)) { - return false; - } - - // Calculate overall grade using correct penalty method - question_apply_penalty_and_timelimit($question, $state, $attempt, $cmoptions); - } - - // If the state was graded we need to ... - if (question_state_is_graded($state)) { - // update the attempt grade - $attempt->sumgrades -= (float)$state->last_graded->grade; - $attempt->sumgrades += (float)$state->grade; - - // and update the last_graded field. - unset($state->last_graded); - $state->last_graded = clone($state); - unset($state->last_graded->changed); - } - } - $attempt->timemodified = $action->timestamp; - - return true; -} - -/** -* Determine if event requires grading -*/ -function question_isgradingevent($event) { - return (QUESTION_EVENTSUBMIT == $event || QUESTION_EVENTCLOSE == $event); -} - -/** -* Applies the penalty from the previous graded responses to the raw grade -* for the current responses -* -* The grade for the question in the current state is computed by subtracting the -* penalty accumulated over the previous graded responses at the question from the -* raw grade. If the timestamp is more than 1 minute beyond the end of the attempt -* the grade is set to zero. The ->grade field of the state object is modified to -* reflect the new grade but is never allowed to decrease. -* @param object $question The question for which the penalty is to be applied. -* @param object $state The state for which the grade is to be set from the -* raw grade and the cumulative penalty from the last -* graded state. The ->grade field is updated by applying -* the penalty scheme determined in $cmoptions to the ->raw_grade and -* ->last_graded->penalty fields. -* @param object $cmoptions The options set by the course module. -* The ->penaltyscheme field determines whether penalties -* for incorrect earlier responses are subtracted. -*/ -function question_apply_penalty_and_timelimit(&$question, &$state, $attempt, $cmoptions) { - // TODO. Quiz dependancy. The fact that the attempt that is passed in here - // is from quiz_attempts, and we use things like $cmoptions->timelimit. - - // deal with penalty - if ($cmoptions->penaltyscheme) { - $state->grade = $state->raw_grade - $state->sumpenalty; - $state->sumpenalty += (float) $state->penalty; - } else { - $state->grade = $state->raw_grade; - } - - // deal with timelimit - if ($cmoptions->timelimit) { - // We allow for 5% uncertainty in the following test - if ($state->timestamp - $attempt->timestart > $cmoptions->timelimit * 1.05) { - $cm = get_coursemodule_from_instance('quiz', $cmoptions->id); - if (!has_capability('mod/quiz:ignoretimelimits', get_context_instance(CONTEXT_MODULE, $cm->id), - $attempt->userid, false)) { - $state->grade = 0; - } - } - } - - // deal with closing time - if ($cmoptions->timeclose and $state->timestamp > ($cmoptions->timeclose + 60) // allowing 1 minute lateness - and !$attempt->preview) { // ignore closing time for previews - $state->grade = 0; - } - - // Ensure that the grade does not go down - $state->grade = max($state->grade, $state->last_graded->grade); -} - /** * Print the icon for the question type * - * @global array - * @global object -* @param object $question The question object for which the icon is required -* only $question->qtype is used. -* @param boolean $return If true the functions returns the link as a string +* @param object $question The question object for which the icon is required. +* Only $question->qtype is used. +* @return string the HTML for the img tag. */ -function print_question_icon($question, $return = false) { - global $QTYPES, $CFG, $OUTPUT; +function print_question_icon($question) { + global $OUTPUT; - if (array_key_exists($question->qtype, $QTYPES)) { - $namestr = $QTYPES[$question->qtype]->local_name(); - } else { - $namestr = 'missingtype'; - } - $html = '' .
+    $qtype = question_bank::get_qtype($question->qtype, false);
+    $namestr = $qtype->menu_name();
+
+    // TODO convert to return a moodle_icon object, or whatever the class is.
+    $html = '<img src=pix_url('icon', $qtype->plugin_name()) . '" alt="' . $namestr . '" title="' . $namestr . '" />'; - if ($return) { - return $html; - } else { - echo $html; - } -} -/** - * @param $question - * @param $state - * @param $prefix - * @param $cmoptions - * @param $caption - */ -function question_print_comment_fields($question, $state, $prefix, $cmoptions, $caption = '') { - global $QTYPES; - $idprefix = preg_replace('/[^-_a-zA-Z0-9]/', '', $prefix); - $otherquestionsinuse = ''; - if (!empty($cmoptions->questions)) { - $otherquestionsinuse = $cmoptions->questions; - } - if (!question_state_is_graded($state) && $QTYPES[$question->qtype]->is_question_manual_graded($question, $otherquestionsinuse)) { - $grade = ''; - } else { - $grade = question_format_grade($cmoptions, $state->last_graded->grade); - } - $maxgrade = question_format_grade($cmoptions, $question->maxgrade); - $fieldsize = strlen($maxgrade) - 1; - if (empty($caption)) { - $caption = format_string($question->name); - } - ?> -
- - -
- $question->maxgrade) { - $a = new stdClass; - $a->grade = $grade; - $a->maxgrade = $question->maxgrade; - $a->name = $question->name; - return get_string('errormanualgradeoutofrange', 'question', $a); - } - - // Update the comment and save it in the database - $comment = trim($comment); - $state->manualcomment = $comment; - $state->manualcommentformat = $commentformat; - $state->newflaggedstate = $state->flagged; - $DB->set_field('question_sessions', 'manualcomment', $comment, array('attemptid'=>$attempt->uniqueid, 'questionid'=>$question->id)); - - // Update the attempt if the score has changed. - if ($grade !== '' && (abs($state->last_graded->grade - $grade) > 0.002 || $state->last_graded->event != QUESTION_EVENTMANUALGRADE)) { - $attempt->sumgrades = $attempt->sumgrades - $state->last_graded->grade + $grade; - $attempt->timemodified = time(); - $DB->update_record('quiz_attempts', $attempt); - - // We want to update existing state (rather than creating new one) if it - // was itself created by a manual grading event. - $state->update = $state->event == QUESTION_EVENTMANUALGRADE; - - // Update the other parts of the state object. - $state->raw_grade = $grade; - $state->grade = $grade; - $state->penalty = 0; - $state->timestamp = time(); - $state->seq_number++; - $state->event = QUESTION_EVENTMANUALGRADE; - - // Update the last graded state (don't simplify!) - unset($state->last_graded); - $state->last_graded = clone($state); - - // We need to indicate that the state has changed in order for it to be saved. - $state->changed = 1; - } - - return true; -} - -/** -* Construct name prefixes for question form element names -* -* Construct the name prefix that should be used for example in the -* names of form elements created by questions. -* This is called by {@link get_question_options()} -* to set $question->name_prefix. -* This name prefix includes the question id which can be -* extracted from it with {@link question_get_id_from_name_prefix()}. -* -* @return string -* @param integer $id The question id -*/ -function question_make_name_prefix($id) { - return 'resp' . $id . '_'; -} - -/** - * Extract question id from the prefix of form element names - * - * @return integer The question id - * @param string $name The name that contains a prefix that was - * constructed with {@link question_make_name_prefix()} - */ -function question_get_id_from_name_prefix($name) { - if (!preg_match('/^resp([0-9]+)_/', $name, $matches)) { - return false; - } - return (integer) $matches[1]; -} - -/** - * Extract question id from the prefix of form element names - * - * @return integer The question id - * @param string $name The name that contains a prefix that was - * constructed with {@link question_make_name_prefix()} - */ -function question_id_and_key_from_post_name($name) { - if (!preg_match('/^resp([0-9]+)_(.*)$/', $name, $matches)) { - return array(false, false); - } - return array((integer) $matches[1], $matches[2]); -} - -/** - * Returns the unique id for a new attempt - * - * Every module can keep their own attempts table with their own sequential ids but - * the question code needs to also have a unique id by which to identify all these - * attempts. Hence a module, when creating a new attempt, calls this function and - * stores the return value in the 'uniqueid' field of its attempts table. - * - * @global object - */ -function question_new_attempt_uniqueid($modulename='quiz') { - global $DB; - - $attempt = new stdClass; - $attempt->modulename = $modulename; - $id = $DB->insert_record('question_attempts', $attempt); - return $id; + return $html; } /** @@ -2053,124 +925,22 @@ function question_hash($question) { return make_unique_id_code(); } -/** - * Round a grade to to the correct number of decimal places, and format it for display. - * If $cmoptions->questiondecimalpoints is set, that is used, otherwise - * else if $cmoptions->decimalpoints is used, - * otherwise a default of 2 is used, but this should not be relied upon, and generated a developer debug warning. - * However, if $cmoptions->questiondecimalpoints is -1, the means use $cmoptions->decimalpoints. - * - * @param object $cmoptions The modules settings. - * @param float $grade The grade to round. - */ -function question_format_grade($cmoptions, $grade) { - if (isset($cmoptions->questiondecimalpoints) && $cmoptions->questiondecimalpoints != -1) { - $decimalplaces = $cmoptions->questiondecimalpoints; - } else if (isset($cmoptions->decimalpoints)) { - $decimalplaces = $cmoptions->decimalpoints; - } else { - $decimalplaces = 2; - debugging('Code that leads to question_format_grade being called should set ' . - '$cmoptions->questiondecimalpoints or $cmoptions->decimalpoints', DEBUG_DEVELOPER); - } - return format_float($grade, $decimalplaces); -} - -/** - * @return string An inline script that creates a JavaScript object storing - * various strings and bits of configuration that the scripts in qengine.js need - * to get from PHP. - */ -function question_init_qengine_js() { - global $CFG, $PAGE, $OUTPUT; - static $done = false; - if ($done) { - return; - } - $module = array( - 'name' => 'core_question_flags', - 'fullpath' => '/question/flags.js', - 'requires' => array('base', 'dom', 'event-delegate', 'io-base'), - ); - $actionurl = $CFG->wwwroot . '/question/toggleflag.php'; - $flagattributes = array( - 0 => array( - 'src' => $OUTPUT->pix_url('i/unflagged') . '', - 'title' => get_string('clicktoflag', 'question'), - 'alt' => get_string('notflagged', 'question'), - ), - 1 => array( - 'src' => $OUTPUT->pix_url('i/flagged') . '', - 'title' => get_string('clicktounflag', 'question'), - 'alt' => get_string('flagged', 'question'), - ), - ); - $PAGE->requires->js_init_call('M.core_question_flags.init', - array($actionurl, $flagattributes), false, $module); - $done = true; -} - /// FUNCTIONS THAT SIMPLY WRAP QUESTIONTYPE METHODS ////////////////////////////////// /** - * Give the questions in $questionlist a chance to request the CSS or JavaScript - * they need, before the header is printed. - * - * If your code is going to call the print_question function, it must call this - * funciton before print_header. - * - * @param array $questionlist a list of questionids of the questions what will appear on this page. - * @param array $questions an array of question objects, whose keys are question ids. - * Must contain all the questions in $questionlist - * @param array $states an array of question state objects, whose keys are question ids. - * Must contain the state of all the questions in $questionlist - */ -function question_get_html_head_contributions($questionlist, &$questions, &$states) { - global $CFG, $PAGE, $QTYPES; - - // The question engine's own JavaScript. - question_init_qengine_js(); - - // Anything that questions on this page need. - foreach ($questionlist as $questionid) { - $question = $questions[$questionid]; - $QTYPES[$question->qtype]->get_html_head_contributions($question, $states[$questionid]); - } -} - -/** - * Like {@link get_html_head_contributions()} but for the editing page - * question/question.php. + * Get anything that needs to be included in the head of the question editing page + * for a particular question type. This function is called by question/question.php. * * @param $question A question object. Only $question->qtype is used. * @return string Deprecated. Some HTML code that can go inside the head tag. */ function question_get_editing_head_contributions($question) { - global $QTYPES; - $QTYPES[$question->qtype]->get_editing_head_contributions(); + question_bank::get_qtype($question->qtype, false)->get_editing_head_contributions(); } -/** - * Prints a question - * - * Simply calls the question type specific print_question() method. - * - * @global array - * @param object $question The question to be rendered. - * @param object $state The state to render the question in. - * @param integer $number The number for this question. - * @param object $cmoptions The options specified by the course module - * @param object $options An object specifying the rendering options. - */ -function print_question(&$question, &$state, $number, $cmoptions, $options=null, $context=null) { - global $QTYPES; - $QTYPES[$question->qtype]->print_question($question, $state, $number, $cmoptions, $options, $context); -} /** * Saves question options * * Simply calls the question type specific save_question_options() method. - * - * @global array */ function save_question_options($question) { global $QTYPES; @@ -2178,66 +948,12 @@ function save_question_options($question) { $QTYPES[$question->qtype]->save_question_options($question); } -/** -* Gets all teacher stored answers for a given question -* -* Simply calls the question type specific get_all_responses() method. - * - * @global array -*/ -// ULPGC ecastro -function get_question_responses($question, $state) { - global $QTYPES; - $r = $QTYPES[$question->qtype]->get_all_responses($question, $state); - return $r; -} - -/** -* Gets the response given by the user in a particular state -* -* Simply calls the question type specific get_actual_response() method. - * - * @global array -*/ -// ULPGC ecastro -function get_question_actual_response($question, $state) { - global $QTYPES; - - $r = $QTYPES[$question->qtype]->get_actual_response($question, $state); - return $r; -} - -/** -* TODO: document this - * - * @global array -*/ -// ULPGc ecastro -function get_question_fraction_grade($question, $state) { - global $QTYPES; - - $r = $QTYPES[$question->qtype]->get_fractional_grade($question, $state); - return $r; -} -/** - * @global array -* @return integer grade out of 1 that a random guess by a student might score. -*/ -// ULPGc ecastro -function question_get_random_guess_score($question) { - global $QTYPES; - - $r = $QTYPES[$question->qtype]->get_random_guess_score($question); - return $r; -} /// CATEGORY FUNCTIONS ///////////////////////////////////////////////////////////////// /** * returns the categories with their names ordered following parent-child relationships * finally it tries to return pending categories (those being orphaned, whose parent is * incorrect) to avoid missing any category from original array. - * - * @global object */ function sort_categories_by_tree(&$categories, $id = 0, $level = 1) { global $DB; @@ -2364,7 +1080,6 @@ function question_category_select_menu($contexts, $top = false, $currentcat = 0, } /** - * @global object * @param integer $contextid a context id. * @return object the default question category for that context, or false if none. */ @@ -2378,42 +1093,10 @@ function question_get_default_category($contextid) { } } -/** - * @global object - * @global object - * @param object $context a context - * @return string A URL for editing questions in this context. - */ -function question_edit_url($context) { - global $CFG, $SITE; - if (!has_any_capability(question_get_question_capabilities(), $context)) { - return false; - } - $baseurl = $CFG->wwwroot . '/question/edit.php?'; - $defaultcategory = question_get_default_category($context->id); - if ($defaultcategory) { - $baseurl .= 'cat=' . $defaultcategory->id . ',' . $context->id . '&'; - } - switch ($context->contextlevel) { - case CONTEXT_SYSTEM: - return $baseurl . 'courseid=' . $SITE->id; - case CONTEXT_COURSECAT: - // This is nasty, becuase we can only edit questions in a course - // context at the moment, so for now we just return false. - return false; - case CONTEXT_COURSE: - return $baseurl . 'courseid=' . $context->instanceid; - case CONTEXT_MODULE: - return $baseurl . 'cmid=' . $context->instanceid; - } - -} - /** * Gets the default category in the most specific context. * If no categories exist yet then default ones are created in all contexts. * - * @global object * @param array $contexts The context objects for this context and all parent contexts. * @return object The default category - the category in the course context */ @@ -2461,7 +1144,6 @@ function question_make_default_categories($contexts) { * Get all the category objects, including a count of the number of questions in that category, * for all the categories in the lists $contexts. * - * @global object * @param mixed $contexts either a single contextid, or a comma-separated list of context ids. * @param string $sortorder used as the ORDER BY clause in the select statement. * @return array of category objects. @@ -2478,7 +1160,6 @@ function get_categories_for_contexts($contexts, $sortorder = 'parent, sortorder, /** * Output an array of question categories. - * @global object */ function question_category_options($contexts, $top = false, $currentcat = 0, $popupform = false, $nochildrenof = -1) { global $CFG; @@ -2536,6 +1217,7 @@ function question_add_context_in_key($categories){ } return $newcatarray; } + function question_add_tops($categories, $pcontexts){ $topcats = array(); foreach ($pcontexts as $context){ @@ -2552,14 +1234,13 @@ function question_add_tops($categories, $pcontexts){ /** * Returns a comma separated list of ids of the category and all subcategories - * @global object */ function question_categorylist($categoryid) { global $DB; // returns a comma separated list of ids of the category and all subcategories $categorylist = $categoryid; - if ($subcategories = $DB->get_records('question_categories', array('parent'=>$categoryid), 'sortorder ASC', 'id, 1')) { + if ($subcategories = $DB->get_records('question_categories', array('parent' => $categoryid), 'sortorder ASC', 'id, 1')) { foreach ($subcategories as $subcategory) { $categorylist .= ','. question_categorylist($subcategory->id); } @@ -2567,52 +1248,40 @@ function question_categorylist($categoryid) { return $categorylist; } - - - //=========================== // Import/Export Functions //=========================== /** * Get list of available import or export formats - * - * @global object * @param string $type 'import' if import list, otherwise export list assumed * @return array sorted list of import/export formats available */ -function get_import_export_formats( $type ) { +function get_import_export_formats($type) { global $CFG; - $fileformats = get_plugin_list("qformat"); + $fileformats = get_plugin_list('qformat'); $fileformatname=array(); require_once( "{$CFG->dirroot}/question/format.php" ); - foreach ($fileformats as $fileformat=>$fdir) { - $format_file = "$fdir/format.php"; - if (file_exists($format_file) ) { - require_once($format_file); - } - else { + foreach ($fileformats as $fileformat => $fdir) { + $formatfile = $fdir . '/format.php'; + if (is_readable($formatfile)) { + include_once($formatfile); + } else { continue; } + $classname = "qformat_$fileformat"; - $format_class = new $classname(); - if ($type=='import') { + $formatclass = new $classname(); + if ($type == 'import') { $provided = $format_class->provide_import(); - } - else { + } else { $provided = $format_class->provide_export(); } + if ($provided) { - $formatname = get_string($fileformat, 'quiz'); - if ($formatname == "[[$fileformat]]") { - $formatname = get_string($fileformat, 'qformat_'.$fileformat); - if ($formatname == "[[$fileformat]]") { - $formatname = $fileformat; // Just use the raw folder name - } - } - $fileformatnames[$fileformat] = $formatname; + $fileformatnames[$fileformat] = get_string($fileformat, 'qformat_'.$fileformat); } } natcasesort($fileformatnames); @@ -2650,8 +1319,9 @@ function question_default_export_filename($course, $category) { } /** - * @package moodlecore - * @subpackage question + * Converts contextlevels to strings and back to help with reading/writing contexts + * to/from import/export files. + * * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ @@ -2659,26 +1329,26 @@ class context_to_string_translator{ /** * @var array used to translate between contextids and strings for this context. */ - var $contexttostringarray = array(); + protected $contexttostringarray = array(); - function context_to_string_translator($contexts){ + public function __construct($contexts) { $this->generate_context_to_string_array($contexts); } - function context_to_string($contextid){ + public function context_to_string($contextid) { return $this->contexttostringarray[$contextid]; } - function string_to_context($contextname){ + public function string_to_context($contextname) { $contextid = array_search($contextname, $this->contexttostringarray); return $contextid; } - function generate_context_to_string_array($contexts){ + protected function generate_context_to_string_array($contexts) { if (!$this->contexttostringarray){ $catno = 1; foreach ($contexts as $context){ - switch ($context->contextlevel){ + switch ($context->contextlevel){ case CONTEXT_MODULE : $contextstring = 'module'; break; @@ -2700,38 +1370,9 @@ class context_to_string_translator{ } -/** - * @return array all the capabilities that relate to accessing particular questions. - */ -function question_get_question_capabilities() { - return array( - 'moodle/question:add', - 'moodle/question:editmine', - 'moodle/question:editall', - 'moodle/question:viewmine', - 'moodle/question:viewall', - 'moodle/question:usemine', - 'moodle/question:useall', - 'moodle/question:movemine', - 'moodle/question:moveall', - ); -} - -/** - * @return array all the question bank capabilities. - */ -function question_get_all_capabilities() { - $caps = question_get_question_capabilities(); - $caps[] = 'moodle/question:managecategory'; - $caps[] = 'moodle/question:flag'; - return $caps; -} - /** * Check capability on category * - * @global object - * @global object * @param mixed $question object or id * @param string $cap 'add', 'edit', 'view', 'use', 'move' * @param integer $cachecat useful to cache all question records in a category @@ -2740,11 +1381,6 @@ function question_get_all_capabilities() { function question_has_capability_on($question, $cap, $cachecat = -1){ global $USER, $DB; - // nicolasconnault@gmail.com In some cases I get $question === false. Since no such object exists, it can't be deleted, we can safely return true - if ($question === false) { - return true; - } - // these are capabilities on existing questions capabilties are //set per category. Each of these has a mine and all version. Append 'mine' and 'all' $question_questioncaps = array('edit', 'view', 'use', 'move'); @@ -2819,62 +1455,32 @@ function question_get_real_state($state) { } /** - * Update the flagged state of a particular question session. - * - * @global object - * @param integer $sessionid question_session id. - * @param boolean $newstate the new state for the flag. - * @return boolean success or failure. + * @param object $context a context + * @return string A URL for editing questions in this context. */ -function question_update_flag($sessionid, $newstate) { - global $DB; - return $DB->set_field('question_sessions', 'flagged', $newstate, array('id' => $sessionid)); -} +function question_edit_url($context) { + global $CFG, $SITE; + if (!has_any_capability(question_get_question_capabilities(), $context)) { + return false; + } + $baseurl = $CFG->wwwroot . '/question/edit.php?'; + $defaultcategory = question_get_default_category($context->id); + if ($defaultcategory) { + $baseurl .= 'cat=' . $defaultcategory->id . ',' . $context->id . '&'; + } + switch ($context->contextlevel) { + case CONTEXT_SYSTEM: + return $baseurl . 'courseid=' . $SITE->id; + case CONTEXT_COURSECAT: + // This is nasty, becuase we can only edit questions in a course + // context at the moment, so for now we just return false. + return false; + case CONTEXT_COURSE: + return $baseurl . 'courseid=' . $context->instanceid; + case CONTEXT_MODULE: + return $baseurl . 'cmid=' . $context->instanceid; + } -/** - * Update the flagged state of all the questions in an attempt, where a new . - * - * @global object - * @param integer $sessionid question_session id. - * @param boolean $newstate the new state for the flag. - * @return boolean success or failure. - */ -function question_save_flags($formdata, $attemptid, $questionids) { - global $DB; - $donequestionids = array(); - foreach ($formdata as $postvariable => $value) { - list($qid, $key) = question_id_and_key_from_post_name($postvariable); - if ($qid !== false && in_array($qid, $questionids)) { - if ($key == '_flagged') { - $DB->set_field('question_sessions', 'flagged', !empty($value), - array('attemptid' => $attemptid, 'questionid' => $qid)); - $donequestionids[$qid] = 1; - } - } - } - foreach ($questionids as $qid) { - if (!isset($donequestionids[$qid])) { - $DB->set_field('question_sessions', 'flagged', 0, - array('attemptid' => $attemptid, 'questionid' => $qid)); - } - } -} - -/** - * - * @global object - * @param integer $attemptid the question_attempt id. - * @param integer $questionid the question id. - * @param integer $sessionid the question_session id. - * @param object $user a user, or null to use $USER. - * @return string that needs to be sent to question/toggleflag.php for it to work. - */ -function question_get_toggleflag_checksum($attemptid, $questionid, $sessionid, $user = null) { - if (is_null($user)) { - global $USER; - $user = $USER; - } - return md5($attemptid . "_" . $user->secret . "_" . $questionid . "_" . $sessionid); } /** @@ -2914,6 +1520,33 @@ function question_extend_settings_navigation(navigation_node $navigationnode, $c return $questionnode; } +/** + * @return array all the capabilities that relate to accessing particular questions. + */ +function question_get_question_capabilities() { + return array( + 'moodle/question:add', + 'moodle/question:editmine', + 'moodle/question:editall', + 'moodle/question:viewmine', + 'moodle/question:viewall', + 'moodle/question:usemine', + 'moodle/question:useall', + 'moodle/question:movemine', + 'moodle/question:moveall', + ); +} + +/** + * @return array all the question bank capabilities. + */ +function question_get_all_capabilities() { + $caps = question_get_question_capabilities(); + $caps[] = 'moodle/question:managecategory'; + $caps[] = 'moodle/question:flag'; + return $caps; +} + class question_edit_contexts { public static $CAPS = array( @@ -2942,7 +1575,7 @@ class question_edit_contexts { /** * @param current context */ - public function question_edit_contexts($thiscontext){ + public function question_edit_contexts($thiscontext) { $pcontextids = get_parent_contexts($thiscontext); $contexts = array($thiscontext); foreach ($pcontextids as $pcontextid){ @@ -2953,20 +1586,20 @@ class question_edit_contexts { /** * @return array all parent contexts */ - public function all(){ + public function all() { return $this->allcontexts; } /** * @return object lowest context which must be either the module or course context */ - public function lowest(){ + public function lowest() { return $this->allcontexts[0]; } /** * @param string $cap capability * @return array parent contexts having capability, zero based index */ - public function having_cap($cap){ + public function having_cap($cap) { $contextswithcap = array(); foreach ($this->allcontexts as $context){ if (has_capability($cap, $context)){ @@ -2979,7 +1612,7 @@ class question_edit_contexts { * @param array $caps capabilities * @return array parent contexts having at least one of $caps, zero based index */ - public function having_one_cap($caps){ + public function having_one_cap($caps) { $contextswithacap = array(); foreach ($this->allcontexts as $context){ foreach ($caps as $cap){ @@ -2995,7 +1628,7 @@ class question_edit_contexts { * @param string $tabname edit tab name * @return array parent contexts having at least one of $caps, zero based index */ - public function having_one_edit_tab_cap($tabname){ + public function having_one_edit_tab_cap($tabname) { return $this->having_one_cap(self::$CAPS[$tabname]); } /** @@ -3004,7 +1637,7 @@ class question_edit_contexts { * @param string $cap capability * @return boolean */ - public function have_cap($cap){ + public function have_cap($cap) { return (count($this->having_cap($cap))); } @@ -3014,7 +1647,7 @@ class question_edit_contexts { * @param array $caps capability * @return boolean */ - public function have_one_cap($caps){ + public function have_one_cap($caps) { foreach ($caps as $cap) { if ($this->have_cap($cap)) { return true; @@ -3022,6 +1655,7 @@ class question_edit_contexts { } return false; } + /** * Has at least one parent context got one of the caps for actions on $tabname * @@ -3031,6 +1665,7 @@ class question_edit_contexts { public function have_one_edit_tab_cap($tabname){ return $this->have_one_cap(self::$CAPS[$tabname]); } + /** * Throw error if at least one parent context hasn't got the cap $cap * @@ -3041,6 +1676,7 @@ class question_edit_contexts { print_error('nopermissions', '', '', $cap); } } + /** * Throw error if at least one parent context hasn't got one of the caps $caps * diff --git a/question/engine/bank.php b/question/engine/bank.php index b4a8c0b9b41..8fed6afb2ba 100644 --- a/question/engine/bank.php +++ b/question/engine/bank.php @@ -63,8 +63,9 @@ abstract class question_bank { if (isset(self::$questiontypes[$qtypename])) { return self::$questiontypes[$qtypename]; } - $file = $CFG->dirroot . '/question/type/' . $qtypename . '/questiontype.php'; + $file = get_plugin_directory('qtype', $qtypename) . '/questiontype.php'; if (!is_readable($file)) { + echo 'problem'; if ($mustexist || $qtypename == 'missingtype') { throw new Exception('Unknown question type ' . $qtypename); } else { @@ -73,6 +74,9 @@ abstract class question_bank { } include_once($file); $class = 'qtype_' . $qtypename; + if (!class_exists($class)) { + throw new coding_exception("Class $class must be defined in $file"); + } self::$questiontypes[$qtypename] = new $class(); return self::$questiontypes[$qtypename]; } @@ -82,7 +86,7 @@ abstract class question_bank { * @return boolean whether users are allowed to create questions of this type. */ public static function qtype_enabled($qtypename) { - ; + return true; // TODO } /** @@ -98,9 +102,12 @@ abstract class question_bank { */ public static function get_all_qtypes() { $qtypes = array(); - $plugins = get_list_of_plugins('question/type', 'datasetdependent'); - foreach ($plugins as $plugin) { - $qtypes[$plugin] = self::get_qtype($plugin); + foreach (get_plugin_list('qtype') as $plugin => $notused) { + try { + $qtypes[$plugin] = self::get_qtype($plugin); + } catch (Exception $e) { + // TODO ingore, but reivew this later. + } } return $qtypes; } diff --git a/question/engine/lib.php b/question/engine/lib.php index 7c556e46ba4..e7d49b88ed8 100644 --- a/question/engine/lib.php +++ b/question/engine/lib.php @@ -551,21 +551,32 @@ abstract class question_flags { } public static function initialise_js() { - global $CFG; - - require_js(array('yui_yahoo','yui_dom','yui_event','yui_connection')); - require_js($CFG->wwwroot . '/question/qengine.js'); - - $config = array( - 'actionurl' => $CFG->wwwroot . '/question/toggleflag.php', - 'flagicon' => $CFG->pixpath . '/i/flagged.png', - 'unflagicon' => $CFG->pixpath . '/i/unflagged.png', - 'flagtooltip' => get_string('clicktoflag', 'question'), - 'unflagtooltip' => get_string('clicktounflag', 'question'), - 'flaggedalt' => get_string('flagged', 'question'), - 'unflaggedalt' => get_string('notflagged', 'question'), + global $CFG, $PAGE, $OUTPUT; + static $done = false; + if ($done) { + return; + } + $module = array( + 'name' => 'core_question_flags', + 'fullpath' => '/question/flags.js', + 'requires' => array('base', 'dom', 'event-delegate', 'io-base'), ); - return print_js_config($config, 'qengine_config', true); + $actionurl = $CFG->wwwroot . '/question/toggleflag.php'; + $flagattributes = array( + 0 => array( + 'src' => $OUTPUT->pix_url('i/unflagged') . '', + 'title' => get_string('clicktoflag', 'question'), + 'alt' => get_string('notflagged', 'question'), + ), + 1 => array( + 'src' => $OUTPUT->pix_url('i/flagged') . '', + 'title' => get_string('clicktounflag', 'question'), + 'alt' => get_string('flagged', 'question'), + ), + ); + $PAGE->requires->js_init_call('M.core_question_flags.init', + array($actionurl, $flagattributes), false, $module); + $done = true; } } diff --git a/question/engine/renderer.php b/question/engine/renderer.php index 81d58fdea6b..71dd3ce0a2c 100644 --- a/question/engine/renderer.php +++ b/question/engine/renderer.php @@ -213,7 +213,7 @@ class core_question_renderer extends renderer_base { '' . '' . '' . "\n" . + $qa->is_flagged(), $id . 'img') . '' . "\n"; break; default: $flagcontent = ''; diff --git a/question/todo/diffstat.txt b/question/todo/diffstat.txt index d35ff868d27..b5ab9b08b9b 100644 --- a/question/todo/diffstat.txt +++ b/question/todo/diffstat.txt @@ -52,7 +52,7 @@ Internal changes lang/en_utf8/quiz_regrade.php | 7 - lang/en_utf8/quiz_responses.php | 11 - - lib/questionlib.php | 1434 ++-------- +DONE lib/questionlib.php | 1434 ++-------- mod/quiz/accessrules.php | 828 ++++++ mod/quiz/attempt.php | 742 ++---- @@ -158,7 +158,7 @@ DONE question/comment.html | 25 - DONE question/editlib.php | 36 +- GONE question/exportfile.php | 52 +- DONE question/file.php | 171 +- | but this file is probably obsolete. -!!!TODO question/import_form.php | 19 + | the change is to add validation that a file has been uploaded. + question/import_form.php | 19 + | the change is to add validation that a file has been uploaded. DONE question/move_form.php | 32 +- DONE question/preview.js | 47 + question/preview.php | 408 ++-- @@ -168,6 +168,14 @@ DONE question/question.php | 3 +- question/restorelib.php | 88 +- DONE question/toggleflag.php | 49 + + question/type/edit_question_form.php | 264 ++- + question/type/question.html | 46 - + question/type/questionbase.php | 787 ++++++ +DONE question/type/questiontype.php | 1302 ++------- + question/type/rendererbase.php | 265 ++ -- TODO diff questointype to get necessary changes. + question/type/simpletest/testquestionbase.php | 117 + +DONE question/type/simpletest/testquestiontype.php | 91 +- + question/behaviour/behaviourbase.php | 627 +++++ question/behaviour/rendererbase.php | 200 ++ @@ -423,14 +431,6 @@ DONE question/toggleflag.php | 49 + question/type/truefalse/simpletest/testquestiontype.php | 73 + question/type/truefalse/version.php | 4 +- - question/type/edit_question_form.php | 264 ++- - question/type/question.html | 46 - - question/type/questionbase.php | 787 ++++++ - question/type/questiontype.php | 1302 ++------- - question/type/rendererbase.php | 265 ++ - question/type/simpletest/testquestionbase.php | 117 + - question/type/simpletest/testquestiontype.php | 91 +- - theme/standard/styles_color.css | 72 +- theme/standard/styles_fonts.css | 20 +- theme/standard/styles_layout.css | 265 ++- diff --git a/question/todo/questionlib_2.0.diff.txt b/question/todo/questionlib_2.0.diff.txt new file mode 100644 index 00000000000..b889602310a --- /dev/null +++ b/question/todo/questionlib_2.0.diff.txt @@ -0,0 +1,807 @@ + + /** + * Prints a question + * + * Simply calls the question type specific print_question() method. ++ * ++ * @global array + * @param object $question The question to be rendered. + * @param object $state The state to render the question in. + * @param integer $number The number for this question. + * @param object $cmoptions The options specified by the course module + * @param object $options An object specifying the rendering options. + */ +-function print_question(&$question, &$state, $number, $cmoptions, $options=null) { ++function print_question(&$question, &$state, $number, $cmoptions, $options=null, $context=null) { + global $QTYPES; +- $QTYPES[$question->qtype]->print_question($question, $state, $number, $cmoptions, $options); ++ $QTYPES[$question->qtype]->print_question($question, $state, $number, $cmoptions, $options, $context); + } + /** + * Saves question options + * + * Simply calls the question type specific save_question_options() method. ++ * ++ * @global array + */ + function save_question_options($question) { + global $QTYPES; +@@ -2075,8 +2255,9 @@ function sort_categories_by_tree(&$categories, $id = 0, $level = 1) { + //If level = 1, we have finished, try to look for non processed categories (bad parent) and sort them too + if ($level == 1) { + foreach ($keys as $key) { +- //If not processed and it's a good candidate to start (because its parent doesn't exist in the course) +- if (!isset($categories[$key]->processed) && !$DB->record_exists('question_categories', array('course'=>$categories[$key]->course, 'id'=>$categories[$key]->parent))) { ++ // If not processed and it's a good candidate to start (because its parent doesn't exist in the course) ++ if (!isset($categories[$key]->processed) && !$DB->record_exists( ++ 'question_categories', array('contextid'=>$categories[$key]->contextid, 'id'=>$categories[$key]->parent))) { + $children[$key] = $categories[$key]; + $categories[$key]->processed = true; + $children = $children + sort_categories_by_tree($categories, $children[$key]->id, $level+1); +@@ -2167,16 +2348,23 @@ function add_indented_names($categories, $nochildrenof = -1) { + * @param integer $selected optionally, the id of a category to be selected by default in the dropdown. + */ + function question_category_select_menu($contexts, $top = false, $currentcat = 0, $selected = "", $nochildrenof = -1) { ++ global $OUTPUT; + $categoriesarray = question_category_options($contexts, $top, $currentcat, false, $nochildrenof); + if ($selected) { +- $nothing = ''; ++ $choose = ''; + } else { +- $nothing = 'choose'; ++ $choose = 'choosedots'; ++ } ++ $options = array(); ++ foreach($categoriesarray as $group=>$opts) { ++ $options[] = array($group=>$opts); + } +- choose_from_menu_nested($categoriesarray, 'category', $selected, $nothing); ++ ++ echo html_writer::select($options, 'category', $selected, $choose); + } +@@ -2216,23 +2406,31 @@ function question_edit_url($context) { + /** + * Gets the default category in the most specific context. + * If no categories exist yet then default ones are created in all contexts. + * ++ * @global object + * @param array $contexts The context objects for this context and all parent contexts. + * @return object The default category - the category in the course context + */ + function question_make_default_categories($contexts) { + global $DB; ++ static $preferredlevels = array( ++ CONTEXT_COURSE => 4, ++ CONTEXT_MODULE => 3, ++ CONTEXT_COURSECAT => 2, ++ CONTEXT_SYSTEM => 1, ++ ); + + $toreturn = null; ++ $preferredness = 0; + // If it already exists, just return it. + foreach ($contexts as $key => $context) { +- if (!$exists = $DB->record_exists("question_categories", array('contextid'=>$context->id))){ ++ if (!$exists = $DB->record_exists("question_categories", array('contextid'=>$context->id))) { + // Otherwise, we need to make one + $category = new stdClass; + $contextname = print_context_name($context, false, true); +@@ -2242,19 +2440,20 @@ function question_make_default_categories($contexts) { + $category->parent = 0; + $category->sortorder = 999; // By default, all categories get this number, and are sorted alphabetically. + $category->stamp = make_unique_id_code(); +- if (!$category->id = $DB->insert_record('question_categories', $category)) { +- print_error('cannotcreatedefaultcat', '', '', print_context_name($context)); +- } ++ $category->id = $DB->insert_record('question_categories', $category); + } else { + $category = question_get_default_category($context->id); + } +- +- if ($context->contextlevel == CONTEXT_COURSE){ +- $toreturn = clone($category); ++ if ($preferredlevels[$context->contextlevel] > $preferredness && ++ has_any_capability(array('moodle/question:usemine', 'moodle/question:useall'), $context)) { ++ $toreturn = $category; ++ $preferredness = $preferredlevels[$context->contextlevel]; + } + } + +- ++ if (!is_null($toreturn)) { ++ $toreturn = clone($toreturn); ++ } + return $toreturn; + } + +@@ -2313,9 +2514,12 @@ function question_category_options($contexts, $top = false, $currentcat = 0, $po + if ($popupform){ + $popupcats = array(); + foreach ($categoriesarray as $contextstring => $optgroup){ +- $popupcats[] = '--'.$contextstring; +- $popupcats = array_merge($popupcats, $optgroup); +- $popupcats[] = '--'; ++ $group = array(); ++ foreach ($optgroup as $key=>$value) { ++ $key = str_replace($CFG->wwwroot, '', $key); ++ $group[$key] = $value; ++ } ++ $popupcats[] = array($contextstring=>$group); + } + return $popupcats; + } else { +@@ -2335,7 +2539,7 @@ function question_add_context_in_key($categories){ + function question_add_tops($categories, $pcontexts){ + $topcats = array(); + foreach ($pcontexts as $context){ +- $newcat = new object(); ++ $newcat = new stdClass(); + $newcat->id = "0,$context"; + $newcat->name = get_string('top'); + $newcat->parent = -1; + function get_import_export_formats( $type ) { + + global $CFG; +- $fileformats = get_list_of_plugins("question/format"); ++ $fileformats = get_plugin_list("qformat"); + + $fileformatname=array(); + require_once( "{$CFG->dirroot}/question/format.php" ); +- foreach ($fileformats as $key => $fileformat) { +- $format_file = $CFG->dirroot . "/question/format/$fileformat/format.php"; +- if (file_exists( $format_file ) ) { +- require_once( $format_file ); ++ foreach ($fileformats as $fileformat=>$fdir) { ++ $format_file = "$fdir/format.php"; ++ if (file_exists($format_file) ) { ++ require_once($format_file); + } + else { + continue; +@@ -2400,7 +2607,10 @@ function get_import_export_formats( $type ) { + if ($provided) { + $formatname = get_string($fileformat, 'quiz'); + if ($formatname == "[[$fileformat]]") { +- $formatname = $fileformat; // Just use the raw folder name ++ $formatname = get_string($fileformat, 'qformat_'.$fileformat); ++ if ($formatname == "[[$fileformat]]") { ++ $formatname = $fileformat; // Just use the raw folder name ++ } + } + $fileformatnames[$fileformat] = $formatname; + } +@@ -2412,50 +2622,39 @@ function get_import_export_formats( $type ) { + + + /** +-* Create default export filename +-* +-* @return string default export filename +-* @param object $course +-* @param object $category ++* Create a reasonable default file name for exporting questions from a particular ++* category. ++* @param object $course the course the questions are in. ++* @param object $category the question category. ++* @return string the filename. + */ +-function default_export_filename($course,$category) { +- //Take off some characters in the filename !! +- $takeoff = array(" ", ":", "/", "\\", "|"); +- $export_word = str_replace($takeoff,"_",moodle_strtolower(get_string("exportfilename","quiz"))); +- //If non-translated, use "export" +- if (substr($export_word,0,1) == "[") { +- $export_word= "export"; +- } +- +- //Calculate the date format string +- $export_date_format = str_replace(" ","_",get_string("exportnameformat","quiz")); +- //If non-translated, use "%Y%m%d-%H%M" +- if (substr($export_date_format,0,1) == "[") { +- $export_date_format = "%%Y%%m%%d-%%H%%M"; +- } +- +- //Calculate the shortname +- $export_shortname = clean_filename($course->shortname); +- if (empty($export_shortname) or $export_shortname == '_' ) { +- $export_shortname = $course->id; +- } +- +- //Calculate the category name +- $export_categoryname = clean_filename($category->name); +- +- //Calculate the final export filename +- //The export word +- $export_name = $export_word."-"; +- //The shortname +- $export_name .= moodle_strtolower($export_shortname)."-"; +- //The category name +- $export_name .= moodle_strtolower($export_categoryname)."-"; +- //The date format +- $export_name .= userdate(time(),$export_date_format,99,false); +- //Extension is supplied by format later. ++function question_default_export_filename($course, $category) { ++ // We build a string that is an appropriate name (questions) from the lang pack, ++ // then the corse shortname, then the question category name, then a timestamp. ++ ++ $base = clean_filename(get_string('exportfilename', 'question')); ++ ++ $dateformat = str_replace(' ', '_', get_string('exportnameformat', 'question')); ++ $timestamp = clean_filename(userdate(time(), $dateformat, 99, false)); ++ ++ $shortname = clean_filename($course->shortname); ++ if ($shortname == '' || $shortname == '_' ) { ++ $shortname = $course->id; ++ } ++ ++ $categoryname = clean_filename(format_string($category->name)); ++ ++ return "{$base}-{$shortname}-{$categoryname}-{$timestamp}"; + + return $export_name; + } ++ ++/** ++ * @package moodlecore ++ * @subpackage question ++ * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} ++ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later ++ */ + class context_to_string_translator{ + /** + * @var array used to translate between contextids and strings for this context. +@@ -2549,13 +2751,13 @@ function question_has_capability_on($question, $cap, $cachecat = -1){ + static $questions = array(); + static $categories = array(); + static $cachedcat = array(); +- if ($cachecat != -1 && (array_search($cachecat, $cachedcat)===FALSE)){ +- $questions += $DB->get_records('question', array('category'=>$cachecat)); ++ if ($cachecat != -1 && array_search($cachecat, $cachedcat) === false) { ++ $questions += $DB->get_records('question', array('category' => $cachecat)); + $cachedcat[] = $cachecat; + } + if (!is_object($question)){ + if (!isset($questions[$question])){ +- if (!$questions[$question] = $DB->get_record('question', array('id'=>$question), 'id,category,createdby')) { ++ if (!$questions[$question] = $DB->get_record('question', array('id' => $question), 'id,category,createdby')) { + print_error('questiondoesnotexist', 'question'); + } + } +@@ -2567,11 +2769,12 @@ function question_has_capability_on($question, $cap, $cachecat = -1){ + } + } + $category = $categories[$question->category]; ++ $context = get_context_instance_by_id($category->contextid); + + if (array_search($cap, $question_questioncaps)!== FALSE){ +- if (!has_capability('moodle/question:'.$cap.'all', get_context_instance_by_id($category->contextid))){ ++ if (!has_capability('moodle/question:'.$cap.'all', $context)){ + if ($question->createdby == $USER->id){ +- return has_capability('moodle/question:'.$cap.'mine', get_context_instance_by_id($category->contextid)); ++ return has_capability('moodle/question:'.$cap.'mine', $context); + } else { + return false; + } +@@ -2579,7 +2782,7 @@ function question_has_capability_on($question, $cap, $cachecat = -1){ + return true; + } + } else { +- return has_capability('moodle/question:'.$cap, get_context_instance_by_id($category->contextid)); ++ return has_capability('moodle/question:'.$cap, $context); + } + + } +@@ -2594,107 +2797,6 @@ function question_require_capability_on($question, $cap){ + return true; + } + +-function question_file_links_base_url($courseid){ +- global $CFG; +- $baseurl = preg_quote("$CFG->wwwroot/file.php", '!'); +- $baseurl .= '('.preg_quote('?file=', '!').')?';//may or may not +- //be using slasharguments, accept either +- $baseurl .= "/$courseid/";//course directory +- return $baseurl; +-} +- +-/* +- * Find all course / site files linked to in a piece of html. +- * @param string html the html to search +- * @param int course search for files for courseid course or set to siteid for +- * finding site files. +- * @return array files with keys being files. +- */ +-function question_find_file_links_from_html($html, $courseid){ +- global $CFG; +- $baseurl = question_file_links_base_url($courseid); +- $searchfor = '!'. +- '(<\s*(a|img)\s[^>]*(href|src)\s*=\s*")'.$baseurl.'([^"]*)"'. +- '|'. +- '(<\s*(a|img)\s[^>]*(href|src)\s*=\s*\')'.$baseurl.'([^\']*)\''. +- '!i'; +- $matches = array(); +- $no = preg_match_all($searchfor, $html, $matches); +- if ($no){ +- $rawurls = array_filter(array_merge($matches[5], $matches[10]));//array_filter removes empty elements +- //remove any links that point somewhere they shouldn't +- foreach (array_keys($rawurls) as $rawurlkey){ +- if (!$cleanedurl = question_url_check($rawurls[$rawurlkey])){ +- unset($rawurls[$rawurlkey]); +- } else { +- $rawurls[$rawurlkey] = $cleanedurl; +- } +- +- } +- $urls = array_flip($rawurls);// array_flip removes duplicate files +- // and when we merge arrays will continue to automatically remove duplicates +- } else { +- $urls = array(); +- } +- return $urls; +-} +- +-/** +- * Check that url doesn't point anywhere it shouldn't +- * +- * @param $url string relative url within course files directory +- * @return mixed boolean false if not OK or cleaned URL as string if OK +- */ +-function question_url_check($url){ +- global $CFG; +- if ((substr(strtolower($url), 0, strlen($CFG->moddata)) == strtolower($CFG->moddata)) || +- (substr(strtolower($url), 0, 10) == 'backupdata')){ +- return false; +- } else { +- return clean_param($url, PARAM_PATH); +- } +-} +- +-/** +- * Find all course / site files linked to in a piece of html. +- * @param string html the html to search +- * @param int course search for files for courseid course or set to siteid for +- * finding site files. +- * @return array files with keys being files. +- */ +-function question_replace_file_links_in_html($html, $fromcourseid, $tocourseid, $url, $destination, &$changed){ +- global $CFG; +- require_once($CFG->libdir .'/filelib.php'); +- $tourl = get_file_url("$tocourseid/$destination"); +- $fromurl = question_file_links_base_url($fromcourseid).preg_quote($url, '!'); +- $searchfor = array('!(<\s*(a|img)\s[^>]*(href|src)\s*=\s*")'.$fromurl.'(")!i', +- '!(<\s*(a|img)\s[^>]*(href|src)\s*=\s*\')'.$fromurl.'(\')!i'); +- $newhtml = preg_replace($searchfor, '\\1'.$tourl.'\\5', $html); +- if ($newhtml != $html){ +- $changed = true; +- } +- return $newhtml; +-} +- +-function get_filesdir_from_context($context){ +- global $DB; +- +- switch ($context->contextlevel){ +- case CONTEXT_COURSE : +- $courseid = $context->instanceid; +- break; +- case CONTEXT_MODULE : +- $courseid = $DB->get_field('course_modules', 'course', array('id'=>$context->instanceid)); +- break; +- case CONTEXT_COURSECAT : +- case CONTEXT_SYSTEM : +- $courseid = SITEID; +- break; +- default : +- print_error('invalidcontext'); +- } +- return $courseid; +-} + /** + * Get the real state - the correct question id and answer - for a random + * question. +@@ -2702,11 +2804,12 @@ function get_filesdir_from_context($context){ + * @return mixed return integer real question id or false if there was an + * error.. + */ +-function question_get_real_state($state){ ++function question_get_real_state($state) { ++ global $OUTPUT; + $realstate = clone($state); + $matches = array(); + if (!preg_match('|^random([0-9]+)-(.*)|', $state->answer, $matches)){ +- notify(get_string('errorrandom', 'quiz_statistics')); ++ echo $OUTPUT->notification(get_string('errorrandom', 'quiz_statistics')); + return false; + } else { + $realstate->question = $matches[1]; +@@ -2770,4 +2877,389 @@ function question_get_toggleflag_checksum($attemptid, $questionid, $sessionid, $ + return md5($attemptid . "_" . $user->secret . "_" . $questionid . "_" . $sessionid); + } + +-?> ++/** ++ * Adds question bank setting links to the given navigation node if caps are met. ++ * ++ * @param navigation_node $navigationnode The navigation node to add the question branch to ++ * @param stdClass $context ++ * @return navigation_node Returns the question branch that was added ++ */ ++function question_extend_settings_navigation(navigation_node $navigationnode, $context) { ++ global $PAGE; ++ ++ if ($context->contextlevel == CONTEXT_COURSE) { ++ $params = array('courseid'=>$context->instanceid); ++ } else if ($context->contextlevel == CONTEXT_MODULE) { ++ $params = array('cmid'=>$context->instanceid); ++ } else { ++ return; ++ } ++ ++ $questionnode = $navigationnode->add(get_string('questionbank','question'), new moodle_url('/question/edit.php', $params), navigation_node::TYPE_CONTAINER); ++ ++ $contexts = new question_edit_contexts($context); ++ if ($contexts->have_one_edit_tab_cap('questions')) { ++ $questionnode->add(get_string('questions', 'quiz'), new moodle_url('/question/edit.php', $params), navigation_node::TYPE_SETTING); ++ } ++ if ($contexts->have_one_edit_tab_cap('categories')) { ++ $questionnode->add(get_string('categories', 'quiz'), new moodle_url('/question/category.php', $params), navigation_node::TYPE_SETTING); ++ } ++ if ($contexts->have_one_edit_tab_cap('import')) { ++ $questionnode->add(get_string('import', 'quiz'), new moodle_url('/question/import.php', $params), navigation_node::TYPE_SETTING); ++ } ++ if ($contexts->have_one_edit_tab_cap('export')) { ++ $questionnode->add(get_string('export', 'quiz'), new moodle_url('/question/export.php', $params), navigation_node::TYPE_SETTING); ++ } ++ ++ return $questionnode; ++} ++ ++class question_edit_contexts { ++ ++ public static $CAPS = array( ++ 'editq' => array('moodle/question:add', ++ 'moodle/question:editmine', ++ 'moodle/question:editall', ++ 'moodle/question:viewmine', ++ 'moodle/question:viewall', ++ 'moodle/question:usemine', ++ 'moodle/question:useall', ++ 'moodle/question:movemine', ++ 'moodle/question:moveall'), ++ 'questions'=>array('moodle/question:add', ++ 'moodle/question:editmine', ++ 'moodle/question:editall', ++ 'moodle/question:viewmine', ++ 'moodle/question:viewall', ++ 'moodle/question:movemine', ++ 'moodle/question:moveall'), ++ 'categories'=>array('moodle/question:managecategory'), ++ 'import'=>array('moodle/question:add'), ++ 'export'=>array('moodle/question:viewall', 'moodle/question:viewmine')); ++ ++ protected $allcontexts; ++ ++ /** ++ * @param current context ++ */ ++ public function question_edit_contexts($thiscontext){ ++ $pcontextids = get_parent_contexts($thiscontext); ++ $contexts = array($thiscontext); ++ foreach ($pcontextids as $pcontextid){ ++ $contexts[] = get_context_instance_by_id($pcontextid); ++ } ++ $this->allcontexts = $contexts; ++ } ++ /** ++ * @return array all parent contexts ++ */ ++ public function all(){ ++ return $this->allcontexts; ++ } ++ /** ++ * @return object lowest context which must be either the module or course context ++ */ ++ public function lowest(){ ++ return $this->allcontexts[0]; ++ } ++ /** ++ * @param string $cap capability ++ * @return array parent contexts having capability, zero based index ++ */ ++ public function having_cap($cap){ ++ $contextswithcap = array(); ++ foreach ($this->allcontexts as $context){ ++ if (has_capability($cap, $context)){ ++ $contextswithcap[] = $context; ++ } ++ } ++ return $contextswithcap; ++ } ++ /** ++ * @param array $caps capabilities ++ * @return array parent contexts having at least one of $caps, zero based index ++ */ ++ public function having_one_cap($caps){ ++ $contextswithacap = array(); ++ foreach ($this->allcontexts as $context){ ++ foreach ($caps as $cap){ ++ if (has_capability($cap, $context)){ ++ $contextswithacap[] = $context; ++ break; //done with caps loop ++ } ++ } ++ } ++ return $contextswithacap; ++ } ++ /** ++ * @param string $tabname edit tab name ++ * @return array parent contexts having at least one of $caps, zero based index ++ */ ++ public function having_one_edit_tab_cap($tabname){ ++ return $this->having_one_cap(self::$CAPS[$tabname]); ++ } ++ /** ++ * Has at least one parent context got the cap $cap? ++ * ++ * @param string $cap capability ++ * @return boolean ++ */ ++ public function have_cap($cap){ ++ return (count($this->having_cap($cap))); ++ } ++ ++ /** ++ * Has at least one parent context got one of the caps $caps? ++ * ++ * @param array $caps capability ++ * @return boolean ++ */ ++ public function have_one_cap($caps){ ++ foreach ($caps as $cap) { ++ if ($this->have_cap($cap)) { ++ return true; ++ } ++ } ++ return false; ++ } ++ /** ++ * Has at least one parent context got one of the caps for actions on $tabname ++ * ++ * @param string $tabname edit tab name ++ * @return boolean ++ */ ++ public function have_one_edit_tab_cap($tabname){ ++ return $this->have_one_cap(self::$CAPS[$tabname]); ++ } ++ /** ++ * Throw error if at least one parent context hasn't got the cap $cap ++ * ++ * @param string $cap capability ++ */ ++ public function require_cap($cap){ ++ if (!$this->have_cap($cap)){ ++ print_error('nopermissions', '', '', $cap); ++ } ++ } ++ /** ++ * Throw error if at least one parent context hasn't got one of the caps $caps ++ * ++ * @param array $cap capabilities ++ */ ++ public function require_one_cap($caps) { ++ if (!$this->have_one_cap($caps)) { ++ $capsstring = join($caps, ', '); ++ print_error('nopermissions', '', '', $capsstring); ++ } ++ } ++ ++ /** ++ * Throw error if at least one parent context hasn't got one of the caps $caps ++ * ++ * @param string $tabname edit tab name ++ */ ++ public function require_one_edit_tab_cap($tabname){ ++ if (!$this->have_one_edit_tab_cap($tabname)) { ++ print_error('nopermissions', '', '', 'access question edit tab '.$tabname); ++ } ++ } ++} ++ ++/** ++ * Rewrite question url, file_rewrite_pluginfile_urls always build url by ++ * $file/$contextid/$component/$filearea/$itemid/$pathname_in_text, so we cannot add ++ * extra questionid and attempted in url by it, so we create quiz_rewrite_question_urls ++ * to build url here ++ * ++ * @param string $text text being processed ++ * @param string $file the php script used to serve files ++ * @param int $contextid ++ * @param string $component component ++ * @param string $filearea filearea ++ * @param array $ids other IDs will be used to check file permission ++ * @param int $itemid ++ * @param array $options ++ * @return string ++ */ ++function quiz_rewrite_question_urls($text, $file, $contextid, $component, $filearea, array $ids, $itemid, array $options=null) { ++ global $CFG; ++ ++ $options = (array)$options; ++ if (!isset($options['forcehttps'])) { ++ $options['forcehttps'] = false; ++ } ++ ++ if (!$CFG->slasharguments) { ++ $file = $file . '?file='; ++ } ++ ++ $baseurl = "$CFG->wwwroot/$file/$contextid/$component/$filearea/"; ++ ++ if (!empty($ids)) { ++ $baseurl .= (implode('/', $ids) . '/'); ++ } ++ ++ if ($itemid !== null) { ++ $baseurl .= "$itemid/"; ++ } ++ ++ if ($options['forcehttps']) { ++ $baseurl = str_replace('http://', 'https://', $baseurl); ++ } ++ ++ return str_replace('@@PLUGINFILE@@/', $baseurl, $text); ++} ++ ++/** ++ * Called by pluginfile.php to serve files related to the 'question' core ++ * component and for files belonging to qtypes. ++ * ++ * For files that relate to questions in a question_attempt, then we delegate to ++ * a function in the component that owns the attempt (for example in the quiz, ++ * or in core question preview) to get necessary inforation. ++ * ++ * (Note that, at the moment, all question file areas relate to questions in ++ * attempts, so the If at the start of the last paragraph is always true.) ++ * ++ * Does not return, either calls send_file_not_found(); or serves the file. ++ * ++ * @param object $course course settings object ++ * @param object $context context object ++ * @param string $component the name of the component we are serving files for. ++ * @param string $filearea the name of the file area. ++ * @param array $args the remaining bits of the file path. ++ * @param bool $forcedownload whether the user must be forced to download the file. ++ */ ++function question_pluginfile($course, $context, $component, $filearea, $args, $forcedownload) { ++ global $DB, $CFG; ++ ++ list($context, $course, $cm) = get_context_info_array($context->id); ++ require_login($course, false, $cm); ++ ++ if ($filearea === 'export') { ++ require_once($CFG->dirroot . '/question/editlib.php'); ++ $contexts = new question_edit_contexts($context); ++ // check export capability ++ $contexts->require_one_edit_tab_cap('export'); ++ $category_id = (int)array_shift($args); ++ $format = array_shift($args); ++ $cattofile = array_shift($args); ++ $contexttofile = array_shift($args); ++ $filename = array_shift($args); ++ ++ // load parent class for import/export ++ require_once($CFG->dirroot . '/question/format.php'); ++ require_once($CFG->dirroot . '/question/editlib.php'); ++ require_once($CFG->dirroot . '/question/format/' . $format . '/format.php'); ++ ++ $classname = 'qformat_' . $format; ++ if (!class_exists($classname)) { ++ send_file_not_found(); ++ } ++ ++ $qformat = new $classname(); ++ ++ if (!$category = $DB->get_record('question_categories', array('id' => $category_id))) { ++ send_file_not_found(); ++ } ++ ++ $qformat->setCategory($category); ++ $qformat->setContexts($contexts->having_one_edit_tab_cap('export')); ++ $qformat->setCourse($course); ++ ++ if ($cattofile == 'withcategories') { ++ $qformat->setCattofile(true); ++ } else { ++ $qformat->setCattofile(false); ++ } ++ ++ if ($contexttofile == 'withcontexts') { ++ $qformat->setContexttofile(true); ++ } else { ++ $qformat->setContexttofile(false); ++ } ++ ++ if (!$qformat->exportpreprocess()) { ++ send_file_not_found(); ++ print_error('exporterror', 'question', $thispageurl->out()); ++ } ++ ++ // export data to moodle file pool ++ if (!$content = $qformat->exportprocess(true)) { ++ send_file_not_found(); ++ } ++ ++ //DEBUG ++ //echo ''; ++ //die; ++ send_file($content, $filename, 0, 0, true, true, $qformat->mime_type()); ++ } ++ ++ $attemptid = (int)array_shift($args); ++ $questionid = (int)array_shift($args); ++ ++ ++ if ($attemptid === 0) { ++ // preview ++ require_once($CFG->dirroot . '/question/previewlib.php'); ++ return question_preview_question_pluginfile($course, $context, ++ $component, $filearea, $attemptid, $questionid, $args, $forcedownload); ++ ++ } else { ++ $module = $DB->get_field('question_attempts', 'modulename', ++ array('id' => $attemptid)); ++ ++ $dir = get_component_directory($module); ++ if (!file_exists("$dir/lib.php")) { ++ send_file_not_found(); ++ } ++ include_once("$dir/lib.php"); ++ ++ $filefunction = $module . '_question_pluginfile'; ++ if (!function_exists($filefunction)) { ++ send_file_not_found(); ++ } ++ ++ $filefunction($course, $context, $component, $filearea, $attemptid, $questionid, ++ $args, $forcedownload); ++ ++ send_file_not_found(); ++ } ++} ++ ++/** ++ * Final test for whether a studnet should be allowed to see a particular file. ++ * This delegates the decision to the question type plugin. ++ * ++ * @param object $question The question to be rendered. ++ * @param object $state The state to render the question in. ++ * @param object $options An object specifying the rendering options. ++ * @param string $component the name of the component we are serving files for. ++ * @param string $filearea the name of the file area. ++ * @param array $args the remaining bits of the file path. ++ * @param bool $forcedownload whether the user must be forced to download the file. ++ */ ++function question_check_file_access($question, $state, $options, $contextid, $component, ++ $filearea, $args, $forcedownload) { ++ global $QTYPES; ++ return $QTYPES[$question->qtype]->check_file_access($question, $state, $options, $contextid, $component, ++ $filearea, $args, $forcedownload); ++} ++ ++/** ++ * Create url for question export ++ * ++ * @param int $contextid, current context ++ * @param int $categoryid, categoryid ++ * @param string $format ++ * @param string $withcategories ++ * @param string $ithcontexts ++ * @param moodle_url export file url ++ */ ++function question_make_export_url($contextid, $categoryid, $format, $withcategories, $withcontexts, $filename) { ++ global $CFG; ++ $urlbase = "$CFG->httpswwwroot/pluginfile.php"; ++ return moodle_url::make_file_url($urlbase, "/$contextid/question/export/{$categoryid}/{$format}/{$withcategories}/{$withcontexts}/{$filename}", true); ++} diff --git a/question/todo/questionlib_qe.diff.txt b/question/todo/questionlib_qe.diff.txt new file mode 100644 index 00000000000..31ce5d9ebd9 --- /dev/null +++ b/question/todo/questionlib_qe.diff.txt @@ -0,0 +1,1720 @@ +diff --git a/lib/questionlib.php b/lib/questionlib.php +index 04752b2..3211e92 100644 +--- a/lib/questionlib.php ++++ b/lib/questionlib.php +@@ -1,4 +1,20 @@ +-. ++ + /** + * Code for handling and processing questions + * +@@ -10,34 +26,18 @@ + * TODO: separate those functions which form part of the API + * from the helper functions. + * +- * @author Martin Dougiamas and many others. This has recently been completely +- * rewritten by Alex Smith, Julian Sedding and Gustav Delius as part of +- * the Serving Mathematics project +- * {@link http://maths.york.ac.uk/serving_maths} +- * @license http://www.gnu.org/copyleft/gpl.html GNU Public License +- * @package question ++ * @package moodlecore ++ * @subpackage questionbank ++ * @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com} ++ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +-/// CONSTANTS /////////////////////////////////// + +-/**#@+ +- * The different types of events that can create question states +- */ +-define('QUESTION_EVENTOPEN', '0'); // The state was created by Moodle +-define('QUESTION_EVENTNAVIGATE', '1'); // The responses were saved because the student navigated to another page (this is not currently used) +-define('QUESTION_EVENTSAVE', '2'); // The student has requested that the responses should be saved but not submitted or validated +-define('QUESTION_EVENTGRADE', '3'); // Moodle has graded the responses. A SUBMIT event can be changed to a GRADE event by Moodle. +-define('QUESTION_EVENTDUPLICATE', '4'); // The responses submitted were the same as previously +-define('QUESTION_EVENTVALIDATE', '5'); // The student has requested a validation. This causes the responses to be saved as well, but not graded. +-define('QUESTION_EVENTCLOSEANDGRADE', '6'); // Moodle has graded the responses. A CLOSE event can be changed to a CLOSEANDGRADE event by Moodle. +-define('QUESTION_EVENTSUBMIT', '7'); // The student response has been submitted but it has not yet been marked +-define('QUESTION_EVENTCLOSE', '8'); // The response has been submitted and the session has been closed, either because the student requested it or because Moodle did it (e.g. because of a timelimit). The responses have not been graded. +-define('QUESTION_EVENTMANUALGRADE', '9'); // Grade was entered by teacher +- +-define('QUESTION_EVENTS_GRADED', QUESTION_EVENTGRADE.','. +- QUESTION_EVENTCLOSEANDGRADE.','. +- QUESTION_EVENTMANUALGRADE); +-/**#@-*/ ++require_once($CFG->dirroot . '/question/engine/lib.php'); ++require_once($CFG->dirroot . '/question/type/questiontype.php'); ++ ++ ++/// CONSTANTS /////////////////////////////////// + + /**#@+ + * The core question types. +@@ -59,7 +59,7 @@ define("ESSAY", "essay"); + * Constant determines the number of answer boxes supplied in the editing + * form for multiple choice and similar question types. + */ +-define("QUESTION_NUMANS", "10"); ++define("QUESTION_NUMANS", 10); + + /** + * Constant determines the number of answer boxes supplied in the editing +@@ -78,22 +78,10 @@ define("QUESTION_NUMANS_ADD", 3); + /** + * The options used when popping up a question preview window in Javascript. + */ +-define('QUESTION_PREVIEW_POPUP_OPTIONS', 'scrollbars=yes,resizable=yes,width=700,height=540'); ++define('QUESTION_PREVIEW_POPUP_OPTIONS', 'scrollbars=yes,resizable=yes,width=800,height=600'); + + /**#@+ +- * Option flags for ->optionflags +- * The options are read out via bitwise operation using these constants +- */ +-/** +- * Whether the questions is to be run in adaptive mode. If this is not set then +- * a question closes immediately after the first submission of responses. This +- * is how question is Moodle always worked before version 1.5 +- */ +-define('QUESTION_ADAPTIVE', 1); +- +-/** + * options used in forms that move files. +- * + */ + define('QUESTION_FILENOTHINGSELECTED', 0); + define('QUESTION_FILEDONOTHING', 1); +@@ -103,64 +91,13 @@ define('QUESTION_FILEMOVELINKSONLY', 4); + + /**#@-*/ + +-/// QTYPES INITIATION ////////////////// +-// These variables get initialised via calls to question_register_questiontype +-// as the question type classes are included. +-global $QTYPES, $QTYPE_MANUAL, $QTYPE_EXCLUDE_FROM_RANDOM; +-/** +- * Array holding question type objects +- */ +-$QTYPES = array(); + /** +- * String in the format "'type1','type2'" that can be used in SQL clauses like +- * "WHERE q.type IN ($QTYPE_MANUAL)". ++ * @global array holding question type objects ++ * @deprecated + */ +-$QTYPE_MANUAL = ''; +-/** +- * String in the format "'type1','type2'" that can be used in SQL clauses like +- * "WHERE q.type NOT IN ($QTYPE_EXCLUDE_FROM_RANDOM)". +- */ +-$QTYPE_EXCLUDE_FROM_RANDOM = ''; +- +-/** +- * Add a new question type to the various global arrays above. +- * +- * @param object $qtype An instance of the new question type class. +- */ +-function question_register_questiontype($qtype) { +- global $QTYPES, $QTYPE_MANUAL, $QTYPE_EXCLUDE_FROM_RANDOM; +- +- $name = $qtype->name(); +- $QTYPES[$name] = $qtype; +- if ($qtype->is_manual_graded()) { +- if ($QTYPE_MANUAL) { +- $QTYPE_MANUAL .= ','; +- } +- $QTYPE_MANUAL .= "'$name'"; +- } +- if (!$qtype->is_usable_by_random()) { +- if ($QTYPE_EXCLUDE_FROM_RANDOM) { +- $QTYPE_EXCLUDE_FROM_RANDOM .= ','; +- } +- $QTYPE_EXCLUDE_FROM_RANDOM .= "'$name'"; +- } +-} +- +-require_once("$CFG->dirroot/question/type/questiontype.php"); ++global $QTYPES; ++$QTYPES = question_bank::get_all_qtypes(); + +-// Load the questiontype.php file for each question type +-// These files in turn call question_register_questiontype() +-// with a new instance of each qtype class. +-$qtypenames= get_list_of_plugins('question/type'); +-foreach($qtypenames as $qtypename) { +- // Instanciates all plug-in question types +- $qtypefilepath= "$CFG->dirroot/question/type/$qtypename/questiontype.php"; +- +- // echo "Loading $qtypename
"; // Uncomment for debugging +- if (is_readable($qtypefilepath)) { +- require_once($qtypefilepath); +- } +-} + + /** + * An array of question type names translated to the user's language, suitable for use when +@@ -173,11 +110,10 @@ foreach($qtypenames as $qtypename) { + * @return array an array of question type names translated to the user's language. + */ + function question_type_menu() { +- global $QTYPES; + static $menu_options = null; + if (is_null($menu_options)) { + $menu_options = array(); +- foreach ($QTYPES as $name => $qtype) { ++ foreach (question_bank::get_all_qtypes() as $name => $qtype) { + $menuname = $qtype->menu_name(); + if ($menuname) { + $menu_options[$name] = $menuname; +@@ -187,86 +123,58 @@ function question_type_menu() { + return $menu_options; + } + +-/// OTHER CLASSES ///////////////////////////////////////////////////////// +- +-/** +- * This holds the options that are set by the course module +- */ +-class cmoptions { +- /** +- * Whether a new attempt should be based on the previous one. If true +- * then a new attempt will start in a state where all responses are set +- * to the last responses from the previous attempt. +- */ +- var $attemptonlast = false; +- +- /** +- * Various option flags. The flags are accessed via bitwise operations +- * using the constants defined in the CONSTANTS section above. +- */ +- var $optionflags = QUESTION_ADAPTIVE; +- +- /** +- * Determines whether in the calculation of the score for a question +- * penalties for earlier wrong responses within the same attempt will +- * be subtracted. +- */ +- var $penaltyscheme = true; +- +- /** +- * The maximum time the user is allowed to answer the questions withing +- * an attempt. This is measured in minutes so needs to be multiplied by +- * 60 before compared to timestamps. If set to 0 no timelimit will be applied +- */ +- var $timelimit = 0; +- +- /** +- * Timestamp for the closing time. Responses submitted after this time will +- * be saved but no credit will be given for them. +- */ +- var $timeclose = 9999999999; +- +- /** +- * The id of the course from withing which the question is currently being used +- */ +- var $course = SITEID; +- +- /** +- * Whether the answers in a multiple choice question should be randomly +- * shuffled when a new attempt is started. +- */ +- var $shuffleanswers = true; +- +- /** +- * The number of decimals to be shown when scores are printed +- */ +- var $decimalpoints = 2; +-} +- +- + /// FUNCTIONS ////////////////////////////////////////////////////// + + /** + * Returns an array of names of activity modules that use this question + * ++ * @deprecated since Moodle 2.1. Use {@link questions_in_use} instead. ++ + * @param object $questionid + * @return array of strings + */ + function question_list_instances($questionid) { ++ throw new coding_exception('question_list_instances has been deprectated. Please use questions_in_use instead.'); ++} ++ ++/** ++ * @param array $questionids of question ids. ++ * @return boolean whether any of these questions are being used by any part of Moodle. ++ */ ++function questions_in_use($questionids) { + global $CFG; +- $instances = array(); +- $modules = get_records('modules'); +- foreach ($modules as $module) { +- $fullmod = $CFG->dirroot . '/mod/' . $module->name; +- if (file_exists($fullmod . '/lib.php')) { +- include_once($fullmod . '/lib.php'); +- $fn = $module->name.'_question_list_instances'; ++ ++ if (question_engine::questions_in_use($questionids)) { ++ return true; ++ } ++ ++ foreach (get_records('modules') as $module) { ++ $lib = $CFG->dirroot . '/mod/' . $module->name . '/lib.php'; ++ if (file_exists($lib)) { ++ include_once($lib); ++ ++ $fn = $module->name . '_questions_in_use'; + if (function_exists($fn)) { +- $instances = $instances + $fn($questionid); ++ if ($fn($questionids)) { ++ return true; ++ } ++ } else { ++ ++ // Fallback for legacy modules. ++ $fn = $module->name.'_question_list_instances'; ++ foreach ($questionids as $questionid) { ++ if (function_exists($fn)) { ++ $instances = $fn($questionid); ++ if (!empty($instances)) { ++ return true; ++ } ++ } ++ } + } + } + } +- return $instances; ++ ++ return false; + } + + /** +@@ -298,39 +206,42 @@ function question_context_has_any_questions($context) { + * @return object ->gradeoptionsfull full array ->gradeoptions +ve only + */ + function get_grade_options() { +- // define basic array of grades ++ // define basic array of grades. This list comprises all fractions of the form: ++ // a. p/q for q <= 6, 0 <= p <= q ++ // b. p/10 for 0 <= p <= 10 ++ // c. 1/q for 1 <= q <= 10 ++ // d. 1/20 + $grades = array( +- 1.00, +- 0.90, +- 0.83333, +- 0.80, +- 0.75, +- 0.70, +- 0.66666, +- 0.60, +- 0.50, +- 0.40, +- 0.33333, +- 0.30, +- 0.25, +- 0.20, +- 0.16666, +- 0.142857, +- 0.125, +- 0.11111, +- 0.10, +- 0.05, +- 0); ++ 1.0000000, ++ 0.9000000, ++ 0.8333333, ++ 0.8000000, ++ 0.7500000, ++ 0.7000000, ++ 0.6666667, ++ 0.6000000, ++ 0.5000000, ++ 0.4000000, ++ 0.3333333, ++ 0.3000000, ++ 0.2500000, ++ 0.2000000, ++ 0.1666667, ++ 0.1428571, ++ 0.1250000, ++ 0.1111111, ++ 0.1000000, ++ 0.0500000, ++ 0.0000000); + + // iterate through grades generating full range of options + $gradeoptionsfull = array(); + $gradeoptions = array(); + foreach ($grades as $grade) { + $percentage = 100 * $grade; +- $neggrade = -$grade; +- $gradeoptions["$grade"] = "$percentage %"; +- $gradeoptionsfull["$grade"] = "$percentage %"; +- $gradeoptionsfull["$neggrade"] = -$percentage." %"; ++ $gradeoptions["$grade"] = $percentage . '%'; ++ $gradeoptionsfull["$grade"] = $percentage . '%'; ++ $gradeoptionsfull['' . (-$grade)] = (-$percentage) . '%'; + } + $gradeoptionsfull["0"] = $gradeoptions["0"] = get_string("none"); + +@@ -386,60 +297,43 @@ function match_grade_options($gradeoptionsfull, $grade, $matchgrades='error') { + } + + /** +- * Tests whether a category is in use by any activity module +- * +- * @return boolean +- * @param integer $categoryid +- * @param boolean $recursive Whether to examine category children recursively ++ * @deprecated Since Moodle 2.1. Use {@link question_category_in_use} instead. ++ * @param integer $categoryid a question category id. ++ * @param boolean $recursive whether to check child categories too. ++ * @return boolean whether any question in this category is in use. + */ + function question_category_isused($categoryid, $recursive = false) { +- +- //Look at each question in the category +- if ($questions = get_records('question', 'category', $categoryid)) { +- foreach ($questions as $question) { +- if (count(question_list_instances($question->id))) { +- return true; +- } +- } +- } +- +- //Look under child categories recursively +- if ($recursive) { +- if ($children = get_records('question_categories', 'parent', $categoryid)) { +- foreach ($children as $child) { +- if (question_category_isused($child->id, $recursive)) { +- return true; +- } +- } +- } +- } +- +- return false; ++ throw new coding_exception('question_category_isused has been deprectated. Please use question_category_in_use instead.'); + } + + /** +- * Deletes all data associated to an attempt from the database ++ * Tests whether any question in a category is used by any part of Moodle. + * +- * @param integer $attemptid The id of the attempt being deleted ++ * @param integer $categoryid a question category id. ++ * @param boolean $recursive whether to check child categories too. ++ * @return boolean whether any question in this category is in use. + */ +-function delete_attempt($attemptid) { +- global $QTYPES; ++function question_category_in_use($categoryid, $recursive = false) { + +- $states = get_records('question_states', 'attempt', $attemptid); +- if ($states) { +- $stateslist = implode(',', array_keys($states)); +- +- // delete question-type specific data +- foreach ($QTYPES as $qtype) { +- $qtype->delete_states($stateslist); ++ // Look at each question in the category ++ if ($questions = get_records('question', 'category', $categoryid)) { ++ if (questions_in_use($questions)) { ++ return true; + } + } ++ if (!$recursive) { ++ return false; ++ } + +- // delete entries from all other question tables +- // It is important that this is done only after calling the questiontype functions +- delete_records("question_states", "attempt", $attemptid); +- delete_records("question_sessions", "attemptid", $attemptid); +- delete_records("question_attempts", "id", $attemptid); ++ // Look under child categories recursively ++ if ($children = get_records('question_categories', 'parent', $categoryid)) { ++ foreach ($children as $child) { ++ if (question_category_in_use($child->id, $recursive)) { ++ return true; ++ } ++ } ++ } ++ return false; + } + + /** +@@ -448,7 +342,7 @@ function delete_attempt($attemptid) { + * It will not delete a question if it is used by an activity module + * @param object $question The question being deleted + */ +-function delete_question($questionid) { ++function question_delete_question($questionid) { + global $QTYPES; + + if (!$question = get_record('question', 'id', $questionid)) { +@@ -459,12 +353,17 @@ function delete_question($questionid) { + } + + // Do not delete a question if it is used by an activity module +- if (count(question_list_instances($questionid))) { ++ if (questions_in_use(array($questionid))) { + return; + } + +- // delete questiontype-specific data ++ // Check permissions. + question_require_capability_on($question, 'edit'); ++ ++ $dm = new question_engine_data_mapper(); ++ $dm->delete_previews($questionid); ++ ++ // Delete questiontype-specific data + if ($question) { + if (isset($QTYPES[$question->qtype])) { + $QTYPES[$question->qtype]->delete_question($questionid); +@@ -473,26 +372,11 @@ function delete_question($questionid) { + echo "Question with id $questionid does not exist.
"; + } + +- if ($states = get_records('question_states', 'question', $questionid)) { +- $stateslist = implode(',', array_keys($states)); +- +- // delete questiontype-specific data +- foreach ($QTYPES as $qtype) { +- $qtype->delete_states($stateslist); +- } +- } +- +- // delete entries from all other question tables +- // It is important that this is done only after calling the questiontype functions +- delete_records("question_answers", "question", $questionid); +- delete_records("question_states", "question", $questionid); +- delete_records("question_sessions", "questionid", $questionid); +- +- // Now recursively delete all child questions ++ // Recursively delete all child questions + if ($children = get_records('question', 'parent', $questionid)) { + foreach ($children as $child) { + if ($child->id != $questionid) { +- delete_question($child->id); ++ question_delete_question($child->id); + } + } + } +@@ -517,7 +401,7 @@ function question_delete_course($course, $feedback=true) { + //Cache some strings + $strcatdeleted = get_string('unusedcategorydeleted', 'quiz'); + $coursecontext = get_context_instance(CONTEXT_COURSE, $course->id); +- $categoriescourse = get_records('question_categories', 'contextid', $coursecontext->id, 'parent', 'id, parent, name'); ++ $categoriescourse = get_records('question_categories', 'contextid', $coursecontext->id, 'parent', 'id, parent, name, contextid'); + + if ($categoriescourse) { + +@@ -531,7 +415,7 @@ function question_delete_course($course, $feedback=true) { + //deleting questions + if ($questions = get_records("question", "category", $category->id)) { + foreach ($questions as $question) { +- delete_question($question->id); ++ question_delete_question($question->id); + } + delete_records("question", "category", $category->id); + } +@@ -578,7 +462,7 @@ function question_delete_course_category($category, $newcategory, $feedback=true + + // Try to delete each question. + foreach ($questions as $question) { +- delete_question($question->id); ++ question_delete_question($question->id); + } + + // Check to see if there were any questions that were kept because they are +@@ -676,7 +560,7 @@ function question_delete_activity($cm, $feedback=true) { + //Cache some strings + $strcatdeleted = get_string('unusedcategorydeleted', 'quiz'); + $modcontext = get_context_instance(CONTEXT_MODULE, $cm->id); +- if ($categoriesmods = get_records('question_categories', 'contextid', $modcontext->id, 'parent', 'id, parent, name')){ ++ if ($categoriesmods = get_records('question_categories', 'contextid', $modcontext->id, 'parent', 'id, parent, name, contextid')){ + //Sort categories following their tree (parent-child) relationships + //this will make the feedback more readable + $categoriesmods = sort_categories_by_tree($categoriesmods); +@@ -687,7 +571,7 @@ function question_delete_activity($cm, $feedback=true) { + //deleting questions + if ($questions = get_records("question", "category", $category->id)) { + foreach ($questions as $question) { +- delete_question($question->id); ++ question_delete_question($question->id); + } + delete_records("question", "category", $category->id); + } +@@ -751,725 +635,148 @@ function questionbank_navigation_tabs(&$row, $contexts, $querystring) { + } + + /** +- * Private function to factor common code out of get_question_options(). +- * +- * @param object $question the question to tidy. +- * @return boolean true if successful, else false. ++ * Generate the URL for starting a new preview of a given question with the given options. ++ * @param integer $questionid the question to preview. ++ * @param string $preferredbehaviour the behaviour to use for the preview. ++ * @param float $maxmark the maximum to mark the question out of. ++ * @param question_display_options $displayoptions the display options to use. ++ * @return string the URL. + */ +-function _tidy_question(&$question) { +- global $QTYPES; +- if (!array_key_exists($question->qtype, $QTYPES)) { +- $question->qtype = 'missingtype'; +- $question->questiontext = '

' . get_string('warningmissingtype', 'quiz') . '

' . $question->questiontext; +- } +- $question->name_prefix = question_make_name_prefix($question->id); +- return $QTYPES[$question->qtype]->get_question_options($question); +-} +- +-/** +- * Updates the question objects with question type specific +- * information by calling {@link get_question_options()} ++function question_preview_url($questionid, $preferredbehaviour, $maxmark, $displayoptions) { ++ global $CFG; ++ return $CFG->wwwroot . '/question/preview.php?id=' . $questionid . ++ '&behaviour=' . $preferredbehaviour . ++ '&maxmark=' . $maxmark . ++ '&correctness=' . $displayoptions->correctness . ++ '&marks=' . $displayoptions->marks . ++ '&markdp=' . $displayoptions->markdp . ++ '&feedback=' . (bool) $displayoptions->feedback . ++ '&generalfeedback=' . (bool) $displayoptions->generalfeedback . ++ '&rightanswer=' . (bool) $displayoptions->rightanswer . ++ '&history=' . (bool) $displayoptions->history; ++} ++ ++/** ++ * Given a list of ids, load the basic information about a set of questions from the questions table. ++ * The $join and $extrafields arguments can be used together to pull in extra data. ++ * See, for example, the usage in mod/quiz/attemptlib.php, and ++ * read the code below to see how the SQL is assembled. Throws exceptions on error. + * +- * Can be called either with an array of question objects or with a single +- * question object. ++ * @global object ++ * @global object ++ * @param array $questionids array of question ids. ++ * @param string $extrafields extra SQL code to be added to the query. ++ * @param string $join extra SQL code to be added to the query. ++ * @param array $extraparams values for any placeholders in $join. ++ * You are strongly recommended to use named placeholder. + * +- * @param mixed $questions Either an array of question objects to be updated +- * or just a single question object +- * @return bool Indicates success or failure. ++ * @return array partially complete question objects. You need to call get_question_options ++ * on them before they can be properly used. + */ +-function get_question_options(&$questions) { +- if (is_array($questions)) { // deal with an array of questions +- foreach ($questions as $i => $notused) { +- if (!_tidy_question($questions[$i])) { +- return false; +- } +- } +- return true; +- } else { // deal with single question +- return _tidy_question($questions); +- } +-} +- +-/** +-* Loads the most recent state of each question session from the database +-* or create new one. +-* +-* For each question the most recent session state for the current attempt +-* is loaded from the question_states table and the question type specific data and +-* responses are added by calling {@link restore_question_state()} which in turn +-* calls {@link restore_session_and_responses()} for each question. +-* If no states exist for the question instance an empty state object is +-* created representing the start of a session and empty question +-* type specific information and responses are created by calling +-* {@link create_session_and_responses()}. +-* +-* @return array An array of state objects representing the most recent +-* states of the question sessions. +-* @param array $questions The questions for which sessions are to be restored or +-* created. +-* @param object $cmoptions +-* @param object $attempt The attempt for which the question sessions are +-* to be restored or created. +-* @param mixed either the id of a previous attempt, if this attmpt is +-* building on a previous one, or false for a clean attempt. +-*/ +-function get_question_states(&$questions, $cmoptions, $attempt, $lastattemptid = false) { +- global $CFG, $QTYPES; +- +- // get the question ids +- $ids = array_keys($questions); +- $questionlist = implode(',', $ids); +- +- // The question field must be listed first so that it is used as the +- // array index in the array returned by get_records_sql +- $statefields = 'n.questionid as question, s.id, s.attempt, s.originalquestion, ' . +- 's.seq_number, s.answer, s.timestamp, s.event, s.grade, s.raw_grade, ' . +- 's.penalty, n.sumpenalty, n.manualcomment'; +- // Load the newest states for the questions +- $sql = "SELECT $statefields". +- " FROM {$CFG->prefix}question_states s,". +- " {$CFG->prefix}question_sessions n". +- " WHERE s.id = n.newest". +- " AND n.attemptid = '$attempt->uniqueid'". +- " AND n.questionid IN ($questionlist)"; +- $states = get_records_sql($sql); +- +- // Load the newest graded states for the questions +- $sql = "SELECT $statefields". +- " FROM {$CFG->prefix}question_states s,". +- " {$CFG->prefix}question_sessions n". +- " WHERE s.id = n.newgraded". +- " AND n.attemptid = '$attempt->uniqueid'". +- " AND n.questionid IN ($questionlist)"; +- $gradedstates = get_records_sql($sql); +- +- // loop through all questions and set the last_graded states +- foreach ($ids as $i) { +- if (isset($states[$i])) { +- restore_question_state($questions[$i], $states[$i]); +- if (isset($gradedstates[$i])) { +- restore_question_state($questions[$i], $gradedstates[$i]); +- $states[$i]->last_graded = $gradedstates[$i]; +- } else { +- $states[$i]->last_graded = clone($states[$i]); +- } +- } else { +- if ($lastattemptid) { +- // If the new attempt is to be based on this previous attempt. +- // Find the responses from the previous attempt and save them to the new session +- +- // Load the last graded state for the question. Note, $statefields is +- // the same as above, except that we don't want n.manualcomment. +- $statefields = 'n.questionid as question, s.id, s.attempt, s.originalquestion, ' . +- 's.seq_number, s.answer, s.timestamp, s.event, s.grade, s.raw_grade, ' . +- 's.penalty, n.sumpenalty'; +- $sql = "SELECT $statefields". +- " FROM {$CFG->prefix}question_states s,". +- " {$CFG->prefix}question_sessions n". +- " WHERE s.id = n.newest". +- " AND n.attemptid = '$lastattemptid'". +- " AND n.questionid = '$i'"; +- if (!$laststate = get_record_sql($sql)) { +- // Only restore previous responses that have been graded +- continue; +- } +- // Restore the state so that the responses will be restored +- restore_question_state($questions[$i], $laststate); +- $states[$i] = clone($laststate); +- unset($states[$i]->id); +- } else { +- // create a new empty state +- $states[$i] = new object; +- $states[$i]->question = $i; +- $states[$i]->responses = array('' => ''); +- $states[$i]->raw_grade = 0; +- } +- +- // now fill/overide initial values +- $states[$i]->attempt = $attempt->uniqueid; +- $states[$i]->seq_number = 0; +- $states[$i]->timestamp = $attempt->timestart; +- $states[$i]->event = ($attempt->timefinish) ? QUESTION_EVENTCLOSE : QUESTION_EVENTOPEN; +- $states[$i]->grade = 0; +- $states[$i]->penalty = 0; +- $states[$i]->sumpenalty = 0; +- $states[$i]->manualcomment = ''; +- +- // Prevent further changes to the session from incrementing the +- // sequence number +- $states[$i]->changed = true; +- +- if ($lastattemptid) { +- // prepare the previous responses for new processing +- $action = new stdClass; +- $action->responses = $laststate->responses; +- $action->timestamp = $laststate->timestamp; +- $action->event = QUESTION_EVENTSAVE; //emulate save of questions from all pages MDL-7631 +- +- // Process these responses ... +- question_process_responses($questions[$i], $states[$i], $action, $cmoptions, $attempt); +- +- // Fix for Bug #5506: When each attempt is built on the last one, +- // preserve the options from any previous attempt. +- if ( isset($laststate->options) ) { +- $states[$i]->options = $laststate->options; +- } +- } else { +- // Create the empty question type specific information +- if (!$QTYPES[$questions[$i]->qtype]->create_session_and_responses( +- $questions[$i], $states[$i], $cmoptions, $attempt)) { +- return false; +- } +- } +- $states[$i]->last_graded = clone($states[$i]); +- } ++function question_preload_questions($questionids, $extrafields = '', $join = '') { ++ global $CFG; ++ if (empty($questionids)) { ++ return array(); + } +- return $states; +-} +- +- +-/** +-* Creates the run-time fields for the states +-* +-* Extends the state objects for a question by calling +-* {@link restore_session_and_responses()} +-* @param object $question The question for which the state is needed +-* @param object $state The state as loaded from the database +-* @return boolean Represents success or failure +-*/ +-function restore_question_state(&$question, &$state) { +- global $QTYPES; +- +- // initialise response to the value in the answer field +- $state->responses = array('' => addslashes($state->answer)); +- unset($state->answer); +- $state->manualcomment = isset($state->manualcomment) ? addslashes($state->manualcomment) : ''; +- +- // Set the changed field to false; any code which changes the +- // question session must set this to true and must increment +- // ->seq_number. The save_question_session +- // function will save the new state object to the database if the field is +- // set to true. +- $state->changed = false; +- +- // Load the question type specific data +- return $QTYPES[$question->qtype] +- ->restore_session_and_responses($question, $state); +- +-} +- +-/** +-* Saves the current state of the question session to the database +-* +-* The state object representing the current state of the session for the +-* question is saved to the question_states table with ->responses[''] saved +-* to the answer field of the database table. The information in the +-* question_sessions table is updated. +-* The question type specific data is then saved. +-* @return mixed The id of the saved or updated state or false +-* @param object $question The question for which session is to be saved. +-* @param object $state The state information to be saved. In particular the +-* most recent responses are in ->responses. The object +-* is updated to hold the new ->id. +-*/ +-function save_question_session(&$question, &$state) { +- global $QTYPES; +- // Check if the state has changed +- if (!$state->changed && isset($state->id)) { +- return $state->id; ++ if ($join) { ++ $join = ' JOIN '.$join; + } +- // Set the legacy answer field +- $state->answer = isset($state->responses['']) ? $state->responses[''] : ''; +- +- // Save the state +- if (!empty($state->update)) { // this forces the old state record to be overwritten +- update_record('question_states', $state); +- } else { +- if (!$state->id = insert_record('question_states', $state)) { +- unset($state->id); +- unset($state->answer); +- return false; +- } ++ if ($extrafields) { ++ $extrafields = ', ' . $extrafields; + } ++ $sql = 'SELECT q.*' . $extrafields . " FROM {$CFG->prefix}question q" . $join . ++ ' WHERE q.id IN (' . implode(',', $questionids) . ')'; + +- // create or update the session +- if (!$session = get_record('question_sessions', 'attemptid', +- $state->attempt, 'questionid', $question->id)) { +- $session->attemptid = $state->attempt; +- $session->questionid = $question->id; +- $session->newest = $state->id; +- // The following may seem weird, but the newgraded field needs to be set +- // already even if there is no graded state yet. +- $session->newgraded = $state->id; +- $session->sumpenalty = $state->sumpenalty; +- $session->manualcomment = $state->manualcomment; +- if (!insert_record('question_sessions', $session)) { +- error('Could not insert entry in question_sessions'); +- } +- } else { +- $session->newest = $state->id; +- if (question_state_is_graded($state) or $state->event == QUESTION_EVENTOPEN) { +- // this state is graded or newly opened, so it goes into the lastgraded field as well +- $session->newgraded = $state->id; +- $session->sumpenalty = $state->sumpenalty; +- $session->manualcomment = $state->manualcomment; +- } else { +- $session->manualcomment = addslashes($session->manualcomment); +- } +- update_record('question_sessions', $session); ++ // Load the questions ++ if (!$questions = get_records_sql($sql)) { ++ return array(); + } + +- unset($state->answer); +- +- // Save the question type specific state information and responses +- if (!$QTYPES[$question->qtype]->save_session_and_responses( +- $question, $state)) { +- return false; ++ foreach ($questions as $question) { ++ $question->_partiallyloaded = true; + } +- // Reset the changed flag +- $state->changed = false; +- return $state->id; +-} + +-/** +-* Determines whether a state has been graded by looking at the event field +-* +-* @return boolean true if the state has been graded +-* @param object $state +-*/ +-function question_state_is_graded($state) { +- $gradedevents = explode(',', QUESTION_EVENTS_GRADED); +- return (in_array($state->event, $gradedevents)); +-} ++ // Note, a possible optimisation here would be to not load the TEXT fields ++ // (that is, questiontext and generalfeedback) here, and instead load them in ++ // question_load_questions. That would add one DB query, but reduce the amount ++ // of data transferred most of the time. I am not going to do this optimisation ++ // until it is shown to be worthwhile. + +-/** +-* Determines whether a state has been closed by looking at the event field +-* +-* @return boolean true if the state has been closed +-* @param object $state +-*/ +-function question_state_is_closed($state) { +- return ($state->event == QUESTION_EVENTCLOSE +- or $state->event == QUESTION_EVENTCLOSEANDGRADE +- or $state->event == QUESTION_EVENTMANUALGRADE); ++ return $questions; + } + +- + /** +- * Extracts responses from submitted form ++ * Load a set of questions, given a list of ids. The $join and $extrafields arguments can be used ++ * together to pull in extra data. See, for example, the usage in mod/quiz/attempt.php, and ++ * read the code below to see how the SQL is assembled. Throws exceptions on error. + * +- * This can extract the responses given to one or several questions present on a page +- * It returns an array with one entry for each question, indexed by question id +- * Each entry is an object with the properties +- * ->event The event that has triggered the submission. This is determined by which button +- * the user has pressed. +- * ->responses An array holding the responses to an individual question, indexed by the +- * name of the corresponding form element. +- * ->timestamp A unix timestamp +- * @return array array of action objects, indexed by question ids. +- * @param array $questions an array containing at least all questions that are used on the form +- * @param array $formdata the data submitted by the form on the question page +- * @param integer $defaultevent the event type used if no 'mark' or 'validate' is submitted ++ * @param array $questionids array of question ids. ++ * @param string $extrafields extra SQL code to be added to the query. ++ * @param string $join extra SQL code to be added to the query. ++ * @param array $extraparams values for any placeholders in $join. ++ * You are strongly recommended to use named placeholder. ++ * ++ * @return array question objects. + */ +-function question_extract_responses($questions, $formdata, $defaultevent=QUESTION_EVENTSAVE) { +- +- $time = time(); +- $actions = array(); +- foreach ($formdata as $key => $response) { +- // Get the question id from the response name +- if (false !== ($quid = question_get_id_from_name_prefix($key))) { +- // check if this is a valid id +- if (!isset($questions[$quid])) { +- error('Form contained question that is not in questionids'); +- } +- +- // Remove the name prefix from the name +- //decrypt trying +- $key = substr($key, strlen($questions[$quid]->name_prefix)); +- if (false === $key) { +- $key = ''; +- } +- // Check for question validate and mark buttons & set events +- if ($key === 'validate') { +- $actions[$quid]->event = QUESTION_EVENTVALIDATE; +- } else if ($key === 'submit') { +- $actions[$quid]->event = QUESTION_EVENTSUBMIT; +- } else { +- $actions[$quid]->event = $defaultevent; +- } +- +- // Update the state with the new response +- $actions[$quid]->responses[$key] = $response; ++function question_load_questions($questionids, $extrafields = '', $join = '') { ++ $questions = question_preload_questions($questionids, $extrafields, $join); + +- // Set the timestamp +- $actions[$quid]->timestamp = $time; +- } +- } +- foreach ($actions as $quid => $notused) { +- ksort($actions[$quid]->responses); ++ // Load the question type specific information ++ if (!get_question_options($questions)) { ++ return 'Could not load the question options'; + } +- return $actions; +-} +- +- +-/** +- * Returns the html for question feedback image. +- * @param float $fraction value representing the correctness of the user's +- * response to a question. +- * @param boolean $selected whether or not the answer is the one that the +- * user picked. +- * @return string +- */ +-function question_get_feedback_image($fraction, $selected=true) { +- +- global $CFG; + +- if ($fraction >= 1.0) { +- if ($selected) { +- $feedbackimg = ''; +- } else { +- $feedbackimg = ''; +- } +- } else if ($fraction > 0.0 && $fraction < 1.0) { +- if ($selected) { +- $feedbackimg = ''; +- } else { +- $feedbackimg = ''; +- } +- } else { +- if ($selected) { +- $feedbackimg = ''; +- } else { +- $feedbackimg = ''; +- } +- } +- return $feedbackimg; ++ return $questions; + } + +- + /** +- * Returns the class name for question feedback. +- * @param float $fraction value representing the correctness of the user's +- * response to a question. +- * @return string ++ * Private function to factor common code out of get_question_options(). ++ * ++ * @param object $question the question to tidy. ++ * @param boolean $loadtags load the question tags from the tags table. Optional, default false. ++ * @return boolean true if successful, else false. + */ +-function question_get_feedback_class($fraction) { +- +- global $CFG; +- +- if ($fraction >= 1.0) { +- $class = 'correct'; +- } else if ($fraction > 0.0 && $fraction < 1.0) { +- $class = 'partiallycorrect'; +- } else { +- $class = 'incorrect'; ++function _tidy_question(&$question, $loadtags = false) { ++ global $CFG, $QTYPES; ++ if (!array_key_exists($question->qtype, $QTYPES)) { ++ $question->qtype = 'missingtype'; ++ $question->questiontext = '

' . get_string('warningmissingtype', 'quiz') . '

' . $question->questiontext; + } +- return $class; +-} +- +- +-/** +-* For a given question in an attempt we walk the complete history of states +-* and recalculate the grades as we go along. +-* +-* This is used when a question is changed and old student +-* responses need to be marked with the new version of a question. +-* +-* TODO: Make sure this is not quiz-specific +-* +-* @return boolean Indicates whether the grade has changed +-* @param object $question A question object +-* @param object $attempt The attempt, in which the question needs to be regraded. +-* @param object $cmoptions +-* @param boolean $verbose Optional. Whether to print progress information or not. +-*/ +-function regrade_question_in_attempt($question, $attempt, $cmoptions, $verbose=false) { +- +- // load all states for this question in this attempt, ordered in sequence +- if ($states = get_records_select('question_states', +- "attempt = '{$attempt->uniqueid}' AND question = '{$question->id}'", +- 'seq_number ASC')) { +- $states = array_values($states); +- +- // Subtract the grade for the latest state from $attempt->sumgrades to get the +- // sumgrades for the attempt without this question. +- $attempt->sumgrades -= $states[count($states)-1]->grade; +- +- // Initialise the replaystate +- $state = clone($states[0]); +- $state->manualcomment = get_field('question_sessions', 'manualcomment', 'attemptid', +- $attempt->uniqueid, 'questionid', $question->id); +- restore_question_state($question, $state); +- $state->sumpenalty = 0.0; +- $replaystate = clone($state); +- $replaystate->last_graded = $state; +- +- $changed = false; +- for($j = 1; $j < count($states); $j++) { +- restore_question_state($question, $states[$j]); +- $action = new stdClass; +- $action->responses = $states[$j]->responses; +- $action->timestamp = $states[$j]->timestamp; +- +- // Change event to submit so that it will be reprocessed +- if (QUESTION_EVENTCLOSE == $states[$j]->event +- or QUESTION_EVENTGRADE == $states[$j]->event +- or QUESTION_EVENTCLOSEANDGRADE == $states[$j]->event) { +- $action->event = QUESTION_EVENTSUBMIT; +- +- // By default take the event that was saved in the database +- } else { +- $action->event = $states[$j]->event; +- } +- +- if ($action->event == QUESTION_EVENTMANUALGRADE) { +- // Ensure that the grade is in range - in the past this was not checked, +- // but now it is (MDL-14835) - so we need to ensure the data is valid before +- // proceeding. +- if ($states[$j]->grade < 0) { +- $states[$j]->grade = 0; +- } else if ($states[$j]->grade > $question->maxgrade) { +- $states[$j]->grade = $question->maxgrade; +- } +- $error = question_process_comment($question, $replaystate, $attempt, +- $replaystate->manualcomment, $states[$j]->grade); +- if (is_string($error)) { +- notify($error); +- } +- } else { +- +- // Reprocess (regrade) responses +- if (!question_process_responses($question, $replaystate, +- $action, $cmoptions, $attempt)) { +- $verbose && notify("Couldn't regrade state #{$state->id}!"); +- } +- } +- +- // We need rounding here because grades in the DB get truncated +- // e.g. 0.33333 != 0.3333333, but we want them to be equal here +- if ((round((float)$replaystate->raw_grade, 5) != round((float)$states[$j]->raw_grade, 5)) +- or (round((float)$replaystate->penalty, 5) != round((float)$states[$j]->penalty, 5)) +- or (round((float)$replaystate->grade, 5) != round((float)$states[$j]->grade, 5))) { +- $changed = true; +- } +- +- $replaystate->id = $states[$j]->id; +- $replaystate->changed = true; +- $replaystate->update = true; // This will ensure that the existing database entry is updated rather than a new one created +- save_question_session($question, $replaystate); ++ if ($success = $QTYPES[$question->qtype]->get_question_options($question)) { ++ if (isset($question->_partiallyloaded)) { ++ unset($question->_partiallyloaded); + } +- if ($changed) { +- // TODO, call a method in quiz to do this, where 'quiz' comes from +- // the question_attempts table. +- update_record('quiz_attempts', $attempt); +- } +- +- return $changed; +- } +- return false; +-} +- +-/** +-* Processes an array of student responses, grading and saving them as appropriate +-* +-* @param object $question Full question object, passed by reference +-* @param object $state Full state object, passed by reference +-* @param object $action object with the fields ->responses which +-* is an array holding the student responses, +-* ->action which specifies the action, e.g., QUESTION_EVENTGRADE, +-* and ->timestamp which is a timestamp from when the responses +-* were submitted by the student. +-* @param object $cmoptions +-* @param object $attempt The attempt is passed by reference so that +-* during grading its ->sumgrades field can be updated +-* @return boolean Indicates success/failure +-*/ +-function question_process_responses(&$question, &$state, $action, $cmoptions, &$attempt) { +- global $QTYPES; +- +- // if no responses are set initialise to empty response +- if (!isset($action->responses)) { +- $action->responses = array('' => ''); + } +- +- // make sure these are gone! +- unset($action->responses['submit'], $action->responses['validate']); +- +- // Check the question session is still open +- if (question_state_is_closed($state)) { +- return true; +- } +- +- // If $action->event is not set that implies saving +- if (! isset($action->event)) { +- debugging('Ambiguous action in question_process_responses.' , DEBUG_DEVELOPER); +- $action->event = QUESTION_EVENTSAVE; +- } +- // If submitted then compare against last graded +- // responses, not last given responses in this case +- if (question_isgradingevent($action->event)) { +- $state->responses = $state->last_graded->responses; +- } +- +- // Check for unchanged responses (exactly unchanged, not equivalent). +- // We also have to catch questions that the student has not yet attempted +- $sameresponses = $QTYPES[$question->qtype]->compare_responses($question, $action, $state); +- if (!empty($state->last_graded) && $state->last_graded->event == QUESTION_EVENTOPEN && +- question_isgradingevent($action->event)) { +- $sameresponses = false; +- } +- +- // If the response has not been changed then we do not have to process it again +- // unless the attempt is closing or validation is requested +- if ($sameresponses and QUESTION_EVENTCLOSE != $action->event +- and QUESTION_EVENTVALIDATE != $action->event) { +- return true; +- } +- +- // Roll back grading information to last graded state and set the new +- // responses +- $newstate = clone($state->last_graded); +- $newstate->responses = $action->responses; +- $newstate->seq_number = $state->seq_number + 1; +- $newstate->changed = true; // will assure that it gets saved to the database +- $newstate->last_graded = clone($state->last_graded); +- $newstate->timestamp = $action->timestamp; +- $state = $newstate; +- +- // Set the event to the action we will perform. The question type specific +- // grading code may override this by setting it to QUESTION_EVENTCLOSE if the +- // attempt at the question causes the session to close +- $state->event = $action->event; +- +- if (!question_isgradingevent($action->event)) { +- // Grade the response but don't update the overall grade +- if (!$QTYPES[$question->qtype]->grade_responses($question, $state, $cmoptions)) { +- return false; +- } +- +- // Temporary hack because question types are not given enough control over what is going +- // on. Used by Opaque questions. +- // TODO fix this code properly. +- if (!empty($state->believeevent)) { +- // If the state was graded we need to ... +- if (question_state_is_graded($state)) { +- question_apply_penalty_and_timelimit($question, $state, $attempt, $cmoptions); +- +- // update the attempt grade +- $attempt->sumgrades -= (float)$state->last_graded->grade; +- $attempt->sumgrades += (float)$state->grade; +- +- // and update the last_graded field. +- unset($state->last_graded); +- $state->last_graded = clone($state); +- unset($state->last_graded->changed); +- } +- } else { +- // Don't allow the processing to change the event type +- $state->event = $action->event; +- } +- +- } else { // grading event +- +- // Unless the attempt is closing, we want to work out if the current responses +- // (or equivalent responses) were already given in the last graded attempt. +- if(QUESTION_EVENTCLOSE != $action->event && QUESTION_EVENTOPEN != $state->last_graded->event && +- $QTYPES[$question->qtype]->compare_responses($question, $state, $state->last_graded)) { +- $state->event = QUESTION_EVENTDUPLICATE; +- } +- +- // If we did not find a duplicate or if the attempt is closing, perform grading +- if ((!$sameresponses and QUESTION_EVENTDUPLICATE != $state->event) or +- QUESTION_EVENTCLOSE == $action->event) { +- if (!$QTYPES[$question->qtype]->grade_responses($question, $state, $cmoptions)) { +- return false; +- } +- +- // Calculate overall grade using correct penalty method +- question_apply_penalty_and_timelimit($question, $state, $attempt, $cmoptions); +- } +- +- // If the state was graded we need to ... +- if (question_state_is_graded($state)) { +- // update the attempt grade +- $attempt->sumgrades -= (float)$state->last_graded->grade; +- $attempt->sumgrades += (float)$state->grade; +- +- // and update the last_graded field. +- unset($state->last_graded); +- $state->last_graded = clone($state); +- unset($state->last_graded->changed); +- } ++ if ($loadtags && !empty($CFG->usetags)) { ++ require_once($CFG->dirroot . '/tag/lib.php'); ++ $question->tags = tag_get_tags_array('question', $question->id); + } +- $attempt->timemodified = $action->timestamp; +- +- return true; ++ return $success; + } + + /** +-* Determine if event requires grading +-*/ +-function question_isgradingevent($event) { +- return (QUESTION_EVENTSUBMIT == $event || QUESTION_EVENTCLOSE == $event); +-} +- +-/** +-* Applies the penalty from the previous graded responses to the raw grade +-* for the current responses +-* +-* The grade for the question in the current state is computed by subtracting the +-* penalty accumulated over the previous graded responses at the question from the +-* raw grade. If the timestamp is more than 1 minute beyond the end of the attempt +-* the grade is set to zero. The ->grade field of the state object is modified to +-* reflect the new grade but is never allowed to decrease. +-* @param object $question The question for which the penalty is to be applied. +-* @param object $state The state for which the grade is to be set from the +-* raw grade and the cumulative penalty from the last +-* graded state. The ->grade field is updated by applying +-* the penalty scheme determined in $cmoptions to the ->raw_grade and +-* ->last_graded->penalty fields. +-* @param object $cmoptions The options set by the course module. +-* The ->penaltyscheme field determines whether penalties +-* for incorrect earlier responses are subtracted. +-*/ +-function question_apply_penalty_and_timelimit(&$question, &$state, $attempt, $cmoptions) { +- // TODO. Quiz dependancy. The fact that the attempt that is passed in here +- // is from quiz_attempts, and we use things like $cmoptions->timelimit. +- +- // deal with penalty +- if ($cmoptions->penaltyscheme) { +- $state->grade = $state->raw_grade - $state->sumpenalty; +- $state->sumpenalty += (float) $state->penalty; +- } else { +- $state->grade = $state->raw_grade; +- } +- +- // deal with timelimit +- if ($cmoptions->timelimit) { +- // We allow for 5% uncertainty in the following test +- if ($state->timestamp - $attempt->timestart > $cmoptions->timelimit * 63) { +- $cm = get_coursemodule_from_instance('quiz', $cmoptions->id); +- if (!has_capability('mod/quiz:ignoretimelimits', get_context_instance(CONTEXT_MODULE, $cm->id), +- $attempt->userid, false)) { +- $state->grade = 0; ++ * Updates the question objects with question type specific ++ * information by calling {@link get_question_options()} ++ * ++ * Can be called either with an array of question objects or with a single ++ * question object. ++ * ++ * @param mixed $questions Either an array of question objects to be updated ++ * or just a single question object ++ * @param boolean $loadtags load the question tags from the tags table. Optional, default false. ++ * @return bool Indicates success or failure. ++ */ ++function get_question_options(&$questions, $loadtags = false) { ++ if (is_array($questions)) { // deal with an array of questions ++ foreach ($questions as $i => $notused) { ++ if (!_tidy_question($questions[$i], $loadtags)) { ++ return false; + } + } ++ return true; ++ } else { // deal with single question ++ return _tidy_question($questions, $loadtags); + } +- +- // deal with closing time +- if ($cmoptions->timeclose and $state->timestamp > ($cmoptions->timeclose + 60) // allowing 1 minute lateness +- and !$attempt->preview) { // ignore closing time for previews +- $state->grade = 0; +- } +- +- // Ensure that the grade does not go down +- $state->grade = max($state->grade, $state->last_graded->grade); + } + + /** +@@ -1497,168 +804,6 @@ function print_question_icon($question, $return = false) { + } + + /** +-* Returns a html link to the question image if there is one +-* +-* @return string The html image tag or the empy string if there is no image. +-* @param object $question The question object +-*/ +-function get_question_image($question) { +- +- global $CFG; +- $img = ''; +- +- if (!$category = get_record('question_categories', 'id', $question->category)){ +- error('invalid category id '.$question->category); +- } +- $coursefilesdir = get_filesdir_from_context(get_context_instance_by_id($category->contextid)); +- +- if ($question->image) { +- +- if (substr(strtolower($question->image), 0, 7) == 'http://') { +- $img .= $question->image; +- +- } else { +- require_once($CFG->libdir .'/filelib.php'); +- $img = get_file_url("$coursefilesdir/{$question->image}"); +- } +- } +- return $img; +-} +- +-function question_print_comment_box($question, $state, $attempt, $url) { +- global $CFG, $QTYPES; +- +- $prefix = 'response'; +- $usehtmleditor = can_use_richtext_editor(); +- if (!question_state_is_graded($state) && $QTYPES[$question->qtype]->is_question_manual_graded($question, $attempt->layout)) { +- $grade = ''; +- } else { +- $grade = round($state->last_graded->grade, 3); +- } +- echo '
'; +- include($CFG->dirroot.'/question/comment.html'); +- echo ''; +- echo ''; +- echo ''; +- echo ''; +- echo '
'; +- +- if ($usehtmleditor) { +- use_html_editor(); +- } +-} +- +-/** +- * Process a manual grading action. That is, use $comment and $grade to update +- * $state and $attempt. The attempt and the comment text are stored in the +- * database. $state is only updated in memory, it is up to the call to store +- * that, if appropriate. +- * +- * @param object $question the question +- * @param object $state the state to be updated. +- * @param object $attempt the attempt the state belongs to, to be updated. +- * @param string $comment the new comment from the teacher. +- * @param mixed $grade the grade the teacher assigned, or '' to not change the grade. +- * @return mixed true on success, a string error message if a problem is detected +- * (for example score out of range). +- */ +-function question_process_comment($question, &$state, &$attempt, $comment, $grade) { +- $grade = trim($grade); +- if ($grade < 0 || $grade > $question->maxgrade) { +- $a = new stdClass; +- $a->grade = $grade; +- $a->maxgrade = $question->maxgrade; +- $a->name = $question->name; +- return get_string('errormanualgradeoutofrange', 'question', $a); +- } +- +- // Update the comment and save it in the database +- $comment = trim($comment); +- $state->manualcomment = $comment; +- if (!set_field('question_sessions', 'manualcomment', $comment, 'attemptid', $attempt->uniqueid, 'questionid', $question->id)) { +- return get_string('errorsavingcomment', 'question', $question); +- } +- +- // Update the attempt if the score has changed. +- if ($grade !== '' && (abs($state->last_graded->grade - $grade) > 0.002 || $state->last_graded->event != QUESTION_EVENTMANUALGRADE)) { +- $attempt->sumgrades = $attempt->sumgrades - $state->last_graded->grade + $grade; +- $attempt->timemodified = time(); +- if (!update_record('quiz_attempts', $attempt)) { +- return get_string('errorupdatingattempt', 'question', $attempt); +- } +- +- // We want to update existing state (rather than creating new one) if it +- // was itself created by a manual grading event. +- $state->update = $state->event == QUESTION_EVENTMANUALGRADE; +- +- // Update the other parts of the state object. +- $state->raw_grade = $grade; +- $state->grade = $grade; +- $state->penalty = 0; +- $state->timestamp = time(); +- $state->seq_number++; +- $state->event = QUESTION_EVENTMANUALGRADE; +- +- // Update the last graded state (don't simplify!) +- unset($state->last_graded); +- $state->last_graded = clone($state); +- +- // We need to indicate that the state has changed in order for it to be saved. +- $state->changed = 1; +- } +- +- return true; +-} +- +-/** +-* Construct name prefixes for question form element names +-* +-* Construct the name prefix that should be used for example in the +-* names of form elements created by questions. +-* This is called by {@link get_question_options()} +-* to set $question->name_prefix. +-* This name prefix includes the question id which can be +-* extracted from it with {@link question_get_id_from_name_prefix()}. +-* +-* @return string +-* @param integer $id The question id +-*/ +-function question_make_name_prefix($id) { +- return 'resp' . $id . '_'; +-} +- +-/** +-* Extract question id from the prefix of form element names +-* +-* @return integer The question id +-* @param string $name The name that contains a prefix that was +-* constructed with {@link question_make_name_prefix()} +-*/ +-function question_get_id_from_name_prefix($name) { +- if (!preg_match('/^resp([0-9]+)_/', $name, $matches)) +- return false; +- return (integer) $matches[1]; +-} +- +-/** +- * Returns the unique id for a new attempt +- * +- * Every module can keep their own attempts table with their own sequential ids but +- * the question code needs to also have a unique id by which to identify all these +- * attempts. Hence a module, when creating a new attempt, calls this function and +- * stores the return value in the 'uniqueid' field of its attempts table. +- */ +-function question_new_attempt_uniqueid($modulename='quiz') { +- global $CFG; +- $attempt = new stdClass; +- $attempt->modulename = $modulename; +- if (!$id = insert_record('question_attempts', $attempt)) { +- error('Could not create new entry in question_attempts table'); +- } +- return $id; +-} +- +-/** + * Creates a stamp that uniquely identifies this version of the question + * + * In future we want this to use a hash of the question data to guarantee that +@@ -1674,33 +819,8 @@ function question_hash($question) { + + /// FUNCTIONS THAT SIMPLY WRAP QUESTIONTYPE METHODS ////////////////////////////////// + /** +- * Get the HTML that needs to be included in the head tag when the +- * questions in $questionlist are printed in the gives states. +- * +- * @param array $questionlist a list of questionids of the questions what will appear on this page. +- * @param array $questions an array of question objects, whose keys are question ids. +- * Must contain all the questions in $questionlist +- * @param array $states an array of question state objects, whose keys are question ids. +- * Must contain the state of all the questions in $questionlist +- * +- * @return string some HTML code that can go inside the head tag. +- */ +-function get_html_head_contributions(&$questionlist, &$questions, &$states) { +- global $QTYPES; +- +- $contributions = array(); +- foreach ($questionlist as $questionid) { +- $question = $questions[$questionid]; +- $contributions = array_merge($contributions, +- $QTYPES[$question->qtype]->get_html_head_contributions( +- $question, $states[$questionid])); +- } +- return implode("\n", array_unique($contributions)); +-} +- +-/** +- * Like @see{get_html_head_contributions} but for the editing page +- * question/question.php. ++ * Get anything that needs to be included in the head of the question editing page ++ * for a particular question type. This function is called by question/question.php. + * + * @param $question A question object. Only $question->qtype is used. + * @return string some HTML code that can go inside the head tag. +@@ -1712,20 +832,6 @@ function get_editing_head_contributions($question) { + } + + /** +- * Prints a question +- * +- * Simply calls the question type specific print_question() method. +- * @param object $question The question to be rendered. +- * @param object $state The state to render the question in. +- * @param integer $number The number for this question. +- * @param object $cmoptions The options specified by the course module +- * @param object $options An object specifying the rendering options. +- */ +-function print_question(&$question, &$state, $number, $cmoptions, $options=null) { +- global $QTYPES; +- $QTYPES[$question->qtype]->print_question($question, $state, $number, $cmoptions, $options); +-} +-/** + * Saves question options + * + * Simply calls the question type specific save_question_options() method. +@@ -1736,44 +842,6 @@ function save_question_options($question) { + $QTYPES[$question->qtype]->save_question_options($question); + } + +-/** +-* Gets all teacher stored answers for a given question +-* +-* Simply calls the question type specific get_all_responses() method. +-*/ +-// ULPGC ecastro +-function get_question_responses($question, $state) { +- global $QTYPES; +- $r = $QTYPES[$question->qtype]->get_all_responses($question, $state); +- return $r; +-} +- +- +-/** +-* Gets the response given by the user in a particular state +-* +-* Simply calls the question type specific get_actual_response() method. +-*/ +-// ULPGC ecastro +-function get_question_actual_response($question, $state) { +- global $QTYPES; +- +- $r = $QTYPES[$question->qtype]->get_actual_response($question, $state); +- return $r; +-} +- +-/** +-* TODO: document this +-*/ +-// ULPGc ecastro +-function get_question_fraction_grade($question, $state) { +- global $QTYPES; +- +- $r = $QTYPES[$question->qtype]->get_fractional_grade($question, $state); +- return $r; +-} +- +- + /// CATEGORY FUNCTIONS ///////////////////////////////////////////////////////////////// + + /** +@@ -1796,7 +864,7 @@ function sort_categories_by_tree(&$categories, $id = 0, $level = 1) { + if ($level == 1) { + foreach ($keys as $key) { + //If not processed and it's a good candidate to start (because its parent doesn't exist in the course) +- if (!isset($categories[$key]->processed) && !record_exists('question_categories', 'course', $categories[$key]->course, 'id', $categories[$key]->parent)) { ++ if (!isset($categories[$key]->processed) && !record_exists('question_categories', 'contextid', $categories[$key]->contextid, 'id', $categories[$key]->parent)) { + $children[$key] = $categories[$key]; + $categories[$key]->processed = true; + $children = $children + sort_categories_by_tree($categories, $children[$key]->id, $level+1); +@@ -2019,6 +1087,7 @@ function question_add_context_in_key($categories){ + } + return $newcatarray; + } ++ + function question_add_tops($categories, $pcontexts){ + $topcats = array(); + foreach ($pcontexts as $context){ +@@ -2048,8 +1117,6 @@ function question_categorylist($categoryid) { + } + + +- +- + //=========================== + // Import/Export Functions + //=========================== +@@ -2098,7 +1165,6 @@ function get_import_export_formats( $type ) { + return $fileformatnames; + } + +- + /** + * Create default export filename + * +@@ -2144,6 +1210,7 @@ function default_export_filename($course,$category) { + + return $export_name; + } ++ + class context_to_string_translator{ + /** + * @var array used to translate between contextids and strings for this context. +@@ -2352,4 +1419,3 @@ function get_filesdir_from_context($context){ + } + return $courseid; + } +-?> diff --git a/question/type/description/questiontype.php b/question/type/description/questiontype.php index b79a547a73a..9f632ebe6c2 100644 --- a/question/type/description/questiontype.php +++ b/question/type/description/questiontype.php @@ -33,6 +33,10 @@ require_once($CFG->libdir . '/questionlib.php'); * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class qtype_description extends question_type { + function is_real_question_type() { + return false; + } + public function is_usable_by_random() { return false; } diff --git a/question/type/old_questiontype.php b/question/type/old_questiontype.php index 5aaf4d323d8..3c56eb4844f 100644 --- a/question/type/old_questiontype.php +++ b/question/type/old_questiontype.php @@ -192,7 +192,7 @@ class default_questiontype { /** * Return an instance of the question editing form definition. This looks for a * class called edit_{$this->name()}_question_form in the file - * {$CFG->dirroot}/question/type/{$this->name()}/edit_{$this->name()}_question_form.php + * question/type/{$this->name()}/edit_{$this->name()}_question_form.php * and if it exists returns an instance of it. * * @param string $submiturl passed on to the constructor call. @@ -402,13 +402,13 @@ class default_questiontype { } /** - * Saves question-type specific options - * - * This is called by {@link save_question()} to save the question-type specific data - * @return object $result->error or $result->noticeyesno or $result->notice - * @param object $question This holds the information from the editing form, - * it is not a standard question object. - */ + * Saves question-type specific options + * + * This is called by {@link save_question()} to save the question-type specific data + * @return object $result->error or $result->noticeyesno or $result->notice + * @param object $question This holds the information from the editing form, + * it is not a standard question object. + */ function save_question_options($question) { global $DB; $extra_question_fields = $this->extra_question_fields(); @@ -449,17 +449,17 @@ class default_questiontype { } /** - * Loads the question type specific options for the question. - * - * This function loads any question type specific options for the - * question from the database into the question object. This information - * is placed in the $question->options field. A question type is - * free, however, to decide on a internal structure of the options field. - * @return bool Indicates success or failure. - * @param object $question The question object for the question. This object - * should be updated to include the question type - * specific information (it is passed by reference). - */ + * Loads the question type specific options for the question. + * + * This function loads any question type specific options for the + * question from the database into the question object. This information + * is placed in the $question->options field. A question type is + * free, however, to decide on a internal structure of the options field. + * @return bool Indicates success or failure. + * @param object $question The question object for the question. This object + * should be updated to include the question type + * specific information (it is passed by reference). + */ function get_question_options(&$question) { global $CFG, $DB, $OUTPUT; @@ -880,606 +880,6 @@ class default_questiontype { } } - /** - * Prints the question including the number, grading details, content, - * feedback and interactions - * - * This function prints the question including the question number, - * grading details, content for the question, any feedback for the previously - * submitted responses and the interactions. The default implementation calls - * various other methods to print each of these parts and most question types - * will just override those methods. - * @param object $question The question to be rendered. Question type - * specific information is included. The - * maximum possible grade is in ->maxgrade. The name - * prefix for any named elements is in ->name_prefix. - * @param object $state The state to render the question in. The grading - * information is in ->grade, ->raw_grade and - * ->penalty. The current responses are in - * ->responses. This is an associative array (or the - * empty string or null in the case of no responses - * submitted). The last graded state is in - * ->last_graded (hence the most recently graded - * responses are in ->last_graded->responses). The - * question type specific information is also - * included. - * @param integer $number The number for this question. - * @param object $cmoptions - * @param object $options An object describing the rendering options. - */ - function print_question(&$question, &$state, $number, $cmoptions, $options, $context=null) { - /* The default implementation should work for most question types - provided the member functions it calls are overridden where required. - The layout is determined by the template question.html */ - - global $CFG, $OUTPUT; - - $context = $this->get_context_by_category_id($question->category); - $question->questiontext = quiz_rewrite_question_urls($question->questiontext, 'pluginfile.php', $context->id, 'question', 'questiontext', array($state->attempt, $state->question), $question->id); - - $question->generalfeedback = quiz_rewrite_question_urls($question->generalfeedback, 'pluginfile.php', $context->id, 'question', 'generalfeedback', array($state->attempt, $state->question), $question->id); - - $isgraded = question_state_is_graded($state->last_graded); - - if (isset($question->randomquestionid)) { - $actualquestionid = $question->randomquestionid; - } else { - $actualquestionid = $question->id; - } - - // For editing teachers print a link to an editing popup window - $editlink = $this->get_question_edit_link($question, $cmoptions, $options); - - $generalfeedback = ''; - if ($isgraded && $options->generalfeedback) { - $generalfeedback = $this->format_text($question->generalfeedback, - $question->generalfeedbackformat, $cmoptions); - } - - $grade = ''; - if ($question->maxgrade > 0 && $options->scores) { - if ($cmoptions->optionflags & QUESTION_ADAPTIVE) { - if ($isgraded) { - $grade = question_format_grade($cmoptions, $state->last_graded->grade).'/'; - } else { - $grade = '--/'; - } - } - $grade .= question_format_grade($cmoptions, $question->maxgrade); - } - - $formatoptions = new stdClass; - $formatoptions->para = false; - $comment = format_text($state->manualcomment, $state->manualcommentformat, - $formatoptions, $cmoptions->course); - $commentlink = ''; - - if (!empty($options->questioncommentlink)) { - $strcomment = get_string('commentorgrade', 'quiz'); - - $link = new moodle_url("$options->questioncommentlink?attempt=$state->attempt&question=$actualquestionid"); - $action = new popup_action('click', $link, 'commentquestion', array('height' => 480, 'width' => 750)); - $commentlink = $OUTPUT->container($OUTPUT->action_link($link, $strcomment, $action), 'commentlink'); - } - - $history = $this->history($question, $state, $number, $cmoptions, $options); - - include "$CFG->dirroot/question/type/question.html"; - } - - /** - * Render the question flag, assuming $flagsoption allows it. You will probably - * never need to override this method. - * - * @param object $question the question - * @param object $state its current state - * @param integer $flagsoption the option that says whether flags should be displayed. - */ - protected function print_question_flag($question, $state, $flagsoption) { - global $CFG, $PAGE; - switch ($flagsoption) { - case QUESTION_FLAGSSHOWN: - $flagcontent = $this->get_question_flag_tag($state->flagged); - break; - case QUESTION_FLAGSEDITABLE: - $id = $question->name_prefix . '_flagged'; - if ($state->flagged) { - $checked = 'checked="checked" '; - } else { - $checked = ''; - } - $qsid = $state->questionsessionid; - $aid = $state->attempt; - $qid = $state->question; - $checksum = question_get_toggleflag_checksum($aid, $qid, $qsid); - $postdata = "qsid=$qsid&aid=$aid&qid=$qid&checksum=$checksum&sesskey=" . - sesskey() . '&newstate='; - $flagcontent = '' . - '' . - '' . "\n"; - question_init_qengine_js(); - break; - default: - $flagcontent = ''; - } - if ($flagcontent) { - echo '
' . $flagcontent . "
\n"; - } - } - - /** - * Work out the actual img tag needed for the flag - * - * @param boolean $flagged whether the question is currently flagged. - * @param string $id an id to be added as an attribute to the img (optional). - * @return string the img tag. - */ - protected function get_question_flag_tag($flagged, $id = '') { - global $OUTPUT; - if ($id) { - $id = 'id="' . $id . '" '; - } - if ($flagged) { - $img = 'i/flagged'; - } else { - $img = 'i/unflagged'; - } - return '' . get_string('flagthisquestion', 'question') . ''; - } - - /** - * Get a link to an edit icon for this question, if the current user is allowed - * to edit it. - * - * @param object $question the question object. - * @param object $cmoptions the options from the module. If $cmoptions->thispageurl is set - * then the link will be to edit the question in this browser window, then return to - * $cmoptions->thispageurl. Otherwise the link will be to edit in a popup. - * @return string the HTML of the link, or nothing it the currenty user is not allowed to edit. - */ - function get_question_edit_link($question, $cmoptions, $options) { - global $CFG, $OUTPUT; - - /// Is this user allowed to edit this question? - if (!empty($options->noeditlink) || !question_has_capability_on($question, 'edit')) { - return ''; - } - - /// Work out the right URL. - $url = new moodle_url('/question/question.php', array('id' => $question->id)); - if (!empty($cmoptions->cmid)) { - $url->param('cmid', $cmoptions->cmid); - } else if (!empty($cmoptions->course)) { - $url->param('courseid', $cmoptions->course); - } else { - print_error('missingcourseorcmidtolink', 'question'); - } - - $icon = new pix_icon('t/edit', get_string('edit')); - - $action = null; - if (!empty($cmoptions->thispageurl)) { - // The module allow editing in the same window, print an ordinary - // link with a returnurl. - $url->param('returnurl', $cmoptions->thispageurl); - } else { - // We have to edit in a pop-up. - $url->param('inpopup', 1); - $action = new popup_action('click', $link, 'editquestion'); - } - - return $OUTPUT->action_icon($url, $icon, $action); - } - - /** - * Print history of responses - * - * Used by print_question() - */ - function history($question, $state, $number, $cmoptions, $options) { - global $DB, $OUTPUT; - - if (empty($options->history)) { - return ''; - } - - $params = array('aid' => $state->attempt); - if (isset($question->randomquestionid)) { - $params['qid'] = $question->randomquestionid; - $randomprefix = 'random' . $question->id . '-'; - } else { - $params['qid'] = $question->id; - $randomprefix = ''; - } - if ($options->history == 'all') { - $eventtest = 'event > 0'; - } else { - $eventtest = 'event IN (' . QUESTION_EVENTS_GRADED . ')'; - } - $states = $DB->get_records_select('question_states', - 'attempt = :aid AND question = :qid AND ' . $eventtest, $params, 'seq_number ASC'); - if (count($states) <= 1) { - return ''; - } - - $strreviewquestion = get_string('reviewresponse', 'quiz'); - $table = new html_table(); - $table->width = '100%'; - $table->head = array ( - get_string('numberabbr', 'quiz'), - get_string('action', 'quiz'), - get_string('response', 'quiz'), - get_string('time'), - ); - if ($options->scores) { - $table->head[] = get_string('score', 'quiz'); - $table->head[] = get_string('grade', 'quiz'); - } - - foreach ($states as $st) { - if ($randomprefix && strpos($st->answer, $randomprefix) === 0) { - $st->answer = substr($st->answer, strlen($randomprefix)); - } - $st->responses[''] = $st->answer; - $this->restore_session_and_responses($question, $st); - - if ($state->id == $st->id) { - $link = '' . $st->seq_number . ''; - } else if (isset($options->questionreviewlink)) { - $reviewlink = new moodle_url($options->questionreviewlink); - $reviewlink->params(array('state'=>$st->id,'question'=>$question->id)); - $link = new moodle_url($reviewlink); - $action = new popup_action('click', $link, 'reviewquestion', array('height' => 450, 'width' => 650)); - $link = $OUTPUT->action_link($link, $st->seq_number, $action, array('title'=>$strreviewquestion)); - } else { - $link = $st->seq_number; - } - - if ($state->id == $st->id) { - $b = ''; - $be = ''; - } else { - $b = ''; - $be = ''; - } - - $data = array ( - $link, - $b.get_string('event'.$st->event, 'quiz').$be, - $b.$this->response_summary($question, $st).$be, - $b.userdate($st->timestamp, get_string('timestr', 'quiz')).$be, - ); - if ($options->scores) { - $data[] = $b.question_format_grade($cmoptions, $st->raw_grade).$be; - $data[] = $b.question_format_grade($cmoptions, $st->raw_grade).$be; - } - $table->data[] = $data; - } - return html_writer::table($table); - } - - /** - * Prints the score obtained and maximum score available plus any penalty - * information - * - * This function prints a summary of the scoring in the most recently - * graded state (the question may not have been submitted for marking at - * the current state). The default implementation should be suitable for most - * question types. - * @param object $question The question for which the grading details are - * to be rendered. Question type specific information - * is included. The maximum possible grade is in - * ->maxgrade. - * @param object $state The state. In particular the grading information - * is in ->grade, ->raw_grade and ->penalty. - * @param object $cmoptions - * @param object $options An object describing the rendering options. - */ - function print_question_grading_details(&$question, &$state, $cmoptions, $options) { - /* The default implementation prints the number of marks if no attempt - has been made. Otherwise it displays the grade obtained out of the - maximum grade available and a warning if a penalty was applied for the - attempt and displays the overall grade obtained counting all previous - responses (and penalties) */ - - if (QUESTION_EVENTDUPLICATE == $state->event) { - echo ' '; - print_string('duplicateresponse', 'quiz'); - } - if ($question->maxgrade > 0 && $options->scores) { - if (question_state_is_graded($state->last_graded)) { - // Display the grading details from the last graded state - $grade = new stdClass; - $grade->cur = question_format_grade($cmoptions, $state->last_graded->grade); - $grade->max = question_format_grade($cmoptions, $question->maxgrade); - $grade->raw = question_format_grade($cmoptions, $state->last_graded->raw_grade); - - // let student know wether the answer was correct - $class = question_get_feedback_class($state->last_graded->raw_grade / - $question->maxgrade); - echo '
' . get_string($class, 'quiz') . '
'; - - echo '
'; - // print grade for this submission - print_string('gradingdetails', 'quiz', $grade); - if ($cmoptions->penaltyscheme) { - // print details of grade adjustment due to penalties - if ($state->last_graded->raw_grade > $state->last_graded->grade){ - echo ' '; - print_string('gradingdetailsadjustment', 'quiz', $grade); - } - // print info about new penalty - // penalty is relevant only if the answer is not correct and further attempts are possible - if (($state->last_graded->raw_grade < $question->maxgrade / 1.01) - and (QUESTION_EVENTCLOSEANDGRADE != $state->event)) { - - if ('' !== $state->last_graded->penalty && ((float)$state->last_graded->penalty) > 0.0) { - // A penalty was applied so display it - echo ' '; - print_string('gradingdetailspenalty', 'quiz', question_format_grade($cmoptions, $state->last_graded->penalty)); - } else { - /* No penalty was applied even though the answer was - not correct (eg. a syntax error) so tell the student - that they were not penalised for the attempt */ - echo ' '; - print_string('gradingdetailszeropenalty', 'quiz'); - } - } - } - echo '
'; - } - } - } - - /** - * Prints the main content of the question including any interactions - * - * This function prints the main content of the question including the - * interactions for the question in the state given. The last graded responses - * are printed or indicated and the current responses are selected or filled in. - * Any names (eg. for any form elements) are prefixed with $question->name_prefix. - * This method is called from the print_question method. - * @param object $question The question to be rendered. Question type - * specific information is included. The name - * prefix for any named elements is in ->name_prefix. - * @param object $state The state to render the question in. The grading - * information is in ->grade, ->raw_grade and - * ->penalty. The current responses are in - * ->responses. This is an associative array (or the - * empty string or null in the case of no responses - * submitted). The last graded state is in - * ->last_graded (hence the most recently graded - * responses are in ->last_graded->responses). The - * question type specific information is also - * included. - * The state is passed by reference because some adaptive - * questions may want to update it during rendering - * @param object $cmoptions - * @param object $options An object describing the rendering options. - */ - function print_question_formulation_and_controls(&$question, &$state, $cmoptions, $options) { - /* This default implementation prints an error and must be overridden - by all question type implementations, unless the default implementation - of print_question has been overridden. */ - global $OUTPUT; - echo $OUTPUT->notification('Error: Question formulation and input controls has not' - .' been implemented for question type '.$this->name()); - } - - function check_file_access($question, $state, $options, $contextid, $component, - $filearea, $args) { - - if ($component == 'question' && $filearea == 'questiontext') { - // Question text always visible. - return true; - - } else if ($component == 'question' && $filearea = 'generalfeedback') { - return $options->generalfeedback && question_state_is_graded($state->last_graded); - - } else { - // Unrecognised component or filearea. - return false; - } - } - - /** - * Prints the submit button(s) for the question in the given state - * - * This function prints the submit button(s) for the question in the - * given state. The name of any button created will be prefixed with the - * unique prefix for the question in $question->name_prefix. The suffix - * 'submit' is reserved for the single question submit button and the suffix - * 'validate' is reserved for the single question validate button (for - * question types which support it). Other suffixes will result in a response - * of that name in $state->responses which the printing and grading methods - * can then use. - * @param object $question The question for which the submit button(s) are to - * be rendered. Question type specific information is - * included. The name prefix for any - * named elements is in ->name_prefix. - * @param object $state The state to render the buttons for. The - * question type specific information is also - * included. - * @param object $cmoptions - * @param object $options An object describing the rendering options. - */ - function print_question_submit_buttons(&$question, &$state, $cmoptions, $options) { - // The default implementation should be suitable for most question types. - // It prints a mark button in the case where individual marking is allowed. - if (($cmoptions->optionflags & QUESTION_ADAPTIVE) and !$options->readonly) { - echo ''; - } - } - - /** - * Return a summary of the student response - * - * This function returns a short string of no more than a given length that - * summarizes the student's response in the given $state. This is used for - * example in the response history table. This string should already be - * formatted for output. - * @return string The summary of the student response - * @param object $question - * @param object $state The state whose responses are to be summarized - * @param int $length The maximum length of the returned string - */ - function response_summary($question, $state, $length = 80, $formatting = true) { - // This should almost certainly be overridden - $responses = $this->get_actual_response($question, $state); - if ($formatting){ - $responses = $this->format_responses($responses, $question->questiontextformat); - } - $responses = implode('; ', $responses); - return shorten_text($responses, $length); - } - /** - * @param array responses is an array of responses. - * @return formatted responses - */ - function format_responses($responses, $format){ - $toreturn = array(); - foreach ($responses as $response){ - $toreturn[] = $this->format_response($response, $format); - } - return $toreturn; - } - /** - * @param string response is a response. - * @return formatted response - */ - function format_response($response, $format){ - return s($response); - } - /** - * Renders the question for printing and returns the LaTeX source produced - * - * This function should render the question suitable for a printed problem - * or solution sheet in LaTeX and return the rendered output. - * @return string The LaTeX output. - * @param object $question The question to be rendered. Question type - * specific information is included. - * @param object $state The state to render the question in. The - * question type specific information is also - * included. - * @param object $cmoptions - * @param string $type Indicates if the question or the solution is to be - * rendered with the values 'question' and - * 'solution'. - */ - function get_texsource(&$question, &$state, $cmoptions, $type) { - // The default implementation simply returns a string stating that - // the question is only available online. - - return get_string('onlineonly', 'texsheet'); - } - - /** - * Compares two question states for equivalence of the student's responses - * - * The responses for the two states must be examined to see if they represent - * equivalent answers to the question by the student. This method will be - * invoked for each of the previous states of the question before grading - * occurs. If the student is found to have already attempted the question - * with equivalent responses then the attempt at the question is ignored; - * grading does not occur and the state does not change. Thus they are not - * penalized for this case. - * @return boolean - * @param object $question The question for which the states are to be - * compared. Question type specific information is - * included. - * @param object $state The state of the question. The responses are in - * ->responses. This is the only field of $state - * that it is safe to use. - * @param object $teststate The state whose responses are to be - * compared. The state will be of the same age or - * older than $state. If possible, the method should - * only use the field $teststate->responses, however - * any field that is set up by restore_session_and_responses - * can be used. - */ - function compare_responses(&$question, $state, $teststate) { - // The default implementation performs a comparison of the response - // arrays. The ordering of the arrays does not matter. - // Question types may wish to override this (eg. to ignore trailing - // white space or to make "7.0" and "7" compare equal). - - // In php neither == nor === compare arrays the way you want. The following - // ensures that the arrays have the same keys, with the same values. - $result = false; - $diff1 = array_diff_assoc($state->responses, $teststate->responses); - if (empty($diff1)) { - $diff2 = array_diff_assoc($teststate->responses, $state->responses); - $result = empty($diff2); - } - - return $result; - } - - /** - * Checks whether a response matches a given answer - * - * This method only applies to questions that use teacher-defined answers - * - * @return boolean - */ - function test_response(&$question, &$state, $answer) { - $response = isset($state->responses['']) ? $state->responses[''] : ''; - return ($response == $answer->answer); - } - - /** - * Performs response processing and grading - * - * This function performs response processing and grading and updates - * the state accordingly. - * @return boolean Indicates success or failure. - * @param object $question The question to be graded. Question type - * specific information is included. - * @param object $state The state of the question to grade. The current - * responses are in ->responses. The last graded state - * is in ->last_graded (hence the most recently graded - * responses are in ->last_graded->responses). The - * question type specific information is also - * included. The ->raw_grade and ->penalty fields - * must be updated. The method is able to - * close the question session (preventing any further - * attempts at this question) by setting - * $state->event to QUESTION_EVENTCLOSEANDGRADE - * @param object $cmoptions - */ - function grade_responses(&$question, &$state, $cmoptions) { - // The default implementation uses the test_response method to - // compare what the student entered against each of the possible - // answers stored in the question, and uses the grade from the - // first one that matches. It also sets the marks and penalty. - // This should be good enought for most simple question types. - - $state->raw_grade = 0; - foreach($question->options->answers as $answer) { - if($this->test_response($question, $state, $answer)) { - $state->raw_grade = $answer->fraction; - break; - } - } - - // Make sure we don't assign negative or too high marks. - $state->raw_grade = min(max((float) $state->raw_grade, - 0.0), 1.0) * $question->maxgrade; - - // Update the penalty. - $state->penalty = $question->penalty * $question->maxgrade; - - // mark the state as graded - $state->event = ($state->event == QUESTION_EVENTCLOSE) ? QUESTION_EVENTCLOSEANDGRADE : QUESTION_EVENTGRADE; - - return true; - } - /** * Returns true if the editing wizard is finished, false otherwise. * diff --git a/question/type/questiontype.php b/question/type/questiontype.php index d711416650d..cd2be0d9099 100644 --- a/question/type/questiontype.php +++ b/question/type/questiontype.php @@ -1,4 +1,20 @@ -. + /** * The default questiontype class. * @@ -11,14 +27,10 @@ * @subpackage questiontypes */ + require_once($CFG->dirroot . '/question/engine/lib.php'); -// DONOTCOMMIT -class default_questiontype { - function plugin_dir() { - return ''; - } -} + /** * This is the base class for Moodle question types. * @@ -36,6 +48,11 @@ class default_questiontype { * @subpackage questiontypes */ class question_type { + protected $fileoptions = array( + 'subdirs' => false, + 'maxfiles' => -1, + 'maxbytes' => 0, + ); public function __construct() { } @@ -47,15 +64,54 @@ class question_type { return substr(get_class($this), 6); } + /** + * @return string the full frankenstyle name for this plugin. + */ + public function plugin_name() { + return get_class($this); + } + + /** + * @return string the name of this question type in the user's language. + * You should not need to override this method, the default behaviour should be fine. + */ + public function local_name() { + return get_string($this->name(), $this->plugin_name()); + } + /** * The name this question should appear as in the create new question - * dropdown. + * dropdown. Override this method to return false if you don't want your + * question type to be createable, for example if it is an abstract base type, + * otherwise, you should not need to override this method. * * @return mixed the desired string, or false to hide this question type in the menu. */ public function menu_name() { - $name = $this->name(); - return get_string($name, 'qtype_' . $name); + return $this->local_name(); + } + + /** + * Returns a list of other question types that this one requires in order to + * work. For example, the calculated question type is a subclass of the + * numerical question type, which is a subclass of the shortanswer question + * type; and the randomsamatch question type requires the shortanswer type + * to be installed. + * + * @return array any other question types that this one relies on. An empty + * array if none. + */ + public function requires_qtypes() { + return array(); + } + + /** + * @return boolean override this to return false if this is not really a + * question type, for example the description question type is not + * really a question type. + */ + public function is_real_question_type() { + return true; } /** @@ -139,7 +195,7 @@ class question_type { /** * Return an instance of the question editing form definition. This looks for a * class called edit_{$this->name()}_question_form in the file - * {$CFG->docroot}/question/type/{$this->name()}/edit_{$this->name()}_question_form.php + * question/type/{$this->name()}/edit_{$this->name()}_question_form.php * and if it exists returns an instance of it. * * @param string $submiturl passed on to the constructor call. @@ -184,9 +240,12 @@ class question_type { * @param object $question * @param string $wizardnow is '' for first page. */ - public function display_question_editing_page(&$mform, $question, $wizardnow) { - $name = $this->name(); - print_heading_with_help($this->get_heading(empty($question->id)), $name, 'qtype_' . $name); + public function display_question_editing_page($mform, $question, $wizardnow) { + global $OUTPUT; + $heading = $this->get_heading(empty($question->id)); + + echo $OUTPUT->heading_with_help($heading, $this->name(), $this->plugin_name()); + $permissionstrs = array(); if (!empty($question->id)){ if ($question->formoptions->canedit){ @@ -200,13 +259,13 @@ class question_type { } } if (!$question->formoptions->movecontext && count($permissionstrs)){ - print_heading(get_string('permissionto', 'question'), 'center', 3); + echo $OUTPUT->heading(get_string('permissionto', 'question'), 3); $html = ''; - print_box($html, 'boxwidthnarrow boxaligncenter generalbox'); + echo $OUTPUT->box($html, 'boxwidthnarrow boxaligncenter generalbox'); } $mform->display(); } @@ -217,13 +276,12 @@ class question_type { * @return string the heading */ public function get_heading($adding = false){ - $name = $this->name(); - if ($adding){ + if ($adding) { $action = 'adding'; } else { $action = 'editing'; } - return get_string($action . $name, 'qtype_' . $name); + return get_string($action . $this->name(), $this->plugin_name()); } /** @@ -261,34 +319,37 @@ class question_type { * redisplayed with validation errors, from validation_errors field, which * is itself an object, shown next to the form fields. (I don't think this is accurate any more.) */ - public function save_question($question, $form, $course) { - global $USER; + function save_question($question, $form) { + global $USER, $DB, $OUTPUT; + + list($question->category) = explode(',', $form->category); + $context = $this->get_context_by_category_id($question->category); // This default implementation is suitable for most // question types. // First, save the basic question itself $question->name = trim($form->name); - $question->questiontext = trim($form->questiontext); - $question->questiontextformat = $form->questiontextformat; $question->parent = isset($form->parent) ? $form->parent : 0; $question->length = $this->actual_number_of_questions($question); $question->penalty = isset($form->penalty) ? $form->penalty : 0; - if (empty($form->image)) { - $question->image = ''; + if (empty($form->questiontext['text'])) { + $question->questiontext = ''; } else { - $question->image = $form->image; + $question->questiontext = trim($form->questiontext['text']);; } + $question->questiontextformat = !empty($form->questiontext['format'])?$form->questiontext['format']:0; - if (empty($form->generalfeedback)) { + if (empty($form->generalfeedback['text'])) { $question->generalfeedback = ''; } else { - $question->generalfeedback = trim($form->generalfeedback); + $question->generalfeedback = trim($form->generalfeedback['text']); } + $question->generalfeedbackformat = !empty($form->generalfeedback['format'])?$form->generalfeedback['format']:0; if (empty($question->name)) { - $question->name = shorten_text(strip_tags($question->questiontext), 15); + $question->name = shorten_text(strip_tags($form->questiontext['text']), 15); if (empty($question->name)) { $question->name = '-'; } @@ -302,39 +363,42 @@ class question_type { $question->defaultmark = $form->defaultmark; } - list($question->category) = explode(',', $form->category); - - if (!empty($question->id)) { - /// Question already exists, update. - $question->modifiedby = $USER->id; - $question->timemodified = time(); - if (!update_record('question', $question)) { - error('Could not update question!'); - } - - } else { - /// New question. + // If the question is new, create it. + if (empty($question->id)) { // Set the unique code $question->stamp = make_unique_id_code(); $question->createdby = $USER->id; - $question->modifiedby = $USER->id; $question->timecreated = time(); - $question->timemodified = time(); - if (!$question->id = insert_record('question', $question)) { - error('Could not insert new question!'); - } + $question->id = $DB->insert_record('question', $question); } + // Now, whether we are updating a existing question, or creating a new + // one, we have to do the files processing and update the record. + /// Question already exists, update. + $question->modifiedby = $USER->id; + $question->timemodified = time(); + + if (!empty($question->questiontext) && !empty($form->questiontext['itemid'])) { + $question->questiontext = file_save_draft_area_files($form->questiontext['itemid'], $context->id, 'question', 'questiontext', (int)$question->id, $this->fileoptions, $question->questiontext); + } + if (!empty($question->generalfeedback) && !empty($form->generalfeedback['itemid'])) { + $question->generalfeedback = file_save_draft_area_files($form->generalfeedback['itemid'], $context->id, 'question', 'generalfeedback', (int)$question->id, $this->fileoptions, $question->generalfeedback); + } + $DB->update_record('question', $question); + // Now to save all the answers and type-specific options $form->id = $question->id; $form->qtype = $question->qtype; $form->category = $question->category; $form->questiontext = $question->questiontext; + $form->questiontextformat = $question->questiontextformat; + // current context + $form->context = $context; $result = $this->save_question_options($form); if (!empty($result->error)) { - error($result->error); + print_error($result->error); } if (!empty($result->notice)) { @@ -342,16 +406,11 @@ class question_type { } if (!empty($result->noticeyesno)) { - notice_yesno($result->noticeyesno, "question.php?id=$question->id&courseid={$course->id}", - "edit.php?courseid={$course->id}"); - print_footer($course); - exit; + throw new coding_exception('$result->noticeyesno no longer supported in save_question.'); } // Give the question a unique version stamp determined by question_hash() - if (!set_field('question', 'version', question_hash($question), 'id', $question->id)) { - error('Could not update question version field'); - } + $DB->set_field('question', 'version', question_hash($question), array('id' => $question->id)); return $question; } @@ -365,6 +424,7 @@ class question_type { * it is not a standard question object. */ public function save_question_options($question) { + global $DB; $extra_question_fields = $this->extra_question_fields(); if (is_array($extra_question_fields)) { @@ -372,7 +432,7 @@ class question_type { $function = 'update_record'; $questionidcolname = $this->questionid_column_name(); - $options = get_record($question_extension_table, $questionidcolname, $question->id); + $options = $DB->get_record($question_extension_table, array($questionidcolname => $question->id)); if (!$options) { $function = 'insert_record'; $options = new stdClass; @@ -388,7 +448,7 @@ class question_type { $options->$field = $question->$field; } - if (!$function($question_extension_table, $options)) { + if (!$DB->{$function}($question_extension_table, $options)) { $result = new stdClass; $result->error = 'Could not save question options for ' . $this->name() . ' question id ' . $question->id; @@ -403,7 +463,8 @@ class question_type { } public function save_hints($formdata, $withparts = false) { - delete_records('question_hints', 'questionid', $formdata->id); + global $DB; + $DB->delete_records('question_hints', array('questionid' => $formdata->id)); if (!empty($formdata->hint)) { $numhints = max(array_keys($formdata->hint)) + 1; @@ -443,7 +504,7 @@ class question_type { continue; } - insert_record('question_hints', $hint); + $DB->insert_record('question_hints', $hint); } } @@ -460,18 +521,22 @@ class question_type { * specific information (it is passed by reference). */ public function get_question_options($question) { - global $CFG; + global $CFG, $DB, $OUTPUT; + + if (!isset($question->options)) { + $question->options = new stdClass(); + } $extra_question_fields = $this->extra_question_fields(); if (is_array($extra_question_fields)) { $question_extension_table = array_shift($extra_question_fields); - $extra_data = get_record($question_extension_table, $this->questionid_column_name(), $question->id, '', '', '', '', implode(', ', $extra_question_fields)); + $extra_data = $DB->get_record($question_extension_table, array($this->questionid_column_name() => $question->id), implode(', ', $extra_question_fields)); if ($extra_data) { foreach ($extra_question_fields as $field) { $question->options->$field = $extra_data->$field; } } else { - notify("Failed to load question options from the table $question_extension_table for questionid " . + echo $OUTPUT->notification("Failed to load question options from the table $question_extension_table for questionid " . $question->id); return false; } @@ -480,21 +545,21 @@ class question_type { $extra_answer_fields = $this->extra_answer_fields(); if (is_array($extra_answer_fields)) { $answer_extension_table = array_shift($extra_answer_fields); - $question->options->answers = get_records_sql(' - SELECT qa.*, qax.' . implode(', qax.', $extra_answer_fields) . ' - FROM ' . $CFG->prefix . 'question_answers qa, ' . $CFG->prefix . '$answer_extension_table qax - WHERE qa.questionid = ' . $question->id . ' AND qax.answerid = qa.id'); + $question->options->answers = $DB->get_records_sql(" + SELECT qa.*, qax." . implode(', qax.', $extra_answer_fields) . " + FROM {question_answers} qa, {$answer_extension_table} qax + WHERE qa.questionid = ? AND qax.answerid = qa.id", array($question->id)); if (!$question->options->answers) { - notify("Failed to load question answers from the table $answer_extension_table for questionid " . + echo $OUTPUT->notification("Failed to load question answers from the table $answer_extension_table for questionid " . $question->id); return false; } } else { // Don't check for success or failure because some question types do not use the answers table. - $question->options->answers = get_records('question_answers', 'question', $question->id, 'id ASC'); + $question->options->answers = $DB->get_records('question_answers', array('question' => $question->id), 'id ASC'); } - $question->hints = get_records('question_hints', 'questionid', $question->id, 'id ASC'); + $question->hints = $DB->get_records('question_hints', array('questionid' => $question->id), 'id ASC'); return true; } @@ -592,34 +657,32 @@ class question_type { } /** - * Deletes a question from the question-type specific tables - * - * @return boolean Success/Failure - * @param object $question The question being deleted - */ - public function delete_question($questionid) { - global $CFG; - $success = true; + * Deletes the question-type specific data when a question is deleted. + * @param integer $question the question being deleted. + * @param integer $contextid the context this quesiotn belongs to. + */ + public function delete_question($questionid, $contextid) { + global $DB; + + $this->delete_files($questionid, $contextid); $extra_question_fields = $this->extra_question_fields(); if (is_array($extra_question_fields)) { $question_extension_table = array_shift($extra_question_fields); - $success = $success && delete_records($question_extension_table, - $this->questionid_column_name(), $questionid); + $DB->delete_records($question_extension_table, + array($this->questionid_column_name() => $questionid)); } $extra_answer_fields = $this->extra_answer_fields(); if (is_array($extra_answer_fields)) { $answer_extension_table = array_shift($extra_answer_fields); - $success = $success && delete_records_select($answer_extension_table, - "answerid IN (SELECT qa.id FROM {$CFG->prefix}question_answers qa WHERE qa.question = $questionid)"); + $DB->delete_records_select($answer_extension_table, + "answerid IN (SELECT qa.id FROM {question_answers} qa WHERE qa.question = ?)", array($questionid)); } - $success = $success && delete_records('question_answers', 'question', $questionid); + $DB->delete_records('question_answers', array('question' => $questionid)); - $success = $success && delete_records('question_hints', 'questionid', $questionid); - - return $success; + $DB->delete_records('question_hints', array('questionid' => $questionid)); } /** @@ -681,53 +744,35 @@ class question_type { } /** - * Return any CSS JavaScript required on the head of the question editing - * page question/question.php. - * - * @return an array of bits of HTML to add to the head of pages where - * this question is displayed in the body. The array should use - * integer array keys, which have no significance. + * Like @see{get_html_head_contributions}, but this method is for CSS and + * JavaScript required on the question editing page question/question.php. */ public function get_editing_head_contributions() { // By default, we link to any of the files styles.css, styles.php, // script.js or script.php that exist in the plugin folder. // Core question types should not use this mechanism. Their styles // should be included in the standard theme. - return $this->find_standard_scripts_and_css(); + $this->find_standard_scripts(); } /** - * Utility method used by @see{get_editing_head_contributions} and + * Utility method used by @see{get_html_head_contributions} and * @see{get_editing_head_contributions}. This looks for any of the files - * styles.css, styles.php, script.js or script.php that exist in the plugin - * folder and ensures they get included. - * - * @return array as required by get_editing_head_contributions. + * script.js or script.php that exist in the plugin folder and ensures they + * get included. */ - public function find_standard_scripts_and_css() { + protected function find_standard_scripts() { + global $PAGE; + $plugindir = $this->plugin_dir(); - $baseurl = $this->plugin_baseurl(); + $plugindirrel = 'question/type/' . $this->name(); if (file_exists($plugindir . '/script.js')) { - require_js($baseurl . '/script.js'); + $PAGE->requires->js('/' . $plugindirrel . '/script.js'); } if (file_exists($plugindir . '/script.php')) { - require_js($baseurl . '/script.php'); + $PAGE->requires->js('/' . $plugindirrel . '/script.php'); } - - $stylesheets = array(); - if (file_exists($plugindir . '/styles.css')) { - $stylesheets[] = 'styles.css'; - } - if (file_exists($plugindir . '/styles.php')) { - $stylesheets[] = 'styles.php'; - } - $contributions = array(); - foreach ($stylesheets as $stylesheet) { - $contributions[] = ''; - } - return $contributions; } /** @@ -746,223 +791,11 @@ class question_type { * * @return boolean Whether the wizard's last page was submitted or not. */ - public function finished_edit_wizard(&$form) { + public function finished_edit_wizard($form) { //In the default case there is only one edit page. return true; } - /* - * Find all course / site files linked from a question. - * - * Need to check for links to files in question_answers.answer and feedback - * and in question table in generalfeedback and questiontext fields. Methods - * on child classes will also check extra question specific fields. - * - * Needs to be overriden for child classes that have extra fields containing - * html. - * - * @param string html the html to search - * @param int courseid search for files for courseid course or set to siteid for - * finding site files. - * @return array of url, relative url is key and array with one item = question id as value - * relative url is relative to course/site files directory root. - */ - public function find_file_links($question, $courseid){ - $urls = array(); - - /// Question image - if ($question->image != ''){ - if (substr(strtolower($question->image), 0, 7) == 'http://') { - $matches = array(); - - //support for older questions where we have a complete url in image field - if (preg_match('!^'.question_file_links_base_url($courseid).'(.*)!i', $question->image, $matches)){ - if ($cleanedurl = question_url_check($urls[$matches[2]])){ - $urls[$cleanedurl] = null; - } - } - } else { - if ($question->image != ''){ - if ($cleanedurl = question_url_check($question->image)){ - $urls[$cleanedurl] = null;//will be set later - } - } - - } - - } - - /// Questiontext and general feedback. - $urls += question_find_file_links_from_html($question->questiontext, $courseid); - $urls += question_find_file_links_from_html($question->generalfeedback, $courseid); - - /// Answers, if this question uses them. - if (isset($question->options->answers)){ - foreach ($question->options->answers as $answerkey => $answer){ - /// URLs in the answers themselves, if appropriate. - if ($this->has_html_answers()) { - $urls += question_find_file_links_from_html($answer->answer, $courseid); - } - /// URLs in the answer feedback. - $urls += question_find_file_links_from_html($answer->feedback, $courseid); - } - } - - /// Set all the values of the array to the question object - if ($urls){ - $urls = array_combine(array_keys($urls), array_fill(0, count($urls), array($question->id))); - } - return $urls; - } - - /* - * Find all course / site files linked from a question. - * - * Need to check for links to files in question_answers.answer and feedback - * and in question table in generalfeedback and questiontext fields. Methods - * on child classes will also check extra question specific fields. - * - * Needs to be overriden for child classes that have extra fields containing - * html. - * - * @param string html the html to search - * @param int course search for files for courseid course or set to siteid for - * finding site files. - * @return array of files, file name is key and array with one item = question id as value - */ - public function replace_file_links($question, $fromcourseid, $tocourseid, $url, $destination){ - global $CFG; - $updateqrec = false; - - /// Question image - if (!empty($question->image)){ - //support for older questions where we have a complete url in image field - if (substr(strtolower($question->image), 0, 7) == 'http://') { - $questionimage = preg_replace('!^'.question_file_links_base_url($fromcourseid).preg_quote($url, '!').'$!i', $destination, $question->image, 1); - } else { - $questionimage = preg_replace('!^'.preg_quote($url, '!').'$!i', $destination, $question->image, 1); - } - if ($questionimage != $question->image){ - $question->image = $questionimage; - $updateqrec = true; - } - } - - /// Questiontext and general feedback. - $question->questiontext = question_replace_file_links_in_html($question->questiontext, $fromcourseid, $tocourseid, $url, $destination, $updateqrec); - $question->generalfeedback = question_replace_file_links_in_html($question->generalfeedback, $fromcourseid, $tocourseid, $url, $destination, $updateqrec); - - /// If anything has changed, update it in the database. - if ($updateqrec){ - if (!update_record('question', addslashes_recursive($question))){ - error ('Couldn\'t update question '.$question->name); - } - } - - - /// Answers, if this question uses them. - if (isset($question->options->answers)){ - //answers that do not need updating have been unset - foreach ($question->options->answers as $answer){ - $answerchanged = false; - /// URLs in the answers themselves, if appropriate. - if ($this->has_html_answers()) { - $answer->answer = question_replace_file_links_in_html($answer->answer, $fromcourseid, $tocourseid, $url, $destination, $answerchanged); - } - /// URLs in the answer feedback. - $answer->feedback = question_replace_file_links_in_html($answer->feedback, $fromcourseid, $tocourseid, $url, $destination, $answerchanged); - /// If anything has changed, update it in the database. - if ($answerchanged){ - if (!update_record('question_answers', addslashes_recursive($answer))){ - error ('Couldn\'t update question ('.$question->name.') answer '.$answer->id); - } - } - } - } - } - -/// BACKUP FUNCTIONS //////////////////////////// - - /* - * Backup the data in the question - * - * This is used in question/backuplib.php - */ - public function backup($bf,$preferences,$question,$level=6) { - - $status = true; - $extraquestionfields = $this->extra_question_fields(); - - if (is_array($extraquestionfields)) { - $questionextensiontable = array_shift($extraquestionfields); - $record = get_record($questionextensiontable, $this->questionid_column_name(), $question); - if ($record) { - $tagname = strtoupper($this->name()); - $status = $status && fwrite($bf, start_tag($tagname, $level, true)); - foreach ($extraquestionfields as $field) { - if (!isset($record->$field)) { - echo "No data for field $field when backuping " . - $this->name() . ' question id ' . $question; - return false; - } - fwrite($bf, full_tag(strtoupper($field), $level + 1, false, $record->$field)); - } - $status = $status && fwrite($bf, end_tag($tagname, $level, true)); - } - } - - $extraasnwersfields = $this->extra_answer_fields(); - if (is_array($extraasnwersfields)) { - //TODO backup the answers, with any extra data. - } else { - $status = $status && question_backup_answers($bf, $preferences, $question); - } - return $status; - } - -/// RESTORE FUNCTIONS ///////////////// - - /* - * Restores the data in the question - * - * This is used in question/restorelib.php - */ - public function restore($old_question_id,$new_question_id,$info,$restore) { - - $status = true; - $extraquestionfields = $this->extra_question_fields(); - - if (is_array($extraquestionfields)) { - $questionextensiontable = array_shift($extraquestionfields); - $tagname = strtoupper($this->name()); - $recordinfo = $info['#'][$tagname][0]; - - $record = new stdClass; - $qidcolname = $this->questionid_column_name(); - $record->$qidcolname = $new_question_id; - foreach ($extraquestionfields as $field) { - $record->$field = backup_todb($recordinfo['#'][strtoupper($field)]['0']['#']); - } - if (!insert_record($questionextensiontable, $record)) { - echo "Can't insert record in $questionextensiontable when restoring " . - $this->name() . ' question id ' . $question; - $status = false; - } - } - //TODO restore extra data in answers - return $status; - } - - public function restore_map($old_question_id,$new_question_id,$info,$restore) { - // There is nothing to decode - return true; - } - - public function restore_recode_answer($state, $restore) { - // There is nothing to decode - return $state->answer; - } - /// IMPORT/EXPORT FUNCTIONS ///////////////// /* @@ -988,7 +821,7 @@ class question_type { $qo->qtype = $question_type; foreach ($extraquestionfields as $field) { - $qo->$field = addslashes($format->getpath($data, array('#',$field,0,'#'), $qo->$field)); + $qo->$field = $format->getpath($data, array('#',$field,0,'#'), $qo->$field); } // run through the answers @@ -1072,6 +905,142 @@ class question_type { $question->qtype = $this->qtype; return array($form, $question); } + + /** + * Get question context by category id + * @param int $category + * @return object $context + */ + function get_context_by_category_id($category) { + global $DB; + $contextid = $DB->get_field('question_categories', 'contextid', array('id'=>$category)); + $context = get_context_instance_by_id($contextid); + return $context; + } + + /** + * Save the file belonging to one text field. + * + * @param array $field the data from the form (or from import). This will + * normally have come from the formslib editor element, so it will be an + * array with keys 'text', 'format' and 'itemid'. However, when we are + * importing, it will be an array with keys 'text', 'format' and 'files' + * @param object $context the context the question is in. + * @param string $component indentifies the file area question. + * @param string $filearea indentifies the file area questiontext, generalfeedback,answerfeedback. + * @param integer $itemid identifies the file area. + * + * @return string the text for this field, after files have been processed. + */ + protected function import_or_save_files($field, $context, $component, $filearea, $itemid) { + if (!empty($field['itemid'])) { + // This is the normal case. We are safing the questions editing form. + return file_save_draft_area_files($field['itemid'], $context->id, $component, + $filearea, $itemid, $this->fileoptions, trim($field['text'])); + + } else if (!empty($field['files'])) { + // This is the case when we are doing an import. + foreach ($field['files'] as $file) { + $this->import_file($context, $component, $filearea, $itemid, $file); + } + } + return trim($field['text']); + } + + /** + * Move all the files belonging to this question from one context to another. + * @param integer $questionid the question being moved. + * @param integer $oldcontextid the context it is moving from. + * @param integer $newcontextid the context it is moving to. + */ + public function move_files($questionid, $oldcontextid, $newcontextid) { + $fs = get_file_storage(); + $fs->move_area_files_to_new_context($oldcontextid, + $newcontextid, 'question', 'questiontext', $questionid); + $fs->move_area_files_to_new_context($oldcontextid, + $newcontextid, 'question', 'generalfeedback', $questionid); + } + + /** + * Move all the files belonging to this question's answers when the question + * is moved from one context to another. + * @param integer $questionid the question being moved. + * @param integer $oldcontextid the context it is moving from. + * @param integer $newcontextid the context it is moving to. + * @param boolean $answerstoo whether there is an 'answer' question area, + * as well as an 'answerfeedback' one. Default false. + */ + protected function move_files_in_answers($questionid, $oldcontextid, $newcontextid, $answerstoo = false) { + global $DB; + $fs = get_file_storage(); + + $answerids = $DB->get_records_menu('question_answers', + array('question' => $questionid), 'id', 'id,1'); + foreach ($answerids as $answerid => $notused) { + if ($answerstoo) { + $fs->move_area_files_to_new_context($oldcontextid, + $newcontextid, 'question', 'answer', $answerid); + } + $fs->move_area_files_to_new_context($oldcontextid, + $newcontextid, 'question', 'answerfeedback', $answerid); + } + } + + /** + * Delete all the files belonging to this question. + * @param integer $questionid the question being deleted. + * @param integer $contextid the context the question is in. + */ + protected function delete_files($questionid, $contextid) { + $fs = get_file_storage(); + $fs->delete_area_files($contextid, 'question', 'questiontext', $questionid); + $fs->delete_area_files($contextid, 'question', 'generalfeedback', $questionid); + } + + /** + * Delete all the files belonging to this question's answers. + * @param integer $questionid the question being deleted. + * @param integer $contextid the context the question is in. + * @param boolean $answerstoo whether there is an 'answer' question area, + * as well as an 'answerfeedback' one. Default false. + */ + protected function delete_files_in_answers($questionid, $contextid, $answerstoo = false) { + global $DB; + $fs = get_file_storage(); + + $answerids = $DB->get_records_menu('question_answers', + array('question' => $questionid), 'id', 'id,1'); + foreach ($answerids as $answerid => $notused) { + if ($answerstoo) { + $fs->delete_area_files($contextid, 'question', 'answer', $answerid); + } + $fs->delete_area_files($contextid, 'question', 'answerfeedback', $answerid); + } + } + + function import_file($context, $component, $filearea, $itemid, $file) { + $fs = get_file_storage(); + $record = new stdclass; + if (is_object($context)) { + $record->contextid = $context->id; + } else { + $record->contextid = $context; + } + $record->component = $component; + $record->filearea = $filearea; + $record->itemid = $itemid; + $record->filename = $file->name; + $record->filepath = '/'; + return $fs->create_file_from_string($record, $this->decode_file($file)); + } + + function decode_file($file) { + switch ($file->encoding) { + case 'base64': + default: + return base64_decode($file->content); + } + } } diff --git a/question/type/rendererbase.php b/question/type/rendererbase.php index 3fad513b482..57e32a485cc 100644 --- a/question/type/rendererbase.php +++ b/question/type/rendererbase.php @@ -200,7 +200,7 @@ abstract class qtype_renderer extends renderer_base { * @return string html fragment. */ function feedback_image($fraction, $selected = true) { - global $CFG; + global $OUTPUT; $state = question_state::graded_state_for_fraction($fraction); @@ -218,7 +218,7 @@ abstract class qtype_renderer extends renderer_base { } $attributes = array( - 'src' => $CFG->pixpath . '/i/' . $icon . '.gif', + 'src' => $OUTPUT->pix_url('i/' . $icon), 'alt' => get_string($state->get_feedback_class(), 'question'), 'class' => 'questioncorrectnessicon', ); diff --git a/question/type/simpletest/testquestiontype.php b/question/type/simpletest/testquestiontype.php index 77e07b15471..b491527a6e8 100644 --- a/question/type/simpletest/testquestiontype.php +++ b/question/type/simpletest/testquestiontype.php @@ -1,74 +1,44 @@ . + /** - * Tests for some of ../questiontype.php + * Unit tests for the question type base class. * - * @license http://www.gnu.org/copyleft/gpl.html GNU Public License * @package moodlecore + * @subpackage questiontypes + * @copyright 2008 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ + if (!defined('MOODLE_INTERNAL')) { die('Direct access to this script is forbidden.'); /// It must be included from a Moodle page } require_once($CFG->dirroot . '/question/type/questiontype.php'); -class default_questiontype_test extends UnitTestCase { + +/** + * Tests for some of ../questionbase.php + * + * @copyright 2008 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_type_test extends UnitTestCase { public static $includecoverage = array('question/type/questiontype.php'); - protected $qtype; - - public function setUp() { - $this->qtype = new default_questiontype(); - } - - public function tearDown() { - $this->qtype = null; - } - - function test_compare_responses() { - $question = new stdClass; - $state = new stdClass; - $teststate = new stdClass; - - $state->responses = array(); - $teststate->responses = array(); - $this->assertTrue($this->qtype->compare_responses($question, $state, $teststate)); - - $state->responses = array('' => 'frog'); - $teststate->responses = array('' => 'toad'); - $this->assertFalse($this->qtype->compare_responses($question, $state, $teststate)); - - $state->responses = array('x' => 'frog'); - $teststate->responses = array('y' => 'frog'); - $this->assertFalse($this->qtype->compare_responses($question, $state, $teststate)); - - $state->responses = array(1 => 1, 2 => 2); - $teststate->responses = array(2 => 2, 1 => 1); - $this->assertTrue($this->qtype->compare_responses($question, $state, $teststate)); - } } - -