Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.

Differences Between: [Versions 311 and 400] [Versions 311 and 401] [Versions 311 and 402] [Versions 311 and 403]

   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/>.
  16  
  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   */
  24  
  25  defined('MOODLE_INTERNAL') || die();
  26  
  27  use core_grades\component_gradeitems;
  28  
  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;
  49  
  50      $manager = new grading_manager();
  51  
  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  
  63      if (!is_null($context)) {
  64          $manager->set_context($context);
  65      }
  66  
  67      if (!is_null($component)) {
  68          $manager->set_component($component);
  69      }
  70  
  71      if (!is_null($area)) {
  72          $manager->set_area($area);
  73      }
  74  
  75      return $manager;
  76  }
  77  
  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 {
  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 */
 104      protected $area;
 105  
 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  
 109      /**
 110       * Returns grading manager context
 111       *
 112       * @return stdClass grading manager context
 113       */
 114      public function get_context() {
 115          return $this->context;
 116      }
 117  
 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      }
 127  
 128      /**
 129       * Returns grading manager component
 130       *
 131       * @return string grading manager component
 132       */
 133      public function get_component() {
 134          return $this->component;
 135      }
 136  
 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      }
 147  
 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      }
 156  
 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      }
 166  
 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'));
 179  
 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  
 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              }
 196  
 197          } else {
 198              throw new coding_exception('Unsupported gradable area context level');
 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  
 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              }
 223  
 224          } else {
 225              throw new coding_exception('Unsupported context level');
 226          }
 227      }
 228  
 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);
 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      }
 242  
 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) {
 251  
 252          if ($includenone) {
 253              $list = array('' => get_string('gradingmethodnone', 'core_grading'));
 254          } else {
 255              $list = array();
 256          }
 257  
 258          foreach (core_component::get_plugin_list('gradingform') as $name => $location) {
 259              $list[$name] = get_string('pluginname', 'gradingform_'.$name);
 260          }
 261  
 262          return $list;
 263      }
 264  
 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  
 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  
 302          list($plugintype, $pluginname) = core_component::normalize_component($component);
 303  
 304          if ($component === 'core_grading') {
 305              return array();
 306  
 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      }
 320  
 321  
 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  
 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  
 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}");
 346  
 347          } else {
 348              throw new coding_exception('Unsupported gradable area context level');
 349          }
 350      }
 351  
 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;
 359  
 360          $this->ensure_isset(array('context', 'component', 'area'));
 361  
 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          }
 370  
 371          if ($this->areacache === false) {
 372              // no area record yet
 373              return null;
 374          }
 375  
 376          return $this->areacache->activemethod;
 377      }
 378  
 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;
 387  
 388          $this->ensure_isset(array('context', 'component', 'area'));
 389  
 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          }
 402  
 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          }
 411  
 412          $methodchanged = false;
 413  
 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;
 423  
 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          }
 431  
 432          $this->areacache = null;
 433  
 434          return $methodchanged;
 435      }
 436  
 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) {
 447  
 448          $this->ensure_isset(array('context', 'component'));
 449  
 450          $areas = $this->get_available_areas();
 451  
 452          if (empty($areas)) {
 453              // no money, no funny
 454              return;
 455  
 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              }
 468  
 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      }
 485  
 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'));
 497  
 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      }
 506  
 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;
 515  
 516          $this->ensure_isset(array('context', 'component', 'area'));
 517  
 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          }
 526  
 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          }
 535  
 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          }
 546  
 547          require_once($CFG->dirroot.'/grade/grading/form/'.$method.'/lib.php');
 548          $classname = 'gradingform_'.$method.'_controller';
 549  
 550          return new $classname($this->context, $this->component, $this->area, $this->areacache->id);
 551      }
 552  
 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      }
 567  
 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) {
 575  
 576          $this->ensure_isset(array('context', 'component'));
 577  
 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          }
 586  
 587          if (!is_null($returnurl)) {
 588              $params['returnurl'] = $returnurl->out(false);
 589          }
 590  
 591          return new moodle_url('/grade/grading/manage.php', $params);
 592      }
 593  
 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;
 605  
 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      }
 616  
 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;
 626  
 627          $areaids = $DB->get_fieldset_select('grading_areas', 'id', 'contextid = ?', array($contextid));
 628          $methods = array_keys(self::available_methods(false));
 629  
 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          }
 637  
 638          $DB->delete_records_list('grading_areas', 'id', $areaids);
 639      }
 640  
 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) {
 659  
 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          }
 669  
 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          }
 682  
 683          return array_values($tokens);
 684      }
 685  
 686      // //////////////////////////////////////////////////////////////////////////
 687  
 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      }
 701  }