MDL-36655: Assignment - do not add empty group to grading form when there is only...
[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             $DB->insert_record("lesson_answers", $correctanswer);
267             // The second answer should always be the wrong answer
268             $wronganswer = clone($defaultanswer);
269             $wronganswer->answer = get_string('thatsthewronganswer', 'lesson');
270             $DB->insert_record("lesson_answers", $wronganswer);
272             $i = 0;
273             // Insert all the new question+answer pairs
274             foreach ($question->subquestions as $key => $questiontext) {
275                 $answertext = $question->subanswers[$key];
276                 if (!empty($questiontext) and !empty($answertext)) {
277                     $answer = clone($defaultanswer);
278                     $answer->answer = $questiontext['text'];
279                     $answer->answerformat   = $questiontext['format'];
280                     $answer->response   = $answertext;
281                     if ($i == 0) {
282                         // first answer contains the correct answer jump
283                         $answer->jumpto = LESSON_NEXTPAGE;
284                     }
285                     $subquestions[] = $DB->insert_record("lesson_answers", $answer);
286                     $i++;
287                 }
288             }
290             if (count($subquestions) < 3) {
291                 $result->notice = get_string("notenoughsubquestions", "quiz");
292                 return $result;
293             }
294             break;
295         default:
296             $result->error = "Unsupported question type ($question->qtype)!";
297             return $result;
298     }
299     return true;
303 class qformat_default {
305     var $displayerrors = true;
306     var $category = NULL;
307     var $questionids = array();
308     var $qtypeconvert = array(NUMERICAL   => LESSON_PAGE_NUMERICAL,
309                               MULTICHOICE => LESSON_PAGE_MULTICHOICE,
310                               TRUEFALSE   => LESSON_PAGE_TRUEFALSE,
311                               SHORTANSWER => LESSON_PAGE_SHORTANSWER,
312                               MATCH       => LESSON_PAGE_MATCHING
313                               );
315     // Importing functions
316     function provide_import() {
317         return false;
318     }
320     function importpreprocess() {
321         // Does any pre-processing that may be desired
322         return true;
323     }
325     function importprocess($filename, $lesson, $pageid) {
326         global $DB, $OUTPUT;
328     /// Processes a given file.  There's probably little need to change this
329         $timenow = time();
331         if (! $lines = $this->readdata($filename)) {
332             echo $OUTPUT->notification("File could not be read, or was empty");
333             return false;
334         }
336         if (! $questions = $this->readquestions($lines)) {   // Extract all the questions
337             echo $OUTPUT->notification("There are no questions in this file!");
338             return false;
339         }
341         //Avoid category as question type
342         echo $OUTPUT->notification(get_string('importcount', 'lesson',
343                 $this->count_questions($questions)), 'notifysuccess');
345         $count = 0;
347         $unsupportedquestions = 0;
349         foreach ($questions as $question) {   // Process and store each question
350             switch ($question->qtype) {
351                 //TODO: Bad way to bypass category in data... Quickfix for MDL-27964
352                 case 'category':
353                     break;
354                 // the good ones
355                 case SHORTANSWER :
356                 case NUMERICAL :
357                 case TRUEFALSE :
358                 case MULTICHOICE :
359                 case MATCH :
360                     $count++;
362                     //Show nice formated question in one line.
363                     echo "<hr><p><b>$count</b>. ".$this->format_question_text($question)."</p>";
365                     $newpage = new stdClass;
366                     $newpage->lessonid = $lesson->id;
367                     $newpage->qtype = $this->qtypeconvert[$question->qtype];
368                     switch ($question->qtype) {
369                         case SHORTANSWER :
370                             if (isset($question->usecase)) {
371                                 $newpage->qoption = $question->usecase;
372                             }
373                             break;
374                         case MULTICHOICE :
375                             if (isset($question->single)) {
376                                 $newpage->qoption = !$question->single;
377                             }
378                             break;
379                     }
380                     $newpage->timecreated = $timenow;
381                     if ($question->name != $question->questiontext) {
382                         $newpage->title = $question->name;
383                     } else {
384                         $newpage->title = "Page $count";
385                     }
386                     $newpage->contents = $question->questiontext;
387                     $newpage->contentsformat = isset($question->questionformat) ? $question->questionformat : FORMAT_HTML;
389                     // Sometimes, questiontext is not a simple text, but one array
390                     // containing both text and format, so we need to support here
391                     // that case with the following dirty patch. MDL-35147
392                     if (is_array($question->questiontext)) {
393                         $newpage->contents = isset($question->questiontext['text']) ? $question->questiontext['text'] : '';
394                         $newpage->contentsformat = isset($question->questiontext['format']) ? $question->questiontext['format'] : FORMAT_HTML;
395                     }
397                     // set up page links
398                     if ($pageid) {
399                         // the new page follows on from this page
400                         if (!$page = $DB->get_record("lesson_pages", array("id" => $pageid))) {
401                             print_error('invalidpageid', 'lesson');
402                         }
403                         $newpage->prevpageid = $pageid;
404                         $newpage->nextpageid = $page->nextpageid;
405                         // insert the page and reset $pageid
406                         $newpageid = $DB->insert_record("lesson_pages", $newpage);
407                         // update the linked list
408                         $DB->set_field("lesson_pages", "nextpageid", $newpageid, array("id" => $pageid));
410                     } else {
411                         // new page is the first page
412                         // get the existing (first) page (if any)
413                         $params = array ("lessonid" => $lesson->id, "prevpageid" => 0);
414                         if (!$page = $DB->get_record_select("lesson_pages", "lessonid = :lessonid AND prevpageid = :prevpageid", $params)) {
415                             // there are no existing pages
416                             $newpage->prevpageid = 0; // this is a first page
417                             $newpage->nextpageid = 0; // this is the only page
418                             $newpageid = $DB->insert_record("lesson_pages", $newpage);
419                         } else {
420                             // there are existing pages put this at the start
421                             $newpage->prevpageid = 0; // this is a first page
422                             $newpage->nextpageid = $page->id;
423                             $newpageid = $DB->insert_record("lesson_pages", $newpage);
424                             // update the linked list
425                             $DB->set_field("lesson_pages", "prevpageid", $newpageid, array("id" => $page->id));
426                         }
427                     }
428                     // reset $pageid and put the page ID in $question, used in save_question_option()
429                     $pageid = $newpageid;
430                     $question->id = $newpageid;
432                     $this->questionids[] = $question->id;
434                     // Now to save all the answers and type-specific options
436                     $question->lessonid = $lesson->id; // needed for foreign key
437                     $question->qtype = $this->qtypeconvert[$question->qtype];
438                     $result = lesson_save_question_options($question, $lesson);
440                     if (!empty($result->error)) {
441                         echo $OUTPUT->notification($result->error);
442                         return false;
443                     }
445                     if (!empty($result->notice)) {
446                         echo $OUTPUT->notification($result->notice);
447                         return true;
448                     }
449                     break;
450             // the Bad ones
451                 default :
452                     $unsupportedquestions++;
453                     break;
454             }
456         }
457         if ($unsupportedquestions) {
458             echo $OUTPUT->notification(get_string('unknownqtypesnotimported', 'lesson', $unsupportedquestions));
459         }
460         return true;
461     }
463     /**
464      * Count all non-category questions in the questions array.
465      *
466      * @param array questions An array of question objects.
467      * @return int The count.
468      *
469      */
470     protected function count_questions($questions) {
471         $count = 0;
472         if (!is_array($questions)) {
473             return $count;
474         }
475         foreach ($questions as $question) {
476             if (!is_object($question) || !isset($question->qtype) ||
477                     ($question->qtype == 'category')) {
478                 continue;
479             }
480             $count++;
481         }
482         return $count;
483     }
485     function readdata($filename) {
486     /// Returns complete file with an array, one item per line
488         if (is_readable($filename)) {
489             $filearray = file($filename);
491             /// Check for Macintosh OS line returns (ie file on one line), and fix
492             if (preg_match("/\r/", $filearray[0]) AND !preg_match("/\n/", $filearray[0])) {
493                 return explode("\r", $filearray[0]);
494             } else {
495                 return $filearray;
496             }
497         }
498         return false;
499     }
501     protected function readquestions($lines) {
502     /// Parses an array of lines into an array of questions,
503     /// where each item is a question object as defined by
504     /// readquestion().   Questions are defined as anything
505     /// between blank lines.
507         $questions = array();
508         $currentquestion = array();
510         foreach ($lines as $line) {
511             $line = trim($line);
512             if (empty($line)) {
513                 if (!empty($currentquestion)) {
514                     if ($question = $this->readquestion($currentquestion)) {
515                         $questions[] = $question;
516                     }
517                     $currentquestion = array();
518                 }
519             } else {
520                 $currentquestion[] = $line;
521             }
522         }
524         if (!empty($currentquestion)) {  // There may be a final question
525             if ($question = $this->readquestion($currentquestion)) {
526                 $questions[] = $question;
527             }
528         }
530         return $questions;
531     }
534     function readquestion($lines) {
535     /// Given an array of lines known to define a question in
536     /// this format, this function converts it into a question
537     /// object suitable for processing and insertion into Moodle.
539         echo "<p>This flash question format has not yet been completed!</p>";
541         return NULL;
542     }
544     /**
545      * Construct a reasonable default question name, based on the start of the question text.
546      * @param string $questiontext the question text.
547      * @param string $default default question name to use if the constructed one comes out blank.
548      * @return string a reasonable question name.
549      */
550     public function create_default_question_name($questiontext, $default) {
551         $name = $this->clean_question_name(shorten_text($questiontext, 80));
552         if ($name) {
553             return $name;
554         } else {
555             return $default;
556         }
557     }
559     /**
560      * Ensure that a question name does not contain anything nasty, and will fit in the DB field.
561      * @param string $name the raw question name.
562      * @return string a safe question name.
563      */
564     public function clean_question_name($name) {
565         $name = clean_param($name, PARAM_TEXT); // Matches what the question editing form does.
566         $name = trim($name);
567         $trimlength = 251;
568         while (textlib::strlen($name) > 255 && $trimlength > 0) {
569             $name = shorten_text($name, $trimlength);
570             $trimlength -= 10;
571         }
572         return $name;
573     }
575     function defaultquestion() {
576     // returns an "empty" question
577     // Somewhere to specify question parameters that are not handled
578     // by import but are required db fields.
579     // This should not be overridden.
580         global $CFG;
582         $question = new stdClass();
583         $question->shuffleanswers = get_config('quiz', 'shuffleanswers');
584         $question->defaultmark = 1;
585         $question->image = "";
586         $question->usecase = 0;
587         $question->multiplier = array();
588         $question->generalfeedback = '';
589         $question->correctfeedback = '';
590         $question->partiallycorrectfeedback = '';
591         $question->incorrectfeedback = '';
592         $question->answernumbering = 'abc';
593         $question->penalty = 0.1;
594         $question->length = 1;
595         $question->qoption = 0;
596         $question->layout = 1;
598         // this option in case the questiontypes class wants
599         // to know where the data came from
600         $question->export_process = true;
601         $question->import_process = true;
603         return $question;
604     }
606     function importpostprocess() {
607         /// Does any post-processing that may be desired
608         /// Argument is a simple array of question ids that
609         /// have just been added.
610         return true;
611     }
613     /**
614      * Convert the question text to plain text, so it can safely be displayed
615      * during import to let the user see roughly what is going on.
616      */
617     protected function format_question_text($question) {
618         $formatoptions = new stdClass();
619         $formatoptions->noclean = true;
620         return html_to_text(format_text($question->questiontext,
621                 $question->questiontextformat, $formatoptions), 0, false);
622     }
624     /**
625      * Since the lesson module tries to re-use the question bank import classes in
626      * a crazy way, this is necessary to stop things breaking.
627      */
628     protected function add_blank_combined_feedback($question) {
629         return $question;
630     }
634 /**
635  * Since the lesson module tries to re-use the question bank import classes in
636  * a crazy way, this is necessary to stop things breaking. This should be exactly
637  * the same as the class defined in question/format.php.
638  */
639 class qformat_based_on_xml extends qformat_default {
640     /**
641      * A lot of imported files contain unwanted entities.
642      * This method tries to clean up all known problems.
643      * @param string str string to correct
644      * @return string the corrected string
645      */
646     public function cleaninput($str) {
648         $html_code_list = array(
649             "&#039;" => "'",
650             "&#8217;" => "'",
651             "&#8220;" => "\"",
652             "&#8221;" => "\"",
653             "&#8211;" => "-",
654             "&#8212;" => "-",
655         );
656         $str = strtr($str, $html_code_list);
657         // Use textlib entities_to_utf8 function to convert only numerical entities.
658         $str = textlib::entities_to_utf8($str, false);
659         return $str;
660     }
662     /**
663      * Return the array moodle is expecting
664      * for an HTML text. No processing is done on $text.
665      * qformat classes that want to process $text
666      * for instance to import external images files
667      * and recode urls in $text must overwrite this method.
668      * @param array $text some HTML text string
669      * @return array with keys text, format and files.
670      */
671     public function text_field($text) {
672         return array(
673             'text' => trim($text),
674             'format' => FORMAT_HTML,
675             'files' => array(),
676         );
677     }
679     /**
680      * Return the value of a node, given a path to the node
681      * if it doesn't exist return the default value.
682      * @param array xml data to read
683      * @param array path path to node expressed as array
684      * @param mixed default
685      * @param bool istext process as text
686      * @param string error if set value must exist, return false and issue message if not
687      * @return mixed value
688      */
689     public function getpath($xml, $path, $default, $istext=false, $error='') {
690         foreach ($path as $index) {
691             if (!isset($xml[$index])) {
692                 if (!empty($error)) {
693                     $this->error($error);
694                     return false;
695                 } else {
696                     return $default;
697                 }
698             }
700             $xml = $xml[$index];
701         }
703         if ($istext) {
704             if (!is_string($xml)) {
705                 $this->error(get_string('invalidxml', 'qformat_xml'));
706             }
707             $xml = trim($xml);
708         }
710         return $xml;
711     }