. /** * Question type class for the algebra question type. * * @package qtype_algebra * @author Roger Moore * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/type/questiontypebase.php'); require_once($CFG->dirroot . '/question/type/algebra/question.php'); require_once($CFG->dirroot . '/question/type/algebra/parser.php'); require_once($CFG->dirroot . '/question/type/algebra/xmlrpc-utils.php'); /** * ALGEBRA QUESTION TYPE CLASS * * @package questionbank * @subpackage questiontypes */ class qtype_algebra extends question_type { /** * Defines the table which extends the question table. This allows the base questiontype * to automatically save, backup and restore the extra fields. * * @return an array with the table name (first) and then the column names (apart from id and questionid) */ public function extra_question_fields() { return array('qtype_algebra_options', 'compareby', // Name of comparison algorithm to use 'nchecks', // Number of evaluate checks to make when comparing by evaluation 'tolerance', // Max. fractional difference allowed for evaluation checks 'allowedfuncs', // Comma separated list of functions allowed in responses 'disallow', // Response which may be correct but which is not allowed 'answerprefix' // String which is placed in front of the asnwer box. ); } public function questionid_column_name() { return 'questionid'; } public function move_files($questionid, $oldcontextid, $newcontextid) { parent::move_files($questionid, $oldcontextid, $newcontextid); $this->move_files_in_answers($questionid, $oldcontextid, $newcontextid); $this->move_files_in_hints($questionid, $oldcontextid, $newcontextid); } protected function delete_files($questionid, $contextid) { parent::delete_files($questionid, $contextid); $this->delete_files_in_answers($questionid, $contextid); $this->delete_files_in_hints($questionid, $contextid); } public function delete_question($questionid, $contextid) { global $DB; $DB->delete_records('qtype_algebra_options', array('questionid' => $questionid)); $DB->delete_records('qtype_algebra_variables', array('question' => $questionid)); parent::delete_question($questionid, $contextid); } /** * Saves the questions variables to the database * * This is called by {@link save_question_options()} to save the variables of the question to * the database from the data in the submitted form. The method returns an array of the variables * IDs written to the database or, in the event of an error, throws an exception. * * @param object $question This holds the information from the editing form, * it is not a standard question object. * @return array of variable object IDs */ public function save_question_variables($question) { global $DB; // Create the results class. $result = new stdClass; // Get all the old answers from the database as an array. if (!$oldvars = $DB->get_records('qtype_algebra_variables', array('question' => $question->id), 'id ASC')) { $oldvars = array(); } // Create an array of the variable IDs for the question. $variables = array(); // Loop over all the answers in the question form and write them to the database. foreach ($question->variable as $key => $varname) { // Check to see that there is a variable and skip any which are empty. if ($varname == '') { continue; } // Get the old variable from the array and overwrite what is required, if there // is no old variable then we skip to the 'else' clause. if ($oldvar = array_shift($oldvars)) { // Existing variable, so reuse it. $var = $oldvar; $var->name = trim($varname); $var->min = trim($question->varmin[$key]); $var->max = trim($question->varmax[$key]); // Update the record in the database to denote this change. if (!$DB->update_record('qtype_algebra_variables', $var)) { throw new Exception("Could not update algebra question variable (id=$var->id)"); } } else { // This is a completely new variable so we have to create a new record. $var = new stdClass; $var->name = trim($varname); $var->question = $question->id; $var->min = trim($question->varmin[$key]); $var->max = trim($question->varmax[$key]); // Insert a new record into the database table. if (!$var->id = $DB->insert_record('qtype_algebra_variables', $var)) { throw new Exception("Could not insert algebra question variable '$varname'!"); } } // Add the variable ID to the array of IDs. $variables[] = $var->id; } // End loop over variables. // Delete any left over old variables records. foreach ($oldvars as $oldvar) { $DB->delete_records('qtype_algebra_variables', array('id' => $oldvar->id)); } // Finally we are all done so return the result! return $variables; } /** * Saves the questions answers to the database * * This is called by {@link save_question_options()} to save the answers to the question to * the database from the data in the submitted form. This method should probably be in the * questin base class rather than in the algebra subclass since the code is common to multiple * question types and originally comes from the shortanswer question type. The method returns * a list of the answer ID written to the database or throws an exception if an error is detected. * * @param object $question This holds the information from the editing form, * it is not a standard question object. * @return array of answer IDs which were written to the database */ public function save_question_answers($question) { global $CFG, $DB; $context = $question->context; // Create the results class. $result = new stdClass; // Get all the old answers from the database as an array. if (!$oldanswers = $DB->get_records('question_answers', array('question' => $question->id), 'id ASC')) { $oldanswers = array(); } // Create an array of the answer IDs for the question. $answers = array(); // Set the maximum answer fraction to be -1. We will check this at the end of our // loop over the questions and if it is not 100% (=1.0) then we will flag an error. $maxfraction = -1; // Loop over all the answers in the question form and write them to the database. foreach ($question->answer as $key => $answerdata) { // Check for, and ignore, completely blank answer from the form. if (trim($answerdata) == '' && $question->fraction[$key] == 0 && html_is_blank($question->feedback[$key]['text'])) { continue; } // Update an existing answer if possible. $answer = array_shift($oldanswers); if (!$answer) { $answer = new stdClass(); $answer->question = $question->id; $answer->answer = ''; $answer->feedback = ''; $answer->feedbackformat = FORMAT_HTML; if (!$answer->id = $DB->insert_record('question_answers', $answer)) { throw new Exception("Could not create new algebra question answer"); } } $answer->answer = trim($answerdata); $answer->fraction = $question->fraction[$key]; $answer->feedback = $this->import_or_save_files($question->feedback[$key], $context, 'question', 'answerfeedback', $answer->id); $answer->feedbackformat = $question->feedback[$key]['format']; if (!$DB->update_record('question_answers', $answer)) { throw new Exception("Could not update algebra question answer (id=$answer->id)"); } $answers[] = $answer->id; if ($question->fraction[$key] > $maxfraction) { $maxfraction = $question->fraction[$key]; } } // End loop over answers. // Perform sanity check on the maximum fractional grade which should be 100%. if ($maxfraction != 1) { $maxfraction = $maxfraction * 100; throw new Exception(get_string('fractionsnomax', 'quiz', $maxfraction)); } // Delete any left over old answer records. $fs = get_file_storage(); foreach ($oldanswers as $oldanswer) { $fs->delete_area_files($context->id, 'question', 'answerfeedback', $oldanswer->id); $DB->delete_records('question_answers', array('id' => $oldanswer->id)); } // Finally we are all done so return the result! return $answers; } /** * Saves question-type specific options * * This is called by {@link save_question()} to save the question-type specific data from a * submitted form. This method takes the form data and formats into the correct format for * writing to the database. It then calls the parent method to actually write the data. * * @param object $question This holds the information from the editing form, * it is not a standard question object. * @return object $result->error or $result->noticeyesno or $result->notice */ public function save_question_options($question) { // Start a try block to catch any exceptions generated when we attempt to parse and // then add the answers and variables to the database. try { // First write out all the variables associated with the question. $variables = $this->save_question_variables($question); // Loop over all the answers in the question form and parse them to generate // a parser string. This ensures a constant formatting is stored in the database. foreach ($question->answer as &$answer) { $expr = $this->parse_expression($answer); // TODO detect invalid answer and issue a warning. $answer = $expr->sage(); } // Now we need to write out all the answers to the question to the database. $answers = $this->save_question_answers($question); } catch (Exception $e) { // Error when adding answers or variables to the database so create a result class // and put the error string in the error member funtion and then return the class // This keeps us compatible with the existing save_question_options methods. $result = new stdClass; $result->error = $e->getMessage(); return $result; } // Process the allowed functions field. This code just sets up the variable, it is saved // in the parent class' save_question_options method called at the end of this method // Look for the 'all' option. If we find it then set the string to an empty value. if (array_key_exists('all', $question->allowedfuncs)) { $question->allowedfuncs = ''; } else { // Not all functions are allowed so set allowed functions to those which are. // Create a comma separated string of the function names which are stored in the // keys of the array. $question->allowedfuncs = implode(',', array_keys($question->allowedfuncs)); } parent::save_question_options($question); $this->save_hints($question); } /** * Loads the question type specific options for the question. * * This function loads the compare algorithm type, disallowed strings and variables * into the class from the database table in which they are stored. It first uses the * parent class method to get the database information. * * @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). * @return bool Indicates success or failure. */ public function get_question_options($question) { // Get the information from the database table. If this fails then immediately bail. // Note unlike the save_question_options base class method this method DOES get the question's // answers along with any answer extensions. global $DB; if (!parent::get_question_options($question)) { return false; } // Check that we have answers and if not then bail since this question type requires answers. if (count($question->options->answers) == 0) { notify('Failed to load question answers from the table question_answers for questionid ' . $question->id); return false; } // Now get the variables from the database as well. $question->options->variables = $DB->get_records('qtype_algebra_variables', array('question' => $question->id)); // Check that we have variables and if not then bail since this question type requires variables. if (count($question->options->variables) == 0) { notify('Failed to load question variables from the table qtype_algebra_variables '. "for questionid $question->id"); return false; } // Check to see if there are any allowed functions. if ($question->options->allowedfuncs != '') { // Extract the allowed functions as an array. $question->options->allowedfuncs = explode(',', $question->options->allowedfuncs); } else { // Otherwise just create an empty array. $question->options->allowedfuncs = array(); } // Everything worked so return true. return true; } /** * Imports the question from Moodle XML format. * * This method is called by the format class when importing an algebra question from the * Moodle XML format. * * @param $data structure containing the XML data * @param $question question object to fill: ignored by this function (assumed to be null) * @param $format format class importing the question * @param $extra extra information (not required for importing this question in this format) * @return text string containing the question data in XML format */ public function import_from_xml($data, $question, qformat_xml $format, $extra = null) { if (!array_key_exists('@', $data)) { return false; } if (!array_key_exists('type', $data['@'])) { return false; } if ($data['@']['type'] == 'algebra') { // Import the common question headers. $qo = $format->import_headers($data); // Set the question type. $qo->qtype = 'algebra'; $qo->compareby = $format->getpath($data, array('#', 'compareby', 0, '#'), 'eval'); $qo->tolerance = $format->getpath($data, array('#', 'tolerance', 0, '#'), '0'); $qo->nchecks = $format->getpath($data, array('#', 'nchecks', 0, '#'), '10'); $qo->disallow = $format->getpath($data, array('#', 'disallow', 0, '#', 'text', 0, '#'), '', true); $allowedfuncs = $format->getpath($data, array('#', 'allowedfuncs', 0, '#'), ''); if ($allowedfuncs == '') { $qo->allowedfuncs = array('all' => 1); } else { // Need to separate the allowed functions into an array of strings and then // flip the values of this array into the keys because this is what the // save options method requires. $qo->allowedfuncs = array_flip(explode(',', $allowedfuncs)); } $qo->answerprefix = $format->getpath($data, array('#', 'answerprefix', 0, '#', 'text', 0, '#'), '', true); // Import all the answers. $answers = $data['#']['answer']; $acount = 0; // Loop over each answer block found in the XML. foreach ($answers as $answer) { // Use the common answer import function in the format class to load the data. $ans = $format->import_answer($answer); $qo->answer[$acount] = $ans->answer['text']; $qo->fraction[$acount] = $ans->fraction; $qo->feedback[$acount] = $ans->feedback; ++$acount; } // Import all the variables. $vars = $data['#']['variable']; $vcount = 0; // Loop over each answer block found in the XML. foreach ($vars as $var) { $qo->variable[$vcount] = $format->getpath($var, array('@', 'name'), 0); $qo->varmin[$vcount] = $format->getpath($var, array('#', 'min', 0, '#'), '0', false, get_string('novarmin', 'qtype_algebra')); $qo->varmax[$vcount] = $format->getpath($var, array('#', 'max', 0, '#'), '0', false, get_string('novarmax', 'qtype_algebra')); ++$vcount; } $format->import_hints($qo, $data); return $qo; } return false; } /** * Exports the question to Moodle XML format. * * This method is called by the format class when exporting an algebra question into then * Moodle XML format. * * @param $question question to be exported into XML format * @param $format format class exporting the question * @param $extra extra information (not required for exporting this question in this format) * @return text string containing the question data in XML format */ public function export_to_xml($question, qformat_xml $format, $extra = null) { $expout = ''; // Create a text string of the allowed functions from the array. $allowedfuncs = implode(',', $question->options->allowedfuncs); // Write out all the extra fields belonging to the algebra question type. $expout .= " {$question->options->compareby}\n"; $expout .= " {$question->options->tolerance}\n"; $expout .= " {$question->options->nchecks}\n"; $expout .= " ".$format->writetext($question->options->disallow, 1, true)."\n"; $expout .= " $allowedfuncs\n"; $expout .= " ".$format->writetext($question->options->answerprefix, 1, true). "\n"; // Write out all the answers. $expout .= $format->write_answers($question->options->answers); // Loop over all the variables for the question and write out all their details. foreach ($question->options->variables as $var) { $expout .= "name}\">\n"; $expout .= " {$var->min}\n"; $expout .= " {$var->max}\n"; $expout .= "\n"; } return $expout; } // Gets all the question responses. public function get_all_responses(&$question, &$state) { $result = new stdClass; $answers = array(); // Loop over all the answers. if (is_array($question->options->answers)) { foreach ($question->options->answers as $aid => $answer) { $r = new stdClass; $r->answer = $answer->answer; $r->credit = $answer->fraction; $answers[$aid] = $r; } } $result->id = $question->id; $result->responses = $answers; return $result; } /** * Parses the given expression with the parser if required. * * This method will check to see if the argument it is given is already a parsed * expression and if not will attempt to parse it. * * @param $expr expression which will be parsed * @param $question question containing the expression or null if none * @return top term of the parse tree or a string if an exception is thrown */ public function parse_expression($expr) { // Check to see if this is already a parsed expression. if (is_a($expr, 'qtype_algebra_parser_term')) { // It is a parsed expression so simply return it. return $expr; } // Check whether we have a state object or a simple string. If a state // then replace it with the response string. if (isset($expr->responses[''])) { $expr = $expr->responses['']; } // Create an empty array of variable names for the parser (no variable checking here as it is done in the form validation // TODO see in case of import. $varnames = array(); // We now assume that we have a string to parse. Create a parser instance to // to this and return the parser expression at the top of the parse tree. $p = new qtype_algebra_parser; // Perform the actual parsing inside a try-catch block so that any exceptions. // can be caught and converted into errors. try { return $p->parse($expr, $varnames); } catch (Exception $e) { // If the expression cannot be parsed then return a null term. This will // make Moodle treat the answer as wrong. // TODO: Would be nice to have support for 'invalid answer' in the quiz // engine since an unparseable response is usually caused by a silly typo. return new qtype_algebra_parser_nullterm; } } protected function initialise_question_instance(question_definition $question, $questiondata) { parent::initialise_question_instance($question, $questiondata); $question->variables = array(); if (!empty($questiondata->options->variables)) { foreach ($questiondata->options->variables as $v) { $question->variables[$v->id] = new qtype_algebra_variable($v->id, $v->name, $v->min, $v->max); } } $question->compareby = $questiondata->options->compareby; $question->nchecks = $questiondata->options->nchecks; $question->tolerance = $questiondata->options->tolerance; $question->allowedfuncs = $questiondata->options->allowedfuncs; $question->disallow = $questiondata->options->disallow; $question->answerprefix = $questiondata->options->answerprefix; $this->initialise_question_answers($question, $questiondata); } public function get_random_guess_score($questiondata) { foreach ($questiondata->options->answers as $aid => $answer) { if ('*' == trim($answer->answer)) { return $answer->fraction; } } return 0; } public function get_possible_responses($questiondata) { $responses = array(); foreach ($questiondata->options->answers as $aid => $answer) { $responses[$aid] = new question_possible_response($answer->answer, $answer->fraction); } $responses[0] = new question_possible_response( get_string('didnotmatchanyanswer', 'question'), 0); $responses[null] = question_possible_response::no_response(); return array($questiondata->id => $responses); } }