MDL-59779 analytics: Alternative to query log table
[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     /**
39      * @var \core_analytics\course
40      */
41     protected $course = null;
43     /**
44      * @var array Array of logs by [contextid][userid]
45      */
46     protected $activitylogs = null;
48     /**
49      * @var array Array of grades by [contextid][userid]
50      */
51     protected $grades = null;
53     /**
54      * Constant cognitive indicator type.
55      */
56     const INDICATOR_COGNITIVE = "cognitve";
58     /**
59      * Constant social indicator type.
60      */
61     const INDICATOR_SOCIAL = "social";
63     /**
64      * Fetch the course grades of this activity type instances.
65      *
66      * @param \core_analytics\analysable $analysable
67      * @return void
68      */
69     public function fill_per_analysable_caches(\core_analytics\analysable $analysable) {
71         // Better to check it, we can not be 100% it will be a \core_analytics\course object.
72         if ($analysable instanceof \core_analytics\course) {
73             $this->fetch_student_grades($analysable);
74         }
75     }
77     /**
78      * Returns the activity type. No point in changing this class in children classes.
79      *
80      * @var string The activity name (e.g. assign or quiz)
81      */
82     protected final function get_activity_type() {
83         $class = get_class($this);
84         $package = stristr($class, "\\", true);
85         $type = str_replace("mod_", "", $package);
86         if ($type === $package) {
87             throw new \coding_exception("$class does not belong to any module specific namespace");
88         }
89         return $type;
90     }
92     /**
93      * Returns the potential level of cognitive depth.
94      *
95      * @param \cm_info $cm
96      * @return int
97      */
98     protected function get_cognitive_depth_level(\cm_info $cm) {
99         throw new \coding_exception('Overwrite get_cognitive_depth_level method to set your activity potential cognitive ' .
100             'depth level');
101     }
103     /**
104      * Returns the potential level of social breadth.
105      *
106      * @param \cm_info $cm
107      * @return int
108      */
109     protected function get_social_breadth_level(\cm_info $cm) {
110         throw new \coding_exception('Overwrite get_social_breadth_level method to set your activity potential social ' .
111             'breadth level');
112     }
114     /**
115      * required_sample_data
116      *
117      * @return string[]
118      */
119     public static function required_sample_data() {
120         // Only course because the indicator is valid even without students.
121         return array('course');
122     }
124     /**
125      * Do activity logs contain any log of user in this context?
126      *
127      * If user is empty we look for any log in this context.
128      *
129      * @param int $contextid
130      * @param \stdClass|false $user
131      * @return bool
132      */
133     protected final function any_log($contextid, $user) {
134         if (empty($this->activitylogs[$contextid])) {
135             return false;
136         }
138         // Someone interacted with the activity if there is no user or the user interacted with the
139         // activity if there is a user.
140         if (empty($user) ||
141                 (!empty($user) && !empty($this->activitylogs[$contextid][$user->id]))) {
142             return true;
143         }
145         return false;
146     }
148     /**
149      * Do activity logs contain any write log of user in this context?
150      *
151      * If user is empty we look for any write log in this context.
152      *
153      * @param int $contextid
154      * @param \stdClass|false $user
155      * @return bool
156      */
157     protected final function any_write_log($contextid, $user) {
158         if (empty($this->activitylogs[$contextid])) {
159             return false;
160         }
162         // No specific user, we look at all activity logs.
163         $it = $this->activitylogs[$contextid];
164         if ($user) {
165             if (empty($this->activitylogs[$contextid][$user->id])) {
166                 return false;
167             }
168             $it = array($user->id => $this->activitylogs[$contextid][$user->id]);
169         }
170         foreach ($it as $events) {
171             foreach ($events as $log) {
172                 if ($log->crud === 'c' || $log->crud === 'u') {
173                     return true;
174                 }
175             }
176         }
178         return false;
179     }
181     /**
182      * Is there any feedback activity log for this user in this context?
183      *
184      * This method returns true if $user is empty and there is any feedback activity logs.
185      *
186      * @param string $action
187      * @param \cm_info $cm
188      * @param int $contextid
189      * @param \stdClass|false $user
190      * @return bool
191      */
192     protected function any_feedback($action, \cm_info $cm, $contextid, $user) {
194         if (!in_array($action, ['submitted', 'replied', 'viewed'])) {
195             throw new \coding_exception('Provided action "' . $action . '" is not valid.');
196         }
198         if (empty($this->activitylogs[$contextid])) {
199             return false;
200         }
202         if (empty($this->grades[$contextid]) && $this->feedback_check_grades()) {
203             // If there are no grades there is no feedback.
204             return false;
205         }
207         $it = $this->activitylogs[$contextid];
208         if ($user) {
209             if (empty($this->activitylogs[$contextid][$user->id])) {
210                 return false;
211             }
212             $it = array($user->id => $this->activitylogs[$contextid][$user->id]);
213         }
215         foreach ($this->activitylogs[$contextid] as $userid => $events) {
216             $methodname = 'feedback_' . $action;
217             if ($this->{$methodname}($cm, $contextid, $userid)) {
218                 return true;
219             }
220             // If it wasn't viewed try with the next user.
221         }
222         return false;
223     }
225     /**
226      * $cm is used for this method overrides.
227      *
228      * This function must be fast.
229      *
230      * @param \cm_info $cm
231      * @param mixed $contextid
232      * @param mixed $userid
233      * @param int $after Timestamp, defaults to the graded date or false if we don't check the date.
234      * @return bool
235      */
236     protected function feedback_viewed(\cm_info $cm, $contextid, $userid, $after = null) {
237         return $this->feedback_post_action($cm, $contextid, $userid, $this->feedback_viewed_events(), $after);
238     }
240     /**
241      * $cm is used for this method overrides.
242      *
243      * This function must be fast.
244      *
245      * @param \cm_info $cm
246      * @param mixed $contextid
247      * @param mixed $userid
248      * @param int $after Timestamp, defaults to the graded date or false if we don't check the date.
249      * @return bool
250      */
251     protected function feedback_replied(\cm_info $cm, $contextid, $userid, $after = null) {
252         return $this->feedback_post_action($cm, $contextid, $userid, $this->feedback_replied_events(), $after);
253     }
255     /**
256      * $cm is used for this method overrides.
257      *
258      * This function must be fast.
259      *
260      * @param \cm_info $cm
261      * @param mixed $contextid
262      * @param mixed $userid
263      * @param int $after Timestamp, defaults to the graded date or false if we don't check the date.
264      * @return bool
265      */
266     protected function feedback_submitted(\cm_info $cm, $contextid, $userid, $after = null) {
267         return $this->feedback_post_action($cm, $contextid, $userid, $this->feedback_submitted_events(), $after);
268     }
270     /**
271      * Returns the list of events that involve viewing feedback from other users.
272      *
273      * @return string[]
274      */
275     protected function feedback_viewed_events() {
276         throw new \coding_exception('Activities with a potential cognitive or social level that include viewing feedback ' .
277             'should define "feedback_viewed_events" method or should override feedback_viewed method.');
278     }
280     /**
281      * Returns the list of events that involve replying to feedback from other users.
282      *
283      * @return string[]
284      */
285     protected function feedback_replied_events() {
286         throw new \coding_exception('Activities with a potential cognitive or social level that include replying to feedback ' .
287             'should define "feedback_replied_events" method or should override feedback_replied method.');
288     }
290     /**
291      * Returns the list of events that involve submitting something after receiving feedback from other users.
292      *
293      * @return string[]
294      */
295     protected function feedback_submitted_events() {
296         throw new \coding_exception('Activities with a potential cognitive or social level that include viewing feedback ' .
297             'should define "feedback_submitted_events" method or should override feedback_submitted method.');
298     }
300     /**
301      * Whether this user in this context did any of the provided actions (events)
302      *
303      * @param \cm_info $cm
304      * @param int $contextid
305      * @param int $userid
306      * @param string[] $eventnames
307      * @param int|false $after
308      * @return bool
309      */
310     protected function feedback_post_action(\cm_info $cm, $contextid, $userid, $eventnames, $after = null) {
311         if ($after === null) {
312             if ($this->feedback_check_grades()) {
313                 if (!$after = $this->get_graded_date($contextid, $userid)) {
314                     return false;
315                 }
316             } else {
317                 $after = false;
318             }
319         }
321         if (empty($this->activitylogs[$contextid][$userid])) {
322             return false;
323         }
325         foreach ($eventnames as $eventname) {
326             if (!$after) {
327                 if (!empty($this->activitylogs[$contextid][$userid][$eventname])) {
328                     // If we don't care about when the feedback has been seen we consider this enough.
329                     return true;
330                 }
331             } else {
332                 if (empty($this->activitylogs[$contextid][$userid][$eventname])) {
333                     continue;
334                 }
335                 $timestamps = $this->activitylogs[$contextid][$userid][$eventname]->timecreated;
336                 // Faster to start by the end.
337                 rsort($timestamps);
338                 foreach ($timestamps as $timestamp) {
339                     if ($timestamp > $after) {
340                         return true;
341                     }
342                 }
343             }
344         }
345         return false;
346     }
348     /**
349      * Returns the date a user was graded.
350      *
351      * @param int $contextid
352      * @param int $userid
353      * @param bool $checkfeedback Check that the student was graded or check that feedback was given
354      * @return int|false
355      */
356     protected function get_graded_date($contextid, $userid, $checkfeedback = false) {
357         if (empty($this->grades[$contextid][$userid])) {
358             return false;
359         }
360         foreach ($this->grades[$contextid][$userid] as $gradeitemid => $gradeitem) {
362             // We check that either feedback or the grade is set.
363             if (($checkfeedback && $gradeitem->feedback) || $gradeitem->grade) {
365                 // Grab the first graded date.
366                 if ($gradeitem->dategraded && (empty($after) || $gradeitem->dategraded < $after)) {
367                     $after = $gradeitem->dategraded;
368                 }
369             }
370         }
372         if (!isset($after)) {
373             // False if there are no graded items.
374             return false;
375         }
377         return $after;
378     }
380     /**
381      * Returns the activities the user had access to between a time period.
382      *
383      * @param int $sampleid
384      * @param string $tablename
385      * @param int $starttime
386      * @param int $endtime
387      * @return array
388      */
389     protected function get_student_activities($sampleid, $tablename, $starttime, $endtime) {
391         // May not be available.
392         $user = $this->retrieve('user', $sampleid);
394         if ($this->course === null) {
395             // The indicator scope is a range, so all activities belong to the same course.
396             $this->course = \core_analytics\course::instance($this->retrieve('course', $sampleid));
397         }
399         if ($this->activitylogs === null) {
400             // Fetch all activity logs in each activity in the course, not restricted to a specific sample so we can cache it.
402             $courseactivities = $this->course->get_all_activities($this->get_activity_type());
404             // Null if no activities of this type in this course.
405             if (empty($courseactivities)) {
406                 $this->activitylogs = false;
407                 return null;
408             }
409             $this->activitylogs = $this->fetch_activity_logs($courseactivities, $starttime, $endtime);
410         }
412         if ($this->grades === null) {
413             // Even if this is probably already filled during fill_per_analysable_caches.
414             $this->fetch_student_grades($this->course);
415         }
417         if ($cm = $this->retrieve('cm', $sampleid)) {
418             // Samples are at cm level or below.
419             $useractivities = array(\context_module::instance($cm->id)->id => $cm);
420         } else {
421             // All course activities.
422             $useractivities = $this->course->get_activities($this->get_activity_type(), $starttime, $endtime, $user);
423         }
425         return $useractivities;
426     }
428     /**
429      * Fetch acitivity logs from database
430      *
431      * @param array $activities
432      * @param int $starttime
433      * @param int $endtime
434      * @return array
435      */
436     protected function fetch_activity_logs($activities, $starttime = false, $endtime = false) {
437         global $DB;
439         // Filter by context to use the db table index.
440         list($contextsql, $contextparams) = $DB->get_in_or_equal(array_keys($activities), SQL_PARAMS_NAMED);
441         $select = "contextid $contextsql AND timecreated > :starttime AND timecreated <= :endtime";
442         $params = $contextparams + array('starttime' => $starttime, 'endtime' => $endtime);
444         // Pity that we need to pass through logging readers API when most of the people just uses the standard one.
445         if (!$logstore = \core_analytics\manager::get_analytics_logstore()) {
446             throw new \coding_exception('No log store available');
447         }
448         $events = $logstore->get_events_select_iterator($select, $params, 'timecreated ASC', 0, 0);
450         // Returs the logs organised by contextid, userid and eventname so it is easier to calculate activities data later.
451         // At the same time we want to keep this array reasonably "not-massive".
452         $processedevents = array();
453         foreach ($events as $event) {
454             if (!isset($processedevents[$event->contextid])) {
455                 $processedevents[$event->contextid] = array();
456             }
457             if (!isset($processedevents[$event->contextid][$event->userid])) {
458                 $processedevents[$event->contextid][$event->userid] = array();
459             }
461             // Contextid and userid have already been used to index the events, the next field to index by is eventname:
462             // crud is unique per eventname, courseid is the same for all records and we append timecreated.
463             if (!isset($processedevents[$event->contextid][$event->userid][$event->eventname])) {
465                 // Remove all data that can change between events of the same type.
466                 $data = (object)$event->get_data();
467                 unset($data->id);
468                 unset($data->anonymous);
469                 unset($data->relateduserid);
470                 unset($data->other);
471                 unset($data->origin);
472                 unset($data->ip);
473                 $processedevents[$event->contextid][$event->userid][$event->eventname] = $data;
474                 // We want timecreated attribute to be an array containing all user access times.
475                 $processedevents[$event->contextid][$event->userid][$event->eventname]->timecreated = array();
476             }
478             // Add the event timecreated.
479             $processedevents[$event->contextid][$event->userid][$event->eventname]->timecreated[] = intval($event->timecreated);
480         }
481         $events->close();
483         return $processedevents;
484     }
486     /**
487      * Whether grades should be checked or not when looking for feedback.
488      *
489      * @return bool
490      */
491     protected function feedback_check_grades() {
492         return true;
493     }
495     /**
496      * Calculates the cognitive depth of a sample.
497      *
498      * @param int $sampleid
499      * @param string $tablename
500      * @param int $starttime
501      * @param int $endtime
502      * @return float|int|null
503      * @throws \coding_exception
504      */
505     protected function cognitive_calculate_sample($sampleid, $tablename, $starttime = false, $endtime = false) {
507         // May not be available.
508         $user = $this->retrieve('user', $sampleid);
510         if (!$useractivities = $this->get_student_activities($sampleid, $tablename, $starttime, $endtime)) {
511             // Null if no activities.
512             return null;
513         }
515         $scoreperactivity = (self::get_max_value() - self::get_min_value()) / count($useractivities);
517         $score = self::get_min_value();
519         // Iterate through the module activities/resources which due date is part of this time range.
520         foreach ($useractivities as $contextid => $cm) {
522             $potentiallevel = $this->get_cognitive_depth_level($cm);
523             if (!is_int($potentiallevel) || $potentiallevel > 5 || $potentiallevel < 1) {
524                 throw new \coding_exception('Activities\' potential cognitive depth go from 1 to 5.');
525             }
526             $scoreperlevel = $scoreperactivity / $potentiallevel;
528             switch ($potentiallevel) {
529                 case 5:
530                     // Cognitive level 5 is to submit after feedback.
531                     if ($this->any_feedback('submitted', $cm, $contextid, $user)) {
532                         $score += $scoreperlevel * 5;
533                         break;
534                     }
535                     // The user didn't reach the activity max cognitive depth, continue with level 2.
537                 case 4:
538                     // Cognitive level 4 is to comment on feedback.
539                     if ($this->any_feedback('replied', $cm, $contextid, $user)) {
540                         $score += $scoreperlevel * 4;
541                         break;
542                     }
543                     // The user didn't reach the activity max cognitive depth, continue with level 2.
545                 case 3:
546                     // Cognitive level 3 is to view feedback.
548                     if ($this->any_feedback('viewed', $cm, $contextid, $user)) {
549                         // Max score for level 3.
550                         $score += $scoreperlevel * 3;
551                         break;
552                     }
553                     // The user didn't reach the activity max cognitive depth, continue with level 2.
555                 case 2:
556                     // Cognitive depth level 2 is to submit content.
558                     if ($this->any_write_log($contextid, $user)) {
559                         $score += $scoreperlevel * 2;
560                         break;
561                     }
562                     // The user didn't reach the activity max cognitive depth, continue with level 1.
564                 case 1:
565                     // Cognitive depth level 1 is just accessing the activity.
567                     if ($this->any_log($contextid, $user)) {
568                         $score += $scoreperlevel;
569                     }
571                 default:
572             }
573         }
575         // To avoid decimal problems.
576         if ($score > self::MAX_VALUE) {
577             return self::MAX_VALUE;
578         } else if ($score < self::MIN_VALUE) {
579             return self::MIN_VALUE;
580         }
581         return $score;
582     }
584     /**
585      * Calculates the social breadth of a sample.
586      *
587      * @param int $sampleid
588      * @param string $tablename
589      * @param int $starttime
590      * @param int $endtime
591      * @return float|int|null
592      */
593     protected function social_calculate_sample($sampleid, $tablename, $starttime = false, $endtime = false) {
595         // May not be available.
596         $user = $this->retrieve('user', $sampleid);
598         if (!$useractivities = $this->get_student_activities($sampleid, $tablename, $starttime, $endtime)) {
599             // Null if no activities.
600             return null;
601         }
603         $scoreperactivity = (self::get_max_value() - self::get_min_value()) / count($useractivities);
605         $score = self::get_min_value();
607         foreach ($useractivities as $contextid => $cm) {
609             $potentiallevel = $this->get_social_breadth_level($cm);
610             if (!is_int($potentiallevel) || $potentiallevel > 2 || $potentiallevel < 1) {
611                 throw new \coding_exception('Activities\' potential social breadth go from 1 to 2.');
612             }
613             $scoreperlevel = $scoreperactivity / $potentiallevel;
614             switch ($potentiallevel) {
615                 case 2:
616                     // Social breadth level 2 is to view feedback. (Same as cognitive level 3).
618                     if ($this->any_feedback('viewed', $cm, $contextid, $user)) {
619                         // Max score for level 2.
620                         $score += $scoreperlevel * 2;
621                         break;
622                     }
623                     // The user didn't reach the activity max social breadth, continue with level 1.
625                 case 1:
626                     // Social breadth level 1 is just accessing the activity.
627                     if ($this->any_log($contextid, $user)) {
628                         $score += $scoreperlevel;
629                     }
630             }
632         }
634         // To avoid decimal problems.
635         if ($score > self::MAX_VALUE) {
636             return self::MAX_VALUE;
637         } else if ($score < self::MIN_VALUE) {
638             return self::MIN_VALUE;
639         }
640         return $score;
641     }
643     /**
644      * calculate_sample
645      *
646      * @throws \coding_exception
647      * @param int $sampleid
648      * @param string $tablename
649      * @param int $starttime
650      * @param int $endtime
651      * @return float|int|null
652      */
653     protected function calculate_sample($sampleid, $tablename, $starttime = false, $endtime = false) {
654         if ($this->get_indicator_type() == self::INDICATOR_COGNITIVE) {
655             return $this->cognitive_calculate_sample($sampleid, $tablename, $starttime, $endtime);
656         } else if ($this->get_indicator_type() == self::INDICATOR_SOCIAL) {
657             return $this->social_calculate_sample($sampleid, $tablename, $starttime, $endtime);
658         }
659         throw new \coding_exception("Indicator type is invalid.");
660     }
662     /**
663      * Gets the course student grades.
664      *
665      * @param \core_analytics\course $course
666      * @return void
667      */
668     protected function fetch_student_grades(\core_analytics\course $course) {
669         $courseactivities = $course->get_all_activities($this->get_activity_type());
670         $this->grades = $course->get_student_grades($courseactivities);
671     }
673     /**
674      * Defines indicator type.
675      *
676      * @return string
677      */
678     abstract protected function get_indicator_type();