Merge branch 'MDL-70100-310' of git://github.com/bmbrands/moodle into MOODLE_310_STABLE
[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 use core_grades\component_gradeitems;
29 /**
30  * Factory method returning an instance of the grading manager
31  *
32  * There are basically ways how to use this factory method. If the area record
33  * id is known to the caller, get the manager for that area by providing just
34  * the id. If the area record id is not know, the context, component and area name
35  * can be provided. Note that null values are allowed in the second case as the context,
36  * component and the area name can be set explicitly later.
37  *
38  * @category grading
39  * @example $manager = get_grading_manager($areaid);
40  * @example $manager = get_grading_manager(context_system::instance());
41  * @example $manager = get_grading_manager($context, 'mod_assignment', 'submission');
42  * @param stdClass|int|null $context_or_areaid if $areaid is passed, no other parameter is needed
43  * @param string|null $component the frankenstyle name of the component
44  * @param string|null $area the name of the gradable area
45  * @return grading_manager
46  */
47 function get_grading_manager($context_or_areaid = null, $component = null, $area = null) {
48     global $DB;
50     $manager = new grading_manager();
52     if (is_object($context_or_areaid)) {
53         $context = $context_or_areaid;
54     } else {
55         $context = null;
57         if (is_numeric($context_or_areaid)) {
58             $manager->load($context_or_areaid);
59             return $manager;
60         }
61     }
63     if (!is_null($context)) {
64         $manager->set_context($context);
65     }
67     if (!is_null($component)) {
68         $manager->set_component($component);
69     }
71     if (!is_null($area)) {
72         $manager->set_area($area);
73     }
75     return $manager;
76 }
78 /**
79  * General class providing access to common grading features
80  *
81  * Grading manager provides access to the particular grading method controller
82  * in that area.
83  *
84  * Fully initialized instance of the grading manager operates over a single
85  * gradable area. It is possible to work with a partially initialized manager
86  * that knows just context and component without known area, for example.
87  * It is also possible to change context, component and area of an existing
88  * manager. Such pattern is used when copying form definitions, for example.
89  *
90  * @package    core_grading
91  * @copyright  2011 David Mudrak <david@moodle.com>
92  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
93  * @category   grading
94  */
95 class grading_manager {
97     /** @var stdClass the context */
98     protected $context;
100     /** @var string the frankenstyle name of the component */
101     protected $component;
103     /** @var string the name of the gradable area */
104     protected $area;
106     /** @var stdClass|false|null the raw record from {grading_areas}, false if does not exist, null if invalidated cache */
107     private $areacache = null;
109     /**
110      * Returns grading manager context
111      *
112      * @return stdClass grading manager context
113      */
114     public function get_context() {
115         return $this->context;
116     }
118     /**
119      * Sets the context the manager operates on
120      *
121      * @param stdClass $context
122      */
123     public function set_context(stdClass $context) {
124         $this->areacache = null;
125         $this->context = $context;
126     }
128     /**
129      * Returns grading manager component
130      *
131      * @return string grading manager component
132      */
133     public function get_component() {
134         return $this->component;
135     }
137     /**
138      * Sets the component the manager operates on
139      *
140      * @param string $component the frankenstyle name of the component
141      */
142     public function set_component($component) {
143         $this->areacache = null;
144         list($type, $name) = core_component::normalize_component($component);
145         $this->component = $type.'_'.$name;
146     }
148     /**
149      * Returns grading manager area name
150      *
151      * @return string grading manager area name
152      */
153     public function get_area() {
154         return $this->area;
155     }
157     /**
158      * Sets the area the manager operates on
159      *
160      * @param string $area the name of the gradable area
161      */
162     public function set_area($area) {
163         $this->areacache = null;
164         $this->area = $area;
165     }
167     /**
168      * Returns a text describing the context and the component
169      *
170      * At the moment this works for gradable areas in course modules. In the future, this
171      * method should be improved so it works for other contexts (blocks, gradebook items etc)
172      * or subplugins.
173      *
174      * @return string
175      */
176     public function get_component_title() {
178         $this->ensure_isset(array('context', 'component'));
180         if ($this->get_context()->contextlevel == CONTEXT_SYSTEM) {
181             if ($this->get_component() == 'core_grading') {
182                 $title = ''; // we are in the bank UI
183             } else {
184                 throw new coding_exception('Unsupported component at the system context');
185             }
187         } else if ($this->get_context()->contextlevel >= CONTEXT_COURSE) {
188             list($context, $course, $cm) = get_context_info_array($this->get_context()->id);
190             if ($cm && strval($cm->name) !== '') {
191                 $title = format_string($cm->name, true, array('context' => $context));
192             } else {
193                 debugging('Gradable areas are currently supported at the course module level only', DEBUG_DEVELOPER);
194                 $title = $this->get_component();
195             }
197         } else {
198             throw new coding_exception('Unsupported gradable area context level');
199         }
201         return $title;
202     }
204     /**
205      * Returns the localized title of the currently set area
206      *
207      * @return string
208      */
209     public function get_area_title() {
211         if ($this->get_context()->contextlevel == CONTEXT_SYSTEM) {
212             return '';
214         } else if ($this->get_context()->contextlevel >= CONTEXT_COURSE) {
215             $this->ensure_isset(array('context', 'component', 'area'));
216             $areas = $this->get_available_areas();
217             if (array_key_exists($this->get_area(), $areas)) {
218                 return $areas[$this->get_area()];
219             } else {
220                 debugging('Unknown area!');
221                 return '???';
222             }
224         } else {
225             throw new coding_exception('Unsupported context level');
226         }
227     }
229     /**
230      * Loads the gradable area info from the database
231      *
232      * @param int $areaid
233      */
234     public function load($areaid) {
235         global $DB;
237         $this->areacache = $DB->get_record('grading_areas', array('id' => $areaid), '*', MUST_EXIST);
238         $this->context = context::instance_by_id($this->areacache->contextid, MUST_EXIST);
239         $this->component = $this->areacache->component;
240         $this->area = $this->areacache->areaname;
241     }
243     /**
244      * Returns the list of installed grading plugins together, optionally extended
245      * with a simple direct grading.
246      *
247      * @param bool $includenone should the 'Simple direct grading' be included
248      * @return array of the (string)name => (string)localized title of the method
249      */
250     public static function available_methods($includenone = true) {
252         if ($includenone) {
253             $list = array('' => get_string('gradingmethodnone', 'core_grading'));
254         } else {
255             $list = array();
256         }
258         foreach (core_component::get_plugin_list('gradingform') as $name => $location) {
259             $list[$name] = get_string('pluginname', 'gradingform_'.$name);
260         }
262         return $list;
263     }
265     /**
266      * Returns the list of available grading methods in the given context
267      *
268      * Currently this is just a static list obtained from {@link self::available_methods()}.
269      * In the future, the list of available methods may be controlled per-context.
270      *
271      * Requires the context property to be set in advance.
272      *
273      * @param bool $includenone should the 'Simple direct grading' be included
274      * @return array of the (string)name => (string)localized title of the method
275      */
276     public function get_available_methods($includenone = true) {
277         $this->ensure_isset(array('context'));
278         return self::available_methods($includenone);
279     }
281     /**
282      * Returns the list of gradable areas provided by the given component
283      *
284      * This performs a callback to the library of the relevant plugin to obtain
285      * the list of supported areas.
286      *
287      * @param string $component normalized component name
288      * @return array of (string)areacode => (string)localized title of the area
289      */
290     public static function available_areas($component) {
291         global $CFG;
293         if (component_gradeitems::defines_advancedgrading_itemnames_for_component($component)) {
294             $result = [];
295             foreach (component_gradeitems::get_advancedgrading_itemnames_for_component($component) as $itemnumber => $itemname) {
296                 $result[$itemname] = get_string("gradeitem:{$itemname}", $component);
297             }
299             return $result;
300         }
302         list($plugintype, $pluginname) = core_component::normalize_component($component);
304         if ($component === 'core_grading') {
305             return array();
307         } else if ($plugintype === 'mod') {
308             $callbackfunction = "grading_areas_list";
309             if (component_callback_exists($component, $callbackfunction)) {
310                 debugging(
311                     "Components supporting advanced grading should be updated to implement the component_gradeitems class",
312                     DEBUG_DEVELOPER
313                 );
314                 return component_callback($component, $callbackfunction, [], []);
315             }
316         } else {
317             throw new coding_exception('Unsupported area location');
318         }
319     }
322     /**
323      * Returns the list of gradable areas in the given context and component
324      *
325      * This performs a callback to the library of the relevant plugin to obtain
326      * the list of supported areas.
327      * @return array of (string)areacode => (string)localized title of the area
328      */
329     public function get_available_areas() {
330         global $CFG;
332         $this->ensure_isset(array('context', 'component'));
334         if ($this->get_context()->contextlevel == CONTEXT_SYSTEM) {
335             if ($this->get_component() !== 'core_grading') {
336                 throw new coding_exception('Unsupported component at the system context');
337             } else {
338                 return array();
339             }
341         } else if ($this->get_context()->contextlevel == CONTEXT_MODULE) {
342             $modulecontext = $this->get_context();
343             $coursecontext = $modulecontext->get_course_context();
344             $cm = get_fast_modinfo($coursecontext->instanceid)->get_cm($modulecontext->instanceid);
345             return self::available_areas("mod_{$cm->modname}");
347         } else {
348             throw new coding_exception('Unsupported gradable area context level');
349         }
350     }
352     /**
353      * Returns the currently active grading method in the gradable area
354      *
355      * @return string|null the name of the grading plugin of null if it has not been set
356      */
357     public function get_active_method() {
358         global $DB;
360         $this->ensure_isset(array('context', 'component', 'area'));
362         // get the current grading area record if it exists
363         if (is_null($this->areacache)) {
364             $this->areacache = $DB->get_record('grading_areas', array(
365                 'contextid' => $this->context->id,
366                 'component' => $this->component,
367                 'areaname'  => $this->area),
368             '*', IGNORE_MISSING);
369         }
371         if ($this->areacache === false) {
372             // no area record yet
373             return null;
374         }
376         return $this->areacache->activemethod;
377     }
379     /**
380      * Sets the currently active grading method in the gradable area
381      *
382      * @param string $method the method name, eg 'rubric' (must be available)
383      * @return bool true if the method changed or was just set, false otherwise
384      */
385     public function set_active_method($method) {
386         global $DB;
388         $this->ensure_isset(array('context', 'component', 'area'));
390         // make sure the passed method is empty or a valid plugin name
391         if (empty($method)) {
392             $method = null;
393         } else {
394             if ('gradingform_'.$method !== clean_param('gradingform_'.$method, PARAM_COMPONENT)) {
395                 throw new moodle_exception('invalid_method_name', 'core_grading');
396             }
397             $available = $this->get_available_methods(false);
398             if (!array_key_exists($method, $available)) {
399                 throw new moodle_exception('invalid_method_name', 'core_grading');
400             }
401         }
403         // get the current grading area record if it exists
404         if (is_null($this->areacache)) {
405             $this->areacache = $DB->get_record('grading_areas', array(
406                 'contextid' => $this->context->id,
407                 'component' => $this->component,
408                 'areaname'  => $this->area),
409             '*', IGNORE_MISSING);
410         }
412         $methodchanged = false;
414         if ($this->areacache === false) {
415             // no area record yet, create one with the active method set
416             $area = array(
417                 'contextid'     => $this->context->id,
418                 'component'     => $this->component,
419                 'areaname'      => $this->area,
420                 'activemethod'  => $method);
421             $DB->insert_record('grading_areas', $area);
422             $methodchanged = true;
424         } else {
425             // update the existing record if needed
426             if ($this->areacache->activemethod !== $method) {
427                 $DB->set_field('grading_areas', 'activemethod', $method, array('id' => $this->areacache->id));
428                 $methodchanged = true;
429             }
430         }
432         $this->areacache = null;
434         return $methodchanged;
435     }
437     /**
438      * Extends the settings navigation with the grading settings
439      *
440      * This function is called when the context for the page is an activity module with the
441      * FEATURE_ADVANCED_GRADING and the user has the permission moodle/grade:managegradingforms.
442      *
443      * @param settings_navigation $settingsnav {@link settings_navigation}
444      * @param navigation_node $modulenode {@link navigation_node}
445      */
446     public function extend_settings_navigation(settings_navigation $settingsnav, navigation_node $modulenode=null) {
448         $this->ensure_isset(array('context', 'component'));
450         $areas = $this->get_available_areas();
452         if (empty($areas)) {
453             // no money, no funny
454             return;
456         } else if (count($areas) == 1) {
457             // make just a single node for the management screen
458             $areatitle = reset($areas);
459             $areaname  = key($areas);
460             $this->set_area($areaname);
461             $method = $this->get_active_method();
462             $managementnode = $modulenode->add(get_string('gradingmanagement', 'core_grading'),
463                 $this->get_management_url(), settings_navigation::TYPE_CUSTOM);
464             if ($method) {
465                 $controller = $this->get_controller($method);
466                 $controller->extend_settings_navigation($settingsnav, $managementnode);
467             }
469         } else {
470             // make management screen node for each area
471             $managementnode = $modulenode->add(get_string('gradingmanagement', 'core_grading'),
472                 null, settings_navigation::TYPE_CUSTOM);
473             foreach ($areas as $areaname => $areatitle) {
474                 $this->set_area($areaname);
475                 $method = $this->get_active_method();
476                 $node = $managementnode->add($areatitle,
477                     $this->get_management_url(), settings_navigation::TYPE_CUSTOM);
478                 if ($method) {
479                     $controller = $this->get_controller($method);
480                     $controller->extend_settings_navigation($settingsnav, $node);
481                 }
482             }
483         }
484     }
486     /**
487      * Extends the module navigation with the advanced grading information
488      *
489      * This function is called when the context for the page is an activity module with the
490      * FEATURE_ADVANCED_GRADING.
491      *
492      * @param global_navigation $navigation
493      * @param navigation_node $modulenode
494      */
495     public function extend_navigation(global_navigation $navigation, navigation_node $modulenode=null) {
496         $this->ensure_isset(array('context', 'component'));
498         $areas = $this->get_available_areas();
499         foreach ($areas as $areaname => $areatitle) {
500             $this->set_area($areaname);
501             if ($controller = $this->get_active_controller()) {
502                 $controller->extend_navigation($navigation, $modulenode);
503             }
504         }
505     }
507     /**
508      * Returns the given method's controller in the gradable area
509      *
510      * @param string $method the method name, eg 'rubric' (must be available)
511      * @return gradingform_controller
512      */
513     public function get_controller($method) {
514         global $CFG, $DB;
516         $this->ensure_isset(array('context', 'component', 'area'));
518         // make sure the passed method is a valid plugin name
519         if ('gradingform_'.$method !== clean_param('gradingform_'.$method, PARAM_COMPONENT)) {
520             throw new moodle_exception('invalid_method_name', 'core_grading');
521         }
522         $available = $this->get_available_methods(false);
523         if (!array_key_exists($method, $available)) {
524             throw new moodle_exception('invalid_method_name', 'core_grading');
525         }
527         // get the current grading area record if it exists
528         if (is_null($this->areacache)) {
529             $this->areacache = $DB->get_record('grading_areas', array(
530                 'contextid' => $this->context->id,
531                 'component' => $this->component,
532                 'areaname'  => $this->area),
533             '*', IGNORE_MISSING);
534         }
536         if ($this->areacache === false) {
537             // no area record yet, create one
538             $area = array(
539                 'contextid' => $this->context->id,
540                 'component' => $this->component,
541                 'areaname'  => $this->area);
542             $areaid = $DB->insert_record('grading_areas', $area);
543             // reload the cache
544             $this->areacache = $DB->get_record('grading_areas', array('id' => $areaid), '*', MUST_EXIST);
545         }
547         require_once($CFG->dirroot.'/grade/grading/form/'.$method.'/lib.php');
548         $classname = 'gradingform_'.$method.'_controller';
550         return new $classname($this->context, $this->component, $this->area, $this->areacache->id);
551     }
553     /**
554      * Returns the controller for the active method if it is available
555      *
556      * @return null|gradingform_controller
557      */
558     public function get_active_controller() {
559         if ($gradingmethod = $this->get_active_method()) {
560             $controller = $this->get_controller($gradingmethod);
561             if ($controller->is_form_available()) {
562                 return $controller;
563             }
564         }
565         return null;
566     }
568     /**
569      * Returns the URL of the grading area management page
570      *
571      * @param moodle_url $returnurl optional URL of the page where the user should be sent back to
572      * @return moodle_url
573      */
574     public function get_management_url(moodle_url $returnurl = null) {
576         $this->ensure_isset(array('context', 'component'));
578         if ($this->areacache) {
579             $params = array('areaid' => $this->areacache->id);
580         } else {
581             $params = array('contextid' => $this->context->id, 'component' => $this->component);
582             if ($this->area) {
583                 $params['area'] = $this->area;
584             }
585         }
587         if (!is_null($returnurl)) {
588             $params['returnurl'] = $returnurl->out(false);
589         }
591         return new moodle_url('/grade/grading/manage.php', $params);
592     }
594     /**
595      * Creates a new shared area to hold a grading form template
596      *
597      * Shared area are implemented as virtual gradable areas at the system level context
598      * with the component set to core_grading and unique random area name.
599      *
600      * @param string $method the name of the plugin we create the area for
601      * @return int the new area id
602      */
603     public function create_shared_area($method) {
604         global $DB;
606         // generate some unique random name for the new area
607         $name = $method . '_' . sha1(rand().uniqid($method, true));
608         // create new area record
609         $area = array(
610             'contextid'     => context_system::instance()->id,
611             'component'     => 'core_grading',
612             'areaname'      => $name,
613             'activemethod'  => $method);
614         return $DB->insert_record('grading_areas', $area);
615     }
617     /**
618      * Removes all data associated with the given context
619      *
620      * This is called by {@link context::delete_content()}
621      *
622      * @param int $contextid context id
623      */
624     public static function delete_all_for_context($contextid) {
625         global $DB;
627         $areaids = $DB->get_fieldset_select('grading_areas', 'id', 'contextid = ?', array($contextid));
628         $methods = array_keys(self::available_methods(false));
630         foreach($areaids as $areaid) {
631             $manager = get_grading_manager($areaid);
632             foreach ($methods as $method) {
633                 $controller = $manager->get_controller($method);
634                 $controller->delete_definition();
635             }
636         }
638         $DB->delete_records_list('grading_areas', 'id', $areaids);
639     }
641     /**
642      * Helper method to tokenize the given string
643      *
644      * Splits the given string into smaller strings. This is a helper method for
645      * full text searching in grading forms. If the given string is surrounded with
646      * double quotes, the resulting array consists of a single item containing the
647      * quoted content.
648      *
649      * Otherwise, string like 'grammar, english language' would be tokenized into
650      * the three tokens 'grammar', 'english', 'language'.
651      *
652      * One-letter tokens like are dropped in non-phrase mode. Repeated tokens are
653      * returned just once.
654      *
655      * @param string $needle
656      * @return array
657      */
658     public static function tokenize($needle) {
660         // check if we are searching for the exact phrase
661         if (preg_match('/^[\s]*"[\s]*(.*?)[\s]*"[\s]*$/', $needle, $matches)) {
662             $token = $matches[1];
663             if ($token === '') {
664                 return array();
665             } else {
666                 return array($token);
667             }
668         }
670         // split the needle into smaller parts separated by non-word characters
671         $tokens = preg_split("/\W/u", $needle);
672         // keep just non-empty parts
673         $tokens = array_filter($tokens);
674         // distinct
675         $tokens = array_unique($tokens);
676         // drop one-letter tokens
677         foreach ($tokens as $ix => $token) {
678             if (strlen($token) == 1) {
679                 unset($tokens[$ix]);
680             }
681         }
683         return array_values($tokens);
684     }
686     // //////////////////////////////////////////////////////////////////////////
688     /**
689      * Make sure that the given properties were set to some not-null value
690      *
691      * @param array $properties the list of properties
692      * @throws coding_exception
693      */
694     private function ensure_isset(array $properties) {
695         foreach ($properties as $property) {
696             if (!isset($this->$property)) {
697                 throw new coding_exception('The property "'.$property.'" is not set.');
698             }
699         }
700     }