Merge branch 'MDL-72490-m400' of https://github.com/sammarshallou/moodle into MOODLE_...
[moodle.git] / analytics / classes / course.php
blob6e372cfb7330a4bc47af0eeddb411204732d83d1
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 * @param int|\stdClass $course Course id or mdl_course record
136 * @param \context|null $context
137 * @return void
139 public function __construct($course, ?\context $context = null) {
141 if (is_scalar($course)) {
142 $this->course = new \stdClass();
143 $this->course->id = $course;
144 } else {
145 $this->course = $course;
148 if (!is_null($context)) {
149 $this->coursecontext = $context;
154 * Returns an analytics course instance.
156 * Lazy load of course data, students and teachers.
158 * @param int|\stdClass $course Course object or course id
159 * @param \context|null $context
160 * @return \core_analytics\course
162 public static function instance($course, ?\context $context = null) {
164 $courseid = $course;
165 if (!is_scalar($courseid)) {
166 $courseid = $course->id;
169 if (self::$cachedid === $courseid) {
170 return self::$cachedinstance;
173 $cachedinstance = new \core_analytics\course($course, $context);
174 self::$cachedinstance = $cachedinstance;
175 self::$cachedid = (int)$courseid;
176 return self::$cachedinstance;
180 * get_id
182 * @return int
184 public function get_id() {
185 return $this->course->id;
189 * Loads the analytics course object.
191 * @return void
193 protected function load() {
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);
201 $this->coursecontext = $this->get_context();
203 $this->now = time();
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');
208 // Flag the instance as loaded.
209 $this->loaded = true;
211 if (!$studentroles = $cache->get('student')) {
212 $studentroles = array_keys(get_archetype_roles('student'));
213 $cache->set('student', $studentroles);
215 $this->studentids = $this->get_user_ids($studentroles);
217 if (!$teacherroles = $cache->get('teacher')) {
218 $teacherroles = array_keys(get_archetype_roles('editingteacher') + get_archetype_roles('teacher'));
219 $cache->set('teacher', $teacherroles);
221 $this->teacherids = $this->get_user_ids($teacherroles);
225 * The course short name
227 * @return string
229 public function get_name() {
230 return format_string($this->get_course_data()->shortname, true, array('context' => $this->get_context()));
234 * get_context
236 * @return \context
238 public function get_context() {
239 if ($this->coursecontext === null) {
240 $this->coursecontext = \context_course::instance($this->course->id);
242 return $this->coursecontext;
246 * Get the course start timestamp.
248 * @return int Timestamp or 0 if has not started yet.
250 public function get_start() {
252 if ($this->starttime !== null) {
253 return $this->starttime;
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;
263 return $this->starttime;
267 * Guesses the start of the course based on students' activity and enrolment start dates.
269 * @return int
271 public function guess_start() {
272 global $DB;
274 if (!$this->get_total_logs()) {
275 // Can't guess.
276 return 0;
279 if (!$logstore = \core_analytics\manager::get_analytics_logstore()) {
280 return 0;
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.
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;
299 if (empty($firstlogs)) {
300 // Can't guess if no student accesses.
301 return 0;
304 sort($firstlogs);
305 $firstlogsmedian = $this->median($firstlogs);
307 $studentenrolments = enrol_get_course_users($this->get_id(), $this->get_students());
308 if (empty($studentenrolments)) {
309 return 0;
312 $enrolstart = array();
313 foreach ($studentenrolments as $studentenrolment) {
314 $enrolstart[] = ($studentenrolment->uetimestart) ? $studentenrolment->uetimestart : $studentenrolment->uetimecreated;
316 sort($enrolstart);
317 $enrolstartmedian = $this->median($enrolstart);
319 return intval(($enrolstartmedian + $firstlogsmedian) / 2);
323 * Get the course end timestamp.
325 * @return int Timestamp or 0 if time end was not set.
327 public function get_end() {
328 global $DB;
330 if ($this->endtime !== null) {
331 return $this->endtime;
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;
340 return 0;
344 * Get the course end timestamp.
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.
348 public function guess_end() {
349 global $DB;
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;
357 list($filterselect, $filterparams) = $this->course_students_query_filter('ula');
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;
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;
380 sort($studentlastaccesses);
382 return $this->median($studentlastaccesses);
386 * Returns a course plain object.
388 * @return \stdClass
390 public function get_course_data() {
392 if (!$this->loaded) {
393 $this->load();
396 return $this->course;
400 * Has the course started?
402 * @return bool
404 public function was_started() {
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;
415 return $this->started;
419 * Has the course finished?
421 * @return bool
423 public function is_finished() {
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;
435 return $this->finished;
439 * Returns a list of user ids matching the specified roles in this course.
441 * @param array $roleids
442 * @return array
444 public function get_user_ids($roleids) {
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');
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);
456 * Returns the course students.
458 * @return int[]
460 public function get_students() {
462 if (!$this->loaded) {
463 $this->load();
466 return $this->studentids;
470 * Returns the total number of student logs in the course
472 * @return int
474 public function get_total_logs() {
475 global $DB;
477 // No logs if no students.
478 if (empty($this->get_students())) {
479 return 0;
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);
491 return $this->ntotallogs;
495 * Returns all the activities of the provided type the course has.
497 * @param string $activitytype
498 * @return array
500 public function get_all_activities($activitytype) {
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);
507 if ($instances) {
508 $this->courseactivities[$activitytype] = array();
509 foreach ($instances as $instance) {
510 // By context.
511 $this->courseactivities[$activitytype][$instance->context->id] = $instance;
513 } else {
514 $this->courseactivities[$activitytype] = false;
518 return $this->courseactivities[$activitytype];
522 * Returns the course students grades.
524 * @param array $courseactivities
525 * @return array
527 public function get_student_grades($courseactivities) {
529 if (empty($courseactivities)) {
530 return array();
533 $grades = array();
534 foreach ($courseactivities as $contextid => $instance) {
535 $gradesinfo = grade_get_grades($this->course->id, 'mod', $instance->modname, $instance->instance, $this->studentids);
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();
545 $grades[$contextid][$userid][$gradeitem->id] = $grade;
551 return $grades;
555 * Used by get_user_ids to extract the user id.
557 * @param \stdClass $record
558 * @return int The user id.
560 protected function filter_user_id($record) {
561 return $record->userid;
565 * Returns the average time between 2 timestamps.
567 * @param int $start
568 * @param int $end
569 * @return array [starttime, averagetime, endtime]
571 protected function update_loop_times($start, $end) {
572 $avg = intval(($start + $end) / 2);
573 return array($start, $avg, $end);
577 * Returns the query and params used to filter the logstore by this course students.
579 * @param string $prefix
580 * @return array
582 protected function course_students_query_filter($prefix = false) {
583 global $DB;
585 if ($prefix) {
586 $prefix = $prefix . '.';
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;
594 return array($filterselect, $filterparams);
598 * Calculate median
600 * Keys are ignored.
602 * @param int[]|float[] $values Sorted array of values
603 * @return int
605 protected function median($values) {
606 $count = count($values);
608 if ($count === 1) {
609 return reset($values);
612 $middlevalue = (int)floor(($count - 1) / 2);
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);
623 return intval($median);