Merge remote branch 'origin/master' into rubric
[moodle.git] / grade / grading / form / 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  * Common classes used by gradingform plugintypes are defined here
20  *
21  * @package    core
22  * @subpackage grading
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 /**
30  * Grading method controller represents a plugin used in a particular area
31  */
32 abstract class gradingform_controller {
34     const DEFINITION_STATUS_WORKINPROGRESS  = 0;
35     const DEFINITION_STATUS_PRIVATE         = 1;
36     const DEFINITION_STATUS_PUBLIC          = 2;
38     /** @var stdClass the context */
39     protected $context;
41     /** @var string the frankenstyle name of the component */
42     protected $component;
44     /** @var string the name of the gradable area */
45     protected $area;
47     /** @var int the id of the gradable area record */
48     protected $areaid;
50     /** @var stdClass|false the definition structure */
51     protected $definition = false;
53     /** @var array graderange array of valid grades for this area. Use set_grade_range and get_grade_range to access this */
54     private $graderange = null;
56     /**
57      * Do not instantinate this directly, use {@link grading_manager::get_controller()}
58      *
59      * @param stdClass $context the context of the form
60      * @param string $component the frankenstyle name of the component
61      * @param string $area the name of the gradable area
62      * @param int $areaid the id of the gradable area record
63      */
64     public function __construct(stdClass $context, $component, $area, $areaid) {
65         global $DB;
67         $this->context      = $context;
68         list($type, $name)  = normalize_component($component);
69         $this->component    = $type.'_'.$name;
70         $this->area         = $area;
71         $this->areaid       = $areaid;
73         $this->load_definition();
74     }
76     /**
77      * @return stdClass controller context
78      */
79     public function get_context() {
80         return $this->context;
81     }
83     /**
84      * @return string gradable component name
85      */
86     public function get_component() {
87         return $this->component;
88     }
90     /**
91      * @return string gradable area name
92      */
93     public function get_area() {
94         return $this->area;
95     }
97     /**
98      * @return int gradable area id
99      */
100     public function get_areaid() {
101         return $this->areaid;
102     }
104     /**
105      * Is the form definition record available?
106      *
107      * Note that this actually checks whether the process of defining the form ever started
108      * and not whether the form definition should be considered as final.
109      *
110      * @return boolean
111      */
112     public function is_form_defined() {
113         return ($this->definition !== false);
114     }
116     /**
117      * Is the grading form defined and released for usage by the given user?
118      *
119      * @param int $foruserid the id of the user who attempts to work with the form
120      * @return boolean
121      */
122     public function is_form_available($foruserid = null) {
123         global $USER;
125         if (is_null($foruserid)) {
126             $foruserid = $USER->id;
127         }
129         if (!$this->is_form_defined()) {
130             return false;
131         }
133         if ($this->definition->status == self::DEFINITION_STATUS_PUBLIC) {
134             return true;
135         }
137         if ($this->definition->status == self::DEFINITION_STATUS_PRIVATE) {
138             if ($this->definition->usercreated == $foruserid) {
139                 return true;
140             }
141         }
143         return false;
144     }
146     /**
147      * Returns URL of a page where the grading form can be defined and edited.
148      *
149      * @param moodle_url $returnurl optional URL of a page where the user should be sent once they are finished with editing
150      * @return moodle_url
151      */
152     public function get_editor_url(moodle_url $returnurl = null) {
154         $params = array('areaid' => $this->areaid);
156         if (!is_null($returnurl)) {
157             $params['returnurl'] = $returnurl->out(false);
158         }
160         return new moodle_url('/grade/grading/form/'.$this->get_method_name().'/edit.php', $params);
161     }
163     /**
164      * Extends the module settings navigation
165      *
166      * This function is called when the context for the page is an activity module with the
167      * FEATURE_ADVANCED_GRADING, the user has the permission moodle/grade:managegradingforms
168      * and there is an area with the active grading method set to the given plugin.
169      *
170      * @param settings_navigation $settingsnav {@link settings_navigation}
171      * @param navigation_node $node {@link navigation_node}
172      */
173     public function extend_settings_navigation(settings_navigation $settingsnav, navigation_node $node=null) {
174         // do not extend by default
175     }
177     /**
178      * Returns the grading form definition structure
179      *
180      * @param boolean $force whether to force loading from DB even if it was already loaded
181      * @return stdClass|false definition data or false if the form is not defined yet
182      */
183     public function get_definition($force = false) {
184         if ($this->definition === false || $force) {
185             $this->load_definition();
186         }
187         return $this->definition;
188     }
190     /**
191      * Returns the form definition suitable for cloning into another area
192      *
193      * @param gradingform_controller $target the controller of the new copy
194      * @return stdClass definition structure to pass to the target's {@link update_definition()}
195      */
196     public function get_definition_copy(gradingform_controller $target) {
198         if (get_class($this) != get_class($target)) {
199             throw new coding_exception('The source and copy controller mismatch');
200         }
202         if ($target->is_form_defined()) {
203             throw new coding_exception('The target controller already contains a form definition');
204         }
206         $old = $this->get_definition();
207         // keep our id
208         $new = new stdClass();
209         $new->copiedfromid = $old->id;
210         $new->name = $old->name;
211         // once we support files embedded into the description, we will want to
212         // relink them into the new file area here (that is why we accept $target)
213         $new->description = $old->description;
214         $new->descriptionformat = $old->descriptionformat;
215         $new->options = $old->options;
217         return $new;
218     }
220     /**
221      * Saves the defintion data into the database
222      *
223      * The implementation in this base class stores the common data into the record
224      * into the {grading_definition} table. The plugins are likely to extend this
225      * and save their data into own tables, too.
226      *
227      * @param stdClass $definition data containing values for the {grading_definition} table
228      * @param int|null $usermodified optional userid of the author of the definition, defaults to the current user
229      */
230     public function update_definition(stdClass $definition, $usermodified = null) {
231         global $DB, $USER;
233         if (is_null($usermodified)) {
234             $usermodified = $USER->id;
235         }
237         if (!empty($this->definition->id)) {
238             // prepare a record to be updated
239             $record = new stdClass();
240             // populate it with scalar values from the passed definition structure
241             foreach ($definition as $prop => $val) {
242                 if (is_array($val) or is_object($val)) {
243                     // probably plugin's data
244                     continue;
245                 }
246                 $record->{$prop} = $val;
247             }
248             // make sure we do not override some crucial values by accident
249             if (!empty($record->id) and $record->id != $this->definition->id) {
250                 throw new coding_exception('Attempting to update other definition record.');
251             }
252             $record->id = $this->definition->id;
253             unset($record->areaid);
254             unset($record->method);
255             unset($record->timecreated);
256             // set the modification flags
257             $record->timemodified = time();
258             $record->usermodified = $usermodified;
260             $DB->update_record('grading_definitions', $record);
262         } else if ($this->definition === false) {
263             // prepare a record to be inserted
264             $record = new stdClass();
265             // populate it with scalar values from the passed definition structure
266             foreach ($definition as $prop => $val) {
267                 if (is_array($val) or is_object($val)) {
268                     // probably plugin's data
269                     continue;
270                 }
271                 $record->{$prop} = $val;
272             }
273             // make sure we do not override some crucial values by accident
274             if (!empty($record->id)) {
275                 throw new coding_exception('Attempting to create a new record while there is already one existing.');
276             }
277             unset($record->id);
278             $record->areaid       = $this->areaid;
279             $record->method       = $this->get_method_name();
280             $record->timecreated  = time();
281             $record->usercreated  = $usermodified;
282             $record->timemodified = $record->timecreated;
283             $record->usermodified = $record->usercreated;
284             $record->status       = self::DEFINITION_STATUS_WORKINPROGRESS;
286             $DB->insert_record('grading_definitions', $record);
288         } else {
289             throw new coding_exception('Unknown status of the cached definition record.');
290         }
291     }
293     /**
294      * Returns the ACTIVE instance for this definition for the specified $raterid and $itemid
295      * (if multiple raters are allowed, or only for $itemid otherwise).
296      *
297      * @param int $raterid
298      * @param int $itemid
299      * @param boolean $idonly
300      * @return mixed if $idonly=true returns id of the found instance, otherwise returns the instance object
301      */
302     public function get_current_instance($raterid, $itemid, $idonly = false) {
303         global $DB;
304         $select = array(
305                 'formid'  => $this->definition->id,
306                 'itemid' => $itemid,
307                 'status'  => gradingform_instance::INSTANCE_STATUS_ACTIVE);
308         if (false /* TODO $manager->allow_multiple_raters() */) {
309             $select['raterid'] = $raterid;
310         }
311         if ($idonly) {
312             if ($current = $DB->get_record('grading_instances', $select, 'id', IGNORE_MISSING)) {
313                 return $current->id;
314             }
315         } else {
316             if ($current = $DB->get_record('grading_instances', $select, '*', IGNORE_MISSING)) {
317                 return $this->get_instance($current);
318             }
319         }
320         return null;
321     }
323     /**
324      * Returns list of active instances for the specified $itemid
325      *
326      * @param int $itemid
327      * @return array of gradingform_instance objects
328      */
329     public function get_current_instances($itemid) {
330         global $DB;
331         $conditions = array('formid'  => $this->definition->id,
332                     'itemid' => $itemid,
333                     'status'  => gradingform_instance::INSTANCE_STATUS_ACTIVE);
334         $records = $DB->get_recordset('grading_instances', $conditions);
335         $rv = array();
336         foreach ($records as $record) {
337             $rv[] = $this->get_instance($record);
338         }
339         return $rv;
340     }
342     /**
343      * Returns the object of type gradingform_XXX_instance (where XXX is the plugin method name)
344      *
345      * @param mixed $instance id or row from grading_isntances table
346      * @return gradingform_instance
347      */
348     protected function get_instance($instance) {
349         global $DB;
350         if (is_scalar($instance)) {
351             // instance id is passed as parameter
352             $instance = $DB->get_record('grading_instances', array('id'  => $instance), '*', MUST_EXIST);
353         }
354         if ($instance) {
355             $class = 'gradingform_'. $this->get_method_name(). '_instance';
356             return new $class($this, $instance);
357         }
358         return null;
359     }
361     /**
362      * This function is invoked when user (teacher) starts grading.
363      * It creates and returns copy of the current ACTIVE instance if it exists. If this is the
364      * first grading attempt, a new instance is created.
365      * The status of the returned instance is INCOMPLETE
366      *
367      * @param int $raterid
368      * @param int $itemid
369      * @return gradingform_instance
370      */
371     public function create_instance($raterid, $itemid = null) {
372         global $DB;
373         // first find if there is already an active instance for this itemid
374         if ($itemid && $current = $this->get_current_instance($raterid, $itemid)) {
375             return $this->get_instance($current->copy($raterid, $itemid));
376         } else {
377             $class = 'gradingform_'. $this->get_method_name(). '_instance';
378             return $this->get_instance($class::create_new($this->definition->id, $raterid, $itemid));
379         }
380     }
382     /**
383      * Returns the HTML code displaying the preview of the grading form
384      *
385      * Plugins are supposed to override/extend this. Ideally they should delegate
386      * the task to their own renderer.
387      *
388      * @param moodle_page $page the target page
389      * @return string
390      */
391     public function render_preview(moodle_page $page) {
393         if (!$this->is_form_defined()) {
394             throw new coding_exception('It is the caller\'s responsibility to make sure that the form is actually defined');
395         }
397         $output = $page->get_renderer('core_grading');
399         return $output->preview_definition_header($this);
400     }
402     /**
403      * Deletes the form definition and all the associated data
404      *
405      * @see delete_plugin_definition()
406      * @return void
407      */
408     public function delete_definition() {
409         global $DB;
411         if (!$this->is_form_defined()) {
412             // nothing to do
413             return;
414         }
416         // firstly, let the plugin delete everything from their own tables
417         $this->delete_plugin_definition();
418         // then, delete all instances left
419         $DB->delete_records('grading_instances', array('formid' => $this->definition->id));
420         // finally, delete the main definition record
421         $DB->delete_records('grading_definitions', array('id' => $this->definition->id));
423         $this->definition = false;
424     }
426     ////////////////////////////////////////////////////////////////////////////
428     /**
429      * Loads the form definition if it exists
430      *
431      * The default implementation just tries to load the record from the {grading_definitions}
432      * table. The plugins are likely to override this with a more complex query that loads
433      * all required data at once.
434      */
435     protected function load_definition() {
436         global $DB;
437         $this->definition = $DB->get_record('grading_definitions', array(
438             'areaid' => $this->areaid,
439             'method' => $this->get_method_name()), '*', IGNORE_MISSING);
440     }
442     /**
443      * Deletes all plugin data associated with the given form definiton
444      *
445      * @see delete_definition()
446      */
447     abstract protected function delete_plugin_definition();
449     /**
450      * @return string the name of the grading method plugin, eg 'rubric'
451      * @see PARAM_PLUGIN
452      */
453     protected function get_method_name() {
454         if (preg_match('/^gradingform_([a-z][a-z0-9_]*[a-z0-9])_controller$/', get_class($this), $matches)) {
455             return $matches[1];
456         } else {
457             throw new coding_exception('Invalid class name');
458         }
459     }
461     /**
462      * Returns html code to be included in student's feedback.
463      *
464      * @param moodle_page $page
465      * @param int $itemid
466      * @param array $grading_info result of function grade_get_grades if plugin want to use some of their info
467      * @param string $defaultcontent default string to be returned if no active grading is found or for some reason can not be shown to a user
468      * @return string
469      */
470     public function render_grade($page, $itemid, $grading_info, $defaultcontent) {
471         return $defaultcontent;
472     }
474     /**
475      * Sets the range of grades used in this area. This is usually either range like 0-100
476      * or the scale where keys start from 1. Typical use:
477      * $controller->set_grade_range(make_grades_menu($gradingtype));
478      */
479     public final function set_grade_range(array $graderange) {
480         $this->graderange = $graderange;
481     }
483     /**
484      * Returns the range of grades used in this area
485      * @return array
486      */
487     public final function get_grade_range() {
488         if (empty($this->graderange)) {
489             return array();
490         }
491         return $this->graderange;
492     }
495 /**
496  * Class to manage one grading instance. Stores information and performs actions like
497  * update, copy, validate, submit, etc.
498  *
499  * @copyright  2011 Marina Glancy
500  */
501 abstract class gradingform_instance {
502     const INSTANCE_STATUS_ACTIVE = 1;
503     const INSTANCE_STATUS_INCOMPLETE = 0;
504     const INSTANCE_STATUS_ARCHIVE = 3;
506     /** @var stdClass record from table grading_instances */
507     protected $data;
508     /** @var gradingform_controller link to the corresponding controller */
509     protected $controller;
511     /**
512      * Creates an instance
513      *
514      * @param gradingform_controller $controller
515      * @param stdClass $data
516      */
517     public function __construct($controller, $data) {
518         $this->data = (object)$data;
519         $this->controller = $controller;
520     }
522     /**
523      * Creates a new empty instance in DB and mark its status as INCOMPLETE
524      *
525      * @param int $formid
526      * @param int $raterid
527      * @param int $itemid
528      * @return int id of the created instance
529      */
530     public static function create_new($formid, $raterid, $itemid) {
531         global $DB;
532         $instance = new stdClass();
533         $instance->formid = $formid;
534         $instance->raterid = $raterid;
535         $instance->itemid = $itemid;
536         $instance->status = self::INSTANCE_STATUS_INCOMPLETE;
537         $instance->timemodified = time();
538         $instance->feedbackformat = FORMAT_MOODLE;
539         $instanceid = $DB->insert_record('grading_instances', $instance);
540         return $instanceid;
541     }
543     /**
544      * Duplicates the instance before editing (optionally substitutes raterid and/or itemid with
545      * the specified values)
546      * Plugins may want to override this function to copy data from additional tables as well
547      *
548      * @param int $raterid value for raterid in the duplicate
549      * @param int $itemid value for itemid in the duplicate
550      * @return int id of the new instance
551      */
552     public function copy($raterid, $itemid) {
553         global $DB;
554         $data = (array)$this->data; // Cast to array to make a copy
555         unset($data['id']);
556         $data['raterid'] = $raterid;
557         $data['itemid'] = $itemid;
558         $data['timemodified'] = time();
559         $data['status'] = self::INSTANCE_STATUS_INCOMPLETE;
560         $instanceid = $DB->insert_record('grading_instances', $data);
561         return $instanceid;
562     }
564     /**
565      * Returns the controller
566      *
567      * @return gradingform_controller
568      */
569     public function get_controller() {
570         return $this->controller;
571     }
573     /**
574      * Returns instance id
575      *
576      * @return int
577      */
578     public function get_id() {
579         return $this->data->id;
580     }
582     /**
583      * Marks the instance as ACTIVE and current active instance (if exists) as ARCHIVE
584      */
585     protected function make_active() {
586         global $DB;
587         if ($this->data->status == self::INSTANCE_STATUS_ACTIVE) {
588             // already active
589             return;
590         }
591         if (empty($this->data->itemid)) {
592             throw new coding_exception('You cannot mark active the grading instance without itemid');
593         }
594         $currentid = $this->get_controller()->get_current_instance($this->data->raterid, $this->data->itemid, true);
595         if ($currentid) {
596             if ($currentid != $this->get_id()) {
597                 $DB->update_record('grading_instances', array('id' => $currentid, 'status' => self::INSTANCE_STATUS_ARCHIVE));
598                 $DB->update_record('grading_instances', array('id' => $this->get_id(), 'status' => self::INSTANCE_STATUS_ACTIVE));
599             }
600         } else {
601             $DB->update_record('grading_instances', array('id' => $this->get_id(), 'status' => self::INSTANCE_STATUS_ACTIVE));
602         }
603         $this->data->status = self::INSTANCE_STATUS_ACTIVE;
604     }
606     /**
607      * Deletes this (INCOMPLETE) instance from database. This function is invoked on cancelling the
608      * grading form and/or during cron cleanup.
609      * Plugins using additional tables must override this method to remove additional data.
610      * Note that if the teacher just closes the window or presses 'Back' button of the browser,
611      * this function is not invoked.
612      */
613     public function cancel() {
614         global $DB;
615         // TODO what if we happen delete the ACTIVE instance, shall we rollback to the last ARCHIVE? or throw an exception?
616         // TODO create cleanup cron
617         $DB->delete_records('grading_instances', array('id' => $this->get_id()));
618     }
620     /**
621      * Updates the instance with the data received from grading form. This function may be
622      * called via AJAX when grading is not yet completed, so it does not change the
623      * status of the instance.
624      *
625      * @param array $elementvalue
626      */
627     public function update($elementvalue) {
628         global $DB;
629         $newdata = new stdClass();
630         $newdata->id = $this->get_id();
631         $newdata->timemodified = time();
632         if (isset($elementvalue['itemid']) && $elementvalue['itemid'] != $this->data->itemid) {
633             $newdata->itemid = $elementvalue['itemid'];
634         }
635         // TODO also update: rawgrade, feedback, feedbackformat
636         $DB->update_record('grading_instances', $newdata);
637         foreach ($newdata as $key => $value) {
638             $this->data->$key = $value;
639         }
640     }
642     /**
643      * Calculates the grade to be pushed to the gradebook
644      *
645      * @return int the valid grade from $this->get_controller()->get_grade_range()
646      */
647     abstract public function get_grade();
649     /**
650      * Called when teacher submits the grading form:
651      * updates the instance in DB, marks it as ACTIVE and returns the grade to be pushed to the gradebook.
652      * $itemid must be specified here (it was not required when the instance was
653      * created, because it might not existed in draft)
654      *
655      * @param array $elementvalue
656      * @param int $itemid
657      * @return int the grade on 0-100 scale
658      */
659     public function submit_and_get_grade($elementvalue, $itemid) {
660         $elementvalue['itemid'] = $itemid;
661         $this->update($elementvalue);
662         $this->make_active();
663         return $this->get_grade();
664     }
667     /**
668      * Returns html for form element of type 'grading'. If there is a form input element
669      * it must have the name $gradingformelement->getName().
670      * If there are more than one input elements they MUST be elements of array with
671      * name $gradingformelement->getName().
672      * Example: {NAME}[myelement1], {NAME}[myelement2][sub1], {NAME}[myelement2][sub2], etc.
673      * ( {NAME} is a shortcut for $gradingformelement->getName() )
674      * After submitting the form the value of $_POST[{NAME}] is passed to the functions
675      * validate_grading_element() and submit_and_get_grade()
676      *
677      * Plugins may use $gradingformelement->getValue() to get the value passed on previous
678      * form submit
679      *
680      * When forming html it is a plugin's responsibility to analyze flags
681      * $gradingformelement->_flagFrozen and $gradingformelement->_persistantFreeze:
682      *
683      * (_flagFrozen == false) => form element is editable
684      *
685      * (_flagFrozen == false && _persistantFreeze == true) => form element is not editable
686      * but all values are passed as hidden elements
687      *
688      * (_flagFrozen == false && _persistantFreeze == false) => form element is not editable
689      * and no values are passed as hidden elements
690      *
691      * Plugins are welcome to use AJAX in the form element. But it is strongly recommended
692      * that the grading only becomes active when teacher presses 'Submit' button (the
693      * method submit_and_get_grade() is invoked)
694      *
695      * Also client-side JS validation may be implemented here
696      *
697      * @see MoodleQuickForm_grading in lib/form/grading.php
698      *
699      * @param moodle_page $page
700      * @param MoodleQuickForm_grading $gradingformelement
701      * @return string
702      */
703     abstract function render_grading_element($page, $gradingformelement);
705     /**
706      * Server-side validation of the data received from grading form.
707      *
708      * @param mixed $elementvalue is the scalar or array received in $_POST
709      * @return boolean true if the form data is validated and contains no errors
710      */
711     public function validate_grading_element($elementvalue) {
712         return true;
713     }
715     /**
716      * Returns the error message displayed if validation failed.
717      * If plugin wants to display custom message, the empty string should be returned here
718      * and the custom message should be output in render_grading_element()
719      *
720      * @see validate_grading_element()
721      * @return string
722      */
723     public function default_validation_error_message() {
724         return '';
725     }