2 // This file is part of Moodle - http://moodle.org/
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.
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/>.
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');
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
{
43 * @var bool Has this course data been already loaded.
45 protected $loaded = false;
48 * @var int $cachedid self::$cachedinstance analysable id.
50 protected static $cachedid = 0;
53 * @var \core_analytics\course $cachedinstance
55 protected static $cachedinstance = null;
62 protected $course = null;
67 * @var \context_course
69 protected $coursecontext = null;
72 * The course activities organized by activity type.
76 protected $courseactivities = array();
83 protected $starttime = null;
87 * Has the course already started?
91 protected $started = null;
98 protected $endtime = null;
101 * Is the course finished?
105 protected $finished = null;
108 * Course students ids.
112 protected $studentids = [];
116 * Course teachers ids
120 protected $teacherids = [];
123 * Cached copy of the total number of logs in the course.
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
139 public function __construct($course, ?\context
$context = null) {
141 if (is_scalar($course)) {
142 $this->course
= new \
stdClass();
143 $this->course
->id
= $course;
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) {
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;
184 public function get_id() {
185 return $this->course
->id
;
189 * Loads the analytics course object.
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();
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
229 public function get_name() {
230 return format_string($this->get_course_data()->shortname
, true, array('context' => $this->get_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
;
260 $this->starttime
= 0;
263 return $this->starttime
;
267 * Guesses the start of the course based on students' activity and enrolment start dates.
271 public function guess_start() {
274 if (!$this->get_total_logs()) {
279 if (!$logstore = \core_analytics\manager
::get_analytics_logstore()) {
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);
295 $event = reset($events);
296 $firstlogs[] = $event->timecreated
;
299 if (empty($firstlogs)) {
300 // Can't guess if no student accesses.
305 $firstlogsmedian = $this->median($firstlogs);
307 $studentenrolments = enrol_get_course_users($this->get_id(), $this->get_students());
308 if (empty($studentenrolments)) {
312 $enrolstart = array();
313 foreach ($studentenrolments as $studentenrolment) {
314 $enrolstart[] = ($studentenrolment->uetimestart
) ?
$studentenrolment->uetimestart
: $studentenrolment->uetimecreated
;
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() {
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
;
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() {
351 if ($this->get_total_logs() === 0) {
352 // No way to guess if there are no logs.
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
367 if ($records = $DB->get_records_sql($sql, $params)) {
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)) {
380 sort($studentlastaccesses);
382 return $this->median($studentlastaccesses);
386 * Returns a course plain object.
390 public function get_course_data() {
392 if (!$this->loaded
) {
396 return $this->course
;
400 * Has the course started?
404 public function was_started() {
406 if ($this->started
=== null) {
407 if ($this->get_start() === 0 ||
$this->now
< $this->get_start()) {
409 $this->started
= false;
411 $this->started
= true;
415 return $this->started
;
419 * Has the course finished?
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;
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
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.
460 public function get_students() {
462 if (!$this->loaded
) {
466 return $this->studentids
;
470 * Returns the total number of student logs in the course
474 public function get_total_logs() {
477 // No logs if no students.
478 if (empty($this->get_students())) {
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;
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
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);
508 $this->courseactivities
[$activitytype] = array();
509 foreach ($instances as $instance) {
511 $this->courseactivities
[$activitytype][$instance->context
->id
] = $instance;
514 $this->courseactivities
[$activitytype] = false;
518 return $this->courseactivities
[$activitytype];
522 * Returns the course students grades.
524 * @param array $courseactivities
527 public function get_student_grades($courseactivities) {
529 if (empty($courseactivities)) {
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;
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.
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
582 protected function course_students_query_filter($prefix = false) {
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);
602 * @param int[]|float[] $values Sorted array of values
605 protected function median($values) {
606 $count = count($values);
609 return reset($values);
612 $middlevalue = (int)floor(($count - 1) / 2);
615 // Odd number, middle is the median.
616 $median = $values[$middlevalue];
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);