From 96bee2b33467a1ce54f88c77b8a867ef1f7a9a97 Mon Sep 17 00:00:00 2001 From: Jean-Michel Vedrine Date: Sun, 9 Sep 2012 16:54:35 +0200 Subject: First version of algebra question type initially written by Roger Moore for Moodle 2.3 --- questiontype.php | 551 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 551 insertions(+) create mode 100644 questiontype.php (limited to 'questiontype.php') diff --git a/questiontype.php b/questiontype.php new file mode 100644 index 0000000..dfd9f3b --- /dev/null +++ b/questiontype.php @@ -0,0 +1,551 @@ +. + +/** + * Question type class for the algebra question type. + * + * @package qtype + * @subpackage 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/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('question_algebra', + '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); + } + + protected function delete_files($questionid, $contextid) { + parent::delete_files($questionid, $contextid); + $this->delete_files_in_answers($questionid, $contextid); + } + + public function delete_question($questionid, $contextid) { + global $DB; + $DB->delete_records('question_algebra', array('questionid' => $questionid)); + $DB->delete_records('question_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('question_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('question_algebra_variables', $var)) { + throw new Exception("Could not update algebra question variable (id=$var->id)"); + } + } + // This is a completely new variable so we have to create a new record + else { + $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('question_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('question_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')) { //'question', $question->'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=''; + } + // Not all functions are allowed so set allowed functions to those which are + else { + // 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)); + } + + // Call the parent method to write the extensions fields to the database. This either returns null + // or an error object so if we get anything then return it otherwise return our existing + $parentresult = parent::save_question_options($question); + if ($parentresult !== null) { + // Parent function returns null if all is OK + return $parentresult; + } + // Otherwise just return true - this mimics the shortanswer return format + else { + return true; + } + } + + /** + * 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('question_algebra_variables', array('question' => $question->id)); + + + + //, 'id ASC'); + // 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 question_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); + } + // Otherwise just create an empty array + else { + $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); + } + // 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 + else { + $qo->allowedfuncs=array_flip(explode(',',$allowedfuncs)); + } + $qo->answerprefix = $format->getpath($data, array('#','answerprefix',0,'#','text',0,'#'),'',true); + + // Import all the answers + $answers = $data['#']['answer']; + $a_count = 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[$a_count] = $ans->answer['text']; + $qo->fraction[$a_count] = $ans->fraction; + $qo->feedback[$a_count] = $ans->feedback; + ++$a_count; + } + + // Import all the variables + $vars = $data['#']['variable']; + $v_count = 0; + // Loop over each answer block found in the XML + foreach($vars as $var) { + $qo->variable[$v_count] = $format->getpath($var, array('@','name'),0); + $qo->varmin[$v_count] = $format->getpath($var, array('#','min',0,'#'),'0',false,get_string('novarmin','qtype_algebra')); + $qo->varmax[$v_count] = $format->getpath($var, array('#','max',0,'#'),'0',false,get_string('novarmax','qtype_algebra')); + ++$v_count; + } + + $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 + */ + 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 question_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[null] = question_possible_response::no_response(); + + return array($questiondata->id => $responses); + } +} \ No newline at end of file -- cgit v1.2.3