Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

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