1dc8981465118be3779b774303d851b84a90ce56
[moodle.git] / grade / grading / 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  * Advanced grading methods support
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  * Factory method returning an instance of the grading manager
31  *
32  * @param stdClass|int $context or $areaid
33  * @param string $component the frankenstyle name of the component
34  * @param string $area the name of the gradable area
35  * @return grading_manager
36  */
37 function get_grading_manager($context_or_areaid = null, $component = null, $area = null) {
38     global $DB;
40     $manager = new grading_manager();
42     if (is_object($context_or_areaid)) {
43         $context = $context_or_areaid;
44     } else {
45         $context = null;
47         if (is_numeric($context_or_areaid)) {
48             $manager->load($context_or_areaid);
49             return $manager;
50         }
51     }
53     if (!is_null($context)) {
54         $manager->set_context($context);
55     }
57     if (!is_null($component)) {
58         $manager->set_component($component);
59     }
61     if (!is_null($area)) {
62         $manager->set_area($area);
63     }
65     return $manager;
66 }
68 /**
69  * General class providing access to common grading features
70  *
71  * Grading manager provides access to the particular grading method controller
72  * in that area.
73  *
74  * Fully initialized instance of the grading manager operates over a single
75  * gradable area. It is possible to work with a partially initialized manager
76  * that knows just context and component without known area, for example.
77  * It is also possible to change context, component and area of an existing
78  * manager. Such pattern is used when copying form definitions, for example.
79  */
80 class grading_manager {
82     /** @var stdClass the context */
83     protected $context;
85     /** @var string the frankenstyle name of the component */
86     protected $component;
88     /** @var string the name of the gradable area */
89     protected $area;
91     /** @var stdClass|false|null the raw record from {grading_areas}, false if does not exist, null if invalidated cache */
92     private $areacache = null;
94     /**
95      * @return stdClass grading manager context
96      */
97     public function get_context() {
98         return $this->context;
99     }
101     /**
102      * Sets the context the manager operates on
103      *
104      * @param stdClass $context
105      */
106     public function set_context(stdClass $context) {
107         $this->areacache = null;
108         $this->context = $context;
109     }
111     /**
112      * @return string grading manager component
113      */
114     public function get_component() {
115         return $this->component;
116     }
118     /**
119      * Sets the component the manager operates on
120      *
121      * @param string $component the frankenstyle name of the component
122      */
123     public function set_component($component) {
124         $this->areacache = null;
125         list($type, $name) = normalize_component($component);
126         $this->component = $type.'_'.$name;
127     }
129     /**
130      * @return string grading manager area name
131      */
132     public function get_area() {
133         return $this->area;
134     }
136     /**
137      * Sets the area the manager operates on
138      *
139      * @param string $area the name of the gradable area
140      */
141     public function set_area($area) {
142         $this->areacache = null;
143         $this->area = $area;
144     }
146     /**
147      * Returns a text describing the context and the component
148      *
149      * At the moment this works for gradable areas in course modules. In the future, this
150      * method should be improved so it works for other contexts (blocks, gradebook items etc)
151      * or subplugins.
152      *
153      * @return string
154      */
155     public function get_component_title() {
157         $this->ensure_isset(array('context', 'component'));
159         if ($this->get_context()->contextlevel == CONTEXT_SYSTEM) {
160             if ($this->get_component() == 'core_grading') {
161                 $title = ''; // we are in the bank UI
162             } else {
163                 throw new coding_exception('Unsupported component at the system context');
164             }
166         } else if ($this->get_context()->contextlevel >= CONTEXT_COURSE) {
167             list($context, $course, $cm) = get_context_info_array($this->get_context()->id);
169             if (!empty($cm->name)) {
170                 $title = $cm->name;
171             } else {
172                 debugging('Gradable areas are currently supported at the course module level only', DEBUG_DEVELOPER);
173                 $title = $this->get_component();
174             }
176         } else {
177             throw new coding_exception('Unsupported gradable area context level');
178         }
180         return $title;
181     }
183     /**
184      * Returns the localized title of the currently set area
185      *
186      * @return string
187      */
188     public function get_area_title() {
190         if ($this->get_context()->contextlevel == CONTEXT_SYSTEM) {
191             return '';
193         } else if ($this->get_context()->contextlevel >= CONTEXT_COURSE) {
194             $this->ensure_isset(array('context', 'component', 'area'));
195             $areas = $this->get_available_areas();
196             if (array_key_exists($this->get_area(), $areas)) {
197                 return $areas[$this->get_area()];
198             } else {
199                 debugging('Unknown area!');
200                 return '???';
201             }
203         } else {
204             throw new coding_exception('Unsupported context level');
205         }
206     }
208     /**
209      * Loads the gradable area info from the database
210      *
211      * @param int $areaid
212      */
213     public function load($areaid) {
214         global $DB;
216         $this->areacache = $DB->get_record('grading_areas', array('id' => $areaid), '*', MUST_EXIST);
217         $this->context = get_context_instance_by_id($this->areacache->contextid, MUST_EXIST);
218         $this->component = $this->areacache->component;
219         $this->area = $this->areacache->areaname;
220     }
222     /**
223      * Returns the list of installed grading plugins together, optionally extended
224      * with a simple direct grading.
225      *
226      * @param bool $includenone should the 'Simple direct grading' be included
227      * @return array of the (string)name => (string)localized title of the method
228      */
229     public static function available_methods($includenone = true) {
231         if ($includenone) {
232             $list = array('' => get_string('gradingmethodnone', 'core_grading'));
233         } else {
234             $list = array();
235         }
237         foreach (get_plugin_list('gradingform') as $name => $location) {
238             $list[$name] = get_string('pluginname', 'gradingform_'.$name);
239         }
241         return $list;
242     }
244     /**
245      * Returns the list of available grading methods in the given context
246      *
247      * Currently this is just a static list obtained from {@link self::available_methods()}.
248      * In the future, the list of available methods may be controlled per-context.
249      *
250      * Requires the context property to be set in advance.
251      *
252      * @param bool $includenone should the 'Simple direct grading' be included
253      * @return array of the (string)name => (string)localized title of the method
254      */
255     public function get_available_methods($includenone = true) {
256         $this->ensure_isset(array('context'));
257         return self::available_methods($includenone);
258     }
260     /**
261      * Returns the list of gradable areas provided by the given component
262      *
263      * This performs a callback to the library of the relevant plugin to obtain
264      * the list of supported areas.
265      *
266      * @param string $component normalized component name
267      * @return array of (string)areacode => (string)localized title of the area
268      */
269     public static function available_areas($component) {
270         global $CFG;
272         list($plugintype, $pluginname) = normalize_component($component);
274         if ($component === 'core_grading') {
275             return array();
277         } else if ($plugintype === 'mod') {
278             return plugin_callback('mod', $pluginname, 'grading', 'areas_list', null, array());
280         } else {
281             throw new coding_exception('Unsupported area location');
282         }
283     }
286     /**
287      * Returns the list of gradable areas in the given context and component
288      *
289      * This performs a callback to the library of the relevant plugin to obtain
290      * the list of supported areas.
291      * @return array of (string)areacode => (string)localized title of the area
292      */
293     public function get_available_areas() {
294         global $CFG;
296         $this->ensure_isset(array('context', 'component'));
298         if ($this->get_context()->contextlevel == CONTEXT_SYSTEM) {
299             if ($this->get_component() !== 'core_grading') {
300                 throw new coding_exception('Unsupported component at the system context');
301             } else {
302                 return array();
303             }
305         } else if ($this->get_context()->contextlevel == CONTEXT_MODULE) {
306             list($context, $course, $cm) = get_context_info_array($this->get_context()->id);
307             return self::available_areas('mod_'.$cm->modname);
309         } else {
310             throw new coding_exception('Unsupported gradable area context level');
311         }
312     }
314     /**
315      * Returns the currently active grading method in the gradable area
316      *
317      * @return string|null the name of the grading plugin of null if it has not been set
318      */
319     public function get_active_method() {
320         global $DB;
322         $this->ensure_isset(array('context', 'component', 'area'));
324         // get the current grading area record if it exists
325         if (is_null($this->areacache)) {
326             $this->areacache = $DB->get_record('grading_areas', array(
327                 'contextid' => $this->context->id,
328                 'component' => $this->component,
329                 'areaname'  => $this->area),
330             '*', IGNORE_MISSING);
331         }
333         if ($this->areacache === false) {
334             // no area record yet
335             return null;
336         }
338         return $this->areacache->activemethod;
339     }
341     /**
342      * Sets the currently active grading method in the gradable area
343      *
344      * @param string $method the method name, eg 'rubric' (must be available)
345      * @return bool true if the method changed or was just set, false otherwise
346      */
347     public function set_active_method($method) {
348         global $DB;
350         $this->ensure_isset(array('context', 'component', 'area'));
352         // make sure the passed method is empty or a valid plugin name
353         if (empty($method)) {
354             $method = null;
355         } else {
356             if ('gradingform_'.$method !== clean_param('gradingform_'.$method, PARAM_COMPONENT)) {
357                 throw new moodle_exception('invalid_method_name', 'core_grading');
358             }
359             $available = $this->get_available_methods(false);
360             if (!array_key_exists($method, $available)) {
361                 throw new moodle_exception('invalid_method_name', 'core_grading');
362             }
363         }
365         // get the current grading area record if it exists
366         if (is_null($this->areacache)) {
367             $this->areacache = $DB->get_record('grading_areas', array(
368                 'contextid' => $this->context->id,
369                 'component' => $this->component,
370                 'areaname'  => $this->area),
371             '*', IGNORE_MISSING);
372         }
374         $methodchanged = false;
376         if ($this->areacache === false) {
377             // no area record yet, create one with the active method set
378             $area = array(
379                 'contextid'     => $this->context->id,
380                 'component'     => $this->component,
381                 'areaname'      => $this->area,
382                 'activemethod'  => $method);
383             $DB->insert_record('grading_areas', $area);
384             $methodchanged = true;
386         } else {
387             // update the existing record if needed
388             if ($this->areacache->activemethod !== $method) {
389                 $DB->set_field('grading_areas', 'activemethod', $method, array('id' => $this->areacache->id));
390                 $methodchanged = true;
391             }
392         }
394         $this->areacache = null;
396         return $methodchanged;
397     }
399     /**
400      * Extends the settings navigation with the grading settings
401      *
402      * This function is called when the context for the page is an activity module with the
403      * FEATURE_ADVANCED_GRADING and the user has the permission moodle/grade:managegradingforms.
404      *
405      * @param settings_navigation $settingsnav {@link settings_navigation}
406      * @param navigation_node $modulenode {@link navigation_node}
407      */
408     public function extend_settings_navigation(settings_navigation $settingsnav, navigation_node $modulenode=null) {
410         $this->ensure_isset(array('context', 'component'));
412         $areas = $this->get_available_areas();
414         if (empty($areas)) {
415             // no money, no funny
416             return;
418         } else if (count($areas) == 1) {
419             // make just a single node for the management screen
420             $areatitle = reset($areas);
421             $areaname  = key($areas);
422             $this->set_area($areaname);
423             $method = $this->get_active_method();
424             $managementnode = $modulenode->add(get_string('gradingmanagement', 'core_grading'),
425                 $this->get_management_url(), settings_navigation::TYPE_CUSTOM);
426             if ($method) {
427                 $controller = $this->get_controller($method);
428                 $controller->extend_settings_navigation($settingsnav, $managementnode);
429             }
431         } else {
432             // make management screen node for each area
433             $managementnode = $modulenode->add(get_string('gradingmanagement', 'core_grading'),
434                 null, settings_navigation::TYPE_CUSTOM);
435             foreach ($areas as $areaname => $areatitle) {
436                 $this->set_area($areaname);
437                 $method = $this->get_active_method();
438                 $node = $managementnode->add($areatitle,
439                     $this->get_management_url(), settings_navigation::TYPE_CUSTOM);
440                 if ($method) {
441                     $controller = $this->get_controller($method);
442                     $controller->extend_settings_navigation($settingsnav, $node);
443                 }
444             }
445         }
446     }
448     /**
449      * Returns the given method's controller in the gradable area
450      *
451      * @param string $method the method name, eg 'rubric' (must be available)
452      * @return grading_controller
453      */
454     public function get_controller($method) {
455         global $CFG;
457         $this->ensure_isset(array('context', 'component', 'area'));
459         // make sure the passed method is a valid plugin name
460         if ('gradingform_'.$method !== clean_param('gradingform_'.$method, PARAM_COMPONENT)) {
461             throw new moodle_exception('invalid_method_name', 'core_grading');
462         }
463         $available = $this->get_available_methods(false);
464         if (!array_key_exists($method, $available)) {
465             throw new moodle_exception('invalid_method_name', 'core_grading');
466         }
468         // get the current grading area record if it exists
469         if (is_null($this->areacache)) {
470             $this->areacache = $DB->get_record('grading_areas', array(
471                 'contextid' => $this->context->id,
472                 'component' => $this->component,
473                 'areaname'  => $this->area),
474             '*', IGNORE_MISSING);
475         }
477         if ($this->areacache === false) {
478             // no area record yet, create one
479             $area = array(
480                 'contextid' => $this->context->id,
481                 'component' => $this->component,
482                 'areaname'  => $this->area);
483             $areaid = $DB->insert_record('grading_areas', $area);
484             // reload the cache
485             $this->areacache = $DB->get_record('grading_areas', array('id' => $areaid), '*', MUST_EXIST);
486         }
488         require_once($CFG->dirroot.'/grade/grading/form/'.$method.'/lib.php');
489         $classname = 'gradingform_'.$method.'_controller';
491         return new $classname($this->context, $this->component, $this->area, $this->areacache->id);
492     }
494     /**
495      * Returns the controller for the active method if it is available
496      *
497      * @return null|grading_controller
498      */
499     public function get_active_controller() {
500         if ($gradingmethod = $this->get_active_method()) {
501             $controller = $this->get_controller($gradingmethod);
502             if ($controller->is_form_available()) {
503                 return $controller;
504             }
505         }
506         return null;
507     }
509     /**
510      * Returns the URL of the grading area management page
511      *
512      * @param moodle_url $returnurl optional URL of the page where the user should be sent back to
513      * @return moodle_url
514      */
515     public function get_management_url(moodle_url $returnurl = null) {
517         $this->ensure_isset(array('context', 'component'));
519         if ($this->areacache) {
520             $params = array('areaid' => $this->areacache->id);
521         } else {
522             $params = array('contextid' => $this->context->id, 'component' => $this->component);
523             if ($this->area) {
524                 $params['area'] = $this->area;
525             }
526         }
528         if (!is_null($returnurl)) {
529             $params['returnurl'] = $returnurl->out(false);
530         }
532         return new moodle_url('/grade/grading/manage.php', $params);
533     }
535     /**
536      * Creates a new shared area to hold a grading form template
537      *
538      * Shared area are implemented as virtual gradable areas at the system level context
539      * with the component set to core_grading and unique random area name.
540      *
541      * @param string $method the name of the plugin we create the area for
542      * @return int the new area id
543      */
544     public function create_shared_area($method) {
545         global $DB;
547         // generate some unique random name for the new area
548         $name = $method . '_' . sha1(rand().uniqid($method, true));
549         // create new area record
550         $area = array(
551             'contextid'     => context_system::instance()->id,
552             'component'     => 'core_grading',
553             'areaname'      => $name,
554             'activemethod'  => $method);
555         return $DB->insert_record('grading_areas', $area);
556     }
558     /**
559      * Helper method to tokenize the given string
560      *
561      * Splits the given string into smaller strings. This is a helper method for
562      * full text searching in grading forms. If the given string is surrounded with
563      * double quotes, the resulting array consists of a single item containing the
564      * quoted content.
565      *
566      * Otherwise, string like 'grammar, english language' would be tokenized into
567      * the three tokens 'grammar', 'english', 'language'.
568      *
569      * One-letter tokens like are dropped in non-phrase mode. Repeated tokens are
570      * returned just once.
571      *
572      * @param string $needle
573      * @return array
574      */
575     public static function tokenize($needle) {
577         // check if we are searching for the exact phrase
578         if (preg_match('/^[\s]*"[\s]*(.*?)[\s]*"[\s]*$/', $needle, $matches)) {
579             $token = $matches[1];
580             if ($token === '') {
581                 return array();
582             } else {
583                 return array($token);
584             }
585         }
587         // split the needle into smaller parts separated by non-word characters
588         $tokens = preg_split("/\W/u", $needle);
589         // keep just non-empty parts
590         $tokens = array_filter($tokens);
591         // distinct
592         $tokens = array_unique($tokens);
593         // drop one-letter tokens
594         foreach ($tokens as $ix => $token) {
595             if (strlen($token) == 1) {
596                 unset($tokens[$ix]);
597             }
598         }
600         return array_values($tokens);
601     }
603     ////////////////////////////////////////////////////////////////////////////
605     /**
606      * Make sure that the given properties were set to some not-null value
607      *
608      * @param array $properties the list of properties
609      * @throws coding_exception
610      */
611     private function ensure_isset(array $properties) {
612         foreach ($properties as $property) {
613             if (!isset($this->$property)) {
614                 throw new coding_exception('The property "'.$property.'" is not set.');
615             }
616         }
617     }