Saving a form as a public template
[moodle.git] / grade / grading / form / rubric / lib.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  * Grading method controller for the Rubric plugin
20  *
21  * @package    gradingform
22  * @subpackage rubric
23  * @copyright  2011 David Mudrak <david@moodle.com>
24  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25  */
27 defined('MOODLE_INTERNAL') || die();
29 require_once($CFG->dirroot.'/grade/grading/form/lib.php');
31 /**
32  * This controller encapsulates the rubric grading logic
33  */
34 class gradingform_rubric_controller extends gradingform_controller {
35     // Modes of displaying the rubric (used in gradingform_rubric_renderer)
36     const DISPLAY_EDIT_FULL     = 1; // For editing (moderator or teacher creates a rubric)
37     const DISPLAY_EDIT_FROZEN   = 2; // Preview the rubric design with hidden fields
38     const DISPLAY_PREVIEW       = 3; // Preview the rubric design
39     const DISPLAY_EVAL          = 4; // For evaluation, enabled (teacher grades a student)
40     const DISPLAY_EVAL_FROZEN   = 5; // For evaluation, with hidden fields
41     const DISPLAY_REVIEW        = 6; // Dispaly filled rubric (i.e. students see their grades)
43     /**
44      * Extends the module settings navigation with the rubric grading settings
45      *
46      * This function is called when the context for the page is an activity module with the
47      * FEATURE_ADVANCED_GRADING, the user has the permission moodle/grade:managegradingforms
48      * and there is an area with the active grading method set to 'rubric'.
49      *
50      * @param settings_navigation $settingsnav {@link settings_navigation}
51      * @param navigation_node $node {@link navigation_node}
52      */
53     public function extend_settings_navigation(settings_navigation $settingsnav, navigation_node $node=null) {
54         $node->add(get_string('definerubric', 'gradingform_rubric'),
55             $this->get_editor_url(), settings_navigation::TYPE_CUSTOM,
56             null, null, new pix_icon('icon', '', 'gradingform_rubric'));
57     }
59     /**
60      * Saves the rubric definition into the database
61      *
62      * @see parent::update_definition()
63      * @param stdClass $newdefinition rubric definition data as coming from {@link self::postupdate_definition_data()}
64      * @param int|null $usermodified optional userid of the author of the definition, defaults to the current user
65      */
66     public function update_definition(stdClass $newdefinition, $usermodified = null) {
67         global $DB;
69         // firstly update the common definition data in the {grading_definition} table
70         parent::update_definition($newdefinition, $usermodified);
71         // reload the definition from the database
72         $this->load_definition();
73         $currentdefinition = $this->get_definition();
75         // update current data
76         $haschanges = false;
77         if (empty($newdefinition->rubric_criteria)) {
78             $newcriteria = array();
79         } else {
80             $newcriteria = $newdefinition->rubric_criteria; // new ones to be saved
81         }
82         $currentcriteria = $currentdefinition->rubric_criteria;
83         $criteriafields = array('sortorder', 'description', 'descriptionformat');
84         $levelfields = array('score', 'definition', 'definitionformat');
85         foreach ($newcriteria as $id => $criterion) {
86             // get list of submitted levels
87             $levelsdata = array();
88             if (array_key_exists('levels', $criterion)) {
89                 $levelsdata = $criterion['levels'];
90             }
91             if (preg_match('/^NEWID\d+$/', $id)) {
92                 // insert criterion into DB
93                 $data = array('formid' => $this->definition->id, 'descriptionformat' => FORMAT_MOODLE); // TODO format is not supported yet
94                 foreach ($criteriafields as $key) {
95                     if (array_key_exists($key, $criterion)) {
96                         $data[$key] = $criterion[$key];
97                     }
98                 }
99                 $id = $DB->insert_record('gradingform_rubric_criteria', $data);
100                 $haschanges = true;
101             } else {
102                 // update criterion in DB
103                 $data = array();
104                 foreach ($criteriafields as $key) {
105                     if (array_key_exists($key, $criterion) && $criterion[$key] != $currentcriteria[$id][$key]) {
106                         $data[$key] = $criterion[$key];
107                     }
108                 }
109                 if (!empty($data)) {
110                     // update only if something is changed
111                     $data['id'] = $id;
112                     $DB->update_record('gradingform_rubric_criteria', $data);
113                     $haschanges = true;
114                 }
115                 // remove deleted levels from DB
116                 foreach (array_keys($currentcriteria[$id]['levels']) as $levelid) {
117                     if (!array_key_exists($levelid, $levelsdata)) {
118                         $DB->delete_records('gradingform_rubric_levels', array('id' => $levelid));
119                         $haschanges = true;
120                     }
121                 }
122             }
123             foreach ($levelsdata as $levelid => $level) {
124                 if (preg_match('/^NEWID\d+$/', $levelid)) {
125                     // insert level into DB
126                     $data = array('criterionid' => $id, 'definitionformat' => FORMAT_MOODLE); // TODO format is not supported yet
127                     foreach ($levelfields as $key) {
128                         if (array_key_exists($key, $level)) {
129                             $data[$key] = $level[$key];
130                         }
131                     }
132                     $levelid = $DB->insert_record('gradingform_rubric_levels', $data);
133                     $haschanges = true;
134                 } else {
135                     // update level in DB
136                     $data = array();
137                     foreach ($levelfields as $key) {
138                         if (array_key_exists($key, $level) && $level[$key] != $currentcriteria[$id]['levels'][$levelid][$key]) {
139                             $data[$key] = $level[$key];
140                         }
141                     }
142                     if (!empty($data)) {
143                         // update only if something is changed
144                         $data['id'] = $levelid;
145                         $DB->update_record('gradingform_rubric_levels', $data);
146                         $haschanges = true;
147                     }
148                 }
149             }
150         }
151         // remove deleted criteria from DB
152         foreach (array_keys($currentcriteria) as $id) {
153             if (!array_key_exists($id, $newcriteria)) {
154                 $DB->delete_records('gradingform_rubric_criteria', array('id' => $id));
155                 $DB->delete_records('gradingform_rubric_levels', array('criterionid' => $id));
156                 $haschanges = true;
157             }
158         }
159         $this->load_definition();
160     }
162     /**
163      * Loads the rubric form definition if it exists
164      *
165      * There is a new array called 'rubric_criteria' appended to the list of parent's definition properties.
166      */
167     protected function load_definition() {
168         global $DB;
170         $sql = "SELECT gd.*,
171                        rc.id AS rcid, rc.sortorder AS rcsortorder, rc.description AS rcdescription, rc.descriptionformat AS rcdescriptionformat,
172                        rl.id AS rlid, rl.score AS rlscore, rl.definition AS rldefinition, rl.definitionformat AS rldefinitionformat
173                   FROM {grading_definitions} gd
174              LEFT JOIN {gradingform_rubric_criteria} rc ON (rc.formid = gd.id)
175              LEFT JOIN {gradingform_rubric_levels} rl ON (rl.criterionid = rc.id)
176                  WHERE gd.areaid = :areaid AND gd.method = :method
177               ORDER BY rc.sortorder,rl.score";
178         $params = array('areaid' => $this->areaid, 'method' => $this->get_method_name());
180         $rs = $DB->get_recordset_sql($sql, $params);
181         $this->definition = false;
182         foreach ($rs as $record) {
183             // pick the common definition data
184             if (empty($this->definition)) {
185                 $this->definition = new stdClass();
186                 foreach (array('id', 'name', 'description', 'descriptionformat', 'status', 'copiedfromid',
187                         'timecreated', 'usercreated', 'timemodified', 'usermodified', 'options') as $fieldname) {
188                     $this->definition->$fieldname = $record->$fieldname;
189                 }
190                 $this->definition->rubric_criteria = array();
191             }
192             // pick the criterion data
193             if (!empty($record->rcid) and empty($this->definition->rubric_criteria[$record->rcid])) {
194                 foreach (array('id', 'sortorder', 'description', 'descriptionformat') as $fieldname) {
195                     $this->definition->rubric_criteria[$record->rcid][$fieldname] = $record->{'rc'.$fieldname};
196                 }
197                 $this->definition->rubric_criteria[$record->rcid]['levels'] = array();
198             }
199             // pick the level data
200             if (!empty($record->rlid)) {
201                 foreach (array('id', 'score', 'definition', 'definitionformat') as $fieldname) {
202                     $this->definition->rubric_criteria[$record->rcid]['levels'][$record->rlid][$fieldname] = $record->{'rl'.$fieldname};
203                 }
204             }
205         }
206         $rs->close();
207     }
209     /**
210      * Converts the current definition into an object suitable for the editor form's set_data()
211      *
212      * @return stdClass
213      */
214     public function get_definition_for_editing() {
216         $definition = $this->get_definition();
217         $properties = new stdClass();
218         $properties->areaid = $this->areaid;
219         if ($definition) {
220             foreach (array('id', 'name', 'description', 'descriptionformat', 'options', 'status') as $key) {
221                 $properties->$key = $definition->$key;
222             }
223             $options = self::description_form_field_options($this->get_context());
224             $properties = file_prepare_standard_editor($properties, 'description', $options, $this->get_context(),
225                 'gradingform_rubric', 'definition_description', $definition->id);
226         }
227         if (!empty($definition->rubric_criteria)) {
228             $properties->rubric_criteria = $definition->rubric_criteria;
229         } else {
230             $properties->rubric_criteria = array();
231         }
233         return $properties;
234     }
236     /**
237      * Returns the form definition suitable for cloning into another area
238      *
239      * @see parent::get_definition_copy()
240      * @param gradingform_controller $target the controller of the new copy
241      * @return stdClass definition structure to pass to the target's {@link update_definition()}
242      */
243     public function get_definition_copy(gradingform_controller $target) {
245         $new = parent::get_definition_copy($target);
246         $old = $this->get_definition();
247         $new->rubric_criteria = array();
248         $newcritid = 1;
249         $newlevid = 1;
250         foreach ($old->rubric_criteria as $oldcritid => $oldcrit) {
251             unset($oldcrit['id']);
252             if (isset($oldcrit['levels'])) {
253                 foreach ($oldcrit['levels'] as $oldlevid => $oldlev) {
254                     unset($oldlev['id']);
255                     $oldcrit['levels']['NEWID'.$newlevid] = $oldlev;
256                     unset($oldcrit['levels'][$oldlevid]);
257                     $newlevid++;
258                 }
259             } else {
260                 $oldcrit['levels'] = array();
261             }
262             $new->rubric_criteria['NEWID'.$newcritid] = $oldcrit;
263             $newcritid++;
264         }
266         return $new;
267     }
269     public function get_grading($raterid, $itemid) {
270         global $DB;
271         $sql = "SELECT f.id, f.criterionid, f.levelid, f.remark, f.remarkformat
272                     FROM {grading_instances} i, {gradingform_rubric_fillings} f
273                     WHERE i.formid = :formid ".
274                     "AND i.raterid = :raterid ".
275                     "AND i.itemid = :itemid
276                     AND i.id = f.forminstanceid";
277         $params = array('formid' => $this->definition->id, 'itemid' => $itemid, 'raterid' => $raterid);
278         $rs = $DB->get_recordset_sql($sql, $params);
279         $grading = array();
280         foreach ($rs as $record) {
281             if ($record->levelid) {
282                 $grading[$record->criterionid] = $record->levelid;
283             }
284             // TODO: remarks
285         }
286         $rs->close();
287         return $grading;
288     }
290     /**
291      * Converts the rubric data to the gradebook score 0-100
292      */
293     protected function calculate_grade($grade, $itemid) {
294         if (!$this->validate_grading_element($grade, $itemid)) {
295             return -1;
296         }
298         $minscore = 0;
299         $maxscore = 0;
300         foreach ($this->definition->rubric_criteria as $id => $criterion) {
301             $keys = array_keys($criterion['levels']);
302             // TODO array_reverse($keys) if levels are sorted DESC
303             $minscore += $criterion['levels'][$keys[0]]['score'];
304             $maxscore += $criterion['levels'][$keys[sizeof($keys)-1]]['score'];
305         }
307         if ($maxscore == 0) {
308             return -1;
309         }
311         $curscore = 0;
312         foreach ($grade as $id => $levelid) {
313             $curscore += $this->definition->rubric_criteria[$id]['levels'][$levelid]['score'];
314         }
315         return $curscore/$maxscore*100; // TODO mapping
316     }
318     /**
319      * Saves non-js data and returns the gradebook grade
320      */
321     public function save_and_get_grade($raterid, $itemid, $formdata) {
322         global $DB, $USER;
323         $instance = $this->prepare_instance($raterid, $itemid);
324         $currentgrade = $this->get_grading($raterid, $itemid);
325         if (!is_array($formdata)) {
326             return $this->calculate_grade($currentgrade, $itemid);
327         }
328         foreach ($formdata as $criterionid => $levelid) {
329             $params = array('forminstanceid' => $instance->id, 'criterionid' => $criterionid);
330             if (!array_key_exists($criterionid, $currentgrade)) {
331                 $DB->insert_record('gradingform_rubric_fillings', $params + array('levelid' => $levelid));
332             } else if ($currentgrade[$criterionid] != $levelid) {
333                 $DB->set_field('gradingform_rubric_fillings', 'levelid', $levelid, $params);
334             }
335         }
336         foreach ($currentgrade as $criterionid => $levelid) {
337             if (!array_key_exists($criterionid, $formdata)) {
338                 $params = array('forminstanceid' => $instance->id, 'criterionid' => $criterionid);
339                 $DB->delete_records('gradingform_rubric_fillings', $params);
340             }
341         }
342         // TODO: remarks
343         return $this->calculate_grade($formdata, $itemid);
344     }
346     /**
347      * Returns html for form element
348      */
349     public function to_html($gradingformelement) {
350         global $PAGE, $USER;
351         if (!$gradingformelement->_flagFrozen) {
352             $module = array('name'=>'gradingform_rubric', 'fullpath'=>'/grade/grading/form/rubric/js/rubric.js');
353             $PAGE->requires->js_init_call('M.gradingform_rubric.init', array(array('name' => $gradingformelement->getName(), 'criteriontemplate' =>'', 'leveltemplate' => '')), true, $module);
354             $mode = self::DISPLAY_EVAL;
355         } else {
356             if ($this->_persistantFreeze) {
357                 $mode = gradingform_rubric_controller::DISPLAY_EVAL_FROZEN;
358             } else {
359                 $mode = gradingform_rubric_controller::DISPLAY_REVIEW;
360             }
361         }
362         $criteria = $this->definition->rubric_criteria;
363         $submissionid = $gradingformelement->get_grading_attribute('submissionid');
364         $raterid = $USER->id; // TODO - this is very strange!
365         $value = $gradingformelement->getValue();
366         if ($value === null) {
367             $value = $this->get_grading($raterid, $submissionid); // TODO maybe implement in form->set_data() ?
368         }
369         return $this->get_renderer($PAGE)->display_rubric($criteria, $mode, $gradingformelement->getName(), $value);
370     }
372     /**
373      * Returns html for form element
374      */
375     public function to_html_old($gradingformelement) {
376         global $PAGE, $USER;
377         //TODO move to renderer
379         //$gradingrenderer = $this->prepare_renderer($PAGE);
380         $html = '';
381         $elementname = $gradingformelement->getName();
382         $elementvalue = $gradingformelement->getValue();
383         $submissionid = $gradingformelement->get_grading_attribute('submissionid');
384         $raterid = $USER->id; // TODO - this is very strange!
385         $html .= "assessing submission $submissionid<br />";
386         //$html .= html_writer::empty_tag('input', array('type' => 'text', 'name' => $elementname.'[grade]', 'size' => '20', 'value' => $elementvalue['grade']));
388         if (!$gradingformelement->_flagFrozen) {
389             $module = array('name'=>'gradingform_rubric', 'fullpath'=>'/grade/grading/form/rubric/js/rubric.js');
390             $PAGE->requires->js_init_call('M.gradingform_rubric.init', array(array('name' => $gradingformelement->getName(), 'criteriontemplate' =>'', 'leveltemplate' => '')), true, $module);
391         }
392         $criteria = $this->definition->rubric_criteria;
394         $html .= html_writer::start_tag('div', array('id' => 'rubric-'.$gradingformelement->getName(), 'class' => 'form_rubric evaluate'));
395         $criteria_cnt = 0;
397         $value = $gradingformelement->getValue();
398         if ($value === null) {
399             $value = $this->get_grading($raterid, $submissionid); // TODO maybe implement in form->set_data() ?
400         }
402         foreach ($criteria as $criterionid => $criterion) {
403             $html .= html_writer::start_tag('div', array('class' => 'criterion'.$this->get_css_class_suffix($criteria_cnt++, count($criteria)-1)));
404             $html .= html_writer::tag('div', $criterion['description'], array('class' => 'description')); // TODO descriptionformat
405             $html .= html_writer::start_tag('div', array('class' => 'levels'));
406             $level_cnt = 0;
407             foreach ($criterion['levels'] as $levelid => $level) {
408                 $checked = (is_array($value) && array_key_exists($criterionid, $value) && ((int)$value[$criterionid] === $levelid));
409                 $classsuffix = $this->get_css_class_suffix($level_cnt++, count($criterion['levels'])-1);
410                 if ($checked) {
411                     $classsuffix .= ' checked';
412                 }
413                 $html .= html_writer::start_tag('div', array('id' => $gradingformelement->getName().'-'.$criterionid.'-levels-'.$levelid, 'class' => 'level'.$classsuffix));
414                 $input = html_writer::empty_tag('input', array('type' => 'radio', 'name' => $gradingformelement->getName().'['.$criterionid.']', 'value' => $levelid) +
415                     ($checked ? array('checked' => 'checked') : array())); // TODO rewrite
416                 $html .= html_writer::tag('div', $input, array('class' => 'radio'));
417                 $html .= html_writer::tag('div', $level['definition'], array('class' => 'definition')); // TODO definitionformat
418                 $html .= html_writer::tag('div', (float)$level['score'].' pts', array('class' => 'score'));  //TODO span, get_string
419                 $html .= html_writer::end_tag('div'); // .level
420             }
421             $html .= html_writer::end_tag('div'); // .levels
422             $html .= html_writer::end_tag('div'); // .criterion
423         }
424         $html .= html_writer::end_tag('div'); // .rubric
425         return $html;
427     }
429     private function get_css_class_suffix($cnt, $maxcnt) {
430         $class = '';
431         if ($cnt == 0) {
432             $class .= ' first';
433         }
434         if ($cnt == $maxcnt) {
435             $class .= ' last';
436         }
437         if ($cnt%2) {
438             $class .= ' odd';
439         } else {
440             $class .= ' even';
441         }
442         return $class;
443     }
445     // TODO the following functions may be moved to parent:
447     /**
448      * @return array options for the form description field
449      */
450     public static function description_form_field_options($context) {
451         global $CFG;
452         return array(
453             'maxfiles' => -1,
454             'maxbytes' => get_max_upload_file_size($CFG->maxbytes),
455             'context'  => $context,
456         );
457     }
459     public function get_formatted_description() {
460         if (!$this->definition) {
461             return null;
462         }
463         $context = $this->get_context();
465         $options = self::description_form_field_options($this->get_context());
466         $description = file_rewrite_pluginfile_urls($this->definition->description, 'pluginfile.php', $context->id,
467             'gradingform_rubric', 'definition_description', $this->definition->id, $options);
469         $formatoptions = array(
470             'noclean' => false,
471             'trusted' => false,
472             'filter' => true,
473             'context' => $context
474         );
475         return format_text($description, $this->definition->descriptionformat, $formatoptions);
476     }
478     /**
479      * Converts the rubric definition data from the submitted form back to the form
480      * suitable for storing in database
481      */
482     public function postupdate_definition_data($data) {
483         if (!$this->definition) {
484             return $data;
485         }
486         $options = self::description_form_field_options($this->get_context());
487         $data = file_postupdate_standard_editor($data, 'description', $options, $this->get_context(),
488             'gradingform_rubric', 'definition_description', $this->definition->id);
489             // TODO change filearea for embedded files in grading_definition.description
490         return $data;
491     }
493     public function is_form_available($foruserid = null) {
494         return true;
495         // TODO this is temporary for testing!
496     }
498     /**
499      * Returns the error message displayed in case of validation failed
500      *
501      * @see validate_grading_element
502      */
503     public function default_validation_error_message() {
504         return 'The rubric is incomplete'; //TODO string
505     }
507     /**
508      * Validates that rubric is fully completed and contains valid grade on each criterion
509      */
510     public function validate_grading_element($elementvalue, $itemid) {
511         // TODO: if there is nothing selected in rubric, we don't enter this function at all :(
512         $criteria = $this->definition->rubric_criteria;
513         if (!is_array($elementvalue) || sizeof($elementvalue) < sizeof($criteria)) {
514             return false;
515         }
516         foreach ($criteria as $id => $criterion) {
517             if (!array_key_exists($id, $elementvalue) || !array_key_exists($elementvalue[$id], $criterion['levels'])) {
518                 return false;
519             }
520         }
521         return true;
522     }
524     /**
525      * Returns the rubric plugin renderer
526      *
527      * @param moodle_page $page the target page
528      * @return renderer_base
529      */
530     public function get_renderer(moodle_page $page) {
531         return $page->get_renderer('gradingform_'. $this->get_method_name());
532     }
534     /**
535      * Returns the HTML code displaying the preview of the grading form
536      *
537      * @param moodle_page $page the target page
538      * @return string
539      */
540     public function render_preview(moodle_page $page) {
542         // use the parent's method to render the common information about the form
543         $header = parent::render_preview($page);
545         // append the rubric itself, using own renderer
546         $output = $this->get_renderer($page);
547         // todo something like $rubric = $output->render_preview($this);
548         $rubric = '[[TODO RUBRIC PREVIEW]]';
550         return $header . $rubric;
551     }