Merge branch 'MDL-62397-master' of git://github.com/andrewnicols/moodle
[moodle.git] / analytics / classes / course.php
blob919f36bd690fb76b794ea84a4e6cef4d8961449e
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.
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/>.
17 /**
18 * Moodle course analysable
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
25 namespace core_analytics;
27 defined('MOODLE_INTERNAL') || die();
29 require_once($CFG->dirroot . '/course/lib.php');
30 require_once($CFG->dirroot . '/lib/gradelib.php');
31 require_once($CFG->dirroot . '/lib/enrollib.php');
33 /**
34 * Moodle course analysable
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
40 class course implements \core_analytics\analysable {
42 /**
43 * @var bool Has this course data been already loaded.
45 protected $loaded = false;
47 /**
48 * @var int $cachedid self::$cachedinstance analysable id.
50 protected static $cachedid = 0;
52 /**
53 * @var \core_analytics\course $cachedinstance
55 protected static $cachedinstance = null;
57 /**
58 * Course object
60 * @var \stdClass
62 protected $course = null;
64 /**
65 * The course context.
67 * @var \context_course
69 protected $coursecontext = null;
71 /**
72 * The course activities organized by activity type.
74 * @var array
76 protected $courseactivities = array();
78 /**
79 * Course start time.
81 * @var int
83 protected $starttime = null;
86 /**
87 * Has the course already started?
89 * @var bool
91 protected $started = null;
93 /**
94 * Course end time.
96 * @var int
98 protected $endtime = null;
101 * Is the course finished?
103 * @var bool
105 protected $finished = null;
108 * Course students ids.
110 * @var int[]
112 protected $studentids = [];
116 * Course teachers ids
118 * @var int[]
120 protected $teacherids = [];
123 * Cached copy of the total number of logs in the course.
125 * @var int
127 protected $ntotallogs = null;
130 * Course manager constructor.
132 * Use self::instance() instead to get cached copies of the course. Instances obtained
133 * through this constructor will not be cached.
135 * Lazy load of course data, students and teachers.
137 * @param int|\stdClass $course Course id
138 * @return void
140 public function __construct($course) {
142 if (is_scalar($course)) {
143 $this->course = new \stdClass();
144 $this->course->id = $course;
145 } else {
146 $this->course = $course;
151 * Returns an analytics course instance.
153 * Lazy load of course data, students and teachers.
155 * @param int|\stdClass $course Course object or course id
156 * @return \core_analytics\course
158 public static function instance($course) {
160 $courseid = $course;
161 if (!is_scalar($courseid)) {
162 $courseid = $course->id;
165 if (self::$cachedid === $courseid) {
166 return self::$cachedinstance;
169 $cachedinstance = new \core_analytics\course($course);
170 self::$cachedinstance = $cachedinstance;
171 self::$cachedid = (int)$courseid;
172 return self::$cachedinstance;
176 * get_id
178 * @return int
180 public function get_id() {
181 return $this->course->id;
185 * Loads the analytics course object.
187 * @return void
189 protected function load() {
191 // The instance constructor could be already loaded with the full course object. Using shortname
192 // because it is a required course field.
193 if (empty($this->course->shortname)) {
194 $this->course = get_course($this->course->id);
197 $this->coursecontext = $this->get_context();
199 $this->now = time();
201 // Get the course users, including users assigned to student and teacher roles at an higher context.
202 $cache = \cache::make_from_params(\cache_store::MODE_REQUEST, 'core_analytics', 'rolearchetypes');
204 // Flag the instance as loaded.
205 $this->loaded = true;
207 if (!$studentroles = $cache->get('student')) {
208 $studentroles = array_keys(get_archetype_roles('student'));
209 $cache->set('student', $studentroles);
211 $this->studentids = $this->get_user_ids($studentroles);
213 if (!$teacherroles = $cache->get('teacher')) {
214 $teacherroles = array_keys(get_archetype_roles('editingteacher') + get_archetype_roles('teacher'));
215 $cache->set('teacher', $teacherroles);
217 $this->teacherids = $this->get_user_ids($teacherroles);
221 * The course short name
223 * @return string
225 public function get_name() {
226 return format_string($this->get_course_data()->shortname, true, array('context' => $this->get_context()));
230 * get_context
232 * @return \context
234 public function get_context() {
235 if ($this->coursecontext === null) {
236 $this->coursecontext = \context_course::instance($this->course->id);
238 return $this->coursecontext;
242 * Get the course start timestamp.
244 * @return int Timestamp or 0 if has not started yet.
246 public function get_start() {
248 if ($this->starttime !== null) {
249 return $this->starttime;
252 // The field always exist but may have no valid if the course is created through a sync process.
253 if (!empty($this->get_course_data()->startdate)) {
254 $this->starttime = (int)$this->get_course_data()->startdate;
255 } else {
256 $this->starttime = 0;
259 return $this->starttime;
263 * Guesses the start of the course based on students' activity and enrolment start dates.
265 * @return int
267 public function guess_start() {
268 global $DB;
270 if (!$this->get_total_logs()) {
271 // Can't guess.
272 return 0;
275 if (!$logstore = \core_analytics\manager::get_analytics_logstore()) {
276 return 0;
279 // We first try to find current course student logs.
280 $firstlogs = array();
281 foreach ($this->get_students() as $studentid) {
282 // Grrr, we are limited by logging API, we could do this easily with a
283 // select min(timecreated) from xx where courseid = yy group by userid.
285 // Filters based on the premise that more than 90% of people will be using
286 // standard logstore, which contains a userid, contextlevel, contextinstanceid index.
287 $select = "userid = :userid AND contextlevel = :contextlevel AND contextinstanceid = :contextinstanceid";
288 $params = array('userid' => $studentid, 'contextlevel' => CONTEXT_COURSE, 'contextinstanceid' => $this->get_id());
289 $events = $logstore->get_events_select($select, $params, 'timecreated ASC', 0, 1);
290 if ($events) {
291 $event = reset($events);
292 $firstlogs[] = $event->timecreated;
295 if (empty($firstlogs)) {
296 // Can't guess if no student accesses.
297 return 0;
300 sort($firstlogs);
301 $firstlogsmedian = $this->median($firstlogs);
303 $studentenrolments = enrol_get_course_users($this->get_id(), $this->get_students());
304 if (empty($studentenrolments)) {
305 return 0;
308 $enrolstart = array();
309 foreach ($studentenrolments as $studentenrolment) {
310 $enrolstart[] = ($studentenrolment->uetimestart) ? $studentenrolment->uetimestart : $studentenrolment->uetimecreated;
312 sort($enrolstart);
313 $enrolstartmedian = $this->median($enrolstart);
315 return intval(($enrolstartmedian + $firstlogsmedian) / 2);
319 * Get the course end timestamp.
321 * @return int Timestamp or 0 if time end was not set.
323 public function get_end() {
324 global $DB;
326 if ($this->endtime !== null) {
327 return $this->endtime;
330 // The enddate field is only available from Moodle 3.2 (MDL-22078).
331 if (!empty($this->get_course_data()->enddate)) {
332 $this->endtime = (int)$this->get_course_data()->enddate;
333 return $this->endtime;
336 return 0;
340 * Get the course end timestamp.
342 * @return int Timestamp, \core_analytics\analysable::MAX_TIME if we don't know but ongoing and 0 if we can not work it out.
344 public function guess_end() {
345 global $DB;
347 if ($this->get_total_logs() === 0) {
348 // No way to guess if there are no logs.
349 $this->endtime = 0;
350 return $this->endtime;
353 list($filterselect, $filterparams) = $this->course_students_query_filter('ula');
355 // Consider the course open if there are still student accesses.
356 $monthsago = time() - (WEEKSECS * 4 * 2);
357 $select = $filterselect . ' AND timeaccess > :timeaccess';
358 $params = $filterparams + array('timeaccess' => $monthsago);
359 $sql = "SELECT DISTINCT timeaccess FROM {user_lastaccess} ula
360 JOIN {enrol} e ON e.courseid = ula.courseid
361 JOIN {user_enrolments} ue ON e.id = ue.enrolid AND ue.userid = ula.userid
362 WHERE $select";
363 if ($records = $DB->get_records_sql($sql, $params)) {
364 return 0;
367 $sql = "SELECT DISTINCT timeaccess FROM {user_lastaccess} ula
368 JOIN {enrol} e ON e.courseid = ula.courseid
369 JOIN {user_enrolments} ue ON e.id = ue.enrolid AND ue.userid = ula.userid
370 WHERE $filterselect AND ula.timeaccess != 0
371 ORDER BY timeaccess DESC";
372 $studentlastaccesses = $DB->get_fieldset_sql($sql, $filterparams);
373 if (empty($studentlastaccesses)) {
374 return 0;
376 sort($studentlastaccesses);
378 return $this->median($studentlastaccesses);
382 * Returns a course plain object.
384 * @return \stdClass
386 public function get_course_data() {
388 if (!$this->loaded) {
389 $this->load();
392 return $this->course;
396 * Has the course started?
398 * @return bool
400 public function was_started() {
402 if ($this->started === null) {
403 if ($this->get_start() === 0 || $this->now < $this->get_start()) {
404 // Not yet started.
405 $this->started = false;
406 } else {
407 $this->started = true;
411 return $this->started;
415 * Has the course finished?
417 * @return bool
419 public function is_finished() {
421 if ($this->finished === null) {
422 $endtime = $this->get_end();
423 if ($endtime === 0 || $this->now < $endtime) {
424 // It is not yet finished or no idea when it finishes.
425 $this->finished = false;
426 } else {
427 $this->finished = true;
431 return $this->finished;
435 * Returns a list of user ids matching the specified roles in this course.
437 * @param array $roleids
438 * @return array
440 public function get_user_ids($roleids) {
442 // We need to index by ra.id as a user may have more than 1 $roles role.
443 $records = get_role_users($roleids, $this->get_context(), true, 'ra.id, u.id AS userid, r.id AS roleid', 'ra.id ASC');
445 // If a user have more than 1 $roles role array_combine will discard the duplicate.
446 $callable = array($this, 'filter_user_id');
447 $userids = array_values(array_map($callable, $records));
448 return array_combine($userids, $userids);
452 * Returns the course students.
454 * @return int[]
456 public function get_students() {
458 if (!$this->loaded) {
459 $this->load();
462 return $this->studentids;
466 * Returns the total number of student logs in the course
468 * @return int
470 public function get_total_logs() {
471 global $DB;
473 // No logs if no students.
474 if (empty($this->get_students())) {
475 return 0;
478 if ($this->ntotallogs === null) {
479 list($filterselect, $filterparams) = $this->course_students_query_filter();
480 if (!$logstore = \core_analytics\manager::get_analytics_logstore()) {
481 $this->ntotallogs = 0;
482 } else {
483 $this->ntotallogs = $logstore->get_events_select_count($filterselect, $filterparams);
487 return $this->ntotallogs;
491 * Returns all the activities of the provided type the course has.
493 * @param string $activitytype
494 * @return array
496 public function get_all_activities($activitytype) {
498 // Using is set because we set it to false if there are no activities.
499 if (!isset($this->courseactivities[$activitytype])) {
500 $modinfo = get_fast_modinfo($this->get_course_data(), -1);
501 $instances = $modinfo->get_instances_of($activitytype);
503 if ($instances) {
504 $this->courseactivities[$activitytype] = array();
505 foreach ($instances as $instance) {
506 // By context.
507 $this->courseactivities[$activitytype][$instance->context->id] = $instance;
509 } else {
510 $this->courseactivities[$activitytype] = false;
514 return $this->courseactivities[$activitytype];
518 * Returns the course students grades.
520 * @param array $courseactivities
521 * @return array
523 public function get_student_grades($courseactivities) {
525 if (empty($courseactivities)) {
526 return array();
529 $grades = array();
530 foreach ($courseactivities as $contextid => $instance) {
531 $gradesinfo = grade_get_grades($this->course->id, 'mod', $instance->modname, $instance->instance, $this->studentids);
533 // Sort them by activity context and user.
534 if ($gradesinfo && $gradesinfo->items) {
535 foreach ($gradesinfo->items as $gradeitem) {
536 foreach ($gradeitem->grades as $userid => $grade) {
537 if (empty($grades[$contextid][$userid])) {
538 // Initialise it as array because a single activity can have multiple grade items (e.g. workshop).
539 $grades[$contextid][$userid] = array();
541 $grades[$contextid][$userid][$gradeitem->id] = $grade;
547 return $grades;
551 * Used by get_user_ids to extract the user id.
553 * @param \stdClass $record
554 * @return int The user id.
556 protected function filter_user_id($record) {
557 return $record->userid;
561 * Returns the average time between 2 timestamps.
563 * @param int $start
564 * @param int $end
565 * @return array [starttime, averagetime, endtime]
567 protected function update_loop_times($start, $end) {
568 $avg = intval(($start + $end) / 2);
569 return array($start, $avg, $end);
573 * Returns the query and params used to filter the logstore by this course students.
575 * @param string $prefix
576 * @return array
578 protected function course_students_query_filter($prefix = false) {
579 global $DB;
581 if ($prefix) {
582 $prefix = $prefix . '.';
585 // Check the amount of student logs in the 4 previous weeks.
586 list($studentssql, $studentsparams) = $DB->get_in_or_equal($this->get_students(), SQL_PARAMS_NAMED);
587 $filterselect = $prefix . 'courseid = :courseid AND ' . $prefix . 'userid ' . $studentssql;
588 $filterparams = array('courseid' => $this->course->id) + $studentsparams;
590 return array($filterselect, $filterparams);
594 * Calculate median
596 * Keys are ignored.
598 * @param int[]|float[] $values Sorted array of values
599 * @return int
601 protected function median($values) {
602 $count = count($values);
604 if ($count === 1) {
605 return reset($values);
608 $middlevalue = (int)floor(($count - 1) / 2);
610 if ($count % 2) {
611 // Odd number, middle is the median.
612 $median = $values[$middlevalue];
613 } else {
614 // Even number, calculate avg of 2 medians.
615 $low = $values[$middlevalue];
616 $high = $values[$middlevalue + 1];
617 $median = (($low + $high) / 2);
619 return intval($median);