MDL-29794 Initial support for re-using a shared grading form
[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 forced to override 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     abstract public function render_preview(moodle_page $page);
393     /**
394      * Deletes the form definition and all the associated data
395      *
396      * @see delete_plugin_definition()
397      * @return void
398      */
399     public function delete_definition() {
400         global $DB;
402         if (!$this->is_form_defined()) {
403             // nothing to do
404             return;
405         }
407         // firstly, let the plugin delete everything from their own tables
408         $this->delete_plugin_definition();
409         // then, delete all instances left
410         $DB->delete_records('grading_instances', array('formid' => $this->definition->id));
411         // finally, delete the main definition record
412         $DB->delete_records('grading_definitions', array('id' => $this->definition->id));
414         $this->definition = false;
415     }
417     /**
418      * Prepare the part of the search query to append to the FROM statement
419      *
420      * @param string $gdid the alias of grading_definitions.id column used by the caller
421      * @return string
422      */
423     public static function sql_search_from_tables($gdid) {
424         return '';
425     }
427     /**
428      * Prepare the parts of the SQL WHERE statement to search for the given token
429      *
430      * The returned array cosists of the list of SQL comparions and the list of
431      * respective parameters for the comparisons. The returned chunks will be joined
432      * with other conditions using the OR operator.
433      *
434      * @param string $token token to search for
435      * @return array
436      */
437     public static function sql_search_where($token) {
438         global $DB;
440         $subsql = array();
441         $params = array();
443         return array($subsql, $params);
444     }
446     ////////////////////////////////////////////////////////////////////////////
448     /**
449      * Loads the form definition if it exists
450      *
451      * The default implementation just tries to load the record from the {grading_definitions}
452      * table. The plugins are likely to override this with a more complex query that loads
453      * all required data at once.
454      */
455     protected function load_definition() {
456         global $DB;
457         $this->definition = $DB->get_record('grading_definitions', array(
458             'areaid' => $this->areaid,
459             'method' => $this->get_method_name()), '*', IGNORE_MISSING);
460     }
462     /**
463      * Deletes all plugin data associated with the given form definiton
464      *
465      * @see delete_definition()
466      */
467     abstract protected function delete_plugin_definition();
469     /**
470      * @return string the name of the grading method plugin, eg 'rubric'
471      * @see PARAM_PLUGIN
472      */
473     protected function get_method_name() {
474         if (preg_match('/^gradingform_([a-z][a-z0-9_]*[a-z0-9])_controller$/', get_class($this), $matches)) {
475             return $matches[1];
476         } else {
477             throw new coding_exception('Invalid class name');
478         }
479     }
481     /**
482      * Returns html code to be included in student's feedback.
483      *
484      * @param moodle_page $page
485      * @param int $itemid
486      * @param array $grading_info result of function grade_get_grades if plugin want to use some of their info
487      * @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
488      * @return string
489      */
490     public function render_grade($page, $itemid, $grading_info, $defaultcontent) {
491         return $defaultcontent;
492     }
494     /**
495      * Sets the range of grades used in this area. This is usually either range like 0-100
496      * or the scale where keys start from 1. Typical use:
497      * $controller->set_grade_range(make_grades_menu($gradingtype));
498      */
499     public final function set_grade_range(array $graderange) {
500         $this->graderange = $graderange;
501     }
503     /**
504      * Returns the range of grades used in this area
505      * @return array
506      */
507     public final function get_grade_range() {
508         if (empty($this->graderange)) {
509             return array();
510         }
511         return $this->graderange;
512     }
515 /**
516  * Class to manage one grading instance. Stores information and performs actions like
517  * update, copy, validate, submit, etc.
518  *
519  * @copyright  2011 Marina Glancy
520  */
521 abstract class gradingform_instance {
522     const INSTANCE_STATUS_ACTIVE = 1;
523     const INSTANCE_STATUS_INCOMPLETE = 0;
524     const INSTANCE_STATUS_ARCHIVE = 3;
526     /** @var stdClass record from table grading_instances */
527     protected $data;
528     /** @var gradingform_controller link to the corresponding controller */
529     protected $controller;
531     /**
532      * Creates an instance
533      *
534      * @param gradingform_controller $controller
535      * @param stdClass $data
536      */
537     public function __construct($controller, $data) {
538         $this->data = (object)$data;
539         $this->controller = $controller;
540     }
542     /**
543      * Creates a new empty instance in DB and mark its status as INCOMPLETE
544      *
545      * @param int $formid
546      * @param int $raterid
547      * @param int $itemid
548      * @return int id of the created instance
549      */
550     public static function create_new($formid, $raterid, $itemid) {
551         global $DB;
552         $instance = new stdClass();
553         $instance->formid = $formid;
554         $instance->raterid = $raterid;
555         $instance->itemid = $itemid;
556         $instance->status = self::INSTANCE_STATUS_INCOMPLETE;
557         $instance->timemodified = time();
558         $instance->feedbackformat = FORMAT_MOODLE;
559         $instanceid = $DB->insert_record('grading_instances', $instance);
560         return $instanceid;
561     }
563     /**
564      * Duplicates the instance before editing (optionally substitutes raterid and/or itemid with
565      * the specified values)
566      * Plugins may want to override this function to copy data from additional tables as well
567      *
568      * @param int $raterid value for raterid in the duplicate
569      * @param int $itemid value for itemid in the duplicate
570      * @return int id of the new instance
571      */
572     public function copy($raterid, $itemid) {
573         global $DB;
574         $data = (array)$this->data; // Cast to array to make a copy
575         unset($data['id']);
576         $data['raterid'] = $raterid;
577         $data['itemid'] = $itemid;
578         $data['timemodified'] = time();
579         $data['status'] = self::INSTANCE_STATUS_INCOMPLETE;
580         $instanceid = $DB->insert_record('grading_instances', $data);
581         return $instanceid;
582     }
584     /**
585      * Returns the controller
586      *
587      * @return gradingform_controller
588      */
589     public function get_controller() {
590         return $this->controller;
591     }
593     /**
594      * Returns instance id
595      *
596      * @return int
597      */
598     public function get_id() {
599         return $this->data->id;
600     }
602     /**
603      * Marks the instance as ACTIVE and current active instance (if exists) as ARCHIVE
604      */
605     protected function make_active() {
606         global $DB;
607         if ($this->data->status == self::INSTANCE_STATUS_ACTIVE) {
608             // already active
609             return;
610         }
611         if (empty($this->data->itemid)) {
612             throw new coding_exception('You cannot mark active the grading instance without itemid');
613         }
614         $currentid = $this->get_controller()->get_current_instance($this->data->raterid, $this->data->itemid, true);
615         if ($currentid) {
616             if ($currentid != $this->get_id()) {
617                 $DB->update_record('grading_instances', array('id' => $currentid, 'status' => self::INSTANCE_STATUS_ARCHIVE));
618                 $DB->update_record('grading_instances', array('id' => $this->get_id(), 'status' => self::INSTANCE_STATUS_ACTIVE));
619             }
620         } else {
621             $DB->update_record('grading_instances', array('id' => $this->get_id(), 'status' => self::INSTANCE_STATUS_ACTIVE));
622         }
623         $this->data->status = self::INSTANCE_STATUS_ACTIVE;
624     }
626     /**
627      * Deletes this (INCOMPLETE) instance from database. This function is invoked on cancelling the
628      * grading form and/or during cron cleanup.
629      * Plugins using additional tables must override this method to remove additional data.
630      * Note that if the teacher just closes the window or presses 'Back' button of the browser,
631      * this function is not invoked.
632      */
633     public function cancel() {
634         global $DB;
635         // TODO what if we happen delete the ACTIVE instance, shall we rollback to the last ARCHIVE? or throw an exception?
636         // TODO create cleanup cron
637         $DB->delete_records('grading_instances', array('id' => $this->get_id()));
638     }
640     /**
641      * Updates the instance with the data received from grading form. This function may be
642      * called via AJAX when grading is not yet completed, so it does not change the
643      * status of the instance.
644      *
645      * @param array $elementvalue
646      */
647     public function update($elementvalue) {
648         global $DB;
649         $newdata = new stdClass();
650         $newdata->id = $this->get_id();
651         $newdata->timemodified = time();
652         if (isset($elementvalue['itemid']) && $elementvalue['itemid'] != $this->data->itemid) {
653             $newdata->itemid = $elementvalue['itemid'];
654         }
655         // TODO also update: rawgrade, feedback, feedbackformat
656         $DB->update_record('grading_instances', $newdata);
657         foreach ($newdata as $key => $value) {
658             $this->data->$key = $value;
659         }
660     }
662     /**
663      * Calculates the grade to be pushed to the gradebook
664      *
665      * @return int the valid grade from $this->get_controller()->get_grade_range()
666      */
667     abstract public function get_grade();
669     /**
670      * Called when teacher submits the grading form:
671      * updates the instance in DB, marks it as ACTIVE and returns the grade to be pushed to the gradebook.
672      * $itemid must be specified here (it was not required when the instance was
673      * created, because it might not existed in draft)
674      *
675      * @param array $elementvalue
676      * @param int $itemid
677      * @return int the grade on 0-100 scale
678      */
679     public function submit_and_get_grade($elementvalue, $itemid) {
680         $elementvalue['itemid'] = $itemid;
681         $this->update($elementvalue);
682         $this->make_active();
683         return $this->get_grade();
684     }
687     /**
688      * Returns html for form element of type 'grading'. If there is a form input element
689      * it must have the name $gradingformelement->getName().
690      * If there are more than one input elements they MUST be elements of array with
691      * name $gradingformelement->getName().
692      * Example: {NAME}[myelement1], {NAME}[myelement2][sub1], {NAME}[myelement2][sub2], etc.
693      * ( {NAME} is a shortcut for $gradingformelement->getName() )
694      * After submitting the form the value of $_POST[{NAME}] is passed to the functions
695      * validate_grading_element() and submit_and_get_grade()
696      *
697      * Plugins may use $gradingformelement->getValue() to get the value passed on previous
698      * form submit
699      *
700      * When forming html it is a plugin's responsibility to analyze flags
701      * $gradingformelement->_flagFrozen and $gradingformelement->_persistantFreeze:
702      *
703      * (_flagFrozen == false) => form element is editable
704      *
705      * (_flagFrozen == false && _persistantFreeze == true) => form element is not editable
706      * but all values are passed as hidden elements
707      *
708      * (_flagFrozen == false && _persistantFreeze == false) => form element is not editable
709      * and no values are passed as hidden elements
710      *
711      * Plugins are welcome to use AJAX in the form element. But it is strongly recommended
712      * that the grading only becomes active when teacher presses 'Submit' button (the
713      * method submit_and_get_grade() is invoked)
714      *
715      * Also client-side JS validation may be implemented here
716      *
717      * @see MoodleQuickForm_grading in lib/form/grading.php
718      *
719      * @param moodle_page $page
720      * @param MoodleQuickForm_grading $gradingformelement
721      * @return string
722      */
723     abstract function render_grading_element($page, $gradingformelement);
725     /**
726      * Server-side validation of the data received from grading form.
727      *
728      * @param mixed $elementvalue is the scalar or array received in $_POST
729      * @return boolean true if the form data is validated and contains no errors
730      */
731     public function validate_grading_element($elementvalue) {
732         return true;
733     }
735     /**
736      * Returns the error message displayed if validation failed.
737      * If plugin wants to display custom message, the empty string should be returned here
738      * and the custom message should be output in render_grading_element()
739      *
740      * @see validate_grading_element()
741      * @return string
742      */
743     public function default_validation_error_message() {
744         return '';
745     }