weekly release 3.4dev
[moodle.git] / analytics / classes / course.php
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/>.
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  */
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
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 {
42     /**
43      * @var \core_analytics\course[] $instances
44      */
45     protected static $instances = array();
47     /**
48      * Course object
49      *
50      * @var \stdClass
51      */
52     protected $course = null;
54     /**
55      * The course context.
56      *
57      * @var \context_course
58      */
59     protected $coursecontext = null;
61     /**
62      * The course activities organized by activity type.
63      *
64      * @var array
65      */
66     protected $courseactivities = array();
68     /**
69      * Course start time.
70      *
71      * @var int
72      */
73     protected $starttime = null;
76     /**
77      * Has the course already started?
78      *
79      * @var bool
80      */
81     protected $started = null;
83     /**
84      * Course end time.
85      *
86      * @var int
87      */
88     protected $endtime = null;
90     /**
91      * Is the course finished?
92      *
93      * @var bool
94      */
95     protected $finished = null;
97     /**
98      * Course students ids.
99      *
100      * @var int[]
101      */
102     protected $studentids = [];
105     /**
106      * Course teachers ids
107      *
108      * @var int[]
109      */
110     protected $teacherids = [];
112     /**
113      * Cached copy of the total number of logs in the course.
114      *
115      * @var int
116      */
117     protected $ntotallogs = null;
119     /**
120      * Course manager constructor.
121      *
122      * Use self::instance() instead to get cached copies of the course. Instances obtained
123      * through this constructor will not be cached.
124      *
125      * Loads course students and teachers.
126      *
127      * @param int|stdClass $course Course id
128      * @return void
129      */
130     public function __construct($course) {
132         if (is_scalar($course)) {
133             $this->course = get_course($course);
134         } else {
135             $this->course = $course;
136         }
138         $this->coursecontext = \context_course::instance($this->course->id);
140         $this->now = time();
142         // Get the course users, including users assigned to student and teacher roles at an higher context.
143         $studentroles = array_keys(get_archetype_roles('student'));
144         $this->studentids = $this->get_user_ids($studentroles);
146         $teacherroles = array_keys(get_archetype_roles('editingteacher') + get_archetype_roles('teacher'));
147         $this->teacherids = $this->get_user_ids($teacherroles);
148     }
150     /**
151      * Returns an analytics course instance.
152      *
153      * @param int|stdClass $course Course id
154      * @return \core_analytics\course
155      */
156     public static function instance($course) {
158         $courseid = $course;
159         if (!is_scalar($courseid)) {
160             $courseid = $course->id;
161         }
163         if (!empty(self::$instances[$courseid])) {
164             return self::$instances[$courseid];
165         }
167         $instance = new \core_analytics\course($course);
168         self::$instances[$courseid] = $instance;
169         return self::$instances[$courseid];
170     }
172     /**
173      * Clears all statically cached instances.
174      *
175      * @return void
176      */
177     public static function reset_caches() {
178         self::$instances = array();
179     }
181     /**
182      * get_id
183      *
184      * @return int
185      */
186     public function get_id() {
187         return $this->course->id;
188     }
190     /**
191      * get_context
192      *
193      * @return \context
194      */
195     public function get_context() {
196         if ($this->coursecontext === null) {
197             $this->coursecontext = \context_course::instance($this->course->id);
198         }
199         return $this->coursecontext;
200     }
202     /**
203      * Get the course start timestamp.
204      *
205      * @return int Timestamp or 0 if has not started yet.
206      */
207     public function get_start() {
209         if ($this->starttime !== null) {
210             return $this->starttime;
211         }
213         // The field always exist but may have no valid if the course is created through a sync process.
214         if (!empty($this->course->startdate)) {
215             $this->starttime = (int)$this->course->startdate;
216         } else {
217             $this->starttime = 0;
218         }
220         return $this->starttime;
221     }
223     /**
224      * Guesses the start of the course based on students' activity and enrolment start dates.
225      *
226      * @return int
227      */
228     public function guess_start() {
229         global $DB;
231         if (!$this->get_total_logs()) {
232             // Can't guess.
233             return 0;
234         }
236         if (!$logstore = \core_analytics\manager::get_analytics_logstore()) {
237             return 0;
238         }
240         // We first try to find current course student logs.
241         $firstlogs = array();
242         foreach ($this->studentids as $studentid) {
243             // Grrr, we are limited by logging API, we could do this easily with a
244             // select min(timecreated) from xx where courseid = yy group by userid.
246             // Filters based on the premise that more than 90% of people will be using
247             // standard logstore, which contains a userid, contextlevel, contextinstanceid index.
248             $select = "userid = :userid AND contextlevel = :contextlevel AND contextinstanceid = :contextinstanceid";
249             $params = array('userid' => $studentid, 'contextlevel' => CONTEXT_COURSE, 'contextinstanceid' => $this->get_id());
250             $events = $logstore->get_events_select($select, $params, 'timecreated ASC', 0, 1);
251             if ($events) {
252                 $event = reset($events);
253                 $firstlogs[] = $event->timecreated;
254             }
255         }
256         if (empty($firstlogs)) {
257             // Can't guess if no student accesses.
258             return 0;
259         }
261         sort($firstlogs);
262         $firstlogsmedian = $this->median($firstlogs);
264         $studentenrolments = enrol_get_course_users($this->get_id(), $this->studentids);
265         if (empty($studentenrolments)) {
266             return 0;
267         }
269         $enrolstart = array();
270         foreach ($studentenrolments as $studentenrolment) {
271             $enrolstart[] = ($studentenrolment->uetimestart) ? $studentenrolment->uetimestart : $studentenrolment->uetimecreated;
272         }
273         sort($enrolstart);
274         $enrolstartmedian = $this->median($enrolstart);
276         return intval(($enrolstartmedian + $firstlogsmedian) / 2);
277     }
279     /**
280      * Get the course end timestamp.
281      *
282      * @return int Timestamp or 0 if time end was not set.
283      */
284     public function get_end() {
285         global $DB;
287         if ($this->endtime !== null) {
288             return $this->endtime;
289         }
291         // The enddate field is only available from Moodle 3.2 (MDL-22078).
292         if (!empty($this->course->enddate)) {
293             $this->endtime = (int)$this->course->enddate;
294             return $this->endtime;
295         }
297         return 0;
298     }
300     /**
301      * Get the course end timestamp.
302      *
303      * @return int Timestamp, \core_analytics\analysable::MAX_TIME if we don't know but ongoing and 0 if we can not work it out.
304      */
305     public function guess_end() {
306         global $DB;
308         if ($this->get_total_logs() === 0) {
309             // No way to guess if there are no logs.
310             $this->endtime = 0;
311             return $this->endtime;
312         }
314         list($filterselect, $filterparams) = $this->course_students_query_filter('ula');
316         // Consider the course open if there are still student accesses.
317         $monthsago = time() - (WEEKSECS * 4 * 2);
318         $select = $filterselect . ' AND timeaccess > :timeaccess';
319         $params = $filterparams + array('timeaccess' => $monthsago);
320         $sql = "SELECT timeaccess FROM {user_lastaccess} ula
321                   JOIN {enrol} e ON e.courseid = ula.courseid
322                   JOIN {user_enrolments} ue ON e.id = ue.enrolid AND ue.userid = ula.userid
323                  WHERE $select";
324         if ($records = $DB->get_records_sql($sql, $params)) {
325             return 0;
326         }
328         $sql = "SELECT timeaccess FROM {user_lastaccess} ula
329                   JOIN {enrol} e ON e.courseid = ula.courseid
330                   JOIN {user_enrolments} ue ON e.id = ue.enrolid AND ue.userid = ula.userid
331                  WHERE $filterselect AND ula.timeaccess != 0
332                  ORDER BY timeaccess DESC";
333         $studentlastaccesses = $DB->get_fieldset_sql($sql, $filterparams);
334         if (empty($studentlastaccesses)) {
335             return 0;
336         }
337         sort($studentlastaccesses);
339         return $this->median($studentlastaccesses);
340     }
342     /**
343      * Returns a course plain object.
344      *
345      * @return \stdClass
346      */
347     public function get_course_data() {
348         return $this->course;
349     }
351     /**
352      * Is the course valid to extract indicators from it?
353      *
354      * @return bool
355      */
356     public function is_valid() {
358         if (!$this->was_started() || !$this->is_finished()) {
359             return false;
360         }
362         return true;
363     }
365     /**
366      * Has the course started?
367      *
368      * @return bool
369      */
370     public function was_started() {
372         if ($this->started === null) {
373             if ($this->get_start() === 0 || $this->now < $this->get_start()) {
374                 // Not yet started.
375                 $this->started = false;
376             } else {
377                 $this->started = true;
378             }
379         }
381         return $this->started;
382     }
384     /**
385      * Has the course finished?
386      *
387      * @return bool
388      */
389     public function is_finished() {
391         if ($this->finished === null) {
392             $endtime = $this->get_end();
393             if ($endtime === 0 || $this->now < $endtime) {
394                 // It is not yet finished or no idea when it finishes.
395                 $this->finished = false;
396             } else {
397                 $this->finished = true;
398             }
399         }
401         return $this->finished;
402     }
404     /**
405      * Returns a list of user ids matching the specified roles in this course.
406      *
407      * @param array $roleids
408      * @return array
409      */
410     public function get_user_ids($roleids) {
412         // We need to index by ra.id as a user may have more than 1 $roles role.
413         $records = get_role_users($roleids, $this->coursecontext, true, 'ra.id, u.id AS userid, r.id AS roleid', 'ra.id ASC');
415         // If a user have more than 1 $roles role array_combine will discard the duplicate.
416         $callable = array($this, 'filter_user_id');
417         $userids = array_values(array_map($callable, $records));
418         return array_combine($userids, $userids);
419     }
421     /**
422      * Returns the course students.
423      *
424      * @return stdClass[]
425      */
426     public function get_students() {
427         return $this->studentids;
428     }
430     /**
431      * Returns the total number of student logs in the course
432      *
433      * @return int
434      */
435     public function get_total_logs() {
436         global $DB;
438         // No logs if no students.
439         if (empty($this->studentids)) {
440             return 0;
441         }
443         if ($this->ntotallogs === null) {
444             list($filterselect, $filterparams) = $this->course_students_query_filter();
445             if (!$logstore = \core_analytics\manager::get_analytics_logstore()) {
446                 $this->ntotallogs = 0;
447             } else {
448                 $this->ntotallogs = $logstore->get_events_select_count($filterselect, $filterparams);
449             }
450         }
452         return $this->ntotallogs;
453     }
455     /**
456      * Returns all the activities of the provided type the course has.
457      *
458      * @param string $activitytype
459      * @return array
460      */
461     public function get_all_activities($activitytype) {
463         // Using is set because we set it to false if there are no activities.
464         if (!isset($this->courseactivities[$activitytype])) {
465             $modinfo = get_fast_modinfo($this->get_course_data(), -1);
466             $instances = $modinfo->get_instances_of($activitytype);
468             if ($instances) {
469                 $this->courseactivities[$activitytype] = array();
470                 foreach ($instances as $instance) {
471                     // By context.
472                     $this->courseactivities[$activitytype][$instance->context->id] = $instance;
473                 }
474             } else {
475                 $this->courseactivities[$activitytype] = false;
476             }
477         }
479         return $this->courseactivities[$activitytype];
480     }
482     /**
483      * Returns the course students grades.
484      *
485      * @param array $courseactivities
486      * @return array
487      */
488     public function get_student_grades($courseactivities) {
490         if (empty($courseactivities)) {
491             return array();
492         }
494         $grades = array();
495         foreach ($courseactivities as $contextid => $instance) {
496             $gradesinfo = grade_get_grades($this->course->id, 'mod', $instance->modname, $instance->instance, $this->studentids);
498             // Sort them by activity context and user.
499             if ($gradesinfo && $gradesinfo->items) {
500                 foreach ($gradesinfo->items as $gradeitem) {
501                     foreach ($gradeitem->grades as $userid => $grade) {
502                         if (empty($grades[$contextid][$userid])) {
503                             // Initialise it as array because a single activity can have multiple grade items (e.g. workshop).
504                             $grades[$contextid][$userid] = array();
505                         }
506                         $grades[$contextid][$userid][$gradeitem->id] = $grade;
507                     }
508                 }
509             }
510         }
512         return $grades;
513     }
515     /**
516      * Guesses all activities that were available during a period of time.
517      *
518      * @param string $activitytype
519      * @param int $starttime
520      * @param int $endtime
521      * @param \stdClass $student
522      * @return array
523      */
524     public function get_activities($activitytype, $starttime, $endtime, $student = false) {
526         // Var $student may not be available, default to not calculating dynamic data.
527         $studentid = -1;
528         if ($student) {
529             $studentid = $student->id;
530         }
531         $modinfo = get_fast_modinfo($this->get_course_data(), $studentid);
532         $activities = $modinfo->get_instances_of($activitytype);
534         $timerangeactivities = array();
535         foreach ($activities as $activity) {
536             if (!$this->completed_by($activity, $starttime, $endtime)) {
537                 continue;
538             }
540             $timerangeactivities[$activity->context->id] = $activity;
541         }
543         return $timerangeactivities;
544     }
546     /**
547      * Was the activity supposed to be completed during the provided time range?.
548      *
549      * @param \cm_info $activity
550      * @param int $starttime
551      * @param int $endtime
552      * @return bool
553      */
554     protected function completed_by(\cm_info $activity, $starttime, $endtime) {
556         // We can't check uservisible because:
557         // - Any activity with available until would not be counted.
558         // - Sites may block student's course view capabilities once the course is closed.
560         // Students can not view hidden activities by default, this is not reliable 100% but accurate in most of the cases.
561         if ($activity->visible === false) {
562             return false;
563         }
565         // We skip activities that were not yet visible or their 'until' was not in this $starttime - $endtime range.
566         if ($activity->availability) {
567             $info = new \core_availability\info_module($activity);
568             $activityavailability = $this->availability_completed_by($info, $starttime, $endtime);
569             if ($activityavailability === false) {
570                 return false;
571             } else if ($activityavailability === true) {
572                 // This activity belongs to this time range.
573                 return true;
574             }
575         }
577         // We skip activities in sections that were not yet visible or their 'until' was not in this $starttime - $endtime range.
578         $section = $activity->get_modinfo()->get_section_info($activity->sectionnum);
579         if ($section->availability) {
580             $info = new \core_availability\info_section($section);
581             $sectionavailability = $this->availability_completed_by($info, $starttime, $endtime);
582             if ($sectionavailability === false) {
583                 return false;
584             } else if ($sectionavailability === true) {
585                 // This activity belongs to this section time range.
586                 return true;
587             }
588         }
590         // When the course is using format weeks we use the week's end date.
591         $format = course_get_format($activity->get_modinfo()->get_course());
592         if ($this->course->format === 'weeks') {
593             $dates = $format->get_section_dates($section);
595             // We need to consider the +2 hours added by get_section_dates.
596             // Avoid $starttime <= $dates->end because $starttime may be the start of the next week.
597             if ($starttime < ($dates->end - 7200) && $endtime >= ($dates->end - 7200)) {
598                 return true;
599             } else {
600                 return false;
601             }
602         }
604         if ($activity->sectionnum == 0) {
605             return false;
606         }
608         if (!$this->get_end() || !$this->get_start()) {
609             debugging('Activities which due date is in a time range can not be calculated ' .
610                 'if the course doesn\'t have start and end date', DEBUG_DEVELOPER);
611             return false;
612         }
614         if (!course_format_uses_sections($this->course->format)) {
615             // If it does not use sections and there are no availability conditions to access it it is available
616             // and we can not magically classify it into any other time range than this one.
617             return true;
618         }
620         // Split the course duration in the number of sections and consider the end of each section the due
621         // date of all activities contained in that section.
622         $formatoptions = $format->get_format_options();
623         if (!empty($formatoptions['numsections'])) {
624             $nsections = $formatoptions['numsections'];
625         } else {
626             // There are course format that use sections but without numsections, we fallback to the number
627             // of cached sections in get_section_info_all, not that accurate though.
628             $coursesections = $activity->get_modinfo()->get_section_info_all();
629             $nsections = count($coursesections);
630             if (isset($coursesections[0])) {
631                 // We don't count section 0 if it exists.
632                 $nsections--;
633             }
634         }
636         $courseduration = $this->get_end() - $this->get_start();
637         $sectionduration = round($courseduration / $nsections);
638         $activitysectionenddate = $this->get_start() + ($sectionduration * $activity->sectionnum);
639         if ($activitysectionenddate > $starttime && $activitysectionenddate <= $endtime) {
640             return true;
641         }
643         return false;
644     }
646     /**
647      * Check if the activity/section should have been completed during the provided period according to its availability rules.
648      *
649      * @param \core_availability\info $info
650      * @param int $starttime
651      * @param int $endtime
652      * @return bool|null
653      */
654     protected function availability_completed_by(\core_availability\info $info, $starttime, $endtime) {
656         $dateconditions = $info->get_availability_tree()->get_all_children('\availability_date\condition');
657         foreach ($dateconditions as $condition) {
658             // Availability API does not allow us to check from / to dates nicely, we need to be naughty.
659             $conditiondata = $condition->save();
661             if ($conditiondata->d === \availability_date\condition::DIRECTION_FROM &&
662                     $conditiondata->t > $endtime) {
663                 // Skip this activity if any 'from' date is later than the end time.
664                 return false;
666             } else if ($conditiondata->d === \availability_date\condition::DIRECTION_UNTIL &&
667                     ($conditiondata->t < $starttime || $conditiondata->t > $endtime)) {
668                 // Skip activity if any 'until' date is not in $starttime - $endtime range.
669                 return false;
670             } else if ($conditiondata->d === \availability_date\condition::DIRECTION_UNTIL &&
671                     $conditiondata->t < $endtime && $conditiondata->t > $starttime) {
672                 return true;
673             }
674         }
676         // This can be interpreted as 'the activity was available but we don't know if its expected completion date
677         // was during this period.
678         return null;
679     }
681     /**
682      * Used by get_user_ids to extract the user id.
683      *
684      * @param \stdClass $record
685      * @return int The user id.
686      */
687     protected function filter_user_id($record) {
688         return $record->userid;
689     }
691     /**
692      * Returns the average time between 2 timestamps.
693      *
694      * @param int $start
695      * @param int $end
696      * @return array [starttime, averagetime, endtime]
697      */
698     protected function update_loop_times($start, $end) {
699         $avg = intval(($start + $end) / 2);
700         return array($start, $avg, $end);
701     }
703     /**
704      * Returns the query and params used to filter the logstore by this course students.
705      *
706      * @param string $prefix
707      * @return array
708      */
709     protected function course_students_query_filter($prefix = false) {
710         global $DB;
712         if ($prefix) {
713             $prefix = $prefix . '.';
714         }
716         // Check the amount of student logs in the 4 previous weeks.
717         list($studentssql, $studentsparams) = $DB->get_in_or_equal($this->studentids, SQL_PARAMS_NAMED);
718         $filterselect = $prefix . 'courseid = :courseid AND ' . $prefix . 'userid ' . $studentssql;
719         $filterparams = array('courseid' => $this->course->id) + $studentsparams;
721         return array($filterselect, $filterparams);
722     }
724     /**
725      * Calculate median
726      *
727      * Keys are ignored.
728      *
729      * @param int|float $values Sorted array of values
730      * @return int
731      */
732     protected function median($values) {
733         $count = count($values);
735         if ($count === 1) {
736             return reset($values);
737         }
739         $middlevalue = floor(($count - 1) / 2);
741         if ($count % 2) {
742             // Odd number, middle is the median.
743             $median = $values[$middlevalue];
744         } else {
745             // Even number, calculate avg of 2 medians.
746             $low = $values[$middlevalue];
747             $high = $values[$middlevalue + 1];
748             $median = (($low + $high) / 2);
749         }
750         return intval($median);
751     }