Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

Differences Between: [Versions 310 and 403] [Versions 311 and 403] [Versions 39 and 403] [Versions 400 and 403] [Versions 401 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   * Moodle course analysable
  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;
  26  
  27  defined('MOODLE_INTERNAL') || die();
  28  
  29  require_once($CFG->dirroot . '/course/lib.php');
  30  require_once($CFG->dirroot . '/lib/gradelib.php');
  31  require_once($CFG->dirroot . '/lib/enrollib.php');
  32  
  33  /**
  34   * Moodle course analysable
  35   *
  36   * @package   core_analytics
  37   * @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
  38   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  39   */
  40  class course implements \core_analytics\analysable {
  41  
  42      /**
  43       * @var bool Has this course data been already loaded.
  44       */
  45      protected $loaded = false;
  46  
  47      /**
  48       * @var int $cachedid self::$cachedinstance analysable id.
  49       */
  50      protected static $cachedid = 0;
  51  
  52      /**
  53       * @var \core_analytics\course $cachedinstance
  54       */
  55      protected static $cachedinstance = null;
  56  
  57      /**
  58       * Course object
  59       *
  60       * @var \stdClass
  61       */
  62      protected $course = null;
  63  
  64      /**
  65       * The course context.
  66       *
  67       * @var \context_course
  68       */
  69      protected $coursecontext = null;
  70  
  71      /**
  72       * The course activities organized by activity type.
  73       *
  74       * @var array
  75       */
  76      protected $courseactivities = array();
  77  
  78      /**
  79       * Course start time.
  80       *
  81       * @var int
  82       */
  83      protected $starttime = null;
  84  
  85  
  86      /**
  87       * Has the course already started?
  88       *
  89       * @var bool
  90       */
  91      protected $started = null;
  92  
  93      /**
  94       * Course end time.
  95       *
  96       * @var int
  97       */
  98      protected $endtime = null;
  99  
 100      /**
 101       * Is the course finished?
 102       *
 103       * @var bool
 104       */
 105      protected $finished = null;
 106  
 107      /**
 108       * Course students ids.
 109       *
 110       * @var int[]
 111       */
 112      protected $studentids = [];
 113  
 114  
 115      /**
 116       * Course teachers ids
 117       *
 118       * @var int[]
 119       */
 120      protected $teacherids = [];
 121  
 122      /**
 123       * Cached copy of the total number of logs in the course.
 124       *
 125       * @var int
 126       */
 127      protected $ntotallogs = null;
 128  
 129      /** @var int Store current Unix timestamp. */
 130      protected int $now = 0;
 131  
 132      /**
 133       * Course manager constructor.
 134       *
 135       * Use self::instance() instead to get cached copies of the course. Instances obtained
 136       * through this constructor will not be cached.
 137       *
 138       * @param int|\stdClass $course Course id or mdl_course record
 139       * @param \context|null $context
 140       * @return void
 141       */
 142      public function __construct($course, ?\context $context = null) {
 143  
 144          if (is_scalar($course)) {
 145              $this->course = new \stdClass();
 146              $this->course->id = $course;
 147          } else {
 148              $this->course = $course;
 149          }
 150  
 151          if (!is_null($context)) {
 152              $this->coursecontext = $context;
 153          }
 154      }
 155  
 156      /**
 157       * Returns an analytics course instance.
 158       *
 159       * Lazy load of course data, students and teachers.
 160       *
 161       * @param int|\stdClass $course Course object or course id
 162       * @param \context|null $context
 163       * @return \core_analytics\course
 164       */
 165      public static function instance($course, ?\context $context = null) {
 166  
 167          $courseid = $course;
 168          if (!is_scalar($courseid)) {
 169              $courseid = $course->id;
 170          }
 171  
 172          if (self::$cachedid === $courseid) {
 173              return self::$cachedinstance;
 174          }
 175  
 176          $cachedinstance = new \core_analytics\course($course, $context);
 177          self::$cachedinstance = $cachedinstance;
 178          self::$cachedid = (int)$courseid;
 179          return self::$cachedinstance;
 180      }
 181  
 182      /**
 183       * get_id
 184       *
 185       * @return int
 186       */
 187      public function get_id() {
 188          return $this->course->id;
 189      }
 190  
 191      /**
 192       * Loads the analytics course object.
 193       *
 194       * @return void
 195       */
 196      protected function load() {
 197  
 198          // The instance constructor could be already loaded with the full course object. Using shortname
 199          // because it is a required course field.
 200          if (empty($this->course->shortname)) {
 201              $this->course = get_course($this->course->id);
 202          }
 203  
 204          $this->coursecontext = $this->get_context();
 205  
 206          $this->now = time();
 207  
 208          // Get the course users, including users assigned to student and teacher roles at an higher context.
 209          $cache = \cache::make_from_params(\cache_store::MODE_REQUEST, 'core_analytics', 'rolearchetypes');
 210  
 211          // Flag the instance as loaded.
 212          $this->loaded = true;
 213  
 214          if (!$studentroles = $cache->get('student')) {
 215              $studentroles = array_keys(get_archetype_roles('student'));
 216              $cache->set('student', $studentroles);
 217          }
 218          $this->studentids = $this->get_user_ids($studentroles);
 219  
 220          if (!$teacherroles = $cache->get('teacher')) {
 221              $teacherroles = array_keys(get_archetype_roles('editingteacher') + get_archetype_roles('teacher'));
 222              $cache->set('teacher', $teacherroles);
 223          }
 224          $this->teacherids = $this->get_user_ids($teacherroles);
 225      }
 226  
 227      /**
 228       * The course short name
 229       *
 230       * @return string
 231       */
 232      public function get_name() {
 233          return format_string($this->get_course_data()->shortname, true, array('context' => $this->get_context()));
 234      }
 235  
 236      /**
 237       * get_context
 238       *
 239       * @return \context
 240       */
 241      public function get_context() {
 242          if ($this->coursecontext === null) {
 243              $this->coursecontext = \context_course::instance($this->course->id);
 244          }
 245          return $this->coursecontext;
 246      }
 247  
 248      /**
 249       * Get the course start timestamp.
 250       *
 251       * @return int Timestamp or 0 if has not started yet.
 252       */
 253      public function get_start() {
 254  
 255          if ($this->starttime !== null) {
 256              return $this->starttime;
 257          }
 258  
 259          // The field always exist but may have no valid if the course is created through a sync process.
 260          if (!empty($this->get_course_data()->startdate)) {
 261              $this->starttime = (int)$this->get_course_data()->startdate;
 262          } else {
 263              $this->starttime = 0;
 264          }
 265  
 266          return $this->starttime;
 267      }
 268  
 269      /**
 270       * Guesses the start of the course based on students' activity and enrolment start dates.
 271       *
 272       * @return int
 273       */
 274      public function guess_start() {
 275          global $DB;
 276  
 277          if (!$this->get_total_logs()) {
 278              // Can't guess.
 279              return 0;
 280          }
 281  
 282          if (!$logstore = \core_analytics\manager::get_analytics_logstore()) {
 283              return 0;
 284          }
 285  
 286          // We first try to find current course student logs.
 287          $firstlogs = array();
 288          foreach ($this->get_students() as $studentid) {
 289              // Grrr, we are limited by logging API, we could do this easily with a
 290              // select min(timecreated) from xx where courseid = yy group by userid.
 291  
 292              // Filters based on the premise that more than 90% of people will be using
 293              // standard logstore, which contains a userid, contextlevel, contextinstanceid index.
 294              $select = "userid = :userid AND contextlevel = :contextlevel AND contextinstanceid = :contextinstanceid";
 295              $params = array('userid' => $studentid, 'contextlevel' => CONTEXT_COURSE, 'contextinstanceid' => $this->get_id());
 296              $events = $logstore->get_events_select($select, $params, 'timecreated ASC', 0, 1);
 297              if ($events) {
 298                  $event = reset($events);
 299                  $firstlogs[] = $event->timecreated;
 300              }
 301          }
 302          if (empty($firstlogs)) {
 303              // Can't guess if no student accesses.
 304              return 0;
 305          }
 306  
 307          sort($firstlogs);
 308          $firstlogsmedian = $this->median($firstlogs);
 309  
 310          $studentenrolments = enrol_get_course_users($this->get_id(), $this->get_students());
 311          if (empty($studentenrolments)) {
 312              return 0;
 313          }
 314  
 315          $enrolstart = array();
 316          foreach ($studentenrolments as $studentenrolment) {
 317              $enrolstart[] = ($studentenrolment->uetimestart) ? $studentenrolment->uetimestart : $studentenrolment->uetimecreated;
 318          }
 319          sort($enrolstart);
 320          $enrolstartmedian = $this->median($enrolstart);
 321  
 322          return intval(($enrolstartmedian + $firstlogsmedian) / 2);
 323      }
 324  
 325      /**
 326       * Get the course end timestamp.
 327       *
 328       * @return int Timestamp or 0 if time end was not set.
 329       */
 330      public function get_end() {
 331          global $DB;
 332  
 333          if ($this->endtime !== null) {
 334              return $this->endtime;
 335          }
 336  
 337          // The enddate field is only available from Moodle 3.2 (MDL-22078).
 338          if (!empty($this->get_course_data()->enddate)) {
 339              $this->endtime = (int)$this->get_course_data()->enddate;
 340              return $this->endtime;
 341          }
 342  
 343          return 0;
 344      }
 345  
 346      /**
 347       * Get the course end timestamp.
 348       *
 349       * @return int Timestamp, \core_analytics\analysable::MAX_TIME if we don't know but ongoing and 0 if we can not work it out.
 350       */
 351      public function guess_end() {
 352          global $DB;
 353  
 354          if ($this->get_total_logs() === 0) {
 355              // No way to guess if there are no logs.
 356              $this->endtime = 0;
 357              return $this->endtime;
 358          }
 359  
 360          list($filterselect, $filterparams) = $this->course_students_query_filter('ula');
 361  
 362          // Consider the course open if there are still student accesses.
 363          $monthsago = time() - (WEEKSECS * 4 * 2);
 364          $select = $filterselect . ' AND timeaccess > :timeaccess';
 365          $params = $filterparams + array('timeaccess' => $monthsago);
 366          $sql = "SELECT DISTINCT timeaccess FROM {user_lastaccess} ula
 367                    JOIN {enrol} e ON e.courseid = ula.courseid
 368                    JOIN {user_enrolments} ue ON e.id = ue.enrolid AND ue.userid = ula.userid
 369                   WHERE $select";
 370          if ($records = $DB->get_records_sql($sql, $params)) {
 371              return 0;
 372          }
 373  
 374          $sql = "SELECT DISTINCT timeaccess FROM {user_lastaccess} ula
 375                    JOIN {enrol} e ON e.courseid = ula.courseid
 376                    JOIN {user_enrolments} ue ON e.id = ue.enrolid AND ue.userid = ula.userid
 377                   WHERE $filterselect AND ula.timeaccess != 0
 378                   ORDER BY timeaccess DESC";
 379          $studentlastaccesses = $DB->get_fieldset_sql($sql, $filterparams);
 380          if (empty($studentlastaccesses)) {
 381              return 0;
 382          }
 383          sort($studentlastaccesses);
 384  
 385          return $this->median($studentlastaccesses);
 386      }
 387  
 388      /**
 389       * Returns a course plain object.
 390       *
 391       * @return \stdClass
 392       */
 393      public function get_course_data() {
 394  
 395          if (!$this->loaded) {
 396              $this->load();
 397          }
 398  
 399          return $this->course;
 400      }
 401  
 402      /**
 403       * Has the course started?
 404       *
 405       * @return bool
 406       */
 407      public function was_started() {
 408  
 409          if ($this->started === null) {
 410              if ($this->get_start() === 0 || $this->now < $this->get_start()) {
 411                  // Not yet started.
 412                  $this->started = false;
 413              } else {
 414                  $this->started = true;
 415              }
 416          }
 417  
 418          return $this->started;
 419      }
 420  
 421      /**
 422       * Has the course finished?
 423       *
 424       * @return bool
 425       */
 426      public function is_finished() {
 427  
 428          if ($this->finished === null) {
 429              $endtime = $this->get_end();
 430              if ($endtime === 0 || $this->now < $endtime) {
 431                  // It is not yet finished or no idea when it finishes.
 432                  $this->finished = false;
 433              } else {
 434                  $this->finished = true;
 435              }
 436          }
 437  
 438          return $this->finished;
 439      }
 440  
 441      /**
 442       * Returns a list of user ids matching the specified roles in this course.
 443       *
 444       * @param array $roleids
 445       * @return array
 446       */
 447      public function get_user_ids($roleids) {
 448  
 449          // We need to index by ra.id as a user may have more than 1 $roles role.
 450          $records = get_role_users($roleids, $this->get_context(), true, 'ra.id, u.id AS userid, r.id AS roleid', 'ra.id ASC');
 451  
 452          // If a user have more than 1 $roles role array_combine will discard the duplicate.
 453          $callable = array($this, 'filter_user_id');
 454          $userids = array_values(array_map($callable, $records));
 455          return array_combine($userids, $userids);
 456      }
 457  
 458      /**
 459       * Returns the course students.
 460       *
 461       * @return int[]
 462       */
 463      public function get_students() {
 464  
 465          if (!$this->loaded) {
 466              $this->load();
 467          }
 468  
 469          return $this->studentids;
 470      }
 471  
 472      /**
 473       * Returns the total number of student logs in the course
 474       *
 475       * @return int
 476       */
 477      public function get_total_logs() {
 478          global $DB;
 479  
 480          // No logs if no students.
 481          if (empty($this->get_students())) {
 482              return 0;
 483          }
 484  
 485          if ($this->ntotallogs === null) {
 486              list($filterselect, $filterparams) = $this->course_students_query_filter();
 487              if (!$logstore = \core_analytics\manager::get_analytics_logstore()) {
 488                  $this->ntotallogs = 0;
 489              } else {
 490                  $this->ntotallogs = $logstore->get_events_select_count($filterselect, $filterparams);
 491              }
 492          }
 493  
 494          return $this->ntotallogs;
 495      }
 496  
 497      /**
 498       * Returns all the activities of the provided type the course has.
 499       *
 500       * @param string $activitytype
 501       * @return array
 502       */
 503      public function get_all_activities($activitytype) {
 504  
 505          // Using is set because we set it to false if there are no activities.
 506          if (!isset($this->courseactivities[$activitytype])) {
 507              $modinfo = get_fast_modinfo($this->get_course_data(), -1);
 508              $instances = $modinfo->get_instances_of($activitytype);
 509  
 510              if ($instances) {
 511                  $this->courseactivities[$activitytype] = array();
 512                  foreach ($instances as $instance) {
 513                      // By context.
 514                      $this->courseactivities[$activitytype][$instance->context->id] = $instance;
 515                  }
 516              } else {
 517                  $this->courseactivities[$activitytype] = false;
 518              }
 519          }
 520  
 521          return $this->courseactivities[$activitytype];
 522      }
 523  
 524      /**
 525       * Returns the course students grades.
 526       *
 527       * @param array $courseactivities
 528       * @return array
 529       */
 530      public function get_student_grades($courseactivities) {
 531  
 532          if (empty($courseactivities)) {
 533              return array();
 534          }
 535  
 536          $grades = array();
 537          foreach ($courseactivities as $contextid => $instance) {
 538              $gradesinfo = grade_get_grades($this->course->id, 'mod', $instance->modname, $instance->instance, $this->studentids);
 539  
 540              // Sort them by activity context and user.
 541              if ($gradesinfo && $gradesinfo->items) {
 542                  foreach ($gradesinfo->items as $gradeitem) {
 543                      foreach ($gradeitem->grades as $userid => $grade) {
 544                          if (empty($grades[$contextid][$userid])) {
 545                              // Initialise it as array because a single activity can have multiple grade items (e.g. workshop).
 546                              $grades[$contextid][$userid] = array();
 547                          }
 548                          $grades[$contextid][$userid][$gradeitem->id] = $grade;
 549                      }
 550                  }
 551              }
 552          }
 553  
 554          return $grades;
 555      }
 556  
 557      /**
 558       * Used by get_user_ids to extract the user id.
 559       *
 560       * @param \stdClass $record
 561       * @return int The user id.
 562       */
 563      protected function filter_user_id($record) {
 564          return $record->userid;
 565      }
 566  
 567      /**
 568       * Returns the average time between 2 timestamps.
 569       *
 570       * @param int $start
 571       * @param int $end
 572       * @return array [starttime, averagetime, endtime]
 573       */
 574      protected function update_loop_times($start, $end) {
 575          $avg = intval(($start + $end) / 2);
 576          return array($start, $avg, $end);
 577      }
 578  
 579      /**
 580       * Returns the query and params used to filter the logstore by this course students.
 581       *
 582       * @param string $prefix
 583       * @return array
 584       */
 585      protected function course_students_query_filter($prefix = false) {
 586          global $DB;
 587  
 588          if ($prefix) {
 589              $prefix = $prefix . '.';
 590          }
 591  
 592          // Check the amount of student logs in the 4 previous weeks.
 593          list($studentssql, $studentsparams) = $DB->get_in_or_equal($this->get_students(), SQL_PARAMS_NAMED);
 594          $filterselect = $prefix . 'courseid = :courseid AND ' . $prefix . 'userid ' . $studentssql;
 595          $filterparams = array('courseid' => $this->course->id) + $studentsparams;
 596  
 597          return array($filterselect, $filterparams);
 598      }
 599  
 600      /**
 601       * Calculate median
 602       *
 603       * Keys are ignored.
 604       *
 605       * @param int[]|float[] $values Sorted array of values
 606       * @return int
 607       */
 608      protected function median($values) {
 609          $count = count($values);
 610  
 611          if ($count === 1) {
 612              return reset($values);
 613          }
 614  
 615          $middlevalue = (int)floor(($count - 1) / 2);
 616  
 617          if ($count % 2) {
 618              // Odd number, middle is the median.
 619              $median = $values[$middlevalue];
 620          } else {
 621              // Even number, calculate avg of 2 medians.
 622              $low = $values[$middlevalue];
 623              $high = $values[$middlevalue + 1];
 624              $median = (($low + $high) / 2);
 625          }
 626          return intval($median);
 627      }
 628  }