MDL-59010 analytics: Direct db calls to logging API
[moodle.git] / analytics / classes / local / indicator / community_of_inquiry_activity.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  * Community of inquiry abstract indicator.
19  *
20  * @package   core_analytics
21  * @copyright 2017 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\local\indicator;
27 defined('MOODLE_INTERNAL') || die();
29 /**
30  * Community of inquire abstract indicator.
31  *
32  * @package   core_analytics
33  * @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
34  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
35  */
36 abstract class community_of_inquiry_activity extends linear {
38     protected $course = null;
39     /**
40      * TODO This should ideally be reused by cognitive depth and social breadth.
41      *
42      * @var array Array of logs by [contextid][userid]
43      */
44     protected $activitylogs = null;
46     /**
47      * @var array Array of grades by [contextid][userid]
48      */
49     protected $grades = null;
51     /**
52      * @const Constant cognitive indicator type.
53      */
54     const INDICATOR_COGNITIVE = "cognitve";
56     /**
57      * @const Constant social indicator type.
58      */
59     const INDICATOR_SOCIAL = "social";
61     /**
62      * Returns the activity type. No point in changing this class in children classes.
63      *
64      * @var string The activity name (e.g. assign or quiz)
65      */
66     final protected function get_activity_type() {
67         $class = get_class($this);
68         $package = stristr($class, "\\", true);
69         $type = str_replace("mod_", "", $package);
70         if ($type === $package) {
71             throw new \coding_exception("$class does not belong to any module specific namespace");
72         }
73         return $type;
74     }
76     protected function get_cognitive_depth_level(\cm_info $cm) {
77         throw new \coding_exception('Overwrite get_cognitive_depth_level method to set your activity potential cognitive ' .
78             'depth level');
79     }
81     protected function get_social_breadth_level(\cm_info $cm) {
82         throw new \coding_exception('Overwrite get_social_breadth_level method to set your activity potential social ' .
83             'breadth level');
84     }
86     public static function required_sample_data() {
87         // Only course because the indicator is valid even without students.
88         return array('course');
89     }
91     protected final function any_log($contextid, $user) {
92         if (empty($this->activitylogs[$contextid])) {
93             return false;
94         }
96         // Someone interacted with the activity if there is no user or the user interacted with the
97         // activity if there is a user.
98         if (empty($user) ||
99                 (!empty($user) && !empty($this->activitylogs[$contextid][$user->id]))) {
100             return true;
101         }
103         return false;
104     }
106     protected final function any_write_log($contextid, $user) {
107         if (empty($this->activitylogs[$contextid])) {
108             return false;
109         }
111         // No specific user, we look at all activity logs.
112         $it = $this->activitylogs[$contextid];
113         if ($user) {
114             if (empty($this->activitylogs[$contextid][$user->id])) {
115                 return false;
116             }
117             $it = array($user->id => $this->activitylogs[$contextid][$user->id]);
118         }
119         foreach ($it as $events) {
120             foreach ($events as $log) {
121                 if ($log->crud === 'c' || $log->crud === 'u') {
122                     return true;
123                 }
124             }
125         }
127         return false;
128     }
130     protected function any_feedback($action, \cm_info $cm, $contextid, $user) {
131         if (empty($this->activitylogs[$contextid])) {
132             return false;
133         }
135         if (empty($this->grades[$contextid]) && $this->feedback_check_grades()) {
136             // If there are no grades there is no feedback.
137             return false;
138         }
140         $it = $this->activitylogs[$contextid];
141         if ($user) {
142             if (empty($this->activitylogs[$contextid][$user->id])) {
143                 return false;
144             }
145             $it = array($user->id => $this->activitylogs[$contextid][$user->id]);
146         }
148         foreach ($this->activitylogs[$contextid] as $userid => $events) {
149             $methodname = 'feedback_' . $action;
150             if ($this->{$methodname}($cm, $contextid, $userid)) {
151                 return true;
152             }
153             // If it wasn't viewed try with the next user.
154         }
155         return false;
156     }
158     /**
159      * $cm is used for this method overrides.
160      *
161      * This function must be fast.
162      *
163      * @param \cm_info $cm
164      * @param mixed $contextid
165      * @param mixed $userid
166      * @param int $after Timestamp, defaults to the graded date or false if we don't check the date.
167      * @return bool
168      */
169     protected function feedback_viewed(\cm_info $cm, $contextid, $userid, $after = null) {
170         return $this->feedback_post_action($cm, $contextid, $userid, $this->feedback_viewed_events(), $after);
171     }
173     protected function feedback_replied(\cm_info $cm, $contextid, $userid, $after = null) {
174         return $this->feedback_post_action($cm, $contextid, $userid, $this->feedback_replied_events(), $after);
175     }
177     protected function feedback_submitted(\cm_info $cm, $contextid, $userid, $after = null) {
178         return $this->feedback_post_action($cm, $contextid, $userid, $this->feedback_submitted_events(), $after);
179     }
181     protected function feedback_viewed_events() {
182         throw new \coding_exception('Activities with a potential cognitive or social level that include viewing feedback ' .
183             'should define "feedback_viewed_events" method or should override feedback_viewed method.');
184     }
186     protected function feedback_replied_events() {
187         throw new \coding_exception('Activities with a potential cognitive or social level that include replying to feedback ' .
188             'should define "feedback_replied_events" method or should override feedback_replied method.');
189     }
191     protected function feedback_submitted_events() {
192         throw new \coding_exception('Activities with a potential cognitive or social level that include viewing feedback ' .
193             'should define "feedback_submitted_events" method or should override feedback_submitted method.');
194     }
196     protected function feedback_post_action(\cm_info $cm, $contextid, $userid, $eventnames, $after = null) {
197         if ($after === null) {
198             if ($this->feedback_check_grades()) {
199                 if (!$after = $this->get_graded_date($contextid, $userid)) {
200                     return false;
201                 }
202             } else {
203                 $after = false;
204             }
205         }
207         if (empty($this->activitylogs[$contextid][$userid])) {
208             return false;
209         }
211         foreach ($eventnames as $eventname) {
212             if (!$after) {
213                 if (!empty($this->activitylogs[$contextid][$userid][$eventname])) {
214                     // If we don't care about when the feedback has been seen we consider this enough.
215                     return true;
216                 }
217             } else {
218                 if (empty($this->activitylogs[$contextid][$userid][$eventname])) {
219                     continue;
220                 }
221                 $timestamps = $this->activitylogs[$contextid][$userid][$eventname]->timecreated;
222                 // Faster to start by the end.
223                 rsort($timestamps);
224                 foreach ($timestamps as $timestamp) {
225                     if ($timestamp > $after) {
226                         return true;
227                     }
228                 }
229             }
230         }
231         return false;
232     }
234     /**
235      * get_graded_date
236      *
237      * @param int $contextid
238      * @param int $userid
239      * @param bool $checkfeedback Check that the student was graded or check that feedback was given
240      * @return int|false
241      */
242     protected function get_graded_date($contextid, $userid, $checkfeedback = false) {
243         if (empty($this->grades[$contextid][$userid])) {
244             return false;
245         }
246         foreach ($this->grades[$contextid][$userid] as $gradeitemid => $gradeitem) {
248             // We check that either feedback or the grade is set.
249             if (($checkfeedback && $gradeitem->feedback) || $gradeitem->grade) {
251                 // Grab the first graded date.
252                 if ($gradeitem->dategraded && (empty($after) || $gradeitem->dategraded < $after)) {
253                     $after = $gradeitem->dategraded;
254                 }
255             }
256         }
258         if (!isset($after)) {
259             // False if there are no graded items.
260             return false;
261         }
263         return $after;
264     }
266     protected function get_student_activities($sampleid, $tablename, $starttime, $endtime) {
268         // May not be available.
269         $user = $this->retrieve('user', $sampleid);
271         if ($this->course === null) {
272             // The indicator scope is a range, so all activities belong to the same course.
273             $this->course = \core_analytics\course::instance($this->retrieve('course', $sampleid));
274         }
276         if ($this->activitylogs === null) {
277             // Fetch all activity logs in each activity in the course, not restricted to a specific sample so we can cache it.
279             $courseactivities = $this->course->get_all_activities($this->get_activity_type());
281             // Null if no activities of this type in this course.
282             if (empty($courseactivities)) {
283                 $this->activitylogs = false;
284                 return null;
285             }
286             $this->activitylogs = $this->fetch_activity_logs($courseactivities, $starttime, $endtime);
287         }
289         if ($this->grades === null) {
290             $courseactivities = $this->course->get_all_activities($this->get_activity_type());
291             $this->grades = $this->course->get_student_grades($courseactivities);
292         }
294         if ($cm = $this->retrieve('cm', $sampleid)) {
295             // Samples are at cm level or below.
296             $useractivities = array(\context_module::instance($cm->id)->id => $cm);
297         } else {
298             // All course activities.
299             $useractivities = $this->course->get_activities($this->get_activity_type(), $starttime, $endtime, $user);
300         }
302         return $useractivities;
303     }
305     protected function fetch_activity_logs($activities, $starttime = false, $endtime = false) {
306         global $DB;
308         // Filter by context to use the db table index.
309         list($contextsql, $contextparams) = $DB->get_in_or_equal(array_keys($activities), SQL_PARAMS_NAMED);
310         $select = "contextid $contextsql AND timecreated > :starttime AND timecreated <= :endtime";
311         $params = $contextparams + array('starttime' => $starttime, 'endtime' => $endtime);
313         // Pity that we need to pass through logging readers API when most of the people just uses the standard one.
314         $logstore = \core_analytics\manager::get_analytics_logstore();
315         $events = $logstore->get_events_select_iterator($select, $params, 'timecreated ASC', 0, 0);
317         // Returs the logs organised by contextid, userid and eventname so it is easier to calculate activities data later.
318         // At the same time we want to keep this array reasonably "not-massive".
319         $processedevents = array();
320         foreach ($events as $event) {
321             if (!isset($processedevents[$event->contextid])) {
322                 $processedevents[$event->contextid] = array();
323             }
324             if (!isset($processedevents[$event->contextid][$event->userid])) {
325                 $processedevents[$event->contextid][$event->userid] = array();
326             }
328             // contextid and userid have already been used to index the events, the next field to index by is eventname:
329             // crud is unique per eventname, courseid is the same for all records and we append timecreated.
330             if (!isset($processedevents[$event->contextid][$event->userid][$event->eventname])) {
332                 // Remove all data that can change between events of the same type.
333                 $data = (object)$event->get_data();
334                 unset($data->id);
335                 unset($data->anonymous);
336                 unset($data->relateduserid);
337                 unset($data->other);
338                 unset($data->origin);
339                 unset($data->ip);
340                 $processedevents[$event->contextid][$event->userid][$event->eventname] = $data;
341                 // We want timecreated attribute to be an array containing all user access times.
342                 $processedevents[$event->contextid][$event->userid][$event->eventname]->timecreated = array();
343             }
345             // Add the event timecreated.
346             $processedevents[$event->contextid][$event->userid][$event->eventname]->timecreated[] = intval($event->timecreated);
347         }
348         $events->close();
350         return $processedevents;
351     }
353     /**
354      * Whether grades should be checked or not when looking for feedback.
355      *
356      * @return void
357      */
358     protected function feedback_check_grades() {
359         return true;
360     }
362     /**
363      * cognitive_calculate_sample
364      *
365      * @param $sampleid
366      * @param $tablename
367      * @param bool $starttime
368      * @param bool $endtime
369      * @return float|int|null
370      * @throws \coding_exception
371      */
372     protected function cognitive_calculate_sample($sampleid, $tablename, $starttime = false, $endtime = false) {
374         // May not be available.
375         $user = $this->retrieve('user', $sampleid);
377         if (!$useractivities = $this->get_student_activities($sampleid, $tablename, $starttime, $endtime)) {
378             // Null if no activities.
379             return null;
380         }
382         $scoreperactivity = (self::get_max_value() - self::get_min_value()) / count($useractivities);
384         $score = self::get_min_value();
386         // Iterate through the module activities/resources which due date is part of this time range.
387         foreach ($useractivities as $contextid => $cm) {
389             $potentiallevel = $this->get_cognitive_depth_level($cm);
390             if (!is_int($potentiallevel) || $potentiallevel > 5 || $potentiallevel < 1) {
391                 throw new \coding_exception('Activities\' potential cognitive depth go from 1 to 5.');
392             }
393             $scoreperlevel = $scoreperactivity / $potentiallevel;
395             switch ($potentiallevel) {
396                 case 5:
397                     // Cognitive level 4 is to comment on feedback.
398                     if ($this->any_feedback('submitted', $cm, $contextid, $user)) {
399                         $score += $scoreperlevel * 5;
400                         break;
401                     }
402                 // The user didn't reach the activity max cognitive depth, continue with level 2.
404                 case 4:
405                     // Cognitive level 4 is to comment on feedback.
406                     if ($this->any_feedback('replied', $cm, $contextid, $user)) {
407                         $score += $scoreperlevel * 4;
408                         break;
409                     }
410                 // The user didn't reach the activity max cognitive depth, continue with level 2.
412                 case 3:
413                     // Cognitive level 3 is to view feedback.
415                     if ($this->any_feedback('viewed', $cm, $contextid, $user)) {
416                         // Max score for level 3.
417                         $score += $scoreperlevel * 3;
418                         break;
419                     }
420                 // The user didn't reach the activity max cognitive depth, continue with level 2.
422                 case 2:
423                     // Cognitive depth level 2 is to submit content.
425                     if ($this->any_write_log($contextid, $user)) {
426                         $score += $scoreperlevel * 2;
427                         break;
428                     }
429                 // The user didn't reach the activity max cognitive depth, continue with level 1.
431                 case 1:
432                     // Cognitive depth level 1 is just accessing the activity.
434                     if ($this->any_log($contextid, $user)) {
435                         $score += $scoreperlevel;
436                     }
438                 default:
439             }
440         }
442         // To avoid decimal problems.
443         if ($score > self::MAX_VALUE) {
444             return self::MAX_VALUE;
445         } else if ($score < self::MIN_VALUE) {
446             return self::MIN_VALUE;
447         }
448         return $score;
449     }
451     /**
452      * social_calculate_sample
453      *
454      * @param $sampleid
455      * @param $tablename
456      * @param bool $starttime
457      * @param bool $endtime
458      * @return float|int|null
459      */
460     protected function social_calculate_sample($sampleid, $tablename, $starttime = false, $endtime = false) {
462         // May not be available.
463         $user = $this->retrieve('user', $sampleid);
465         if (!$useractivities = $this->get_student_activities($sampleid, $tablename, $starttime, $endtime)) {
466             // Null if no activities.
467             return null;
468         }
470         $scoreperactivity = (self::get_max_value() - self::get_min_value()) / count($useractivities);
472         $score = self::get_min_value();
474         foreach ($useractivities as $contextid => $cm) {
476             $potentiallevel = $this->get_social_breadth_level($cm);
477             if (!is_int($potentiallevel) || $potentiallevel > 2 || $potentiallevel < 1) {
478                 throw new \coding_exception('Activities\' potential social breadth go from 1 to 2.');
479             }
480             $scoreperlevel = $scoreperactivity / $potentiallevel;
481             // TODO Add support for other levels than 2.
482             switch ($potentiallevel) {
483                 case 2:
484                     // Social breadth level 2 is to view feedback. (Same as cognitive level 3)
486                     if ($this->any_feedback('viewed', $cm, $contextid, $user)) {
487                         // Max score for level 2.
488                         $score += $scoreperlevel * 2;
489                         break;
490                     }
491                 // The user didn't reach the activity max social breadth, continue with level 1.
492                 case 1:
493                     // Social breadth level 1 is just accessing the activity.
494                     if ($this->any_log($contextid, $user)) {
495                         $score += $scoreperlevel;
496                     }
497             }
499         }
501         // To avoid decimal problems.
502         if ($score > self::MAX_VALUE) {
503             return self::MAX_VALUE;
504         } else if ($score < self::MIN_VALUE) {
505             return self::MIN_VALUE;
506         }
507         return $score;
508     }
510     /**
511      * calculate_sample
512      *
513      * @param int $sampleid
514      * @param string $tablename
515      * @param bool $starttime
516      * @param bool $endtime
517      * @return float|int|null
518      * @throws \coding_exception
519      */
520     protected function calculate_sample($sampleid, $tablename, $starttime = false, $endtime = false) {
521         if ($this->get_indicator_type() == self::INDICATOR_COGNITIVE) {
522             return $this->cognitive_calculate_sample($sampleid, $tablename, $starttime, $endtime);
523         } else if ($this->get_indicator_type() == self::INDICATOR_SOCIAL) {
524             return $this->social_calculate_sample($sampleid, $tablename, $starttime, $endtime);
525         }
526         throw new \coding_exception("Indicator type is invalid.");
527     }
529     /**
530      * Defines indicator type.
531      *
532      * @return mixed
533      */
534     abstract protected function get_indicator_type();