Merge branch 'MDL-36655-m23' of git://github.com/netspotau/moodle-mod_assign into...
[moodle.git] / mod / lesson / format.php
1 <?php
3 // This file is part of Moodle - http://moodle.org/
4 //
5 // Moodle is free software: you can redistribute it and/or modify
6 // it under the terms of the GNU General Public License as published by
7 // the Free Software Foundation, either version 3 of the License, or
8 // (at your option) any later version.
9 //
10 // Moodle is distributed in the hope that it will be useful,
11 // but WITHOUT ANY WARRANTY; without even the implied warranty of
12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 // GNU General Public License for more details.
14 //
15 // You should have received a copy of the GNU General Public License
16 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
18 /**
19  * format.php  - Default format class for file imports/exports. Doesn't do
20  * everything on it's own -- it needs to be extended.
21  *
22  * Included by import.ph
23  *
24  * @package    mod
25  * @subpackage lesson
26  * @copyright  1999 onwards Martin Dougiamas  {@link http://moodle.com}
27  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
28  **/
30 defined('MOODLE_INTERNAL') || die();
32 /**#@+
33  * The core question types.
34  *
35  * These used to be in lib/questionlib.php, but are being deprecated. Copying them
36  * here to keep this code working for now.
37  */
38 if (!defined('SHORTANSWER')) {
39     define("SHORTANSWER",   "shortanswer");
40     define("TRUEFALSE",     "truefalse");
41     define("MULTICHOICE",   "multichoice");
42     define("RANDOM",        "random");
43     define("MATCH",         "match");
44     define("RANDOMSAMATCH", "randomsamatch");
45     define("DESCRIPTION",   "description");
46     define("NUMERICAL",     "numerical");
47     define("MULTIANSWER",   "multianswer");
48     define("CALCULATED",    "calculated");
49     define("ESSAY",         "essay");
50 }
51 /**#@-*/
53 /**
54  * Given some question info and some data about the the answers
55  * this function parses, organises and saves the question
56  *
57  * This is only used when IMPORTING questions and is only called
58  * from format.php
59  * Lifted from mod/quiz/lib.php -
60  *    1. all reference to oldanswers removed
61  *    2. all reference to quiz_multichoice table removed
62  *    3. In SHORTANSWER questions usecase is store in the qoption field
63  *    4. In NUMERIC questions store the range as two answers
64  *    5. TRUEFALSE options are ignored
65  *    6. For MULTICHOICE questions with more than one answer the qoption field is true
66  *
67  * @param opject $question Contains question data like question, type and answers.
68  * @return object Returns $result->error or $result->notice.
69  **/
70 function lesson_save_question_options($question, $lesson) {
71     global $DB;
73     // These lines are required to ensure that all page types have
74     // been loaded for the following switch
75     if (!($lesson instanceof lesson)) {
76         $lesson = new lesson($lesson);
77     }
78     $manager = lesson_page_type_manager::get($lesson);
80     $timenow = time();
81     $result = new stdClass();
82     switch ($question->qtype) {
83         case LESSON_PAGE_SHORTANSWER:
85             $answers = array();
86             $maxfraction = -1;
88             // Insert all the new answers
89             foreach ($question->answer as $key => $dataanswer) {
90                 if ($dataanswer != "") {
91                     $answer = new stdClass;
92                     $answer->lessonid   = $question->lessonid;
93                     $answer->pageid   = $question->id;
94                     if ($question->fraction[$key] >=0.5) {
95                         $answer->jumpto = LESSON_NEXTPAGE;
96                     }
97                     $answer->timecreated   = $timenow;
98                     $answer->grade = $question->fraction[$key] * 100;
99                     $answer->answer   = $dataanswer;
100                     $answer->response = $question->feedback[$key]['text'];
101                     $answer->responseformat = $question->feedback[$key]['format'];
102                     $answer->id = $DB->insert_record("lesson_answers", $answer);
103                     $answers[] = $answer->id;
104                     if ($question->fraction[$key] > $maxfraction) {
105                         $maxfraction = $question->fraction[$key];
106                     }
107                 }
108             }
111             /// Perform sanity checks on fractional grades
112             if ($maxfraction != 1) {
113                 $maxfraction = $maxfraction * 100;
114                 $result->notice = get_string("fractionsnomax", "quiz", $maxfraction);
115                 return $result;
116             }
117             break;
119         case LESSON_PAGE_NUMERICAL:   // Note similarities to SHORTANSWER
121             $answers = array();
122             $maxfraction = -1;
125             // for each answer store the pair of min and max values even if they are the same
126             foreach ($question->answer as $key => $dataanswer) {
127                 if ($dataanswer != "") {
128                     $answer = new stdClass;
129                     $answer->lessonid   = $question->lessonid;
130                     $answer->pageid   = $question->id;
131                     $answer->jumpto = LESSON_NEXTPAGE;
132                     $answer->timecreated   = $timenow;
133                     $answer->grade = $question->fraction[$key] * 100;
134                     $min = $question->answer[$key] - $question->tolerance[$key];
135                     $max = $question->answer[$key] + $question->tolerance[$key];
136                     $answer->answer   = $min.":".$max;
137                     // $answer->answer   = $question->min[$key].":".$question->max[$key]; original line for min/max
138                     $answer->response = $question->feedback[$key]['text'];
139                     $answer->responseformat = $question->feedback[$key]['format'];
140                     $answer->id = $DB->insert_record("lesson_answers", $answer);
142                     $answers[] = $answer->id;
143                     if ($question->fraction[$key] > $maxfraction) {
144                         $maxfraction = $question->fraction[$key];
145                     }
146                 }
147             }
149             /// Perform sanity checks on fractional grades
150             if ($maxfraction != 1) {
151                 $maxfraction = $maxfraction * 100;
152                 $result->notice = get_string("fractionsnomax", "quiz", $maxfraction);
153                 return $result;
154             }
155         break;
158         case LESSON_PAGE_TRUEFALSE:
160             // the truth
161             $answer = new stdClass();
162             $answer->lessonid   = $question->lessonid;
163             $answer->pageid = $question->id;
164             $answer->timecreated   = $timenow;
165             $answer->answer = get_string("true", "quiz");
166             $answer->grade = $question->correctanswer * 100;
167             if ($answer->grade > 50 ) {
168                 $answer->jumpto = LESSON_NEXTPAGE;
169             }
170             if (isset($question->feedbacktrue)) {
171                 $answer->response = $question->feedbacktrue['text'];
172                 $answer->responseformat = $question->feedbacktrue['format'];
173             }
174             $DB->insert_record("lesson_answers", $answer);
176             // the lie
177             $answer = new stdClass;
178             $answer->lessonid   = $question->lessonid;
179             $answer->pageid = $question->id;
180             $answer->timecreated   = $timenow;
181             $answer->answer = get_string("false", "quiz");
182             $answer->grade = (1 - (int)$question->correctanswer) * 100;
183             if ($answer->grade > 50 ) {
184                 $answer->jumpto = LESSON_NEXTPAGE;
185             }
186             if (isset($question->feedbackfalse)) {
187                 $answer->response = $question->feedbackfalse['text'];
188                 $answer->responseformat = $question->feedbackfalse['format'];
189             }
190             $DB->insert_record("lesson_answers", $answer);
192           break;
194         case LESSON_PAGE_MULTICHOICE:
196             $totalfraction = 0;
197             $maxfraction = -1;
199             $answers = array();
201             // Insert all the new answers
202             foreach ($question->answer as $key => $dataanswer) {
203                 if ($dataanswer != "") {
204                     $answer = new stdClass;
205                     $answer->lessonid   = $question->lessonid;
206                     $answer->pageid   = $question->id;
207                     $answer->timecreated   = $timenow;
208                     $answer->grade = $question->fraction[$key] * 100;
209                     // changed some defaults
210                     /* Original Code
211                     if ($answer->grade > 50 ) {
212                         $answer->jumpto = LESSON_NEXTPAGE;
213                     }
214                     Replaced with:                    */
215                     if ($answer->grade > 50 ) {
216                         $answer->jumpto = LESSON_NEXTPAGE;
217                         $answer->score = 1;
218                     }
219                     // end Replace
220                     $answer->answer   = $dataanswer['text'];
221                     $answer->answerformat   = $dataanswer['format'];
222                     $answer->response = $question->feedback[$key]['text'];
223                     $answer->responseformat = $question->feedback[$key]['format'];
224                     $answer->id = $DB->insert_record("lesson_answers", $answer);
225                     // for Sanity checks
226                     if ($question->fraction[$key] > 0) {
227                         $totalfraction += $question->fraction[$key];
228                     }
229                     if ($question->fraction[$key] > $maxfraction) {
230                         $maxfraction = $question->fraction[$key];
231                     }
232                 }
233             }
235             /// Perform sanity checks on fractional grades
236             if ($question->single) {
237                 if ($maxfraction != 1) {
238                     $maxfraction = $maxfraction * 100;
239                     $result->notice = get_string("fractionsnomax", "quiz", $maxfraction);
240                     return $result;
241                 }
242             } else {
243                 $totalfraction = round($totalfraction,2);
244                 if ($totalfraction != 1) {
245                     $totalfraction = $totalfraction * 100;
246                     $result->notice = get_string("fractionsaddwrong", "quiz", $totalfraction);
247                     return $result;
248                 }
249             }
250         break;
252         case LESSON_PAGE_MATCHING:
254             $subquestions = array();
256             $defaultanswer = new stdClass;
257             $defaultanswer->lessonid   = $question->lessonid;
258             $defaultanswer->pageid   = $question->id;
259             $defaultanswer->timecreated   = $timenow;
260             $defaultanswer->grade = 0;
262             // The first answer should always be the correct answer
263             $correctanswer = clone($defaultanswer);
264             $correctanswer->answer = get_string('thatsthecorrectanswer', 'lesson');
265             $correctanswer->jumpto = LESSON_NEXTPAGE;
266             $DB->insert_record("lesson_answers", $correctanswer);
268             // The second answer should always be the wrong answer
269             $wronganswer = clone($defaultanswer);
270             $wronganswer->answer = get_string('thatsthewronganswer', 'lesson');
271             $DB->insert_record("lesson_answers", $wronganswer);
273             $i = 0;
274             // Insert all the new question+answer pairs
275             foreach ($question->subquestions as $key => $questiontext) {
276                 $answertext = $question->subanswers[$key];
277                 if (!empty($questiontext) and !empty($answertext)) {
278                     $answer = clone($defaultanswer);
279                     $answer->answer = $questiontext['text'];
280                     $answer->answerformat   = $questiontext['format'];
281                     $answer->response   = $answertext;
282                     if ($i == 0) {
283                         // first answer contains the correct answer jump
284                         $answer->jumpto = LESSON_NEXTPAGE;
285                     }
286                     $subquestions[] = $DB->insert_record("lesson_answers", $answer);
287                     $i++;
288                 }
289             }
291             if (count($subquestions) < 3) {
292                 $result->notice = get_string("notenoughsubquestions", "quiz");
293                 return $result;
294             }
295             break;
296         default:
297             $result->error = "Unsupported question type ($question->qtype)!";
298             return $result;
299     }
300     return true;
304 class qformat_default {
306     var $displayerrors = true;
307     var $category = NULL;
308     var $questionids = array();
309     var $qtypeconvert = array(NUMERICAL   => LESSON_PAGE_NUMERICAL,
310                               MULTICHOICE => LESSON_PAGE_MULTICHOICE,
311                               TRUEFALSE   => LESSON_PAGE_TRUEFALSE,
312                               SHORTANSWER => LESSON_PAGE_SHORTANSWER,
313                               MATCH       => LESSON_PAGE_MATCHING
314                               );
316     // Importing functions
317     function provide_import() {
318         return false;
319     }
321     function importpreprocess() {
322         // Does any pre-processing that may be desired
323         return true;
324     }
326     function importprocess($filename, $lesson, $pageid) {
327         global $DB, $OUTPUT;
329     /// Processes a given file.  There's probably little need to change this
330         $timenow = time();
332         if (! $lines = $this->readdata($filename)) {
333             echo $OUTPUT->notification("File could not be read, or was empty");
334             return false;
335         }
337         if (! $questions = $this->readquestions($lines)) {   // Extract all the questions
338             echo $OUTPUT->notification("There are no questions in this file!");
339             return false;
340         }
342         //Avoid category as question type
343         echo $OUTPUT->notification(get_string('importcount', 'lesson',
344                 $this->count_questions($questions)), 'notifysuccess');
346         $count = 0;
348         $unsupportedquestions = 0;
350         foreach ($questions as $question) {   // Process and store each question
351             switch ($question->qtype) {
352                 //TODO: Bad way to bypass category in data... Quickfix for MDL-27964
353                 case 'category':
354                     break;
355                 // the good ones
356                 case SHORTANSWER :
357                 case NUMERICAL :
358                 case TRUEFALSE :
359                 case MULTICHOICE :
360                 case MATCH :
361                     $count++;
363                     //Show nice formated question in one line.
364                     echo "<hr><p><b>$count</b>. ".$this->format_question_text($question)."</p>";
366                     $newpage = new stdClass;
367                     $newpage->lessonid = $lesson->id;
368                     $newpage->qtype = $this->qtypeconvert[$question->qtype];
369                     switch ($question->qtype) {
370                         case SHORTANSWER :
371                             if (isset($question->usecase)) {
372                                 $newpage->qoption = $question->usecase;
373                             }
374                             break;
375                         case MULTICHOICE :
376                             if (isset($question->single)) {
377                                 $newpage->qoption = !$question->single;
378                             }
379                             break;
380                     }
381                     $newpage->timecreated = $timenow;
382                     if ($question->name != $question->questiontext) {
383                         $newpage->title = $question->name;
384                     } else {
385                         $newpage->title = "Page $count";
386                     }
387                     $newpage->contents = $question->questiontext;
388                     $newpage->contentsformat = isset($question->questionformat) ? $question->questionformat : FORMAT_HTML;
390                     // Sometimes, questiontext is not a simple text, but one array
391                     // containing both text and format, so we need to support here
392                     // that case with the following dirty patch. MDL-35147
393                     if (is_array($question->questiontext)) {
394                         $newpage->contents = isset($question->questiontext['text']) ? $question->questiontext['text'] : '';
395                         $newpage->contentsformat = isset($question->questiontext['format']) ? $question->questiontext['format'] : FORMAT_HTML;
396                     }
398                     // set up page links
399                     if ($pageid) {
400                         // the new page follows on from this page
401                         if (!$page = $DB->get_record("lesson_pages", array("id" => $pageid))) {
402                             print_error('invalidpageid', 'lesson');
403                         }
404                         $newpage->prevpageid = $pageid;
405                         $newpage->nextpageid = $page->nextpageid;
406                         // insert the page and reset $pageid
407                         $newpageid = $DB->insert_record("lesson_pages", $newpage);
408                         // update the linked list
409                         $DB->set_field("lesson_pages", "nextpageid", $newpageid, array("id" => $pageid));
411                     } else {
412                         // new page is the first page
413                         // get the existing (first) page (if any)
414                         $params = array ("lessonid" => $lesson->id, "prevpageid" => 0);
415                         if (!$page = $DB->get_record_select("lesson_pages", "lessonid = :lessonid AND prevpageid = :prevpageid", $params)) {
416                             // there are no existing pages
417                             $newpage->prevpageid = 0; // this is a first page
418                             $newpage->nextpageid = 0; // this is the only page
419                             $newpageid = $DB->insert_record("lesson_pages", $newpage);
420                         } else {
421                             // there are existing pages put this at the start
422                             $newpage->prevpageid = 0; // this is a first page
423                             $newpage->nextpageid = $page->id;
424                             $newpageid = $DB->insert_record("lesson_pages", $newpage);
425                             // update the linked list
426                             $DB->set_field("lesson_pages", "prevpageid", $newpageid, array("id" => $page->id));
427                         }
428                     }
429                     // reset $pageid and put the page ID in $question, used in save_question_option()
430                     $pageid = $newpageid;
431                     $question->id = $newpageid;
433                     $this->questionids[] = $question->id;
435                     // Now to save all the answers and type-specific options
437                     $question->lessonid = $lesson->id; // needed for foreign key
438                     $question->qtype = $this->qtypeconvert[$question->qtype];
439                     $result = lesson_save_question_options($question, $lesson);
441                     if (!empty($result->error)) {
442                         echo $OUTPUT->notification($result->error);
443                         return false;
444                     }
446                     if (!empty($result->notice)) {
447                         echo $OUTPUT->notification($result->notice);
448                         return true;
449                     }
450                     break;
451             // the Bad ones
452                 default :
453                     $unsupportedquestions++;
454                     break;
455             }
457         }
458         if ($unsupportedquestions) {
459             echo $OUTPUT->notification(get_string('unknownqtypesnotimported', 'lesson', $unsupportedquestions));
460         }
461         return true;
462     }
464     /**
465      * Count all non-category questions in the questions array.
466      *
467      * @param array questions An array of question objects.
468      * @return int The count.
469      *
470      */
471     protected function count_questions($questions) {
472         $count = 0;
473         if (!is_array($questions)) {
474             return $count;
475         }
476         foreach ($questions as $question) {
477             if (!is_object($question) || !isset($question->qtype) ||
478                     ($question->qtype == 'category')) {
479                 continue;
480             }
481             $count++;
482         }
483         return $count;
484     }
486     function readdata($filename) {
487     /// Returns complete file with an array, one item per line
489         if (is_readable($filename)) {
490             $filearray = file($filename);
492             /// Check for Macintosh OS line returns (ie file on one line), and fix
493             if (preg_match("/\r/", $filearray[0]) AND !preg_match("/\n/", $filearray[0])) {
494                 return explode("\r", $filearray[0]);
495             } else {
496                 return $filearray;
497             }
498         }
499         return false;
500     }
502     protected function readquestions($lines) {
503     /// Parses an array of lines into an array of questions,
504     /// where each item is a question object as defined by
505     /// readquestion().   Questions are defined as anything
506     /// between blank lines.
508         $questions = array();
509         $currentquestion = array();
511         foreach ($lines as $line) {
512             $line = trim($line);
513             if (empty($line)) {
514                 if (!empty($currentquestion)) {
515                     if ($question = $this->readquestion($currentquestion)) {
516                         $questions[] = $question;
517                     }
518                     $currentquestion = array();
519                 }
520             } else {
521                 $currentquestion[] = $line;
522             }
523         }
525         if (!empty($currentquestion)) {  // There may be a final question
526             if ($question = $this->readquestion($currentquestion)) {
527                 $questions[] = $question;
528             }
529         }
531         return $questions;
532     }
535     function readquestion($lines) {
536     /// Given an array of lines known to define a question in
537     /// this format, this function converts it into a question
538     /// object suitable for processing and insertion into Moodle.
540         echo "<p>This flash question format has not yet been completed!</p>";
542         return NULL;
543     }
545     /**
546      * Construct a reasonable default question name, based on the start of the question text.
547      * @param string $questiontext the question text.
548      * @param string $default default question name to use if the constructed one comes out blank.
549      * @return string a reasonable question name.
550      */
551     public function create_default_question_name($questiontext, $default) {
552         $name = $this->clean_question_name(shorten_text($questiontext, 80));
553         if ($name) {
554             return $name;
555         } else {
556             return $default;
557         }
558     }
560     /**
561      * Ensure that a question name does not contain anything nasty, and will fit in the DB field.
562      * @param string $name the raw question name.
563      * @return string a safe question name.
564      */
565     public function clean_question_name($name) {
566         $name = clean_param($name, PARAM_TEXT); // Matches what the question editing form does.
567         $name = trim($name);
568         $trimlength = 251;
569         while (textlib::strlen($name) > 255 && $trimlength > 0) {
570             $name = shorten_text($name, $trimlength);
571             $trimlength -= 10;
572         }
573         return $name;
574     }
576     function defaultquestion() {
577     // returns an "empty" question
578     // Somewhere to specify question parameters that are not handled
579     // by import but are required db fields.
580     // This should not be overridden.
581         global $CFG;
583         $question = new stdClass();
584         $question->shuffleanswers = get_config('quiz', 'shuffleanswers');
585         $question->defaultmark = 1;
586         $question->image = "";
587         $question->usecase = 0;
588         $question->multiplier = array();
589         $question->generalfeedback = '';
590         $question->correctfeedback = '';
591         $question->partiallycorrectfeedback = '';
592         $question->incorrectfeedback = '';
593         $question->answernumbering = 'abc';
594         $question->penalty = 0.1;
595         $question->length = 1;
596         $question->qoption = 0;
597         $question->layout = 1;
599         // this option in case the questiontypes class wants
600         // to know where the data came from
601         $question->export_process = true;
602         $question->import_process = true;
604         return $question;
605     }
607     function importpostprocess() {
608         /// Does any post-processing that may be desired
609         /// Argument is a simple array of question ids that
610         /// have just been added.
611         return true;
612     }
614     /**
615      * Convert the question text to plain text, so it can safely be displayed
616      * during import to let the user see roughly what is going on.
617      */
618     protected function format_question_text($question) {
619         $formatoptions = new stdClass();
620         $formatoptions->noclean = true;
621         return html_to_text(format_text($question->questiontext,
622                 $question->questiontextformat, $formatoptions), 0, false);
623     }
625     /**
626      * Since the lesson module tries to re-use the question bank import classes in
627      * a crazy way, this is necessary to stop things breaking.
628      */
629     protected function add_blank_combined_feedback($question) {
630         return $question;
631     }
635 /**
636  * Since the lesson module tries to re-use the question bank import classes in
637  * a crazy way, this is necessary to stop things breaking. This should be exactly
638  * the same as the class defined in question/format.php.
639  */
640 class qformat_based_on_xml extends qformat_default {
641     /**
642      * A lot of imported files contain unwanted entities.
643      * This method tries to clean up all known problems.
644      * @param string str string to correct
645      * @return string the corrected string
646      */
647     public function cleaninput($str) {
649         $html_code_list = array(
650             "&#039;" => "'",
651             "&#8217;" => "'",
652             "&#8220;" => "\"",
653             "&#8221;" => "\"",
654             "&#8211;" => "-",
655             "&#8212;" => "-",
656         );
657         $str = strtr($str, $html_code_list);
658         // Use textlib entities_to_utf8 function to convert only numerical entities.
659         $str = textlib::entities_to_utf8($str, false);
660         return $str;
661     }
663     /**
664      * Return the array moodle is expecting
665      * for an HTML text. No processing is done on $text.
666      * qformat classes that want to process $text
667      * for instance to import external images files
668      * and recode urls in $text must overwrite this method.
669      * @param array $text some HTML text string
670      * @return array with keys text, format and files.
671      */
672     public function text_field($text) {
673         return array(
674             'text' => trim($text),
675             'format' => FORMAT_HTML,
676             'files' => array(),
677         );
678     }
680     /**
681      * Return the value of a node, given a path to the node
682      * if it doesn't exist return the default value.
683      * @param array xml data to read
684      * @param array path path to node expressed as array
685      * @param mixed default
686      * @param bool istext process as text
687      * @param string error if set value must exist, return false and issue message if not
688      * @return mixed value
689      */
690     public function getpath($xml, $path, $default, $istext=false, $error='') {
691         foreach ($path as $index) {
692             if (!isset($xml[$index])) {
693                 if (!empty($error)) {
694                     $this->error($error);
695                     return false;
696                 } else {
697                     return $default;
698                 }
699             }
701             $xml = $xml[$index];
702         }
704         if ($istext) {
705             if (!is_string($xml)) {
706                 $this->error(get_string('invalidxml', 'qformat_xml'));
707             }
708             $xml = trim($xml);
709         }
711         return $xml;
712     }