952ed8933e2b28ab8071c30e16b790ec32fcc107
[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 inquire 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 $logs) {
120             foreach ($logs 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 => $logs) {
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 level that include viewing feedback should define ' .
183             '"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 level that include replying to feedback should define ' .
188             '"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 level that include viewing feedback should define ' .
193             '"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);
311         // Keeping memory usage as low as possible by using recordsets and storing only 1 log
312         // per contextid-userid-eventname + 1 timestamp for each of this combination records.
313         $fields = 'eventname, crud, contextid, contextlevel, contextinstanceid, userid, courseid';
314         $select = "contextid $contextsql AND timecreated > :starttime AND timecreated <= :endtime";
315         $sql = "SELECT $fields, timecreated " .
316             "FROM {logstore_standard_log} " .
317             "WHERE $select " .
318             "ORDER BY timecreated ASC";
319         $params = $contextparams + array('starttime' => $starttime, 'endtime' => $endtime);
320         $logs = $DB->get_recordset_sql($sql, $params);
322         // Returs the logs organised by contextid, userid and eventname so it is easier to calculate activities data later.
323         // At the same time we want to keep this array reasonably "not-massive".
324         $processedlogs = array();
325         foreach ($logs as $log) {
326             if (!isset($processedlogs[$log->contextid])) {
327                 $processedlogs[$log->contextid] = array();
328             }
329             if (!isset($processedlogs[$log->contextid][$log->userid])) {
330                 $processedlogs[$log->contextid][$log->userid] = array();
331             }
333             // contextid and userid have already been used to index the logs, the next field to index by is eventname:
334             // crud is unique per eventname, courseid is the same for all records and we append timecreated.
335             if (!isset($processedlogs[$log->contextid][$log->userid][$log->eventname])) {
336                 $processedlogs[$log->contextid][$log->userid][$log->eventname] = $log;
338                 // We want timecreated attribute to be an array containing all user access times.
339                 $processedlogs[$log->contextid][$log->userid][$log->eventname]->timecreated = array(intval($log->timecreated));
340             } else {
341                 // Add the event timecreated.
342                 $processedlogs[$log->contextid][$log->userid][$log->eventname]->timecreated[] = intval($log->timecreated);
343             }
344         }
345         $logs->close();
347         return $processedlogs;
348     }
350     /**
351      * Whether grades should be checked or not when looking for feedback.
352      *
353      * @return void
354      */
355     protected function feedback_check_grades() {
356         return true;
357     }
359     /**
360      * cognitive_calculate_sample
361      *
362      * @param $sampleid
363      * @param $tablename
364      * @param bool $starttime
365      * @param bool $endtime
366      * @return float|int|null
367      * @throws \coding_exception
368      */
369     protected function cognitive_calculate_sample($sampleid, $tablename, $starttime = false, $endtime = false) {
371         // May not be available.
372         $user = $this->retrieve('user', $sampleid);
374         if (!$useractivities = $this->get_student_activities($sampleid, $tablename, $starttime, $endtime)) {
375             // Null if no activities.
376             return null;
377         }
379         $scoreperactivity = (self::get_max_value() - self::get_min_value()) / count($useractivities);
381         $score = self::get_min_value();
383         // Iterate through the module activities/resources which due date is part of this time range.
384         foreach ($useractivities as $contextid => $cm) {
386             $potentiallevel = $this->get_cognitive_depth_level($cm);
387             if (!is_int($potentiallevel) || $potentiallevel > 5 || $potentiallevel < 1) {
388                 throw new \coding_exception('Activities\' potential level of engagement possible values go from 1 to 5.');
389             }
390             $scoreperlevel = $scoreperactivity / $potentiallevel;
392             switch ($potentiallevel) {
393                 case 5:
394                     // Cognitive level 4 is to comment on feedback.
395                     if ($this->any_feedback('submitted', $cm, $contextid, $user)) {
396                         $score += $scoreperlevel * 5;
397                         break;
398                     }
399                 // The user didn't reach the activity max cognitive depth, continue with level 2.
401                 case 4:
402                     // Cognitive level 4 is to comment on feedback.
403                     if ($this->any_feedback('replied', $cm, $contextid, $user)) {
404                         $score += $scoreperlevel * 4;
405                         break;
406                     }
407                 // The user didn't reach the activity max cognitive depth, continue with level 2.
409                 case 3:
410                     // Cognitive level 3 is to view feedback.
412                     if ($this->any_feedback('viewed', $cm, $contextid, $user)) {
413                         // Max score for level 3.
414                         $score += $scoreperlevel * 3;
415                         break;
416                     }
417                 // The user didn't reach the activity max cognitive depth, continue with level 2.
419                 case 2:
420                     // Cognitive depth level 2 is to submit content.
422                     if ($this->any_write_log($contextid, $user)) {
423                         $score += $scoreperlevel * 2;
424                         break;
425                     }
426                 // The user didn't reach the activity max cognitive depth, continue with level 1.
428                 case 1:
429                     // Cognitive depth level 1 is just accessing the activity.
431                     if ($this->any_log($contextid, $user)) {
432                         $score += $scoreperlevel;
433                     }
435                 default:
436             }
437         }
439         // To avoid decimal problems.
440         if ($score > self::MAX_VALUE) {
441             return self::MAX_VALUE;
442         } else if ($score < self::MIN_VALUE) {
443             return self::MIN_VALUE;
444         }
445         return $score;
446     }
448     /**
449      * social_calculate_sample
450      *
451      * @param $sampleid
452      * @param $tablename
453      * @param bool $starttime
454      * @param bool $endtime
455      * @return float|int|null
456      */
457     protected function social_calculate_sample($sampleid, $tablename, $starttime = false, $endtime = false) {
459         // May not be available.
460         $user = $this->retrieve('user', $sampleid);
462         if (!$useractivities = $this->get_student_activities($sampleid, $tablename, $starttime, $endtime)) {
463             // Null if no activities.
464             return null;
465         }
467         $scoreperactivity = (self::get_max_value() - self::get_min_value()) / count($useractivities);
469         $score = self::get_min_value();
471         foreach ($useractivities as $contextid => $cm) {
473             $potentiallevel = $this->get_social_breadth_level($cm);
474             if (!is_int($potentiallevel) || $potentiallevel > 2 || $potentiallevel < 1) {
475                 throw new \coding_exception('Activities\' potential level of engagement possible values go from 1 to 2.');
476             }
477             $scoreperlevel = $scoreperactivity / $potentiallevel;
478             // TODO Add support for other levels than 2.
479             switch ($potentiallevel) {
480                 case 2:
481                     // Social breadth level 2 is to view feedback. (Same as cognitive level 3)
483                     if ($this->any_feedback('viewed', $cm, $contextid, $user)) {
484                         // Max score for level 2.
485                         $score += $scoreperlevel * 2;
486                         break;
487                     }
488                 // The user didn't reach the activity max social breadth, continue with level 1.
489                 case 1:
490                     // Social breadth level 1 is just accessing the activity.
491                     if ($this->any_log($contextid, $user)) {
492                         $score += $scoreperlevel;
493                     }
494             }
496         }
498         // To avoid decimal problems.
499         if ($score > self::MAX_VALUE) {
500             return self::MAX_VALUE;
501         } else if ($score < self::MIN_VALUE) {
502             return self::MIN_VALUE;
503         }
504         return $score;
505     }
507     /**
508      * calculate_sample
509      *
510      * @param int $sampleid
511      * @param string $tablename
512      * @param bool $starttime
513      * @param bool $endtime
514      * @return float|int|null
515      * @throws \coding_exception
516      */
517     protected function calculate_sample($sampleid, $tablename, $starttime = false, $endtime = false) {
518         if ($this->get_indicator_type() == self::INDICATOR_COGNITIVE) {
519             return $this->cognitive_calculate_sample($sampleid, $tablename, $starttime, $endtime);
520         } else if ($this->get_indicator_type() == self::INDICATOR_SOCIAL) {
521             return $this->social_calculate_sample($sampleid, $tablename, $starttime, $endtime);
522         }
523         throw new \coding_exception("Indicator type is invalid.");
524     }
526     /**
527      * Defines indicator type.
528      *
529      * @return mixed
530      */
531     abstract protected function get_indicator_type();