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 39 and 311]

   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   * Abstract base target.
  19   *
  20   * @package   core_analytics
  21   * @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
  22   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  
  25  namespace core_analytics\local\target;
  26  
  27  defined('MOODLE_INTERNAL') || die();
  28  
  29  /**
  30   * Abstract base target.
  31   *
  32   * @package   core_analytics
  33   * @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
  34   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  35   */
  36  abstract class base extends \core_analytics\calculable {
  37  
  38      /**
  39       * This target have linear or discrete values.
  40       *
  41       * @return bool
  42       */
  43      abstract public function is_linear();
  44  
  45      /**
  46       * Returns the analyser class that should be used along with this target.
  47       *
  48       * @return string The full class name as a string
  49       */
  50      abstract public function get_analyser_class();
  51  
  52      /**
  53       * Allows the target to verify that the analysable is a good candidate.
  54       *
  55       * This method can be used as a quick way to discard invalid analysables.
  56       * e.g. Imagine that your analysable don't have students and you need them.
  57       *
  58       * @param \core_analytics\analysable $analysable
  59       * @param bool $fortraining
  60       * @return true|string
  61       */
  62      abstract public function is_valid_analysable(\core_analytics\analysable $analysable, $fortraining = true);
  63  
  64      /**
  65       * Is this sample from the $analysable valid?
  66       *
  67       * @param int $sampleid
  68       * @param \core_analytics\analysable $analysable
  69       * @param bool $fortraining
  70       * @return bool
  71       */
  72      abstract public function is_valid_sample($sampleid, \core_analytics\analysable $analysable, $fortraining = true);
  73  
  74      /**
  75       * Calculates this target for the provided samples.
  76       *
  77       * In case there are no values to return or the provided sample is not applicable just return null.
  78       *
  79       * @param int $sampleid
  80       * @param \core_analytics\analysable $analysable
  81       * @param int|false $starttime Limit calculations to start time
  82       * @param int|false $endtime Limit calculations to end time
  83       * @return float|null
  84       */
  85      abstract protected function calculate_sample($sampleid, \core_analytics\analysable $analysable, $starttime = false, $endtime = false);
  86  
  87      /**
  88       * Can the provided time-splitting method be used on this target?.
  89       *
  90       * Time-splitting methods not matching the target requirements will not be selectable by models based on this target.
  91       *
  92       * @param  \core_analytics\local\time_splitting\base $timesplitting
  93       * @return bool
  94       */
  95      abstract public function can_use_timesplitting(\core_analytics\local\time_splitting\base $timesplitting): bool;
  96  
  97      /**
  98       * Is this target generating insights?
  99       *
 100       * Defaults to true.
 101       *
 102       * @return bool
 103       */
 104      public static function uses_insights() {
 105          return true;
 106      }
 107  
 108      /**
 109       * Should the insights of this model be linked from reports?
 110       *
 111       * @return bool
 112       */
 113      public function link_insights_report(): bool {
 114          return true;
 115      }
 116  
 117      /**
 118       * Based on facts (processed by machine learning backends) by default.
 119       *
 120       * @return bool
 121       */
 122      public static function based_on_assumptions() {
 123          return false;
 124      }
 125  
 126      /**
 127       * Update the last analysis time on analysable processed or always.
 128       *
 129       * If you overwrite this method to return false the last analysis time
 130       * will only be recorded in DB when the element successfully analysed. You can
 131       * safely return false for lightweight targets.
 132       *
 133       * @return bool
 134       */
 135      public function always_update_analysis_time(): bool {
 136          return true;
 137      }
 138  
 139      /**
 140       * Suggested actions for a user.
 141       *
 142       * @param \core_analytics\prediction $prediction
 143       * @param bool $includedetailsaction
 144       * @param bool $isinsightuser                       Force all the available actions to be returned as it the user who
 145       *                                                  receives the insight is the one logged in.
 146       * @return \core_analytics\prediction_action[]
 147       */
 148      public function prediction_actions(\core_analytics\prediction $prediction, $includedetailsaction = false,
 149              $isinsightuser = false) {
 150          global $PAGE;
 151  
 152          $predictionid = $prediction->get_prediction_data()->id;
 153          $contextid = $prediction->get_prediction_data()->contextid;
 154          $modelid = $prediction->get_prediction_data()->modelid;
 155  
 156          $actions = array();
 157  
 158          if ($this->link_insights_report() && $includedetailsaction) {
 159  
 160              $predictionurl = new \moodle_url('/report/insights/prediction.php', array('id' => $predictionid));
 161              $detailstext = $this->get_view_details_text();
 162  
 163              $actions[] = new \core_analytics\prediction_action(\core_analytics\prediction::ACTION_PREDICTION_DETAILS, $prediction,
 164                  $predictionurl, new \pix_icon('t/preview', $detailstext),
 165                  $detailstext, false, [], \core_analytics\action::TYPE_NEUTRAL);
 166          }
 167  
 168          return $actions;
 169      }
 170  
 171      /**
 172       * Suggested bulk actions for a user.
 173       *
 174       * @param  \core_analytics\prediction[]     $predictions List of predictions suitable for the bulk actions to use.
 175       * @return \core_analytics\bulk_action[]                 The list of bulk actions.
 176       */
 177      public function bulk_actions(array $predictions) {
 178  
 179          $analyserclass = $this->get_analyser_class();
 180          if ($analyserclass::one_sample_per_analysable()) {
 181              // Default actions are useful / not useful.
 182              $actions = [
 183                  \core_analytics\default_bulk_actions::useful(),
 184                  \core_analytics\default_bulk_actions::not_useful()
 185              ];
 186  
 187          } else {
 188              // Accept and not applicable.
 189  
 190              $actions = [
 191                  \core_analytics\default_bulk_actions::accept(),
 192                  \core_analytics\default_bulk_actions::not_applicable()
 193              ];
 194  
 195              if (!self::based_on_assumptions()) {
 196                  // We include incorrectly flagged.
 197                  $actions[] = \core_analytics\default_bulk_actions::incorrectly_flagged();
 198              }
 199          }
 200  
 201          return $actions;
 202      }
 203  
 204      /**
 205       * Adds the JS required to run the bulk actions.
 206       */
 207      public function add_bulk_actions_js() {
 208          global $PAGE;
 209          $PAGE->requires->js_call_amd('report_insights/actions', 'initBulk', ['.insights-bulk-actions']);
 210      }
 211  
 212      /**
 213       * Returns the view details link text.
 214       * @return string
 215       */
 216      private function get_view_details_text() {
 217          if ($this->based_on_assumptions()) {
 218              $analyserclass = $this->get_analyser_class();
 219              if ($analyserclass::one_sample_per_analysable()) {
 220                  $detailstext = get_string('viewinsightdetails', 'analytics');
 221              } else {
 222                  $detailstext = get_string('viewdetails', 'analytics');
 223              }
 224          } else {
 225              $detailstext = get_string('viewprediction', 'analytics');
 226          }
 227  
 228          return $detailstext;
 229      }
 230  
 231      /**
 232       * Callback to execute once a prediction has been returned from the predictions processor.
 233       *
 234       * Note that the analytics_predictions db record is not yet inserted.
 235       *
 236       * @param int $modelid
 237       * @param int $sampleid
 238       * @param int $rangeindex
 239       * @param \context $samplecontext
 240       * @param float|int $prediction
 241       * @param float $predictionscore
 242       * @return void
 243       */
 244      public function prediction_callback($modelid, $sampleid, $rangeindex, \context $samplecontext, $prediction, $predictionscore) {
 245          return;
 246      }
 247  
 248      /**
 249       * Generates insights notifications
 250       *
 251       * @param int $modelid
 252       * @param \context[] $samplecontexts
 253       * @param  \core_analytics\prediction[] $predictions
 254       * @return void
 255       */
 256      public function generate_insight_notifications($modelid, $samplecontexts, array $predictions = []) {
 257          // Delegate the processing of insights to the insights_generator.
 258          $insightsgenerator = new \core_analytics\insights_generator($modelid, $this);
 259          $insightsgenerator->generate($samplecontexts, $predictions);
 260      }
 261  
 262      /**
 263       * Returns the list of users that will receive insights notifications.
 264       *
 265       * Feel free to overwrite if you need to but keep in mind that moodle/analytics:listinsights
 266       * or moodle/analytics:listowninsights capability is required to access the list of insights.
 267       *
 268       * @param \context $context
 269       * @return array
 270       */
 271      public function get_insights_users(\context $context) {
 272          if ($context->contextlevel === CONTEXT_USER) {
 273              if (!has_capability('moodle/analytics:listowninsights', $context, $context->instanceid)) {
 274                  $users = [];
 275              } else {
 276                  $users = [$context->instanceid => \core_user::get_user($context->instanceid)];
 277              }
 278  
 279          } else if ($context->contextlevel >= CONTEXT_COURSE) {
 280              // At course level or below only enrolled users although this is not ideal for
 281              // teachers assigned at category level.
 282              $users = get_enrolled_users($context, 'moodle/analytics:listinsights', 0, 'u.*', null, 0, 0, true);
 283          } else {
 284              $users = get_users_by_capability($context, 'moodle/analytics:listinsights');
 285          }
 286          return $users;
 287      }
 288  
 289      /**
 290       * URL to the insight.
 291       *
 292       * @param  int $modelid
 293       * @param  \context $context
 294       * @return \moodle_url
 295       */
 296      public function get_insight_context_url($modelid, $context) {
 297          return new \moodle_url('/report/insights/insights.php?modelid=' . $modelid . '&contextid=' . $context->id);
 298      }
 299  
 300      /**
 301       * The insight notification subject.
 302       *
 303       * This is just a default message, you should overwrite it for a custom insight message.
 304       *
 305       * @param  int $modelid
 306       * @param  \context $context
 307       * @return string
 308       */
 309      public function get_insight_subject(int $modelid, \context $context) {
 310          return get_string('insightmessagesubject', 'analytics', $context->get_context_name());
 311      }
 312  
 313      /**
 314       * Returns the body message for an insight with multiple predictions.
 315       *
 316       * This default method is executed when the analysable used by the model generates multiple insight
 317       * for each analysable (one_sample_per_analysable === false)
 318       *
 319       * @param  \context     $context
 320       * @param  string       $contextname
 321       * @param  \stdClass    $user
 322       * @param  \moodle_url  $insighturl
 323       * @return string[]                     The plain text message and the HTML message
 324       */
 325      public function get_insight_body(\context $context, string $contextname, \stdClass $user, \moodle_url $insighturl): array {
 326          global $OUTPUT;
 327  
 328          $fullmessage = get_string('insightinfomessageplain', 'analytics', $insighturl->out(false));
 329          $fullmessagehtml = $OUTPUT->render_from_template('core_analytics/insight_info_message',
 330              ['url' => $insighturl->out(false), 'insightinfomessage' => get_string('insightinfomessagehtml', 'analytics')]
 331          );
 332  
 333          return [$fullmessage, $fullmessagehtml];
 334      }
 335  
 336      /**
 337       * Returns the body message for an insight for a single prediction.
 338       *
 339       * This default method is executed when the analysable used by the model generates one insight
 340       * for each analysable (one_sample_per_analysable === true)
 341       *
 342       * @param  \context                             $context
 343       * @param  \stdClass                            $user
 344       * @param  \core_analytics\prediction           $prediction
 345       * @param  \core_analytics\action[]             $actions        Passed by reference to remove duplicate links to actions.
 346       * @return array                                                Plain text msg, HTML message and the main URL for this
 347       *                                                              insight (you can return null if you are happy with the
 348       *                                                              default insight URL calculated in prediction_info())
 349       */
 350      public function get_insight_body_for_prediction(\context $context, \stdClass $user, \core_analytics\prediction $prediction,
 351              array &$actions) {
 352          // No extra message by default.
 353          return [FORMAT_PLAIN => '', FORMAT_HTML => '', 'url' => null];
 354      }
 355  
 356      /**
 357       * Returns an instance of the child class.
 358       *
 359       * Useful to reset cached data.
 360       *
 361       * @return \core_analytics\base\target
 362       */
 363      public static function instance() {
 364          return new static();
 365      }
 366  
 367      /**
 368       * Defines a boundary to ignore predictions below the specified prediction score.
 369       *
 370       * Value should go from 0 to 1.
 371       *
 372       * @return float
 373       */
 374      protected function min_prediction_score() {
 375          // The default minimum discards predictions with a low score.
 376          return \core_analytics\model::PREDICTION_MIN_SCORE;
 377      }
 378  
 379      /**
 380       * This method determines if a prediction is interesing for the model or not.
 381       *
 382       * @param mixed $predictedvalue
 383       * @param float $predictionscore
 384       * @return bool
 385       */
 386      public function triggers_callback($predictedvalue, $predictionscore) {
 387  
 388          $minscore = floatval($this->min_prediction_score());
 389          if ($minscore < 0) {
 390              debugging(get_class($this) . ' minimum prediction score is below 0, please update it to a value between 0 and 1.');
 391          } else if ($minscore > 1) {
 392              debugging(get_class($this) . ' minimum prediction score is above 1, please update it to a value between 0 and 1.');
 393          }
 394  
 395          // We need to consider that targets may not have a min score.
 396          if (!empty($minscore) && floatval($predictionscore) < $minscore) {
 397              return false;
 398          }
 399  
 400          return true;
 401      }
 402  
 403      /**
 404       * Calculates the target.
 405       *
 406       * Returns an array of values which size matches $sampleids size.
 407       *
 408       * Rows with null values will be skipped as invalid by time splitting methods.
 409       *
 410       * @param array $sampleids
 411       * @param \core_analytics\analysable $analysable
 412       * @param int $starttime
 413       * @param int $endtime
 414       * @return array The format to follow is [userid] = scalar|null
 415       */
 416      public function calculate($sampleids, \core_analytics\analysable $analysable, $starttime = false, $endtime = false) {
 417  
 418          if (!PHPUNIT_TEST && CLI_SCRIPT) {
 419              echo '.';
 420          }
 421  
 422          $calculations = [];
 423          foreach ($sampleids as $sampleid => $unusedsampleid) {
 424  
 425              // No time limits when calculating the target to train models.
 426              $calculatedvalue = $this->calculate_sample($sampleid, $analysable, $starttime, $endtime);
 427  
 428              if (!is_null($calculatedvalue)) {
 429                  if ($this->is_linear() &&
 430                          ($calculatedvalue > static::get_max_value() || $calculatedvalue < static::get_min_value())) {
 431                      throw new \coding_exception('Calculated values should be higher than ' . static::get_min_value() .
 432                          ' and lower than ' . static::get_max_value() . '. ' . $calculatedvalue . ' received');
 433                  } else if (!$this->is_linear() && static::is_a_class($calculatedvalue) === false) {
 434                      throw new \coding_exception('Calculated values should be one of the target classes (' .
 435                          json_encode(static::get_classes()) . '). ' . $calculatedvalue . ' received');
 436                  }
 437              }
 438              $calculations[$sampleid] = $calculatedvalue;
 439          }
 440          return $calculations;
 441      }
 442  
 443      /**
 444       * Filters out invalid samples for training.
 445       *
 446       * @param int[] $sampleids
 447       * @param \core_analytics\analysable $analysable
 448       * @param bool $fortraining
 449       * @return void
 450       */
 451      public function filter_out_invalid_samples(&$sampleids, \core_analytics\analysable $analysable, $fortraining = true) {
 452          foreach ($sampleids as $sampleid => $unusedsampleid) {
 453              if (!$this->is_valid_sample($sampleid, $analysable, $fortraining)) {
 454                  // Skip it and remove the sample from the list of calculated samples.
 455                  unset($sampleids[$sampleid]);
 456              }
 457          }
 458      }
 459  }