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.
   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   * Base class for targets whose analysable is a course using user enrolments as samples.
  19   *
  20   * @package   core_course
  21   * @copyright 2019 Victor Deniz <victor@moodle.com>
  22   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  
  25  namespace core_course\analytics\target;
  26  
  27  defined('MOODLE_INTERNAL') || die();
  28  
  29  /**
  30   * Base class for targets whose analysable is a course using user enrolments as samples.
  31   *
  32   * @package   core_course
  33   * @copyright 2019 Victor Deniz <victor@moodle.com>
  34   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  35   */
  36  abstract class course_enrolments extends \core_analytics\local\target\binary {
  37  
  38      /**
  39       * @var string
  40       */
  41      const MESSAGE_ACTION_NAME = 'studentmessage';
  42  
  43      /**
  44       * @var float
  45       */
  46      const ENROL_ACTIVE_PERCENT_REQUIRED = 0.7;
  47  
  48      /**
  49       * Students in the course.
  50       * @var int[]
  51       */
  52      protected $students;
  53  
  54      /**
  55       * Returns the analyser class that should be used along with this target.
  56       *
  57       * @return string The full class name as a string
  58       */
  59      public function get_analyser_class() {
  60          return '\core\analytics\analyser\student_enrolments';
  61      }
  62  
  63      /**
  64       * Only past stuff.
  65       *
  66       * @param  \core_analytics\local\time_splitting\base $timesplitting
  67       * @return bool
  68       */
  69      public function can_use_timesplitting(\core_analytics\local\time_splitting\base $timesplitting): bool {
  70          return ($timesplitting instanceof \core_analytics\local\time_splitting\before_now);
  71      }
  72  
  73      /**
  74       * Overwritten to show a simpler language string.
  75       *
  76       * @param  int $modelid
  77       * @param  \context $context
  78       * @return string
  79       */
  80      public function get_insight_subject(int $modelid, \context $context) {
  81          return get_string('studentsatriskincourse', 'course', $context->get_context_name(false));
  82      }
  83  
  84      /**
  85       * Returns the body message for the insight.
  86       *
  87       * @param  \context     $context
  88       * @param  string       $contextname
  89       * @param  \stdClass    $user
  90       * @param  \moodle_url  $insighturl
  91       * @return string[]                     The plain text message and the HTML message
  92       */
  93      public function get_insight_body(\context $context, string $contextname, \stdClass $user, \moodle_url $insighturl): array {
  94          global $OUTPUT;
  95  
  96          $a = (object)['coursename' => $contextname, 'userfirstname' => $user->firstname];
  97          $fullmessage = get_string('studentsatriskinfomessage', 'course', $a) . PHP_EOL . PHP_EOL . $insighturl->out(false);
  98          $fullmessagehtml = $OUTPUT->render_from_template('core_analytics/insight_info_message',
  99              ['url' => $insighturl->out(false), 'insightinfomessage' => get_string('studentsatriskinfomessage', 'course', $a)]
 100          );
 101  
 102          return [$fullmessage, $fullmessagehtml];
 103      }
 104  
 105      /**
 106       * Discards courses that are not yet ready to be used for training or prediction.
 107       *
 108       * @param \core_analytics\analysable $course
 109       * @param bool $fortraining
 110       * @return true|string
 111       */
 112      public function is_valid_analysable(\core_analytics\analysable $course, $fortraining = true) {
 113  
 114          if (!$course->was_started()) {
 115              return get_string('coursenotyetstarted', 'course');
 116          }
 117  
 118          if (!$fortraining && !$course->get_course_data()->visible) {
 119              return get_string('hiddenfromstudents');
 120          }
 121  
 122          if (!$this->students = $course->get_students()) {
 123              return get_string('nocoursestudents', 'course');
 124          }
 125  
 126          if (!course_format_uses_sections($course->get_course_data()->format)) {
 127              // We can not split activities in time ranges.
 128              return get_string('nocoursesections', 'course');
 129          }
 130  
 131          if ($course->get_end() == 0) {
 132              // We require time end to be set.
 133              return get_string('nocourseendtime', 'course');
 134          }
 135  
 136          if ($course->get_end() < $course->get_start()) {
 137              return get_string('errorendbeforestart', 'course');
 138          }
 139  
 140          // A course that lasts longer than 1 year probably have wrong start or end dates.
 141          if ($course->get_end() - $course->get_start() > (YEARSECS + (WEEKSECS * 4))) {
 142              return get_string('coursetoolong', 'course');
 143          }
 144  
 145          // Finished courses can not be used to get predictions.
 146          if (!$fortraining && $course->is_finished()) {
 147              return get_string('coursealreadyfinished', 'course');
 148          }
 149  
 150          if ($fortraining) {
 151              // Ongoing courses data can not be used to train.
 152              if (!$course->is_finished()) {
 153                  return get_string('coursenotyetfinished', 'course');
 154              }
 155          }
 156  
 157          return true;
 158      }
 159  
 160      /**
 161       * Discard student enrolments that are invalid.
 162       *
 163       * Note that this method assumes that the target is only interested in enrolments that are/were active
 164       * between the current course start and end times. Targets interested in predicting students at risk before
 165       * their enrolment start and targets interested in getting predictions for students whose enrolment already
 166       * finished should overwrite this method as these students are discarded by this method.
 167       *
 168       * @param int $sampleid
 169       * @param \core_analytics\analysable $course
 170       * @param bool $fortraining
 171       * @return bool
 172       */
 173      public function is_valid_sample($sampleid, \core_analytics\analysable $course, $fortraining = true) {
 174  
 175          $now = time();
 176  
 177          $userenrol = $this->retrieve('user_enrolments', $sampleid);
 178          if ($userenrol->timeend && $course->get_start() > $userenrol->timeend) {
 179              // Discard enrolments which time end is prior to the course start. This should get rid of
 180              // old user enrolments that remain on the course.
 181              return false;
 182          }
 183  
 184          $limit = $course->get_start() - (YEARSECS + (WEEKSECS * 4));
 185          if (($userenrol->timestart && $userenrol->timestart < $limit) ||
 186                  (!$userenrol->timestart && $userenrol->timecreated < $limit)) {
 187              // Following what we do in is_valid_analysable, we will discard enrolments that last more than 1 academic year
 188              // because they have incorrect start and end dates or because they are reused along multiple years
 189              // without removing previous academic years students. This may not be very accurate because some courses
 190              // can last just some months, but it is better than nothing.
 191              return false;
 192          }
 193  
 194          if ($course->get_end()) {
 195              if (($userenrol->timestart && $userenrol->timestart > $course->get_end()) ||
 196                      (!$userenrol->timestart && $userenrol->timecreated > $course->get_end())) {
 197                  // Discard user enrolments that start after the analysable official end.
 198                  return false;
 199              }
 200  
 201          }
 202  
 203          if ($now < $userenrol->timestart && $userenrol->timestart) {
 204              // Discard enrolments whose start date is after now (no need to check timecreated > $now :P).
 205              return false;
 206          }
 207  
 208          if (!$fortraining && $userenrol->timeend && $userenrol->timeend < $now) {
 209              // We don't want to generate predictions for finished enrolments.
 210              return false;
 211          }
 212  
 213          return true;
 214      }
 215  
 216      /**
 217       * prediction_actions
 218       *
 219       * @param \core_analytics\prediction $prediction
 220       * @param bool $includedetailsaction
 221       * @param bool $isinsightuser
 222       * @return \core_analytics\prediction_action[]
 223       */
 224      public function prediction_actions(\core_analytics\prediction $prediction, $includedetailsaction = false,
 225              $isinsightuser = false) {
 226  
 227          $actions = array();
 228  
 229          $sampledata = $prediction->get_sample_data();
 230          $studentid = $sampledata['user']->id;
 231  
 232          // View outline report.
 233          $url = new \moodle_url('/report/outline/user.php', array('id' => $studentid, 'course' => $sampledata['course']->id,
 234                  'mode' => 'outline'));
 235          $pix = new \pix_icon('i/report', get_string('outlinereport'));
 236          $actions[] = new \core_analytics\prediction_action('viewoutlinereport', $prediction, $url, $pix,
 237                  get_string('outlinereport'), false, ['target' => '_blank']);
 238  
 239          return array_merge(parent::prediction_actions($prediction, $includedetailsaction, $isinsightuser), $actions);
 240      }
 241  
 242      /**
 243       * Suggested bulk actions for a user.
 244       *
 245       * @param  \core_analytics\prediction[]     $predictions List of predictions suitable for the bulk actions to use.
 246       * @return \core_analytics\bulk_action[]                 The list of bulk actions.
 247       */
 248      public function bulk_actions(array $predictions) {
 249  
 250          $actions = [];
 251  
 252          $userids = [];
 253          foreach ($predictions as $prediction) {
 254              $sampledata = $prediction->get_sample_data();
 255              $userid = $sampledata['user']->id;
 256  
 257              // Indexed by prediction id because we want the predictionid-userid
 258              // mapping later when sending the message.
 259              $userids[$prediction->get_prediction_data()->id] = $userid;
 260          }
 261  
 262          // Send a message for all the students.
 263          $attrs = array(
 264              'data-bulk-sendmessage' => '1',
 265              'data-prediction-to-user-id' => json_encode($userids)
 266          );
 267          $actions[] = new \core_analytics\bulk_action(self::MESSAGE_ACTION_NAME, new \moodle_url(''),
 268              new \pix_icon('t/message', get_string('sendmessage', 'message')),
 269              get_string('sendmessage', 'message'), true, $attrs);
 270  
 271          return array_merge($actions, parent::bulk_actions($predictions));
 272      }
 273  
 274      /**
 275       * Adds the JS required to run the bulk actions.
 276       */
 277      public function add_bulk_actions_js() {
 278          global $PAGE;
 279  
 280          $PAGE->requires->js_call_amd('report_insights/message_users', 'init',
 281              ['.insights-bulk-actions', self::MESSAGE_ACTION_NAME]);
 282          parent::add_bulk_actions_js();
 283      }
 284  
 285      /**
 286       * Is/was this user enrolment active during most of the analysis interval?
 287       *
 288       * This method discards enrolments that were not active during most of the analysis interval. It is
 289       * important to discard these enrolments because the indicator calculations can lead to misleading
 290       * results.
 291       *
 292       * Note that this method assumes that the target is interested in enrolments that are/were active
 293       * during the analysis interval. Targets interested in predicting students at risk before
 294       * their enrolment start should not call this method. Similarly, targets interested in getting
 295       * predictions for students whose enrolment already finished should not call this method either.
 296       *
 297       * @param  int    $sampleid     The id of the sample that is being calculated
 298       * @param  int    $starttime    The analysis interval start time
 299       * @param  int    $endtime      The analysis interval end time
 300       * @return bool
 301       */
 302      protected function enrolment_active_during_analysis_time(int $sampleid, int $starttime, int $endtime) {
 303  
 304          $userenrol = $this->retrieve('user_enrolments', $sampleid);
 305  
 306          if (!empty($userenrol->timestart)) {
 307              $enrolstart = $userenrol->timestart;
 308          } else {
 309              // This is always set.
 310              $enrolstart = $userenrol->timecreated;
 311          }
 312  
 313          if (!empty($userenrol->timeend)) {
 314              $enrolend = $userenrol->timeend;
 315          } else {
 316              // Default to tre end of the world.
 317              $enrolend = PHP_INT_MAX;
 318          }
 319  
 320          if ($endtime && $endtime < $enrolstart) {
 321              /* The enrolment starts/ed after the analysis end time.
 322               *   |=========|        |----------|
 323               * A start    A end   E start     E end
 324               */
 325              return false;
 326          }
 327  
 328          if ($starttime && $enrolend < $starttime) {
 329              /* The enrolment finishes/ed before the analysis start time.
 330               *    |---------|        |==========|
 331               * E start    E end   A start     A end
 332               */
 333              return false;
 334          }
 335  
 336          // Now we want to discard enrolments that were not active for most of the analysis interval. We
 337          // need both a $starttime and an $endtime to calculate this.
 338  
 339          if (!$starttime) {
 340              // Early return. Nothing to discard if there is no start.
 341              return true;
 342          }
 343  
 344          if (!$endtime) {
 345              // We can not calculate in relative terms (percent) how far from the analysis start time
 346              // this enrolment start is/was.
 347              return true;
 348          }
 349  
 350          if ($enrolstart < $starttime && $endtime < $enrolend) {
 351              /* The enrolment is active during all the analysis time.
 352               *    |-----------------------------|
 353               *               |========|
 354               * E start    A start   A end     E end
 355               */
 356              return true;
 357          }
 358  
 359          // If we reach this point is because the enrolment is only active for a portion of the analysis interval.
 360          // Therefore, we check that it was active for most of the analysis interval, a self::ENROL_ACTIVE_PERCENT_REQUIRED.
 361  
 362          if ($starttime <= $enrolstart && $enrolend <= $endtime) {
 363              /*    |=============================|
 364               *               |--------|
 365               * A start    E start   E end     A end
 366               */
 367              $activeenrolduration = $enrolend - $enrolstart;
 368          } else if ($enrolstart <= $starttime && $enrolend <= $endtime) {
 369              /*            |===================|
 370               *    |------------------|
 371               * E start  A start    E end    A end
 372               */
 373              $activeenrolduration = $enrolend - $starttime;
 374          } else if ($starttime <= $enrolstart && $endtime <= $enrolend) {
 375              /*   |===================|
 376               *               |------------------|
 377               * A start    E start  A end    E end
 378               */
 379              $activeenrolduration = $endtime - $enrolstart;
 380          }
 381  
 382          $analysisduration = $endtime - $starttime;
 383  
 384          if (floatval($activeenrolduration) / floatval($analysisduration) < self::ENROL_ACTIVE_PERCENT_REQUIRED) {
 385              // The student was not enroled in the course for most of the analysis interval.
 386              return false;
 387          }
 388  
 389          // We happily return true if the enrolment was active for more than self::ENROL_ACTIVE_PERCENT_REQUIRED of
 390          // the analysis interval.
 391          return true;
 392      }
 393  }