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