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