MDL-29483, MDL-29482 advanced grading in assignment, rubrics editor
[moodle.git] / grade / grading / form / rubric / lib.php
CommitLineData
9b8550f8
DM
1<?php
2
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/>.
17
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 */
26
27defined('MOODLE_INTERNAL') || die();
28
fe817d87 29require_once($CFG->dirroot.'/grade/grading/form/lib.php');
9b8550f8
DM
30
31/**
32 * This controller encapsulates the rubric grading logic
33 */
21d37aa6 34class gradingform_rubric_controller extends gradingform_controller {
ab156741
MG
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)
9b8550f8
DM
42
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'),
6832a102 55 $this->get_editor_url(), settings_navigation::TYPE_CUSTOM,
9b8550f8
DM
56 null, null, new pix_icon('icon', '', 'gradingform_rubric'));
57 }
21d37aa6 58
c586d2bf 59 /**
fe817d87 60 * Saves the rubric definition into the database
c586d2bf 61 *
fe817d87 62 * @see parent::update_definition()
9e2eca0f 63 * @param stdClass $newdefinition rubric definition data as coming from gradingform_rubric_editrubric::get_data()
fe817d87 64 * @param int|null $usermodified optional userid of the author of the definition, defaults to the current user
c586d2bf 65 */
fe817d87 66 public function update_definition(stdClass $newdefinition, $usermodified = null) {
c586d2bf 67 global $DB;
c586d2bf 68
fe817d87 69 // firstly update the common definition data in the {grading_definition} table
9e2eca0f
MG
70 if ($this->definition === false) {
71 // if definition does not exist yet, create a blank one with only required fields set
72 // (we need id to save files embedded in description)
73 parent::update_definition((object)array('descriptionformat' => FORMAT_MOODLE), $usermodified);
74 parent::load_definition();
75 }
76 $options = self::description_form_field_options($this->get_context());
77 $newdefinition = file_postupdate_standard_editor($newdefinition, 'description', $options, $this->get_context(),
78 'gradingform_rubric', 'definition_description', $this->definition->id);
fe817d87 79 parent::update_definition($newdefinition, $usermodified);
9e2eca0f 80
fe817d87 81 // reload the definition from the database
9e2eca0f 82 $currentdefinition = $this->get_definition(true);
fe817d87 83
9e2eca0f 84 // update rubric data
fe817d87
DM
85 $haschanges = false;
86 if (empty($newdefinition->rubric_criteria)) {
87 $newcriteria = array();
c586d2bf 88 } else {
fe817d87 89 $newcriteria = $newdefinition->rubric_criteria; // new ones to be saved
c586d2bf 90 }
fe817d87
DM
91 $currentcriteria = $currentdefinition->rubric_criteria;
92 $criteriafields = array('sortorder', 'description', 'descriptionformat');
93 $levelfields = array('score', 'definition', 'definitionformat');
94 foreach ($newcriteria as $id => $criterion) {
95 // get list of submitted levels
96 $levelsdata = array();
97 if (array_key_exists('levels', $criterion)) {
98 $levelsdata = $criterion['levels'];
99 }
100 if (preg_match('/^NEWID\d+$/', $id)) {
101 // insert criterion into DB
102 $data = array('formid' => $this->definition->id, 'descriptionformat' => FORMAT_MOODLE); // TODO format is not supported yet
103 foreach ($criteriafields as $key) {
104 if (array_key_exists($key, $criterion)) {
105 $data[$key] = $criterion[$key];
106 }
107 }
108 $id = $DB->insert_record('gradingform_rubric_criteria', $data);
109 $haschanges = true;
110 } else {
111 // update criterion in DB
112 $data = array();
113 foreach ($criteriafields as $key) {
114 if (array_key_exists($key, $criterion) && $criterion[$key] != $currentcriteria[$id][$key]) {
115 $data[$key] = $criterion[$key];
116 }
117 }
118 if (!empty($data)) {
119 // update only if something is changed
120 $data['id'] = $id;
121 $DB->update_record('gradingform_rubric_criteria', $data);
122 $haschanges = true;
c586d2bf 123 }
fe817d87
DM
124 // remove deleted levels from DB
125 foreach (array_keys($currentcriteria[$id]['levels']) as $levelid) {
126 if (!array_key_exists($levelid, $levelsdata)) {
127 $DB->delete_records('gradingform_rubric_levels', array('id' => $levelid));
128 $haschanges = true;
129 }
130 }
131 }
132 foreach ($levelsdata as $levelid => $level) {
133 if (preg_match('/^NEWID\d+$/', $levelid)) {
134 // insert level into DB
135 $data = array('criterionid' => $id, 'definitionformat' => FORMAT_MOODLE); // TODO format is not supported yet
136 foreach ($levelfields as $key) {
137 if (array_key_exists($key, $level)) {
138 $data[$key] = $level[$key];
c586d2bf
MG
139 }
140 }
fe817d87 141 $levelid = $DB->insert_record('gradingform_rubric_levels', $data);
c586d2bf
MG
142 $haschanges = true;
143 } else {
fe817d87 144 // update level in DB
c586d2bf 145 $data = array();
fe817d87
DM
146 foreach ($levelfields as $key) {
147 if (array_key_exists($key, $level) && $level[$key] != $currentcriteria[$id]['levels'][$levelid][$key]) {
148 $data[$key] = $level[$key];
c586d2bf
MG
149 }
150 }
151 if (!empty($data)) {
152 // update only if something is changed
fe817d87
DM
153 $data['id'] = $levelid;
154 $DB->update_record('gradingform_rubric_levels', $data);
c586d2bf 155 $haschanges = true;
c586d2bf
MG
156 }
157 }
158 }
c586d2bf 159 }
fe817d87
DM
160 // remove deleted criteria from DB
161 foreach (array_keys($currentcriteria) as $id) {
162 if (!array_key_exists($id, $newcriteria)) {
163 $DB->delete_records('gradingform_rubric_criteria', array('id' => $id));
164 $DB->delete_records('gradingform_rubric_levels', array('criterionid' => $id));
165 $haschanges = true;
166 }
c586d2bf 167 }
fe817d87 168 $this->load_definition();
c586d2bf
MG
169 }
170
171 /**
fe817d87 172 * Loads the rubric form definition if it exists
c586d2bf 173 *
fe817d87 174 * There is a new array called 'rubric_criteria' appended to the list of parent's definition properties.
c586d2bf 175 */
fe817d87 176 protected function load_definition() {
c586d2bf 177 global $DB;
fe817d87
DM
178
179 $sql = "SELECT gd.*,
180 rc.id AS rcid, rc.sortorder AS rcsortorder, rc.description AS rcdescription, rc.descriptionformat AS rcdescriptionformat,
181 rl.id AS rlid, rl.score AS rlscore, rl.definition AS rldefinition, rl.definitionformat AS rldefinitionformat
182 FROM {grading_definitions} gd
183 LEFT JOIN {gradingform_rubric_criteria} rc ON (rc.formid = gd.id)
184 LEFT JOIN {gradingform_rubric_levels} rl ON (rl.criterionid = rc.id)
185 WHERE gd.areaid = :areaid AND gd.method = :method
186 ORDER BY rc.sortorder,rl.score";
187 $params = array('areaid' => $this->areaid, 'method' => $this->get_method_name());
188
189 $rs = $DB->get_recordset_sql($sql, $params);
190 $this->definition = false;
191 foreach ($rs as $record) {
192 // pick the common definition data
193 if (empty($this->definition)) {
194 $this->definition = new stdClass();
195 foreach (array('id', 'name', 'description', 'descriptionformat', 'status', 'copiedfromid',
196 'timecreated', 'usercreated', 'timemodified', 'usermodified', 'options') as $fieldname) {
197 $this->definition->$fieldname = $record->$fieldname;
198 }
199 $this->definition->rubric_criteria = array();
200 }
201 // pick the criterion data
202 if (!empty($record->rcid) and empty($this->definition->rubric_criteria[$record->rcid])) {
203 foreach (array('id', 'sortorder', 'description', 'descriptionformat') as $fieldname) {
204 $this->definition->rubric_criteria[$record->rcid][$fieldname] = $record->{'rc'.$fieldname};
205 }
206 $this->definition->rubric_criteria[$record->rcid]['levels'] = array();
c586d2bf 207 }
fe817d87
DM
208 // pick the level data
209 if (!empty($record->rlid)) {
210 foreach (array('id', 'score', 'definition', 'definitionformat') as $fieldname) {
211 $this->definition->rubric_criteria[$record->rcid]['levels'][$record->rlid][$fieldname] = $record->{'rl'.$fieldname};
c586d2bf
MG
212 }
213 }
214 }
fe817d87 215 $rs->close();
c586d2bf
MG
216 }
217
218 /**
fe817d87 219 * Converts the current definition into an object suitable for the editor form's set_data()
c586d2bf 220 *
fe817d87 221 * @return stdClass
c586d2bf 222 */
fe817d87
DM
223 public function get_definition_for_editing() {
224
225 $definition = $this->get_definition();
c586d2bf 226 $properties = new stdClass();
fe817d87
DM
227 $properties->areaid = $this->areaid;
228 if ($definition) {
c586d2bf 229 foreach (array('id', 'name', 'description', 'descriptionformat', 'options', 'status') as $key) {
fe817d87 230 $properties->$key = $definition->$key;
c586d2bf 231 }
c586d2bf 232 $options = self::description_form_field_options($this->get_context());
fe817d87
DM
233 $properties = file_prepare_standard_editor($properties, 'description', $options, $this->get_context(),
234 'gradingform_rubric', 'definition_description', $definition->id);
235 }
236 if (!empty($definition->rubric_criteria)) {
237 $properties->rubric_criteria = $definition->rubric_criteria;
238 } else {
239 $properties->rubric_criteria = array();
c586d2bf 240 }
c586d2bf 241
c586d2bf
MG
242 return $properties;
243 }
244
fde33804
DM
245 /**
246 * Returns the form definition suitable for cloning into another area
247 *
248 * @see parent::get_definition_copy()
249 * @param gradingform_controller $target the controller of the new copy
250 * @return stdClass definition structure to pass to the target's {@link update_definition()}
251 */
252 public function get_definition_copy(gradingform_controller $target) {
253
254 $new = parent::get_definition_copy($target);
255 $old = $this->get_definition();
256 $new->rubric_criteria = array();
257 $newcritid = 1;
258 $newlevid = 1;
259 foreach ($old->rubric_criteria as $oldcritid => $oldcrit) {
260 unset($oldcrit['id']);
261 if (isset($oldcrit['levels'])) {
262 foreach ($oldcrit['levels'] as $oldlevid => $oldlev) {
263 unset($oldlev['id']);
264 $oldcrit['levels']['NEWID'.$newlevid] = $oldlev;
265 unset($oldcrit['levels'][$oldlevid]);
266 $newlevid++;
267 }
268 } else {
269 $oldcrit['levels'] = array();
270 }
271 $new->rubric_criteria['NEWID'.$newcritid] = $oldcrit;
272 $newcritid++;
273 }
274
275 return $new;
276 }
277
c586d2bf
MG
278 // TODO the following functions may be moved to parent:
279
fe817d87
DM
280 /**
281 * @return array options for the form description field
282 */
c586d2bf
MG
283 public static function description_form_field_options($context) {
284 global $CFG;
285 return array(
286 'maxfiles' => -1,
287 'maxbytes' => get_max_upload_file_size($CFG->maxbytes),
fe817d87 288 'context' => $context,
c586d2bf
MG
289 );
290 }
291
292 public function get_formatted_description() {
293 if (!$this->definition) {
294 return null;
295 }
296 $context = $this->get_context();
297
298 $options = self::description_form_field_options($this->get_context());
fe817d87
DM
299 $description = file_rewrite_pluginfile_urls($this->definition->description, 'pluginfile.php', $context->id,
300 'gradingform_rubric', 'definition_description', $this->definition->id, $options);
c586d2bf
MG
301
302 $formatoptions = array(
303 'noclean' => false,
304 'trusted' => false,
305 'filter' => true,
306 'context' => $context
307 );
308 return format_text($description, $this->definition->descriptionformat, $formatoptions);
309 }
310
18e6298c
MG
311 public function is_form_available($foruserid = null) {
312 return true;
313 // TODO this is temporary for testing!
314 }
6798c63e 315
6832a102
DM
316 /**
317 * Returns the rubric plugin renderer
318 *
319 * @param moodle_page $page the target page
320 * @return renderer_base
321 */
322 public function get_renderer(moodle_page $page) {
323 return $page->get_renderer('gradingform_'. $this->get_method_name());
324 }
325
326 /**
327 * Returns the HTML code displaying the preview of the grading form
328 *
329 * @param moodle_page $page the target page
330 * @return string
331 */
332 public function render_preview(moodle_page $page) {
333
334 // use the parent's method to render the common information about the form
335 $header = parent::render_preview($page);
336
337 // append the rubric itself, using own renderer
338 $output = $this->get_renderer($page);
36937f02
MG
339 $criteria = $this->definition->rubric_criteria;
340 $rubric = $output->display_rubric($criteria, self::DISPLAY_PREVIEW, 'rubric');
6832a102
DM
341
342 return $header . $rubric;
343 }
671ec8f5
DM
344
345 /**
346 * Deletes the rubric definition and all the associated information
347 */
348 protected function delete_plugin_definition() {
349 global $DB;
350
351 // get the list of instances
352 $instances = array_keys($DB->get_records('grading_instances', array('formid' => $this->definition->id), '', 'id'));
353 // delete all fillings
354 $DB->delete_records_list('gradingform_rubric_fillings', 'forminstanceid', $instances);
355 // delete instances
356 $DB->delete_records_list('grading_instances', 'id', $instances);
357 // get the list of criteria records
358 $criteria = array_keys($DB->get_records('gradingform_rubric_criteria', array('formid' => $this->definition->id), '', 'id'));
359 // delete levels
360 $DB->delete_records_list('gradingform_rubric_levels', 'criterionid', $criteria);
361 // delete critera
362 $DB->delete_records_list('gradingform_rubric_criteria', 'id', $criteria);
363 }
36937f02
MG
364
365 /**
366 * Returns html code to be included in student's feedback.
367 *
368 * @param moodle_page $page
369 * @param int $itemid
9e2eca0f 370 * @param array $grading_info result of function grade_get_grades
36937f02
MG
371 * @param string $defaultcontent default string to be returned if no active grading is found
372 * @return string
373 */
9e2eca0f 374 public function render_grade($page, $itemid, $grading_info, $defaultcontent) {
36937f02
MG
375 $instances = $this->get_current_instances($itemid);
376 return $this->get_renderer($page)->display_instances($this->get_current_instances($itemid), $defaultcontent);
377 }
9b8550f8 378}
36937f02
MG
379
380/**
381 * Class to manage one rubric grading instance. Stores information and performs actions like
382 * update, copy, validate, submit, etc.
383 *
384 * @copyright 2011 Marina Glancy
385 */
386class gradingform_rubric_instance extends gradingform_instance {
387
388 /**
389 * Deletes this (INCOMPLETE) instance from database.
390 */
391 public function cancel() {
392 global $DB;
393 parent::cancel();
394 $DB->delete_records('gradingform_rubric_fillings', array('forminstanceid' => $this->get_id()));
395 }
396
397 /**
398 * Duplicates the instance before editing (optionally substitutes raterid and/or itemid with
399 * the specified values)
400 *
401 * @param int $raterid value for raterid in the duplicate
402 * @param int $itemid value for itemid in the duplicate
403 * @return int id of the new instance
404 */
405 public function copy($raterid, $itemid) {
406 global $DB;
407 $instanceid = parent::copy($raterid, $itemid);
408 $currentgrade = $this->get_rubric_filling();
409 foreach ($currentgrade as $criterionid => $levelid) {
410 $params = array('forminstanceid' => $instanceid, 'criterionid' => $criterionid, 'levelid' => $levelid);
411 $DB->insert_record('gradingform_rubric_fillings', $params);
412 }
413 // TODO remarks
414 return $instanceid;
415 }
416
417 /**
418 * Validates that rubric is fully completed and contains valid grade on each criterion
419 * @return boolean true if the form data is validated and contains no errors
420 */
421 public function validate_grading_element($elementvalue) {
422 // TODO: if there is nothing selected in rubric, we don't enter this function at all :(
423 $criteria = $this->get_controller()->get_definition()->rubric_criteria;
424 if (!is_array($elementvalue) || sizeof($elementvalue) < sizeof($criteria)) {
425 return false;
426 }
427 foreach ($criteria as $id => $criterion) {
428 if (!array_key_exists($id, $elementvalue) || !array_key_exists($elementvalue[$id], $criterion['levels'])) {
429 return false;
430 }
431 }
432 return true;
433 }
434
435 /**
436 * Retrieves from DB and returns the data how this rubric was filled
437 *
438 * @return array
439 */
440 public function get_rubric_filling() {
441 // TODO cache
442 global $DB;
443 $rs = $DB->get_records('gradingform_rubric_fillings', array('forminstanceid' => $this->get_id()));
444 $grading = array();
445 foreach ($rs as $record) {
446 if ($record->levelid) {
447 $grading[$record->criterionid] = $record->levelid;
448 }
449 // TODO: remarks
450 }
451 return $grading;
452 }
453
454 /**
455 * Updates the instance with the data received from grading form. This function may be
456 * called via AJAX when grading is not yet completed, so it does not change the
457 * status of the instance.
458 */
459 public function update($data) {
460 global $DB;
461 $currentgrade = $this->get_rubric_filling();
462 parent::update($data); // TODO ? +timemodified
463 foreach ($data as $criterionid => $levelid) {
464 $params = array('forminstanceid' => $this->get_id(), 'criterionid' => $criterionid);
465 if (!array_key_exists($criterionid, $currentgrade)) {
466 $DB->insert_record('gradingform_rubric_fillings', $params + array('levelid' => $levelid));
467 } else if ($currentgrade[$criterionid] != $levelid) {
468 $DB->set_field('gradingform_rubric_fillings', 'levelid', $levelid, $params);
469 }
470 }
471 foreach ($currentgrade as $criterionid => $levelid) {
472 if (!array_key_exists($criterionid, $data)) {
473 $params = array('forminstanceid' => $this->get_id(), 'criterionid' => $criterionid);
474 $DB->delete_records('gradingform_rubric_fillings', $params);
475 }
476 }
477 // TODO: remarks
478 }
479
480 /**
481 * Calculates the grade to be pushed to the gradebook
9e2eca0f
MG
482 *
483 * @return int the valid grade from $this->get_controller()->get_grade_range()
36937f02
MG
484 */
485 public function get_grade() {
486 global $DB, $USER;
487 $grade = $this->get_rubric_filling();
488
489 $minscore = 0;
490 $maxscore = 0;
491 foreach ($this->get_controller()->get_definition()->rubric_criteria as $id => $criterion) {
492 $keys = array_keys($criterion['levels']);
9e2eca0f 493 sort($keys);
36937f02
MG
494 $minscore += $criterion['levels'][$keys[0]]['score'];
495 $maxscore += $criterion['levels'][$keys[sizeof($keys)-1]]['score'];
496 }
497
9e2eca0f
MG
498 if ($maxscore <= $minscore) {
499 return -1;
500 }
501
502 $graderange = array_keys($this->get_controller()->get_grade_range());
503 if (empty($graderange)) {
36937f02
MG
504 return -1;
505 }
9e2eca0f
MG
506 sort($graderange);
507 $mingrade = $graderange[0];
508 $maxgrade = $graderange[sizeof($graderange) - 1];
36937f02
MG
509
510 $curscore = 0;
511 foreach ($grade as $id => $levelid) {
512 $curscore += $this->get_controller()->get_definition()->rubric_criteria[$id]['levels'][$levelid]['score'];
513 }
9e2eca0f 514 return round(($curscore-$minscore)/($maxscore-$minscore)*($maxgrade-$mingrade), 0) + $mingrade; // TODO mapping
36937f02
MG
515 }
516
517 /**
518 * Returns the error message displayed in case of validation failed
519 *
520 * @return string
521 */
522 public function default_validation_error_message() {
523 return 'The rubric is incomplete'; //TODO string
524 }
525
526 /**
527 * Returns html for form element of type 'grading'.
528 *
529 * @param moodle_page $page
530 * @param MoodleQuickForm_grading $formelement
531 * @return string
532 */
533 public function render_grading_element($page, $gradingformelement) {
534 global $USER;
535 if (!$gradingformelement->_flagFrozen) {
536 $module = array('name'=>'gradingform_rubric', 'fullpath'=>'/grade/grading/form/rubric/js/rubric.js');
537 $page->requires->js_init_call('M.gradingform_rubric.init', array(array('name' => $gradingformelement->getName())), true, $module);
538 $mode = gradingform_rubric_controller::DISPLAY_EVAL;
539 } else {
540 if ($gradingformelement->_persistantFreeze) {
541 $mode = gradingform_rubric_controller::DISPLAY_EVAL_FROZEN;
542 } else {
543 $mode = gradingform_rubric_controller::DISPLAY_REVIEW;
544 }
545 }
546 $criteria = $this->get_controller()->get_definition()->rubric_criteria;
547 $value = $gradingformelement->getValue();
548 if ($value === null) {
549 $value = $this->get_rubric_filling();
550 }
551 return $this->get_controller()->get_renderer($page)->display_rubric($criteria, $mode, $gradingformelement->getName(), $value);
552 }
553}