MDL-60528 grading: Accurate class names in PHPDoc
[moodle.git] / grade / grading / lib.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Advanced grading methods support
19  *
20  * @package    core_grading
21  * @copyright  2011 David Mudrak <david@moodle.com>
22  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 defined('MOODLE_INTERNAL') || die();
27 /**
28  * Factory method returning an instance of the grading manager
29  *
30  * There are basically ways how to use this factory method. If the area record
31  * id is known to the caller, get the manager for that area by providing just
32  * the id. If the area record id is not know, the context, component and area name
33  * can be provided. Note that null values are allowed in the second case as the context,
34  * component and the area name can be set explicitly later.
35  *
36  * @category grading
37  * @example $manager = get_grading_manager($areaid);
38  * @example $manager = get_grading_manager(context_system::instance());
39  * @example $manager = get_grading_manager($context, 'mod_assignment', 'submission');
40  * @param stdClass|int|null $context_or_areaid if $areaid is passed, no other parameter is needed
41  * @param string|null $component the frankenstyle name of the component
42  * @param string|null $area the name of the gradable area
43  * @return grading_manager
44  */
45 function get_grading_manager($context_or_areaid = null, $component = null, $area = null) {
46     global $DB;
48     $manager = new grading_manager();
50     if (is_object($context_or_areaid)) {
51         $context = $context_or_areaid;
52     } else {
53         $context = null;
55         if (is_numeric($context_or_areaid)) {
56             $manager->load($context_or_areaid);
57             return $manager;
58         }
59     }
61     if (!is_null($context)) {
62         $manager->set_context($context);
63     }
65     if (!is_null($component)) {
66         $manager->set_component($component);
67     }
69     if (!is_null($area)) {
70         $manager->set_area($area);
71     }
73     return $manager;
74 }
76 /**
77  * General class providing access to common grading features
78  *
79  * Grading manager provides access to the particular grading method controller
80  * in that area.
81  *
82  * Fully initialized instance of the grading manager operates over a single
83  * gradable area. It is possible to work with a partially initialized manager
84  * that knows just context and component without known area, for example.
85  * It is also possible to change context, component and area of an existing
86  * manager. Such pattern is used when copying form definitions, for example.
87  *
88  * @package    core_grading
89  * @copyright  2011 David Mudrak <david@moodle.com>
90  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
91  * @category   grading
92  */
93 class grading_manager {
95     /** @var stdClass the context */
96     protected $context;
98     /** @var string the frankenstyle name of the component */
99     protected $component;
101     /** @var string the name of the gradable area */
102     protected $area;
104     /** @var stdClass|false|null the raw record from {grading_areas}, false if does not exist, null if invalidated cache */
105     private $areacache = null;
107     /**
108      * Returns grading manager context
109      *
110      * @return stdClass grading manager context
111      */
112     public function get_context() {
113         return $this->context;
114     }
116     /**
117      * Sets the context the manager operates on
118      *
119      * @param stdClass $context
120      */
121     public function set_context(stdClass $context) {
122         $this->areacache = null;
123         $this->context = $context;
124     }
126     /**
127      * Returns grading manager component
128      *
129      * @return string grading manager component
130      */
131     public function get_component() {
132         return $this->component;
133     }
135     /**
136      * Sets the component the manager operates on
137      *
138      * @param string $component the frankenstyle name of the component
139      */
140     public function set_component($component) {
141         $this->areacache = null;
142         list($type, $name) = core_component::normalize_component($component);
143         $this->component = $type.'_'.$name;
144     }
146     /**
147      * Returns grading manager area name
148      *
149      * @return string grading manager area name
150      */
151     public function get_area() {
152         return $this->area;
153     }
155     /**
156      * Sets the area the manager operates on
157      *
158      * @param string $area the name of the gradable area
159      */
160     public function set_area($area) {
161         $this->areacache = null;
162         $this->area = $area;
163     }
165     /**
166      * Returns a text describing the context and the component
167      *
168      * At the moment this works for gradable areas in course modules. In the future, this
169      * method should be improved so it works for other contexts (blocks, gradebook items etc)
170      * or subplugins.
171      *
172      * @return string
173      */
174     public function get_component_title() {
176         $this->ensure_isset(array('context', 'component'));
178         if ($this->get_context()->contextlevel == CONTEXT_SYSTEM) {
179             if ($this->get_component() == 'core_grading') {
180                 $title = ''; // we are in the bank UI
181             } else {
182                 throw new coding_exception('Unsupported component at the system context');
183             }
185         } else if ($this->get_context()->contextlevel >= CONTEXT_COURSE) {
186             list($context, $course, $cm) = get_context_info_array($this->get_context()->id);
188             if (strval($cm->name) !== '') {
189                 $title = $cm->name;
190             } else {
191                 debugging('Gradable areas are currently supported at the course module level only', DEBUG_DEVELOPER);
192                 $title = $this->get_component();
193             }
195         } else {
196             throw new coding_exception('Unsupported gradable area context level');
197         }
199         return $title;
200     }
202     /**
203      * Returns the localized title of the currently set area
204      *
205      * @return string
206      */
207     public function get_area_title() {
209         if ($this->get_context()->contextlevel == CONTEXT_SYSTEM) {
210             return '';
212         } else if ($this->get_context()->contextlevel >= CONTEXT_COURSE) {
213             $this->ensure_isset(array('context', 'component', 'area'));
214             $areas = $this->get_available_areas();
215             if (array_key_exists($this->get_area(), $areas)) {
216                 return $areas[$this->get_area()];
217             } else {
218                 debugging('Unknown area!');
219                 return '???';
220             }
222         } else {
223             throw new coding_exception('Unsupported context level');
224         }
225     }
227     /**
228      * Loads the gradable area info from the database
229      *
230      * @param int $areaid
231      */
232     public function load($areaid) {
233         global $DB;
235         $this->areacache = $DB->get_record('grading_areas', array('id' => $areaid), '*', MUST_EXIST);
236         $this->context = context::instance_by_id($this->areacache->contextid, MUST_EXIST);
237         $this->component = $this->areacache->component;
238         $this->area = $this->areacache->areaname;
239     }
241     /**
242      * Returns the list of installed grading plugins together, optionally extended
243      * with a simple direct grading.
244      *
245      * @param bool $includenone should the 'Simple direct grading' be included
246      * @return array of the (string)name => (string)localized title of the method
247      */
248     public static function available_methods($includenone = true) {
250         if ($includenone) {
251             $list = array('' => get_string('gradingmethodnone', 'core_grading'));
252         } else {
253             $list = array();
254         }
256         foreach (core_component::get_plugin_list('gradingform') as $name => $location) {
257             $list[$name] = get_string('pluginname', 'gradingform_'.$name);
258         }
260         return $list;
261     }
263     /**
264      * Returns the list of available grading methods in the given context
265      *
266      * Currently this is just a static list obtained from {@link self::available_methods()}.
267      * In the future, the list of available methods may be controlled per-context.
268      *
269      * Requires the context property to be set in advance.
270      *
271      * @param bool $includenone should the 'Simple direct grading' be included
272      * @return array of the (string)name => (string)localized title of the method
273      */
274     public function get_available_methods($includenone = true) {
275         $this->ensure_isset(array('context'));
276         return self::available_methods($includenone);
277     }
279     /**
280      * Returns the list of gradable areas provided by the given component
281      *
282      * This performs a callback to the library of the relevant plugin to obtain
283      * the list of supported areas.
284      *
285      * @param string $component normalized component name
286      * @return array of (string)areacode => (string)localized title of the area
287      */
288     public static function available_areas($component) {
289         global $CFG;
291         list($plugintype, $pluginname) = core_component::normalize_component($component);
293         if ($component === 'core_grading') {
294             return array();
296         } else if ($plugintype === 'mod') {
297             return plugin_callback('mod', $pluginname, 'grading', 'areas_list', null, array());
299         } else {
300             throw new coding_exception('Unsupported area location');
301         }
302     }
305     /**
306      * Returns the list of gradable areas in the given context and component
307      *
308      * This performs a callback to the library of the relevant plugin to obtain
309      * the list of supported areas.
310      * @return array of (string)areacode => (string)localized title of the area
311      */
312     public function get_available_areas() {
313         global $CFG;
315         $this->ensure_isset(array('context', 'component'));
317         if ($this->get_context()->contextlevel == CONTEXT_SYSTEM) {
318             if ($this->get_component() !== 'core_grading') {
319                 throw new coding_exception('Unsupported component at the system context');
320             } else {
321                 return array();
322             }
324         } else if ($this->get_context()->contextlevel == CONTEXT_MODULE) {
325             list($context, $course, $cm) = get_context_info_array($this->get_context()->id);
326             return self::available_areas('mod_'.$cm->modname);
328         } else {
329             throw new coding_exception('Unsupported gradable area context level');
330         }
331     }
333     /**
334      * Returns the currently active grading method in the gradable area
335      *
336      * @return string|null the name of the grading plugin of null if it has not been set
337      */
338     public function get_active_method() {
339         global $DB;
341         $this->ensure_isset(array('context', 'component', 'area'));
343         // get the current grading area record if it exists
344         if (is_null($this->areacache)) {
345             $this->areacache = $DB->get_record('grading_areas', array(
346                 'contextid' => $this->context->id,
347                 'component' => $this->component,
348                 'areaname'  => $this->area),
349             '*', IGNORE_MISSING);
350         }
352         if ($this->areacache === false) {
353             // no area record yet
354             return null;
355         }
357         return $this->areacache->activemethod;
358     }
360     /**
361      * Sets the currently active grading method in the gradable area
362      *
363      * @param string $method the method name, eg 'rubric' (must be available)
364      * @return bool true if the method changed or was just set, false otherwise
365      */
366     public function set_active_method($method) {
367         global $DB;
369         $this->ensure_isset(array('context', 'component', 'area'));
371         // make sure the passed method is empty or a valid plugin name
372         if (empty($method)) {
373             $method = null;
374         } else {
375             if ('gradingform_'.$method !== clean_param('gradingform_'.$method, PARAM_COMPONENT)) {
376                 throw new moodle_exception('invalid_method_name', 'core_grading');
377             }
378             $available = $this->get_available_methods(false);
379             if (!array_key_exists($method, $available)) {
380                 throw new moodle_exception('invalid_method_name', 'core_grading');
381             }
382         }
384         // get the current grading area record if it exists
385         if (is_null($this->areacache)) {
386             $this->areacache = $DB->get_record('grading_areas', array(
387                 'contextid' => $this->context->id,
388                 'component' => $this->component,
389                 'areaname'  => $this->area),
390             '*', IGNORE_MISSING);
391         }
393         $methodchanged = false;
395         if ($this->areacache === false) {
396             // no area record yet, create one with the active method set
397             $area = array(
398                 'contextid'     => $this->context->id,
399                 'component'     => $this->component,
400                 'areaname'      => $this->area,
401                 'activemethod'  => $method);
402             $DB->insert_record('grading_areas', $area);
403             $methodchanged = true;
405         } else {
406             // update the existing record if needed
407             if ($this->areacache->activemethod !== $method) {
408                 $DB->set_field('grading_areas', 'activemethod', $method, array('id' => $this->areacache->id));
409                 $methodchanged = true;
410             }
411         }
413         $this->areacache = null;
415         return $methodchanged;
416     }
418     /**
419      * Extends the settings navigation with the grading settings
420      *
421      * This function is called when the context for the page is an activity module with the
422      * FEATURE_ADVANCED_GRADING and the user has the permission moodle/grade:managegradingforms.
423      *
424      * @param settings_navigation $settingsnav {@link settings_navigation}
425      * @param navigation_node $modulenode {@link navigation_node}
426      */
427     public function extend_settings_navigation(settings_navigation $settingsnav, navigation_node $modulenode=null) {
429         $this->ensure_isset(array('context', 'component'));
431         $areas = $this->get_available_areas();
433         if (empty($areas)) {
434             // no money, no funny
435             return;
437         } else if (count($areas) == 1) {
438             // make just a single node for the management screen
439             $areatitle = reset($areas);
440             $areaname  = key($areas);
441             $this->set_area($areaname);
442             $method = $this->get_active_method();
443             $managementnode = $modulenode->add(get_string('gradingmanagement', 'core_grading'),
444                 $this->get_management_url(), settings_navigation::TYPE_CUSTOM);
445             if ($method) {
446                 $controller = $this->get_controller($method);
447                 $controller->extend_settings_navigation($settingsnav, $managementnode);
448             }
450         } else {
451             // make management screen node for each area
452             $managementnode = $modulenode->add(get_string('gradingmanagement', 'core_grading'),
453                 null, settings_navigation::TYPE_CUSTOM);
454             foreach ($areas as $areaname => $areatitle) {
455                 $this->set_area($areaname);
456                 $method = $this->get_active_method();
457                 $node = $managementnode->add($areatitle,
458                     $this->get_management_url(), settings_navigation::TYPE_CUSTOM);
459                 if ($method) {
460                     $controller = $this->get_controller($method);
461                     $controller->extend_settings_navigation($settingsnav, $node);
462                 }
463             }
464         }
465     }
467     /**
468      * Extends the module navigation with the advanced grading information
469      *
470      * This function is called when the context for the page is an activity module with the
471      * FEATURE_ADVANCED_GRADING.
472      *
473      * @param global_navigation $navigation
474      * @param navigation_node $modulenode
475      */
476     public function extend_navigation(global_navigation $navigation, navigation_node $modulenode=null) {
477         $this->ensure_isset(array('context', 'component'));
479         $areas = $this->get_available_areas();
480         foreach ($areas as $areaname => $areatitle) {
481             $this->set_area($areaname);
482             if ($controller = $this->get_active_controller()) {
483                 $controller->extend_navigation($navigation, $modulenode);
484             }
485         }
486     }
488     /**
489      * Returns the given method's controller in the gradable area
490      *
491      * @param string $method the method name, eg 'rubric' (must be available)
492      * @return gradingform_controller
493      */
494     public function get_controller($method) {
495         global $CFG, $DB;
497         $this->ensure_isset(array('context', 'component', 'area'));
499         // make sure the passed method is a valid plugin name
500         if ('gradingform_'.$method !== clean_param('gradingform_'.$method, PARAM_COMPONENT)) {
501             throw new moodle_exception('invalid_method_name', 'core_grading');
502         }
503         $available = $this->get_available_methods(false);
504         if (!array_key_exists($method, $available)) {
505             throw new moodle_exception('invalid_method_name', 'core_grading');
506         }
508         // get the current grading area record if it exists
509         if (is_null($this->areacache)) {
510             $this->areacache = $DB->get_record('grading_areas', array(
511                 'contextid' => $this->context->id,
512                 'component' => $this->component,
513                 'areaname'  => $this->area),
514             '*', IGNORE_MISSING);
515         }
517         if ($this->areacache === false) {
518             // no area record yet, create one
519             $area = array(
520                 'contextid' => $this->context->id,
521                 'component' => $this->component,
522                 'areaname'  => $this->area);
523             $areaid = $DB->insert_record('grading_areas', $area);
524             // reload the cache
525             $this->areacache = $DB->get_record('grading_areas', array('id' => $areaid), '*', MUST_EXIST);
526         }
528         require_once($CFG->dirroot.'/grade/grading/form/'.$method.'/lib.php');
529         $classname = 'gradingform_'.$method.'_controller';
531         return new $classname($this->context, $this->component, $this->area, $this->areacache->id);
532     }
534     /**
535      * Returns the controller for the active method if it is available
536      *
537      * @return null|gradingform_controller
538      */
539     public function get_active_controller() {
540         if ($gradingmethod = $this->get_active_method()) {
541             $controller = $this->get_controller($gradingmethod);
542             if ($controller->is_form_available()) {
543                 return $controller;
544             }
545         }
546         return null;
547     }
549     /**
550      * Returns the URL of the grading area management page
551      *
552      * @param moodle_url $returnurl optional URL of the page where the user should be sent back to
553      * @return moodle_url
554      */
555     public function get_management_url(moodle_url $returnurl = null) {
557         $this->ensure_isset(array('context', 'component'));
559         if ($this->areacache) {
560             $params = array('areaid' => $this->areacache->id);
561         } else {
562             $params = array('contextid' => $this->context->id, 'component' => $this->component);
563             if ($this->area) {
564                 $params['area'] = $this->area;
565             }
566         }
568         if (!is_null($returnurl)) {
569             $params['returnurl'] = $returnurl->out(false);
570         }
572         return new moodle_url('/grade/grading/manage.php', $params);
573     }
575     /**
576      * Creates a new shared area to hold a grading form template
577      *
578      * Shared area are implemented as virtual gradable areas at the system level context
579      * with the component set to core_grading and unique random area name.
580      *
581      * @param string $method the name of the plugin we create the area for
582      * @return int the new area id
583      */
584     public function create_shared_area($method) {
585         global $DB;
587         // generate some unique random name for the new area
588         $name = $method . '_' . sha1(rand().uniqid($method, true));
589         // create new area record
590         $area = array(
591             'contextid'     => context_system::instance()->id,
592             'component'     => 'core_grading',
593             'areaname'      => $name,
594             'activemethod'  => $method);
595         return $DB->insert_record('grading_areas', $area);
596     }
598     /**
599      * Removes all data associated with the given context
600      *
601      * This is called by {@link context::delete_content()}
602      *
603      * @param int $contextid context id
604      */
605     public static function delete_all_for_context($contextid) {
606         global $DB;
608         $areaids = $DB->get_fieldset_select('grading_areas', 'id', 'contextid = ?', array($contextid));
609         $methods = array_keys(self::available_methods(false));
611         foreach($areaids as $areaid) {
612             $manager = get_grading_manager($areaid);
613             foreach ($methods as $method) {
614                 $controller = $manager->get_controller($method);
615                 $controller->delete_definition();
616             }
617         }
619         $DB->delete_records_list('grading_areas', 'id', $areaids);
620     }
622     /**
623      * Helper method to tokenize the given string
624      *
625      * Splits the given string into smaller strings. This is a helper method for
626      * full text searching in grading forms. If the given string is surrounded with
627      * double quotes, the resulting array consists of a single item containing the
628      * quoted content.
629      *
630      * Otherwise, string like 'grammar, english language' would be tokenized into
631      * the three tokens 'grammar', 'english', 'language'.
632      *
633      * One-letter tokens like are dropped in non-phrase mode. Repeated tokens are
634      * returned just once.
635      *
636      * @param string $needle
637      * @return array
638      */
639     public static function tokenize($needle) {
641         // check if we are searching for the exact phrase
642         if (preg_match('/^[\s]*"[\s]*(.*?)[\s]*"[\s]*$/', $needle, $matches)) {
643             $token = $matches[1];
644             if ($token === '') {
645                 return array();
646             } else {
647                 return array($token);
648             }
649         }
651         // split the needle into smaller parts separated by non-word characters
652         $tokens = preg_split("/\W/u", $needle);
653         // keep just non-empty parts
654         $tokens = array_filter($tokens);
655         // distinct
656         $tokens = array_unique($tokens);
657         // drop one-letter tokens
658         foreach ($tokens as $ix => $token) {
659             if (strlen($token) == 1) {
660                 unset($tokens[$ix]);
661             }
662         }
664         return array_values($tokens);
665     }
667     // //////////////////////////////////////////////////////////////////////////
669     /**
670      * Make sure that the given properties were set to some not-null value
671      *
672      * @param array $properties the list of properties
673      * @throws coding_exception
674      */
675     private function ensure_isset(array $properties) {
676         foreach ($properties as $property) {
677             if (!isset($this->$property)) {
678                 throw new coding_exception('The property "'.$property.'" is not set.');
679             }
680         }
681     }