Differences Between: [Versions 402 and 403]
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 * This file defines a class with "Number of errors" grading strategy logic 20 * 21 * @package workshopform_numerrors 22 * @copyright 2009 David Mudrak <david.mudrak@gmail.com> 23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 24 */ 25 26 defined('MOODLE_INTERNAL') || die(); 27 28 require_once (__DIR__ . '/../lib.php'); // Interface definition. 29 require_once($CFG->libdir . '/gradelib.php'); // To handle float vs decimal issues. 30 31 /** 32 * Server workshop files 33 * 34 * @category files 35 * @param stdClass $course course object 36 * @param stdClass $cm course module object 37 * @param stdClass $context context object 38 * @param string $filearea file area 39 * @param array $args extra arguments 40 * @param bool $forcedownload whether or not force download 41 * @param array $options additional options affecting the file serving 42 * @return bool 43 */ 44 function workshopform_numerrors_pluginfile($course, $cm, $context, $filearea, array $args, $forcedownload, array $options=array()) { 45 global $DB; 46 47 if ($context->contextlevel != CONTEXT_MODULE) { 48 return false; 49 } 50 51 require_login($course, true, $cm); 52 53 if ($filearea !== 'description') { 54 return false; 55 } 56 57 $itemid = (int)array_shift($args); // the id of the assessment form dimension 58 if (!$workshop = $DB->get_record('workshop', array('id' => $cm->instance))) { 59 send_file_not_found(); 60 } 61 62 if (!$dimension = $DB->get_record('workshopform_numerrors', array('id' => $itemid ,'workshopid' => $workshop->id))) { 63 send_file_not_found(); 64 } 65 66 // TODO now make sure the user is allowed to see the file 67 // (media embedded into the dimension description) 68 $fs = get_file_storage(); 69 $relativepath = implode('/', $args); 70 $fullpath = "/$context->id/workshopform_numerrors/$filearea/$itemid/$relativepath"; 71 if (!$file = $fs->get_file_by_hash(sha1($fullpath)) or $file->is_directory()) { 72 return false; 73 } 74 75 // finally send the file 76 send_stored_file($file, 0, 0, $forcedownload, $options); 77 } 78 79 /** 80 * "Number of errors" grading strategy logic. 81 */ 82 class workshop_numerrors_strategy implements workshop_strategy { 83 84 /** @const default number of dimensions to show */ 85 const MINDIMS = 3; 86 87 /** @const number of dimensions to add */ 88 const ADDDIMS = 2; 89 90 /** @var workshop the parent workshop instance */ 91 protected $workshop; 92 93 /** @var array definition of the assessment form fields */ 94 protected $dimensions = null; 95 96 /** @var array mapping of the number of errors to a grade */ 97 protected $mappings = null; 98 99 /** @var array options for dimension description fields */ 100 protected $descriptionopts; 101 102 /** 103 * Constructor 104 * 105 * @param workshop $workshop The workshop instance record 106 * @return void 107 */ 108 public function __construct(workshop $workshop) { 109 $this->workshop = $workshop; 110 $this->dimensions = $this->load_fields(); 111 $this->mappings = $this->load_mappings(); 112 $this->descriptionopts = array('trusttext' => true, 'subdirs' => false, 'maxfiles' => -1); 113 } 114 115 /** 116 * Factory method returning an instance of an assessment form editor class 117 * 118 * @param $actionurl URL of form handler, defaults to auto detect the current url 119 */ 120 public function get_edit_strategy_form($actionurl=null) { 121 global $CFG; // needed because the included files use it 122 global $PAGE; 123 124 require_once (__DIR__ . '/edit_form.php'); 125 126 $fields = $this->prepare_form_fields($this->dimensions, $this->mappings); 127 $nodimensions = count($this->dimensions); 128 $norepeatsdefault = max($nodimensions + self::ADDDIMS, self::MINDIMS); 129 $norepeats = optional_param('norepeats', $norepeatsdefault, PARAM_INT); // number of dimensions 130 $noadddims = optional_param('noadddims', '', PARAM_ALPHA); // shall we add more? 131 if ($noadddims) { 132 $norepeats += self::ADDDIMS; 133 } 134 135 // Append editor context to editor options, giving preference to existing context. 136 $this->descriptionopts = array_merge(array('context' => $PAGE->context), $this->descriptionopts); 137 138 // prepare the embeded files 139 for ($i = 0; $i < $nodimensions; $i++) { 140 // prepare all editor elements 141 $fields = file_prepare_standard_editor($fields, 'description__idx_'.$i, $this->descriptionopts, 142 $PAGE->context, 'workshopform_numerrors', 'description', $fields->{'dimensionid__idx_'.$i}); 143 } 144 145 $customdata = array(); 146 $customdata['workshop'] = $this->workshop; 147 $customdata['strategy'] = $this; 148 $customdata['norepeats'] = $norepeats; 149 $customdata['nodimensions'] = $nodimensions; 150 $customdata['descriptionopts'] = $this->descriptionopts; 151 $customdata['current'] = $fields; 152 $attributes = array('class' => 'editstrategyform'); 153 154 return new workshop_edit_numerrors_strategy_form($actionurl, $customdata, 'post', '', $attributes); 155 } 156 157 /** 158 * Save the assessment dimensions into database 159 * 160 * Saves data into the main strategy form table. If the record->id is null or zero, 161 * new record is created. If the record->id is not empty, the existing record is updated. Records with 162 * empty 'description' field are removed from database. 163 * The passed data object are the raw data returned by the get_data(). 164 * 165 * @uses $DB 166 * @param stdClass $data Raw data returned by the dimension editor form 167 * @return void 168 */ 169 public function save_edit_strategy_form(stdclass $data) { 170 global $DB, $PAGE; 171 172 $workshopid = $data->workshopid; 173 $norepeats = $data->norepeats; 174 175 $data = $this->prepare_database_fields($data); 176 $records = $data->numerrors; // data to be saved into {workshopform_numerrors} 177 $mappings = $data->mappings; // data to be saved into {workshopform_numerrors_map} 178 $todelete = array(); // dimension ids to be deleted 179 $maxnonegative = 0; // maximum number of (weighted) negative responses 180 181 for ($i=0; $i < $norepeats; $i++) { 182 $record = $records[$i]; 183 if (0 == strlen(trim($record->description_editor['text']))) { 184 if (!empty($record->id)) { 185 // existing dimension record with empty description - to be deleted 186 $todelete[] = $record->id; 187 } 188 continue; 189 } 190 if (empty($record->id)) { 191 // new field 192 $record->id = $DB->insert_record('workshopform_numerrors', $record); 193 } else { 194 // exiting field 195 $DB->update_record('workshopform_numerrors', $record); 196 } 197 $maxnonegative += $record->weight; 198 // re-save with correct path to embeded media files 199 $record = file_postupdate_standard_editor($record, 'description', $this->descriptionopts, $PAGE->context, 200 'workshopform_numerrors', 'description', $record->id); 201 $DB->update_record('workshopform_numerrors', $record); 202 } 203 $this->delete_dimensions($todelete); 204 205 // re-save the mappings 206 $todelete = array(); 207 foreach ($data->mappings as $nonegative => $grade) { 208 if (is_null($grade)) { 209 // no grade set for this number of negative responses 210 $todelete[] = $nonegative; 211 continue; 212 } 213 if (isset($this->mappings[$nonegative])) { 214 $DB->set_field('workshopform_numerrors_map', 'grade', $grade, 215 array('workshopid' => $this->workshop->id, 'nonegative' => $nonegative)); 216 } else { 217 $DB->insert_record('workshopform_numerrors_map', 218 (object)array('workshopid' => $this->workshop->id, 'nonegative' => $nonegative, 'grade' => $grade)); 219 } 220 } 221 // clear mappings that are not valid any more 222 if (!empty($todelete)) { 223 list($insql, $params) = $DB->get_in_or_equal($todelete, SQL_PARAMS_NAMED); 224 $insql = "nonegative $insql OR "; 225 } else { 226 $insql = ''; 227 } 228 $sql = "DELETE FROM {workshopform_numerrors_map} 229 WHERE (($insql nonegative > :maxnonegative) AND (workshopid = :workshopid))"; 230 $params['maxnonegative'] = $maxnonegative; 231 $params['workshopid'] = $this->workshop->id; 232 $DB->execute($sql, $params); 233 } 234 235 /** 236 * Factory method returning an instance of an assessment form 237 * 238 * @param moodle_url $actionurl URL of form handler, defaults to auto detect the current url 239 * @param string $mode Mode to open the form in: preview/assessment 240 * @param stdClass $assessment 241 * @param bool $editable 242 * @param array $options 243 */ 244 public function get_assessment_form(moodle_url $actionurl=null, $mode='preview', stdclass $assessment=null, $editable=true, $options=array()) { 245 global $CFG; // needed because the included files use it 246 global $PAGE; 247 global $DB; 248 require_once (__DIR__ . '/assessment_form.php'); 249 250 $fields = $this->prepare_form_fields($this->dimensions, $this->mappings); 251 $nodimensions = count($this->dimensions); 252 253 // rewrite URLs to the embeded files 254 for ($i = 0; $i < $nodimensions; $i++) { 255 $fields->{'description__idx_'.$i} = file_rewrite_pluginfile_urls($fields->{'description__idx_'.$i}, 256 'pluginfile.php', $PAGE->context->id, 'workshopform_numerrors', 'description', $fields->{'dimensionid__idx_'.$i}); 257 } 258 259 if ('assessment' === $mode and !empty($assessment)) { 260 // load the previously saved assessment data 261 $grades = $this->get_current_assessment_data($assessment); 262 $current = new stdclass(); 263 for ($i = 0; $i < $nodimensions; $i++) { 264 $dimid = $fields->{'dimensionid__idx_'.$i}; 265 if (isset($grades[$dimid])) { 266 $current->{'gradeid__idx_'.$i} = $grades[$dimid]->id; 267 $current->{'grade__idx_'.$i} = ($grades[$dimid]->grade == 0 ? -1 : 1); 268 $current->{'peercomment__idx_'.$i} = $grades[$dimid]->peercomment; 269 } 270 } 271 } 272 273 // set up the required custom data common for all strategies 274 $customdata['workshop'] = $this->workshop; 275 $customdata['strategy'] = $this; 276 $customdata['mode'] = $mode; 277 $customdata['options'] = $options; 278 279 // set up strategy-specific custom data 280 $customdata['nodims'] = $nodimensions; 281 $customdata['fields'] = $fields; 282 $customdata['current'] = isset($current) ? $current : null; 283 $attributes = array('class' => 'assessmentform numerrors'); 284 285 return new workshop_numerrors_assessment_form($actionurl, $customdata, 'post', '', $attributes, $editable); 286 } 287 288 /** 289 * Saves the filled assessment 290 * 291 * This method processes data submitted using the form returned by {@link get_assessment_form()} 292 * 293 * @param stdClass $assessment Assessment being filled 294 * @param stdClass $data Raw data as returned by the assessment form 295 * @return float|null Raw grade (from 0.00000 to 100.00000) for submission as suggested by the peer 296 */ 297 public function save_assessment(stdclass $assessment, stdclass $data) { 298 global $DB; 299 300 if (!isset($data->nodims)) { 301 throw new coding_exception('You did not send me the number of assessment dimensions to process'); 302 } 303 for ($i = 0; $i < $data->nodims; $i++) { 304 $grade = new stdclass(); 305 $grade->id = $data->{'gradeid__idx_' . $i}; 306 $grade->assessmentid = $assessment->id; 307 $grade->strategy = 'numerrors'; 308 $grade->dimensionid = $data->{'dimensionid__idx_' . $i}; 309 $grade->grade = ($data->{'grade__idx_' . $i} <= 0 ? 0 : 1); 310 $grade->peercomment = $data->{'peercomment__idx_' . $i}; 311 $grade->peercommentformat = FORMAT_HTML; 312 if (empty($grade->id)) { 313 // new grade 314 $grade->id = $DB->insert_record('workshop_grades', $grade); 315 } else { 316 // updated grade 317 $DB->update_record('workshop_grades', $grade); 318 } 319 } 320 return $this->update_peer_grade($assessment); 321 } 322 323 /** 324 * Has the assessment form been defined and is ready to be used by the reviewers? 325 * 326 * @return boolean 327 */ 328 public function form_ready() { 329 if (count($this->dimensions) > 0) { 330 return true; 331 } 332 return false; 333 } 334 335 /** 336 * @see parent::get_assessments_recordset() 337 */ 338 public function get_assessments_recordset($restrict=null) { 339 global $DB; 340 341 $sql = 'SELECT s.id AS submissionid, 342 a.id AS assessmentid, a.weight AS assessmentweight, a.reviewerid, a.gradinggrade, 343 g.dimensionid, g.grade 344 FROM {workshop_submissions} s 345 JOIN {workshop_assessments} a ON (a.submissionid = s.id) 346 JOIN {workshop_grades} g ON (g.assessmentid = a.id AND g.strategy = :strategy) 347 WHERE s.example=0 AND s.workshopid=:workshopid'; // to be cont. 348 $params = array('workshopid' => $this->workshop->id, 'strategy' => $this->workshop->strategy); 349 350 if (is_null($restrict)) { 351 // update all users - no more conditions 352 } elseif (!empty($restrict)) { 353 list($usql, $uparams) = $DB->get_in_or_equal($restrict, SQL_PARAMS_NAMED); 354 $sql .= " AND a.reviewerid $usql"; 355 $params = array_merge($params, $uparams); 356 } else { 357 throw new coding_exception('Empty value is not a valid parameter here'); 358 } 359 360 $sql .= ' ORDER BY s.id'; // this is important for bulk processing 361 362 return $DB->get_recordset_sql($sql, $params); 363 364 } 365 366 /** 367 * @see parent::get_dimensions_info() 368 */ 369 public function get_dimensions_info() { 370 global $DB; 371 372 $params = array('workshopid' => $this->workshop->id); 373 $dimrecords = $DB->get_records('workshopform_numerrors', array('workshopid' => $this->workshop->id), 'sort', 'id,weight'); 374 foreach ($dimrecords as $dimid => $dimrecord) { 375 $dimrecords[$dimid]->min = 0; 376 $dimrecords[$dimid]->max = 1; 377 } 378 return $dimrecords; 379 } 380 381 /** 382 * Is a given scale used by the instance of workshop? 383 * 384 * This grading strategy does not use scales. 385 * 386 * @param int $scaleid id of the scale to check 387 * @param int|null $workshopid id of workshop instance to check, checks all in case of null 388 * @return bool 389 */ 390 public static function scale_used($scaleid, $workshopid=null) { 391 return false; 392 } 393 394 /** 395 * Delete all data related to a given workshop module instance 396 * 397 * @see workshop_delete_instance() 398 * @param int $workshopid id of the workshop module instance being deleted 399 * @return void 400 */ 401 public static function delete_instance($workshopid) { 402 global $DB; 403 404 $DB->delete_records('workshopform_numerrors', array('workshopid' => $workshopid)); 405 $DB->delete_records('workshopform_numerrors_map', array('workshopid' => $workshopid)); 406 } 407 408 //////////////////////////////////////////////////////////////////////////////// 409 // Internal methods // 410 //////////////////////////////////////////////////////////////////////////////// 411 412 /** 413 * Loads the fields of the assessment form currently used in this workshop 414 * 415 * @return array definition of assessment dimensions 416 */ 417 protected function load_fields() { 418 global $DB; 419 420 $sql = 'SELECT * 421 FROM {workshopform_numerrors} 422 WHERE workshopid = :workshopid 423 ORDER BY sort'; 424 $params = array('workshopid' => $this->workshop->id); 425 426 return $DB->get_records_sql($sql, $params); 427 } 428 429 /** 430 * Loads the mappings of the number of errors to the grade 431 * 432 * @return array of records 433 */ 434 protected function load_mappings() { 435 global $DB; 436 return $DB->get_records('workshopform_numerrors_map', array('workshopid' => $this->workshop->id), 'nonegative', 437 'nonegative,grade'); // we can use nonegative as key here as it must be unique within workshop 438 } 439 440 /** 441 * Prepares the database data to be used by the mform 442 * 443 * @param array $dims Array of raw dimension records as returned by {@link load_fields()} 444 * @param array $maps Array of raw mapping records as returned by {@link load_mappings()} 445 * @return array Array of fields data to be used by the mform set_data 446 */ 447 protected function prepare_form_fields(array $dims, array $maps) { 448 449 $formdata = new stdclass(); 450 $key = 0; 451 foreach ($dims as $dimension) { 452 $formdata->{'dimensionid__idx_' . $key} = $dimension->id; 453 $formdata->{'description__idx_' . $key} = $dimension->description; 454 $formdata->{'description__idx_' . $key.'format'} = $dimension->descriptionformat; 455 $formdata->{'grade0__idx_' . $key} = $dimension->grade0; 456 $formdata->{'grade1__idx_' . $key} = $dimension->grade1; 457 $formdata->{'weight__idx_' . $key} = $dimension->weight; 458 $key++; 459 } 460 461 foreach ($maps as $nonegative => $map) { 462 $formdata->{'map__idx_' . $nonegative} = $map->grade; 463 } 464 465 return $formdata; 466 } 467 468 /** 469 * Deletes dimensions and removes embedded media from its descriptions 470 * 471 * todo we may check that there are no assessments done using these dimensions and probably remove them 472 * 473 * @param array $ids list to delete 474 * @return void 475 */ 476 protected function delete_dimensions(array $ids) { 477 global $DB, $PAGE; 478 479 $fs = get_file_storage(); 480 foreach ($ids as $id) { 481 $fs->delete_area_files($PAGE->context->id, 'workshopform_numerrors', 'description', $id); 482 } 483 $DB->delete_records_list('workshopform_numerrors', 'id', $ids); 484 } 485 486 /** 487 * Prepares data returned by {@link workshop_edit_numerrors_strategy_form} so they can be saved into database 488 * 489 * It automatically adds some columns into every record. The sorting is 490 * done by the order of the returned array and starts with 1. 491 * Called internally from {@link save_edit_strategy_form()} only. Could be private but 492 * keeping protected for unit testing purposes. 493 * 494 * @param stdClass $raw Raw data returned by mform 495 * @return array Array of objects to be inserted/updated in DB 496 */ 497 protected function prepare_database_fields(stdclass $raw) { 498 global $PAGE; 499 500 $cook = new stdclass(); // to be returned 501 $cook->numerrors = array(); // to be stored in {workshopform_numerrors} 502 $cook->mappings = array(); // to be stored in {workshopform_numerrors_map} 503 504 for ($i = 0; $i < $raw->norepeats; $i++) { 505 $cook->numerrors[$i] = new stdclass(); 506 $cook->numerrors[$i]->id = $raw->{'dimensionid__idx_'.$i}; 507 $cook->numerrors[$i]->workshopid = $this->workshop->id; 508 $cook->numerrors[$i]->sort = $i + 1; 509 $cook->numerrors[$i]->description_editor = $raw->{'description__idx_'.$i.'_editor'}; 510 $cook->numerrors[$i]->grade0 = $raw->{'grade0__idx_'.$i}; 511 $cook->numerrors[$i]->grade1 = $raw->{'grade1__idx_'.$i}; 512 $cook->numerrors[$i]->weight = $raw->{'weight__idx_'.$i}; 513 } 514 515 $i = 1; 516 while (isset($raw->{'map__idx_'.$i})) { 517 if (is_numeric($raw->{'map__idx_'.$i})) { 518 $cook->mappings[$i] = $raw->{'map__idx_'.$i}; // should be a value from 0 to 100 519 } else { 520 $cook->mappings[$i] = null; // the user did not set anything 521 } 522 $i++; 523 } 524 525 return $cook; 526 } 527 528 /** 529 * Returns the list of current grades filled by the reviewer 530 * 531 * @param stdClass $assessment Assessment record 532 * @return array of filtered records from the table workshop_grades 533 */ 534 protected function get_current_assessment_data(stdclass $assessment) { 535 global $DB; 536 537 if (empty($this->dimensions)) { 538 return array(); 539 } 540 list($dimsql, $dimparams) = $DB->get_in_or_equal(array_keys($this->dimensions), SQL_PARAMS_NAMED); 541 // beware! the caller may rely on the returned array is indexed by dimensionid 542 $sql = "SELECT dimensionid, wg.* 543 FROM {workshop_grades} wg 544 WHERE assessmentid = :assessmentid AND strategy= :strategy AND dimensionid $dimsql"; 545 $params = array('assessmentid' => $assessment->id, 'strategy' => 'numerrors'); 546 $params = array_merge($params, $dimparams); 547 548 return $DB->get_records_sql($sql, $params); 549 } 550 551 /** 552 * Aggregates the assessment form data and sets the grade for the submission given by the peer 553 * 554 * @param stdClass $assessment Assessment record 555 * @return float|null Raw grade (0.00000 to 100.00000) for submission as suggested by the peer 556 */ 557 protected function update_peer_grade(stdclass $assessment) { 558 $grades = $this->get_current_assessment_data($assessment); 559 $suggested = $this->calculate_peer_grade($grades); 560 if (!is_null($suggested)) { 561 $this->workshop->set_peer_grade($assessment->id, $suggested); 562 } 563 return $suggested; 564 } 565 566 /** 567 * Calculates the aggregated grade given by the reviewer 568 * 569 * @param array $grades Grade records as returned by {@link get_current_assessment_data} 570 * @return float|null Raw grade (0.00000 to 100.00000) for submission as suggested by the peer 571 */ 572 protected function calculate_peer_grade(array $grades) { 573 if (empty($grades)) { 574 return null; 575 } 576 $sumerrors = 0; // sum of the weighted errors (i.e. the negative responses) 577 foreach ($grades as $grade) { 578 if (grade_floats_different($grade->grade, 1.00000)) { 579 // negative reviewer's response 580 $sumerrors += $this->dimensions[$grade->dimensionid]->weight; 581 } 582 } 583 return $this->errors_to_grade($sumerrors); 584 } 585 586 /** 587 * Returns a grade 0.00000 to 100.00000 for the given number of errors 588 * 589 * This is where we use the mapping table defined by the teacher. If a grade for the given 590 * number of errors (negative assertions) is not defined, the most recently defined one is used. 591 * Example of the defined mapping: 592 * Number of errors | Grade 593 * 0 | 100% (always) 594 * 1 | - (not defined) 595 * 2 | 80% 596 * 3 | 60% 597 * 4 | - 598 * 5 | 30% 599 * 6 | 0% 600 * With this mapping, one error is mapped to 100% grade and 4 errors is mapped to 60%. 601 * 602 * @param mixed $numerrors Number of errors 603 * @return float Raw grade (0.00000 to 100.00000) for the given number of negative assertions 604 */ 605 protected function errors_to_grade($numerrors) { 606 $grade = 100.00000; 607 for ($i = 1; $i <= $numerrors; $i++) { 608 if (isset($this->mappings[$i])) { 609 $grade = $this->mappings[$i]->grade; 610 } 611 } 612 if ($grade > 100.00000) { 613 $grade = 100.00000; 614 } 615 if ($grade < 0.00000) { 616 $grade = 0.00000; 617 } 618 return grade_floatval($grade); 619 } 620 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body