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